diff --git a/README.md b/README.md index e863bc285..745999d64 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TotalFreedomMod r03 ![Release](https://img.shields.io/github/v/release/tfreedomorg/TotalFreedomMod?include_prereleases&style=plastic) -![Version](https://img.shields.io/badge/version-26.7-green?style=plastic) +![Version](https://img.shields.io/badge/version-26.7.1-green?style=plastic) ![License](https://img.shields.io/badge/license-TFGL%20v2.0-orange?style=plastic) ![Code size](https://img.shields.io/github/languages/code-size/tfreedomorg/TotalFreedomMod?style=plastic) diff --git a/build.gradle b/build.gradle index f699247c7..e257fd945 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,11 @@ plugins { id 'java' id 'com.gorylenko.gradle-git-properties' version '2.5.7' + id 'org.openrewrite.rewrite' version '7.38.0' } group = 'me.totalfreedom' -version = '26.7.1' +version = '26.9' java { toolchain { @@ -12,6 +13,14 @@ java { } } +rewrite { + activeRecipe( + "org.openrewrite.java.RemoveUnusedImports", + "org.openrewrite.java.OrderImports" + ) + activeStyle("me.totalfreedom.totalfreedommod.WildcardStyle") +} + repositories { mavenCentral() maven { @@ -39,28 +48,43 @@ repositories { } dependencies { - compileOnly 'io.papermc.paper:paper-api:26.1.2.build.61-stable' + // deprecated compileOnly 'org.projectlombok:lombok:1.18.42' annotationProcessor 'org.projectlombok:lombok:1.18.42' - compileOnly 'org.apache.commons:commons-lang3:3.14.0' compileOnly 'commons-io:commons-io:2.16.1' - compileOnly 'net.kyori:adventure-text-serializer-ansi:4.17.0' compileOnly 'net.milkbowl.vault:VaultUnlockedAPI:2.16' + + // paper + compileOnly 'io.papermc.paper:paper-api:26.1.2.build.61-stable' + compileOnly 'net.kyori:adventure-text-serializer-ansi:4.17.0' + + // plugins compileOnly('com.sk89q.worldedit:worldedit-bukkit:7.3.10') { transitive = false } compileOnly('com.sk89q.worldedit:worldedit-core:7.3.10') { transitive = false } + compileOnly "net.coreprotect:coreprotect:22.4" - compileOnly 'org.apache.sshd:sshd-core:2.17.1' + // packets + compileOnly 'com.github.retrooper:packetevents-spigot:2.12.1' - compileOnly 'org.jline:jline:3.28.0' - compileOnly 'org.apache.logging.log4j:log4j-core:2.24.3' + // discord stuff + compileOnly 'com.discord4j:discord4j-core:3.3.2' - compileOnly 'net.dv8tion:JDA:5.6.1' + // ssh + compileOnly 'org.apache.sshd:sshd-core:2.17.1' - compileOnly 'com.github.retrooper:packetevents-spigot:2.12.1' + // sql + compileOnly 'io.projectreactor:reactor-core:3.8.3' + compileOnly 'org.postgresql:postgresql:42.7.7' + compileOnly 'org.xerial:sqlite-jdbc:3.49.1.0' + compileOnly 'com.mysql:mysql-connector-j:9.3.0' + compileOnly 'com.zaxxer:HikariCP:6.3.0' - compileOnly "net.coreprotect:coreprotect:22.4" + // logging + compileOnly 'org.jline:jline:3.28.0' + compileOnly 'org.apache.logging.log4j:log4j-core:2.24.3' } +// ... is this really necessary??? configurations.all { resolutionStrategy { force 'io.papermc.paper:paper-api:26.1.2.build.61-stable' @@ -136,8 +160,8 @@ processResources { } expand( - buildAuthor: project.findProperty('buildAuthor') ?: 'Ivanomoly', - buildCodeName: project.findProperty('buildCodeName') ?: 'Polaris', + buildAuthor: project.findProperty('buildAuthor') ?: 'TotalFreedom', // Collective Effort :) + buildCodeName: project.findProperty('buildCodeName') ?: 'Sirius', buildNumber: buildNumber, buildVersion: project.version ) diff --git a/COMMAND-SYSTEM-CHANGELOG.md b/docs/COMMAND-SYSTEM-CHANGELOG.md similarity index 100% rename from COMMAND-SYSTEM-CHANGELOG.md rename to docs/COMMAND-SYSTEM-CHANGELOG.md diff --git a/rewrite.yml b/rewrite.yml new file mode 100644 index 000000000..f389fc4f7 --- /dev/null +++ b/rewrite.yml @@ -0,0 +1,42 @@ +--- +type: specs.openrewrite.org/v1beta/style +name: me.totalfreedom.totalfreedommod.WildcardStyle +styleConfigs: + - org.openrewrite.java.style.ImportLayoutStyle: + # anything lower than 5 will cause conflicts, 5 or higher is a good standard. + classCountToUseStarImport: 5 + nameCountToUseStarImport: 5 + layout: + # JDK + - import java.* + - import javax.* + - + # server platform + - import com.destroystokyo.* + - import com.mojang.* + - import io.papermc.* + - import org.bukkit.* + - + # adventure + - import net.kyori.* + - + # third-party plugins + - import com.sk89q.* + - import net.coreprotect.* + - import net.milkbowl.* + - + # github imports + - import com.github.* + - + # reactive + - import discord4j.* + - import io.netty.* + - import reactor.* + - + # project + - import me.totalfreedom.* + - + # everything else + - import all other imports + - + - import static all other imports diff --git a/src/main/java/me/totalfreedom/totalfreedommod/Announcer.java b/src/main/java/me/totalfreedom/totalfreedommod/Announcer.java index 3a2ce8ecc..8f36b5911 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/Announcer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/Announcer.java @@ -1,14 +1,17 @@ package me.totalfreedom.totalfreedommod; -import com.google.common.collect.Lists; import java.util.Collections; import java.util.List; -import lombok.Getter; + +import org.bukkit.scheduler.BukkitRunnable; +import org.bukkit.scheduler.BukkitTask; + import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.scheduler.BukkitRunnable; -import org.bukkit.scheduler.BukkitTask; + +import com.google.common.collect.Lists; +import lombok.Getter; public class Announcer extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/AntiDrop.java b/src/main/java/me/totalfreedom/totalfreedommod/AntiDrop.java index 06cf2cfec..458772d55 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/AntiDrop.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/AntiDrop.java @@ -2,10 +2,7 @@ import java.util.Iterator; import java.util.List; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.entity.Item; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -14,6 +11,12 @@ import org.bukkit.event.player.PlayerDropItemEvent; import org.bukkit.inventory.ItemStack; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class AntiDrop extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/AntiNuke.java b/src/main/java/me/totalfreedom/totalfreedommod/AntiNuke.java index 2ec7e3817..617f7ab5f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/AntiNuke.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/AntiNuke.java @@ -1,9 +1,5 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -11,6 +7,12 @@ import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockPlaceEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class AntiNuke extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/AntiSpam.java b/src/main/java/me/totalfreedom/totalfreedommod/AntiSpam.java index 205477a68..fc28a67d7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/AntiSpam.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/AntiSpam.java @@ -1,22 +1,25 @@ package me.totalfreedom.totalfreedommod; -import io.papermc.paper.event.player.AsyncChatEvent; import java.util.Locale; import java.util.TreeSet; import java.util.regex.Matcher; import java.util.regex.Pattern; + +import io.papermc.paper.event.player.AsyncChatEvent; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; + +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.ChatSpamData; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FSync; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.player.PlayerCommandPreprocessEvent; public class AntiSpam extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/AutoEject.java b/src/main/java/me/totalfreedom/totalfreedommod/AutoEject.java index fc8810c70..6e51bf6b9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/AutoEject.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/AutoEject.java @@ -3,14 +3,17 @@ import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; + +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.GameMode; -import org.bukkit.entity.Player; public class AutoEject extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/AutoKick.java b/src/main/java/me/totalfreedom/totalfreedommod/AutoKick.java index 77195cdbd..f8f105b99 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/AutoKick.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/AutoKick.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitTask; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class AutoKick extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java b/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java index 484b5bfc4..58f46f0f7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/BackupManager.java @@ -1,16 +1,27 @@ package me.totalfreedom.totalfreedommod; import java.io.File; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; -import me.totalfreedom.totalfreedommod.framework.PluginComponent; -import org.bukkit.configuration.file.YamlConfiguration; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; + import org.bukkit.util.FileUtil; +import me.totalfreedom.totalfreedommod.framework.PluginComponent; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + public class BackupManager extends PluginComponent { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Type BACKUP_DATA_TYPE = new TypeToken>() {}.getType(); public BackupManager(TotalFreedomMod plugin) { @@ -25,91 +36,79 @@ public void createBackups(String file) public void createBackups(String file, boolean onlyWeekly) { final String save = file.split("\\.")[0]; - final File configFile = new File(plugin.getDataFolder(), "backup/backup.yml"); - if (!configFile.exists()) - { - try - { - configFile.getParentFile().mkdirs(); - configFile.createNewFile(); - } - catch (IOException ex) - { - FLog.severe("Could not create backup.yml"); - } - } - final YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); + final File configFile = new File(plugin.getDataFolder(), "backup/backup.json"); + final Map data = loadBackupData(configFile); + final BackupEntry entry = data.getOrDefault(save, new BackupEntry(null, null)); - // Weekly - if (!config.isInt(save + ".weekly")) + Long weekly = entry.weekly(); + if (weekly == null || weekly + 3600L * 24 * 7 < FUtil.getUnixTime()) { performBackup(file, "weekly"); - config.set(save + ".weekly", FUtil.getUnixTime()); + weekly = FUtil.getUnixTime(); } - else - { - int lastBackupWeekly = config.getInt(save + ".weekly"); - if (lastBackupWeekly + 3600 * 24 * 7 < FUtil.getUnixTime()) - { - performBackup(file, "weekly"); - config.set(save + ".weekly", FUtil.getUnixTime()); - } + Long daily = entry.daily(); + if (!onlyWeekly && (daily == null || daily + 3600L * 24 < FUtil.getUnixTime())) + { + performBackup(file, "daily"); + daily = FUtil.getUnixTime(); } - if (onlyWeekly) - { - try + data.put(save, new BackupEntry(weekly, daily)); + saveBackupData(configFile, data); + } + + private void performBackup(String file, String type) + { + FLog.info("Backing up " + file + " to " + file + "." + type + ".bak"); + final File backupFolder = new File(plugin.getDataFolder(), "backup"); + + if (!backupFolder.exists()) { - config.save(configFile); + backupFolder.mkdirs(); } - catch (IOException ex) + + final File oldYaml = new File(plugin.getDataFolder(), file); + final File newYaml = new File(backupFolder, file + "." + type + ".bak"); + FileUtil.copy(oldYaml, newYaml); + } + + private Map loadBackupData(File configFile) + { + if (!configFile.exists()) { - FLog.severe("Could not save backup.yml"); - } - return; + return new HashMap<>(); } - // Daily - if (!config.isInt(save + ".daily")) + try (FileReader reader = new FileReader(configFile)) { - performBackup(file, "daily"); - config.set(save + ".daily", FUtil.getUnixTime()); + Map data = GSON.fromJson(reader, BACKUP_DATA_TYPE); + return data != null ? data : new HashMap<>(); } - else + catch (IOException ex) { - int lastBackupDaily = config.getInt(save + ".daily"); - - if (lastBackupDaily + 3600 * 24 < FUtil.getUnixTime()) - { - performBackup(file, "daily"); - config.set(save + ".daily", FUtil.getUnixTime()); - } + FLog.severe("Could not read backup.json: " + ex.getMessage()); + return new HashMap<>(); } + } + private void saveBackupData(File configFile, Map data) + { try { - config.save(configFile); + configFile.getParentFile().mkdirs(); + try (FileWriter writer = new FileWriter(configFile)) + { + GSON.toJson(data, BACKUP_DATA_TYPE, writer); + } } catch (IOException ex) { - FLog.severe("Could not save backup.yml"); + FLog.severe("Could not save backup.json: " + ex.getMessage()); } } - private void performBackup(String file, String type) + private record BackupEntry(Long weekly, Long daily) { - FLog.info("Backing up " + file + " to " + file + "." + type + ".bak"); - final File backupFolder = new File(plugin.getDataFolder(), "backup"); - - if (!backupFolder.exists()) - { - backupFolder.mkdirs(); - } - - final File oldYaml = new File(plugin.getDataFolder(), file); - final File newYaml = new File(backupFolder, file + "." + type + ".bak"); - FileUtil.copy(oldYaml, newYaml); } - } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java new file mode 100644 index 000000000..57c1ab4b5 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/BookSpy.java @@ -0,0 +1,299 @@ +package me.totalfreedom.totalfreedommod; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerEditBookEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.util.*; + +public class BookSpy extends FreedomService +{ + private static final Duration VIEW_LIFETIME = Duration.ofMinutes(10); + private static final int SUMMARY_CHARS = 60; + private static final int MAX_CHANGE_CHARS = 30; + private static final int MIN_CHANGE_CHARS = 8; + private static final int MAX_LISTED_PAGES = 5; + private static final int MAX_BOOK_PAGES = 100; + private static final Component UNTITLED = Component.text("Untitled"); + private static final Component UNSAFE_PAGE = Component.text("[unsafe page withheld]"); + + private record BookSnapshot(Component title, String author, List newPages, int firstChangedPage) + { + } + + private record PageChange(int page, boolean added, String text) + { + } + + public BookSpy(TotalFreedomMod plugin) + { + super(plugin); + } + + @Override + protected void onStart() + { + } + + @Override + protected void onStop() + { + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerEditBook(PlayerEditBookEvent event) + { + final Player editor = event.getPlayer(); + final BookMeta oldMeta = event.getPreviousBookMeta(); + final BookMeta newMeta = event.getNewBookMeta(); + final List oldPages = sanitize(oldMeta.pages()); + final List newPages = sanitize(newMeta.pages()); + + if (!event.isSigning() && oldPages.equals(newPages)) + { + return; + } + + if (!event.isSigning() && isBlank(newPages)) + { + return; + } + + final boolean editorIsAdmin = plugin.al.isAdmin(editor); + final Component rawTitle = newMeta.hasTitle() ? newMeta.title() : UNTITLED; + final Component title = isCursed(rawTitle) ? UNTITLED : rawTitle; + final List changes = diff(oldPages, newPages); + final BookSnapshot snapshot = new BookSnapshot(title, editor.getName(), newPages, + changes.isEmpty() ? 0 : changes.get(0).page()); + + Component message = Component.empty(); + if (editorIsAdmin) + { + final Displayable display = plugin.rm.getDisplay(editor); + String prefix = AdventureUtil.componentToPlainText(display.getColoredTag()).trim(); + if (prefix.isEmpty()) + { + // A rank is free to report no tag at all, so fall back to the empty string + // rather than letting a null reach the isEmpty() check below + final String tag = display.getTag(); + prefix = tag != null ? tag : ""; + } + if (!prefix.isEmpty()) + { + message = Component.text(String.format("%s ", prefix), display.getColor()); + } + } + + final StringBuilder action = new StringBuilder(editor.getName()); + if (event.isSigning()) + { + action.append(String.format(" signed book '%s'", AdventureUtil.componentToPlainText(title).trim())); + } + else + { + action.append(" edited book"); + } + if (!changes.isEmpty()) + { + action.append(String.format(" (%s)", pageLabel(changes))); + } + message = message.append(Component.text(action.toString(), NamedTextColor.GRAY)); + + int budget = SUMMARY_CHARS; + int shown = 0; + for (final PageChange change : changes) + { + if (shown > 0 && budget < MIN_CHANGE_CHARS) + { + break; + } + final int room = Math.min(budget, MAX_CHANGE_CHARS); + final String text = change.text().length() > room + ? String.format("%s[...]", change.text().substring(0, room)) : change.text(); + message = message.append(Component.text(shown == 0 ? " " : ", ", NamedTextColor.GRAY)) + .append(Component.text(change.added() ? "+" : "-", + change.added() ? NamedTextColor.GREEN : NamedTextColor.RED)) + .append(Component.text(String.format("'%s'", text), NamedTextColor.GRAY)); + budget -= text.length(); + shown++; + } + + if (shown < changes.size()) + { + message = message.append(Component.text(String.format(" (and %d more)", changes.size() - shown), + NamedTextColor.GRAY)); + } + + message = message.append(Component.text(" [", NamedTextColor.GRAY)); + if (!isBlank(oldPages)) + { + message = message.append(viewButton("See Edit", "Click to read the first edited page", snapshot, false)) + .append(Component.text(" | ", NamedTextColor.GRAY)); + } + message = message.append(viewButton("Read Book", "Click to read the whole book from page 1", snapshot, true)) + .append(Component.text("]", NamedTextColor.GRAY)); + + for (final Player admin : plugin.al.getOnlineAdmins()) + { + if (admin.equals(editor)) + { + continue; + } + final PlayerData data = plugin.pl.getData(admin); + if (data == null || !data.getBookSpyMode().shows(editorIsAdmin)) + { + continue; + } + FUtil.playerMsg(admin, message); + } + } + + /** + * Drop the component graph of any page that fails inspection, and cap the page count. + * A spy reads this content back through the view buttons, so a book crafted to carry a + * malicious graph must not be relayed to their client verbatim. + */ + private static List sanitize(final List pages) + { + return pages.stream() + .limit(MAX_BOOK_PAGES) + .map(page -> isCursed(page) ? UNSAFE_PAGE : page) + .toList(); + } + + private static boolean isCursed(final Component component) + { + return ComponentScanner.isCursed(component, ConfigEntry.maxComponentNodes()); + } + + private static boolean isBlank(final List pages) + { + return pages.stream() + .map(AdventureUtil::componentToPlainText) + .map(String::trim) + .allMatch(String::isEmpty); + } + + private static List diff(final List oldPages, final List newPages) + { + final int pageCount = Math.max(oldPages.size(), newPages.size()); + final List changes = new ArrayList<>(pageCount); + for (int i = 0; i < pageCount; i++) + { + final List before = lines(oldPages, i); + final List now = lines(newPages, i); + if (before.equals(now)) + { + continue; + } + for (final String line : now) + { + if (!before.contains(line)) + { + changes.add(new PageChange(i, true, line)); + } + } + for (final String line : before) + { + if (!now.contains(line)) + { + changes.add(new PageChange(i, false, line)); + } + } + } + return changes; + } + + private static List lines(final List pages, final int index) + { + if (index >= pages.size()) + { + return List.of(); + } + return AdventureUtil.componentToPlainText(pages.get(index)) + .lines() + .map(String::trim) + .filter(line -> !line.isEmpty()) + .toList(); + } + + private static String pageLabel(final List changes) + { + final List pages = changes.stream() + .map(PageChange::page) + .distinct() + .toList(); + + final StringBuilder label = new StringBuilder(pages.size() == 1 ? "page " : "pages "); + label.append(String.join(", ", pages.stream() + .limit(MAX_LISTED_PAGES) + .map(page -> String.valueOf(page + 1)) + .toList())); + if (pages.size() > MAX_LISTED_PAGES) + { + label.append(String.format(" and %d more", pages.size() - MAX_LISTED_PAGES)); + } + return label.toString(); + } + + private Component viewButton(final String label, final String hover, final BookSnapshot snapshot, + final boolean wholeBook) + { + return Component.text(label, NamedTextColor.YELLOW) + .clickEvent(ClickEvent.callback( + audience -> + { + if (audience instanceof Player viewer) + { + Bukkit.getScheduler().runTask(plugin, FTask.guard("BookSpy/openBook", + () -> openBook(viewer, snapshot, wholeBook))); + } + }, + ClickCallback.Options.builder() + .uses(ClickCallback.UNLIMITED_USES) + .lifetime(VIEW_LIFETIME) + .build())) + .hoverEvent(HoverEvent.showText(Component.text(hover, NamedTextColor.GRAY))); + } + + private void openBook(final Player viewer, final BookSnapshot snapshot, final boolean wholeBook) + { + if (!viewer.isOnline()) return; + + final List source = snapshot.newPages(); + // openBook cannot be told which page to open at, so the copy handed to the viewer + // starts at the edited page instead. + final int start = wholeBook ? 0 : Math.min(snapshot.firstChangedPage(), Math.max(source.size() - 1, 0)); + final List pages = source.isEmpty() + ? List.of(Component.empty()) : List.copyOf(source.subList(start, source.size())); + + final ItemStack book = new ItemStack(Material.WRITTEN_BOOK); + book.editMeta(BookMeta.class, meta -> + { + meta.title(snapshot.title()); + meta.author(Component.text(snapshot.author())); + meta.pages(pages); + }); + + viewer.closeInventory(); + viewer.openBook(book); + } +} \ No newline at end of file diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java b/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java index 47d495c70..00298d86e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ChatManager.java @@ -1,33 +1,32 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FSync; -import me.totalfreedom.totalfreedommod.util.ChatMentionUtil; -import me.totalfreedom.totalfreedommod.util.FUtil; -import me.totalfreedom.totalfreedommod.vault.VaultProviderRegistry; -import static me.totalfreedom.totalfreedommod.util.FUtil.playerMsg; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.serializer.ansi.ANSIComponentSerializer; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import io.papermc.paper.event.player.AsyncChatEvent; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.server.PluginEnableEvent; import org.bukkit.plugin.Plugin; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.ansi.ANSIComponentSerializer; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.util.*; +import me.totalfreedom.totalfreedommod.vault.VaultProviderRegistry; + +import static me.totalfreedom.totalfreedommod.util.FUtil.playerMsg; + public class ChatManager extends FreedomService { // The maximum message length that the Java Minecraft client can currently handle. @@ -329,32 +328,13 @@ private Component getPlayerCustomTag(Player player) * Gets the configured prefix for a display rank/title. * Returns null if not configured (will use default). */ - private String getConfigPrefix(me.totalfreedom.totalfreedommod.rank.Displayable display) { + private String getConfigPrefix(Displayable display) { if (display instanceof me.totalfreedom.totalfreedommod.rank.CustomRank) { me.totalfreedom.totalfreedommod.rank.CustomRank custom = (me.totalfreedom.totalfreedommod.rank.CustomRank) display; if (custom.getPrefix() != null && !custom.getPrefix().isEmpty()) { return custom.getPrefix(); } } - if (display instanceof me.totalfreedom.totalfreedommod.rank.Rank) { - me.totalfreedom.totalfreedommod.rank.Rank rank = (me.totalfreedom.totalfreedommod.rank.Rank) display; - switch (rank) { - case IMPOSTOR: - return ConfigEntry.VAULT_PREFIX_IMPOSTOR.getString(); - case NON_OP: - return ConfigEntry.VAULT_PREFIX_NON_OP.getString(); - case OP: - return ConfigEntry.VAULT_PREFIX_OP.getString(); - case SUPER_ADMIN: - return ConfigEntry.VAULT_PREFIX_SUPER_ADMIN.getString(); - case SENIOR_ADMIN: - return ConfigEntry.VAULT_PREFIX_SENIOR_ADMIN.getString(); - case SENIOR_CONSOLE: - return ConfigEntry.VAULT_PREFIX_SENIOR_CONSOLE.getString(); - default: - return null; - } - } return null; } @@ -380,7 +360,7 @@ private String buildPrefixSection(Player player) return ""; } - me.totalfreedom.totalfreedommod.rank.Displayable display = plugin.rm.getDisplay(player); + Displayable display = plugin.rm.getDisplay(player); String rankPrefix = ""; if (display != null) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java index 7f6d6f893..81f1c933f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/CommandSpy.java @@ -1,17 +1,19 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.player.CommandSpyMode; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Displayable; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.player.SpyMode; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class CommandSpy extends FreedomService { @@ -49,13 +51,7 @@ public void onPlayerCommandPreprocess(PlayerCommandPreprocessEvent event) continue; } - final CommandSpyMode mode = playerData.getCommandSpyMode(); - if (mode == CommandSpyMode.ADMINS && !senderIsAdmin) - { - continue; - } - - if (mode == CommandSpyMode.OPS && senderIsAdmin) + if (!playerData.getCommandSpyMode().shows(senderIsAdmin)) { continue; } @@ -70,9 +66,6 @@ public void onPlayerCommandPreprocess(PlayerCommandPreprocessEvent event) String prefix = AdventureUtil.componentToPlainText(display.getColoredTag()).trim(); if (prefix.isEmpty()) { - // A rank is free to report no tag at all; fall back to the empty string rather than - // letting a null escape into the isEmpty() below, which would throw once per command - // and bury the console in "Could not pass event PlayerCommandPreprocessEvent" traces. final String tag = display.getTag(); prefix = tag != null ? tag : ""; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java index 68641bd4b..b6e4bcf4e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ConfigConverter.java @@ -1,26 +1,42 @@ package me.totalfreedom.totalfreedommod; -import com.google.common.collect.Lists; -import com.google.common.io.Files; import java.io.File; +import java.io.FileWriter; import java.io.IOException; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.admin.AdminList; +import me.totalfreedom.totalfreedommod.banning.PermBan; import me.totalfreedom.totalfreedommod.banning.PermbanList; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.rank.RankManager; -import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.framework.PluginComponent; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.rank.RankRole; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +import com.google.common.collect.Lists; +import com.google.common.io.Files; +import com.google.gson.reflect.TypeToken; public class ConfigConverter extends PluginComponent { public static final int CURRENT_CONFIG_VERSION = 1; + /** + * Suffix shared by the retired console-only rank ids ({@code senior_console}, + * {@code telnet_console}), matched so that any of them is remapped without naming each. + */ + private static final String CONSOLE_RANK_SUFFIX = "_console"; + public ConfigConverter(TotalFreedomMod plugin) { super(plugin); @@ -108,7 +124,7 @@ public void convert() */ public void convertRanksYaml() { - File ranksFile = new File(plugin.getDataFolder(), RankManager.RANKS_FILENAME); + File ranksFile = new File(plugin.getDataFolder(), "ranks.yml"); if (!ranksFile.exists()) { return; @@ -160,7 +176,12 @@ public void convertRanksYaml() } /** - * Remap admins still assigned to deprecated {@code *_CONSOLE} legacy ranks. + * Remaps admins still assigned to one of the retired {@code *_console} ranks. + *

+ * Console-only ranks existed so that a shared channel could be given standing of its own. That + * is now the job of the {@code host_senders:} binding, which the attributed console sender + * resolves per channel, so an admin holding one is remapped to the equivalent real rank rather + * than left pointing at a rank id the registry no longer knows. */ public void convertAdminConsoleRanks() { @@ -169,30 +190,106 @@ public void convertAdminConsoleRanks() return; } - int migrated = 0; - for (Admin admin : plugin.al.getAllAdmins().values()) + final long migrated = plugin.al.getAllAdmins().values() + .stream() + .filter(admin -> admin.getRankId() != null) + .filter(admin -> admin.getRankId().endsWith(CONSOLE_RANK_SUFFIX)) + .peek(admin -> FLog.info(String.format("Remapped admin '%s' from retired rank '%s' to '%s'.", + admin.getName(), admin.getRankId(), seniorRankId()))) + .peek(admin -> admin.setRankId(seniorRankId())) + .count(); + + if (migrated > 0) { - Rank rank = admin.getRank(); - Rank newRank = null; - if (rank == Rank.SENIOR_CONSOLE) - { - newRank = Rank.SENIOR_ADMIN; - } - if (newRank != null) - { - admin.setRank(newRank); - migrated++; - FLog.info("Remapped admin '" + admin.getName() + "' from " + rank.name() + " to " + newRank.name()); - } + plugin.al.saveAsync(); + FLog.info(String.format("Remapped %d admin(s) from retired console ranks.", migrated)); + } + } + + /** + * Moves admins off the cosmetic ranks that are now titles. + *

+ * Developer, Owner and Executive were ranks only because the old ladder had no other way to + * show them: each had to be seated above Senior Admin to display, which handed its holder every + * permission underneath. They are titles now, so a holder keeps the recognition without the + * ladder position, and their actual authority drops to the senior rank they were really + * exercising. Without this pass their stored rank id would no longer resolve and the registry + * would quietly seat them at the default admin rank instead. + */ + public void convertCosmeticRankHolders() + { + if (plugin.al == null || plugin.rm == null || plugin.tm == null || plugin.pl == null) + { + return; } + final String senior = seniorRankId(); + if (senior == null) + { + return; + } + + final long migrated = plugin.al.getAllAdmins().values() + .stream() + .filter(admin -> admin.getRankId() != null) + .filter(admin -> plugin.rm.getCustomRank(admin.getRankId()) == null) + .filter(admin -> plugin.tm.hasTitle(admin.getRankId())) + .peek(admin -> grantTitleOffline(admin.getName(), admin.getRankId())) + .peek(admin -> FLog.info(String.format( + "Moved admin '%s' from retired rank '%s' to the '%s' title, rank '%s'.", + admin.getName(), admin.getRankId(), admin.getRankId(), senior))) + .peek(admin -> admin.setRankId(senior)) + .count(); + if (migrated > 0) { - plugin.al.save(); - FLog.info("Remapped " + migrated + " admin(s) from deprecated console ranks."); + plugin.al.saveAsync(); + FLog.info(String.format("Converted %d admin(s) from cosmetic ranks to titles.", migrated)); + } + } + + /** + * Records a title against a player profile without needing them online, since a migration runs + * at startup when nobody is. + */ + private void grantTitleOffline(final String username, final String titleId) + { + final PlayerData data = plugin.pl.getData(username); + + if (data != null && data.addTitle(titleId)) + { + plugin.pl.saveData(data); } } + /** + * The rank a senior admin should hold, resolved as the least privileged rank that is granted + * senior standing. Derived from {@code ranks.json} rather than named, so a renamed or + * defined senior rank is still found. + */ + private String seniorRankId() + { + return plugin.rm == null + ? null + : plugin.rm.getRegistry() + .requiredFor(AdminList.SENIOR_STATUS_NODE) + .map(CustomRank::getId) + .orElseGet(this::adminDefaultRankId); + } + + /** + * The rank a plain admin should hold, taken from whichever rank fills that role. + */ + private String adminDefaultRankId() + { + return plugin.rm == null + ? null + : plugin.rm.getRegistry() + .byRole(RankRole.ADMIN_DEFAULT) + .map(CustomRank::getId) + .orElse(null); + } + private void convertSuperadmins(File oldFile) { if (!oldFile.exists() || !oldFile.isFile()) @@ -221,22 +318,16 @@ private void convertSuperadmins(File oldFile) } String username = asec.getString("last_login_name"); - Rank rank; - if (asec.getBoolean("is_senior_admin")) - { - rank = Rank.SENIOR_ADMIN; - } - else - { - rank = Rank.SUPER_ADMIN; - } + final String rankId = asec.getBoolean("is_senior_admin") + ? seniorRankId() + : adminDefaultRankId(); List ips = asec.getStringList("ips"); String loginMessage = asec.getString("custom_login_message"); boolean active = asec.getBoolean("is_activated"); Admin admin = new Admin(username); admin.setName(username); - admin.setRank(rank); + admin.setRankId(rankId); admin.addIps(ips); admin.setLoginMessage(loginMessage); admin.setActive(active); @@ -244,15 +335,15 @@ private void convertSuperadmins(File oldFile) conversions.add(admin); } - File newYamlFile = new File(plugin.getDataFolder(), AdminList.CONFIG_FILENAME); - YamlConfiguration newYaml = YamlConfiguration.loadConfiguration(newYamlFile); + File newJsonFile = new File(plugin.getDataFolder(), AdminList.CONFIG_FILENAME); + Map converted = new HashMap<>(); for (Admin admin : conversions) { - admin.saveTo(newYaml.createSection(admin.getName().toLowerCase())); + converted.put(admin.getName().toLowerCase(), admin); } - try + try (FileWriter writer = new FileWriter(newJsonFile)) { - newYaml.save(newYamlFile); + JsonUtil.GSON.toJson(converted, new TypeToken>() {}.getType(), writer); } catch (IOException ex) { @@ -270,16 +361,26 @@ private void convertPermbans(File oldFile) return; } - try + final YamlConfiguration oldYaml = YamlConfiguration.loadConfiguration(oldFile); + final Map converted = new HashMap<>(); + for (String name : oldYaml.getKeys(false)) { - Files.copy(oldFile, new File(plugin.getDataFolder(), PermbanList.CONFIG_FILENAME)); - FLog.info("Converted permban list"); + final String lowerName = name.toLowerCase().trim(); + final PermBan permban = new PermBan(); + permban.setUsername(lowerName); + permban.setIps(oldYaml.getStringList(name)); + converted.put(lowerName, permban); + } + + try (FileWriter writer = new FileWriter(new File(plugin.getDataFolder(), PermbanList.CONFIG_FILENAME))) + { + JsonUtil.GSON.toJson(converted, new TypeToken>() {}.getType(), writer); + FLog.info("Converted " + converted.size() + " permbans"); } catch (IOException ex) { - FLog.warning("Could not copy old permban list!"); + FLog.warning("Could not save converted permban list!"); } - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/EntityWiper.java b/src/main/java/me/totalfreedom/totalfreedommod/EntityWiper.java index f34d3f11a..af2053529 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/EntityWiper.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/EntityWiper.java @@ -1,37 +1,20 @@ package me.totalfreedom.totalfreedommod; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; +import java.util.*; + import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.World; -import org.bukkit.entity.AreaEffectCloud; -import org.bukkit.entity.ArmorStand; -import org.bukkit.entity.Boat; -import org.bukkit.entity.EnderCrystal; -import org.bukkit.entity.EnderSignal; -import org.bukkit.entity.Entity; -import org.bukkit.entity.ExperienceOrb; -import org.bukkit.entity.Explosive; -import org.bukkit.entity.FallingBlock; -import org.bukkit.entity.Firework; -import org.bukkit.entity.Item; -import org.bukkit.entity.Minecart; -import org.bukkit.entity.Projectile; -import org.bukkit.entity.ThrownExpBottle; -import org.bukkit.entity.ThrownPotion; +import org.bukkit.entity.*; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.entity.ItemSpawnEvent; import org.bukkit.scheduler.BukkitTask; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class EntityWiper extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/FreedomService.java b/src/main/java/me/totalfreedom/totalfreedommod/FreedomService.java index 5e7eb7d69..5a2bf8437 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/FreedomService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/FreedomService.java @@ -1,8 +1,9 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.framework.AbstractService; import org.bukkit.event.Listener; +import me.totalfreedom.totalfreedommod.framework.AbstractService; + public abstract class FreedomService extends AbstractService implements Listener { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/Fuckoff.java b/src/main/java/me/totalfreedom/totalfreedommod/Fuckoff.java index b9c252cc6..7c83eaac1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/Fuckoff.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/Fuckoff.java @@ -2,7 +2,7 @@ import java.util.HashSet; import java.util.Set; -import me.totalfreedom.totalfreedommod.player.FPlayer; + import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -11,6 +11,8 @@ import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.util.Vector; +import me.totalfreedom.totalfreedommod.player.FPlayer; + public class Fuckoff extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/GameModeGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/GameModeGuard.java index f6ea7f786..3eb71aa50 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/GameModeGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/GameModeGuard.java @@ -3,16 +3,19 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerGameModeChangeEvent; import org.bukkit.event.player.PlayerQuitEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class GameModeGuard extends FreedomService { private final Map changeWindows = new ConcurrentHashMap<>(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/GameRuleHandler.java b/src/main/java/me/totalfreedom/totalfreedommod/GameRuleHandler.java index b9ae10acd..2203b9e9e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/GameRuleHandler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/GameRuleHandler.java @@ -1,12 +1,12 @@ package me.totalfreedom.totalfreedommod; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + import io.papermc.paper.event.world.WorldGameRuleChangeEvent; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.GameRule; import org.bukkit.Registry; @@ -19,11 +19,13 @@ import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.scheduler.BukkitTask; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; public class GameRuleHandler extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java b/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java index e372ceba9..41e79a35f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/JoinLeaveMessages.java @@ -1,15 +1,17 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class JoinLeaveMessages extends FreedomService { @@ -44,9 +46,14 @@ public void onPlayerQuit(final PlayerQuitEvent event) broadcast(player, " has left the game."); } + /** + * Whether this player's join and leave are worth announcing: staff, or anyone carrying an + * announceable title. Titles replaced the hardcoded developer list this used to consult. + */ private boolean isAdminOrDeveloper(final Player player) { - return plugin.al.isAdmin(player) || FUtil.DEVELOPERS.contains(player.getName()); + return plugin.al.isAdmin(player) + || (plugin.tm != null && plugin.tm.getDisplayTitle(player) != null); } /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/LoginProcess.java b/src/main/java/me/totalfreedom/totalfreedommod/LoginProcess.java index 357bad1fa..da9da17c4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/LoginProcess.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/LoginProcess.java @@ -3,26 +3,26 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; import java.util.regex.Pattern; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FSync; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; +import com.destroystokyo.paper.profile.PlayerProfile; +import org.bukkit.Location; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.AsyncPlayerPreLoginEvent; import org.bukkit.event.player.PlayerJoinEvent; -import com.destroystokyo.paper.profile.PlayerProfile; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; -import java.util.concurrent.ThreadLocalRandom; -import org.bukkit.Location; -import org.bukkit.World; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FSync; +import me.totalfreedom.totalfreedommod.util.FUtil; public class LoginProcess extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/MovementValidator.java b/src/main/java/me/totalfreedom/totalfreedommod/MovementValidator.java index 3e981280f..cd0eadbb2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/MovementValidator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/MovementValidator.java @@ -1,8 +1,7 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; +import io.papermc.paper.event.player.PlayerClientLoadedWorldEvent; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.WorldBorder; @@ -10,7 +9,9 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.world.WorldLoadEvent; -import io.papermc.paper.event.player.PlayerClientLoadedWorldEvent; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; public class MovementValidator extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/Muter.java b/src/main/java/me/totalfreedom/totalfreedommod/Muter.java index 301851286..aeb401299 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/Muter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/Muter.java @@ -1,21 +1,24 @@ package me.totalfreedom.totalfreedommod; import java.util.List; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FSync; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + +import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.command.Command; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; -import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.event.player.AsyncPlayerChatEvent; import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FSync; + public class Muter extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/Orbiter.java b/src/main/java/me/totalfreedom/totalfreedommod/Orbiter.java index fdf8d49ab..37f4a3e45 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/Orbiter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/Orbiter.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.player.FPlayer; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.util.Vector; +import me.totalfreedom.totalfreedommod.player.FPlayer; + public class Orbiter extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java index eb46f8493..220227f1c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/PotionSpy.java @@ -3,7 +3,6 @@ import java.util.HashMap; import java.util.Map; -import org.apache.commons.lang3.tuple.Pair; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.entity.Projectile; @@ -13,12 +12,15 @@ import org.bukkit.event.entity.ProjectileLaunchEvent; import org.bukkit.projectiles.ProjectileSource; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import org.apache.commons.lang3.tuple.Pair; + public class PotionSpy extends FreedomService { private static final long POTION_SPY_RESET_TIMEOUT = 60000L; @@ -61,6 +63,8 @@ public void onPotionThrown(ProjectileLaunchEvent e) if (source == null || !(source instanceof final Player thrower)) return; + final boolean throwerIsAdmin = plugin.al.isAdmin(thrower); + // Grab old data about the offender, if it exists final Pair offenderData = offenders.getOrDefault(thrower, Pair.of(0, System.currentTimeMillis())); final int amount = offenderData.getLeft() + 1; @@ -77,7 +81,7 @@ public void onPotionThrown(ProjectileLaunchEvent e) final PlayerData data = plugin.pl.getData(player); if (data == null) continue; - if (!data.isPotionSpy()) + if (!data.getPotionSpyMode().shows(throwerIsAdmin)) continue; // Issue the message along 3^n, so less messages occur over time diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java index 50dc07791..e6ea71529 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ProtectArea.java @@ -1,23 +1,9 @@ package me.totalfreedom.totalfreedommod; -import com.google.common.collect.Maps; - -import lombok.Getter; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.Serializable; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; +import java.io.*; +import java.lang.reflect.Type; +import java.util.*; +import java.util.stream.Collectors; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -25,51 +11,48 @@ import org.bukkit.block.Block; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Item; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Player; +import org.bukkit.entity.*; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; -import org.bukkit.event.block.BlockBreakEvent; -import org.bukkit.event.block.BlockBurnEvent; -import org.bukkit.event.block.BlockExplodeEvent; -import org.bukkit.event.block.BlockFadeEvent; -import org.bukkit.event.block.BlockFromToEvent; -import org.bukkit.event.block.BlockIgniteEvent; -import org.bukkit.event.block.BlockPistonExtendEvent; -import org.bukkit.event.block.BlockPistonRetractEvent; -import org.bukkit.event.block.BlockPlaceEvent; -import org.bukkit.event.block.BlockSpreadEvent; -import org.bukkit.event.block.SignChangeEvent; -import org.bukkit.event.entity.AreaEffectCloudApplyEvent; -import org.bukkit.event.entity.EntityChangeBlockEvent; -import org.bukkit.event.entity.EntityDamageEvent; -import org.bukkit.event.entity.EntityExplodeEvent; -import org.bukkit.event.entity.EntityPickupItemEvent; -import org.bukkit.event.entity.LingeringPotionSplashEvent; -import org.bukkit.event.entity.PotionSplashEvent; -import org.bukkit.event.hanging.HangingBreakByEntityEvent; -import org.bukkit.event.hanging.HangingPlaceEvent; +import org.bukkit.event.block.*; +import org.bukkit.event.entity.*; +import org.bukkit.event.hanging.*; import org.bukkit.event.inventory.InventoryPickupItemEvent; -import org.bukkit.event.player.PlayerBucketEmptyEvent; -import org.bukkit.event.player.PlayerBucketFillEvent; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.*; import org.bukkit.event.vehicle.VehicleDestroyEvent; import org.bukkit.scheduler.BukkitTask; import org.bukkit.util.Vector; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; +import me.totalfreedom.totalfreedommod.util.*; + +import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; + public class ProtectArea extends FreedomService { + private static final long ITEM_SWEEP_RATE = 40L; + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; + private static final Type PROTECTED_AREA_LIST_TYPE = new TypeToken>() {}.getType(); - public static final String DATA_FILENAME = "protectedareas.yml"; + public static final String DATA_FILENAME = "protectedareas.json"; + public static final String LEGACY_YAML_FILENAME = "protectedareas.yml"; public static final String LEGACY_DATA_FILENAME = "protectedareas.dat"; public static final double MAX_RADIUS = 50.0; - // How often (in ticks) to sweep loose items out of protected areas. - private static final long ITEM_SWEEP_RATE = 40L; - // + private final Map areas = Maps.newHashMap(); + + private final PersistenceQueue writes = new PersistenceQueue("protected area"); + + private File dataFile; + private boolean usingSql = false; private BukkitTask itemSweepTask; public ProtectArea(TotalFreedomMod plugin) @@ -81,34 +64,183 @@ public ProtectArea(TotalFreedomMod plugin) protected void onStart() { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) + return; + + dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); + + load(); + plugin.dm.whenReady(this::load); + + itemSweepTask = Bukkit.getScheduler().runTaskTimer( + plugin, FTask.guard("ProtectArea/sweepItems", this::sweepItems), ITEM_SWEEP_RATE, ITEM_SWEEP_RATE); + } + + /** + * Populate the area list from SQL where it is available and the JSON snapshot otherwise. + * Never blocks: the SQL read runs off-thread and is applied back on the main thread. + */ + public void load() + { + if (dataFile == null) + dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); + + if (plugin.dm != null && plugin.dm.isInitialized()) { + loadFromSqlAsync(); return; } - File ymlFile = new File(plugin.getDataFolder(), DATA_FILENAME); - File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); + loadFromJsonOrLegacy(); + } - if (legacyFile.exists() && !ymlFile.exists()) + private void loadFromSqlAsync() + { + final ProtectedAreaRepository repo = plugin.dm.getProtectedAreaRepository(); + plugin.dm.readAsync("ProtectArea/loadFromSql", repo.loadAllAsync(), + loaded -> applyLoadedAreas(repo, loaded), + () -> + { + usingSql = false; + loadFromJsonOrLegacy(); + }); + } + + private void applyLoadedAreas(final ProtectedAreaRepository repo, final List loaded) + { + usingSql = true; + + if (loaded.isEmpty() && !dataFile.exists()) { - migrateLegacyData(legacyFile, ymlFile); + final File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); + if (legacyFile.exists()) + migrateLegacyData(legacyFile); + + return; } - loadFromYaml(ymlFile); + areas.clear(); + loaded.forEach(region -> areas.put(region.getUuid(), region)); + FLog.info(String.format("Loaded %d protected area(s) from SQL database.", areas.size())); - itemSweepTask = Bukkit.getScheduler().runTaskTimer( - plugin, FTask.guard("ProtectArea/sweepItems", this::sweepItems), ITEM_SWEEP_RATE, ITEM_SWEEP_RATE); + reconcileFromJsonIfNewer(repo); + } + + private void loadFromJsonOrLegacy() + { + if (!dataFile.exists()) + { + File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); + if (legacyFile.exists()) + migrateLegacyData(legacyFile); + + return; + } + + loadFromJson(); + } + + private void loadFromJson() + { + areas.clear(); + try + { + readJsonAreas().forEach(region -> areas.put(region.getUuid(), region)); + } + catch (IOException ex) + { + FLog.severe("Failed to read " + DATA_FILENAME + ": " + ex.getMessage()); + } + FLog.info("Loaded " + areas.size() + " protected area(s)."); + } + + private List readJsonAreas() throws IOException + { + try (FileReader reader = new FileReader(dataFile)) + { + List loaded = JsonUtil.GSON.fromJson(reader, PROTECTED_AREA_LIST_TYPE); + return loaded != null ? loaded : new ArrayList<>(); + } + } + + /** + * If protectedareas.json was written more recently than the database's last update, re-import + * it into SQL. The comparison and the re-import both ride the write queue off the main thread. + */ + private void reconcileFromJsonIfNewer(final ProtectedAreaRepository repo) + { + if (!dataFile.exists()) + { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + writes.enqueue(writeJsonAsync()); + return; + } + + final List jsonAreas; + try + { + jsonAreas = readJsonAreas(); + } + catch (IOException ex) + { + FLog.warning(String.format("Failed to read %s: %s", DATA_FILENAME, ex.getMessage())); + return; + } + + if (jsonAreas.isEmpty()) + { + writes.enqueue(writeJsonAsync()); + return; + } + + final long fileModified = dataFile.lastModified(); + + writes.enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMap(ignored -> + { + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d protected area(s).", + DATA_FILENAME, jsonAreas.size())); + final Set keep = jsonAreas.stream() + .map(ProtectedRegion::getUuid) + .collect(Collectors.toSet()); + return Flux.fromIterable(jsonAreas) + .concatMap(repo::save) + .then(repo.loadAllAsync()) + .flatMapMany(Flux::fromIterable) + .map(ProtectedRegion::getUuid) + .filter(uuid -> !keep.contains(uuid)) + .concatMap(repo::deleteAsync) + .then(Mono.fromRunnable(() -> plugin.dm.sync("ProtectArea/applyReconciled", + () -> + { + areas.clear(); + jsonAreas.forEach(region -> areas.put(region.getUuid(), region)); + }))); + }) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + DATA_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); } @SuppressWarnings("unchecked") - private void migrateLegacyData(File legacyFile, File ymlFile) + private void migrateLegacyData(File legacyFile) { - FLog.info("Migrating protected areas from legacy .dat format to .yml format..."); + FLog.info("Migrating protected areas from legacy .dat format..."); try (FileInputStream fis = new FileInputStream(legacyFile); ObjectInputStream ois = new ObjectInputStream(fis)) { - HashMap legacyAreas = + HashMap legacyAreas = (HashMap) ois.readObject(); - + areas.clear(); for (Map.Entry entry : legacyAreas.entrySet()) { @@ -122,18 +254,15 @@ private void migrateLegacyData(File legacyFile, File ymlFile) legacy.worldUUID.toString() )); } - + save(); - + File oldFile = new File(legacyFile.getParent(), LEGACY_DATA_FILENAME + ".old"); if (legacyFile.renameTo(oldFile)) - { FLog.info("Migration complete. Legacy file renamed to " + LEGACY_DATA_FILENAME + ".old"); - } else - { FLog.warning("Migration complete but could not rename legacy file."); - } + } catch (Exception ex) { @@ -142,32 +271,27 @@ private void migrateLegacyData(File legacyFile, File ymlFile) } } + @Deprecated private void loadFromYaml(File file) { areas.clear(); - + if (!file.exists()) - { return; - } try { YamlConfiguration config = YamlConfiguration.loadConfiguration(file); ConfigurationSection areasSection = config.getConfigurationSection("areas"); - + if (areasSection == null) - { return; - } for (String id : areasSection.getKeys(false)) { ConfigurationSection areaSection = areasSection.getConfigurationSection(id); if (areaSection == null) - { continue; - } UUID uuid = UUID.fromString(id); String name = areaSection.getString("name"); @@ -204,41 +328,63 @@ protected void onStop() itemSweepTask.cancel(); itemSweepTask = null; } + + // Let the queue drain first, then flush: a queued write landing after the flush would + // restore a stale snapshot. save(); + awaitPendingWrites(SHUTDOWN_FLUSH_TIMEOUT_MS); } + /** + * Queue a write of every protected area to SQL, followed by a refresh of the + * protectedareas.json snapshot. Falls back to a JSON-only write when SQL is unavailable. + * Safe from a command handler: the SQL round trips run off the main thread. + */ public void save() { - try + if (!usingSql || plugin.dm == null || !plugin.dm.isInitialized()) { - YamlConfiguration config = new YamlConfiguration(); - ConfigurationSection areasSection = config.createSection("areas"); + writes.enqueue(writeJsonAsync()); + return; + } - for (Map.Entry entry : areas.entrySet()) - { - ConfigurationSection areaSection = areasSection.createSection(entry.getKey().toString()); - ProtectedRegion region = entry.getValue(); - - areaSection.set("name", region.getName()); - try - { - areaSection.set("min_x", region.getMinimumPoint().getBlockX()); - areaSection.set("min_y", region.getMinimumPoint().getBlockY()); - areaSection.set("min_z", region.getMinimumPoint().getBlockZ()); - areaSection.set("max_x", region.getMaximumPoint().getBlockX()); - areaSection.set("max_y", region.getMaximumPoint().getBlockY()); - areaSection.set("max_z", region.getMaximumPoint().getBlockZ()); - areaSection.set("world", region.getWorld().getUID().toString()); - } - catch (CantFindWorldException ex) - { - FLog.warning(String.format("Failed to save protected area '%s' (%s) because the UUID of the world it's in was invalid", - region.getName(), - region.getUuid())); - } - } + final ProtectedAreaRepository repo = plugin.dm.getProtectedAreaRepository(); + final List snapshot = new ArrayList<>(areas.values()); + + writes.enqueue(Flux.fromIterable(snapshot) + .concatMap(region -> repo.save(region) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save protected area %s to SQL: %s", + region.getName(), ex.getMessage())); + return Mono.empty(); + })) + .then(writeJsonAsync())); + } + + /** + * Wait for queued protected-area writes to land, up to {@code timeoutMs}. + */ + public void awaitPendingWrites(long timeoutMs) + { + writes.await(timeoutMs); + } - config.save(new File(plugin.getDataFolder(), DATA_FILENAME)); + private Mono writeJsonAsync() + { + final List snapshot = new ArrayList<>(areas.values()); + return Mono.fromRunnable(() -> writeJson(snapshot)) + .subscribeOn(Schedulers.boundedElastic()); + } + + private void writeJson(final List snapshot) + { + if (dataFile == null) + dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); + + try (FileWriter writer = new FileWriter(dataFile)) + { + JsonUtil.GSON.toJson(snapshot, PROTECTED_AREA_LIST_TYPE, writer); } catch (IOException ex) { @@ -251,83 +397,57 @@ public void save() public void onBlockBreak(BlockBreakEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (plugin.al.isAdmin(player)) - { return; - } final Location location = event.getBlock().getLocation(); if (isInProtectedArea(location)) - { event.setCancelled(true); - } } @EventHandler(priority = EventPriority.NORMAL) public void onBlockPlace(BlockPlaceEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (plugin.al.isAdmin(player)) - { return; - } final Location location = event.getBlock().getLocation(); if (isInProtectedArea(location)) - { event.setCancelled(true); - } } // Entity explosions (TNT, Creepers, Withers, etc.) @EventHandler(priority = EventPriority.NORMAL) public void onEntityExplode(EntityExplodeEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - event.blockList().removeIf(block -> isInProtectedArea(block.getLocation())); + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) + event.blockList().removeIf(block -> isInProtectedArea(block.getLocation())); } // Block explosions (beds in nether, respawn anchors) @EventHandler(priority = EventPriority.NORMAL) public void onBlockExplode(BlockExplodeEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - event.blockList().removeIf(block -> isInProtectedArea(block.getLocation())); + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) + event.blockList().removeIf(block -> isInProtectedArea(block.getLocation())); } // Enderman picking up blocks, falling blocks, etc. @EventHandler(priority = EventPriority.NORMAL) public void onEntityChangeBlock(EntityChangeBlockEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (isInProtectedArea(event.getBlock().getLocation())) - { + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + && isInProtectedArea(event.getBlock().getLocation())) event.setCancelled(true); - } } // Water/lava bucket placement @@ -335,20 +455,14 @@ public void onEntityChangeBlock(EntityChangeBlockEvent event) public void onBucketEmpty(PlayerBucketEmptyEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (plugin.al.isAdmin(player)) - { return; - } if (isInProtectedArea(event.getBlock().getLocation())) - { event.setCancelled(true); - } } // Water/lava bucket removal @@ -356,20 +470,14 @@ public void onBucketEmpty(PlayerBucketEmptyEvent event) public void onBucketFill(PlayerBucketFillEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (plugin.al.isAdmin(player)) - { return; - } if (isInProtectedArea(event.getBlock().getLocation())) - { event.setCancelled(true); - } } // Fire starting @@ -377,70 +485,43 @@ public void onBucketFill(PlayerBucketFillEvent event) public void onBlockIgnite(BlockIgniteEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (player != null && plugin.al.isAdmin(player)) - { return; - } if (isInProtectedArea(event.getBlock().getLocation())) - { event.setCancelled(true); - } } // Fire spread @EventHandler(priority = EventPriority.NORMAL) public void onBlockSpread(BlockSpreadEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - // Only block fire spread - if (event.getSource().getType() == org.bukkit.Material.FIRE) - { - if (isInProtectedArea(event.getBlock().getLocation())) - { - event.setCancelled(true); - } - } + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + && (event.getSource().getType() == org.bukkit.Material.FIRE) + && isInProtectedArea(event.getBlock().getLocation())) + event.setCancelled(true); } // Blocks burning @EventHandler(priority = EventPriority.NORMAL) public void onBlockBurn(BlockBurnEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (isInProtectedArea(event.getBlock().getLocation())) - { - event.setCancelled(true); - } + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + && isInProtectedArea(event.getBlock().getLocation())) + event.setCancelled(true); } // Water/lava flow @EventHandler(priority = EventPriority.NORMAL) public void onBlockFromTo(BlockFromToEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - // Check if liquid is flowing INTO a protected area from outside - if (!isInProtectedArea(event.getBlock().getLocation()) && isInProtectedArea(event.getToBlock().getLocation())) - { - event.setCancelled(true); - } + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + && !isInProtectedArea(event.getBlock().getLocation()) + && isInProtectedArea(event.getToBlock().getLocation())) + event.setCancelled(true); } // Piston extend @@ -448,9 +529,7 @@ public void onBlockFromTo(BlockFromToEvent event) public void onPistonExtend(BlockPistonExtendEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } for (Block block : event.getBlocks()) { @@ -467,18 +546,14 @@ public void onPistonExtend(BlockPistonExtendEvent event) public void onPistonRetract(BlockPistonRetractEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } for (Block block : event.getBlocks()) - { if (isInProtectedArea(block.getLocation())) { event.setCancelled(true); return; } - } } // Placing paintings, item frames, etc. @@ -486,20 +561,14 @@ public void onPistonRetract(BlockPistonRetractEvent event) public void onHangingPlace(HangingPlaceEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (player != null && plugin.al.isAdmin(player)) - { return; - } if (isInProtectedArea(event.getEntity().getLocation())) - { event.setCancelled(true); - } } // Breaking paintings, item frames by entity @@ -507,24 +576,18 @@ public void onHangingPlace(HangingPlaceEvent event) public void onHangingBreak(HangingBreakByEntityEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } Entity remover = event.getRemover(); if (remover instanceof Player) { Player player = (Player) remover; if (plugin.al.isAdmin(player)) - { return; - } } if (isInProtectedArea(event.getEntity().getLocation())) - { event.setCancelled(true); - } } // Vehicle destruction (minecarts, boats) @@ -532,39 +595,27 @@ public void onHangingBreak(HangingBreakByEntityEvent event) public void onVehicleDestroy(VehicleDestroyEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } Entity attacker = event.getAttacker(); if (attacker instanceof Player) { Player player = (Player) attacker; if (plugin.al.isAdmin(player)) - { return; - } } if (isInProtectedArea(event.getVehicle().getLocation())) - { event.setCancelled(true); - } } // Block fade (ice melting, snow melting, etc.) - protect structure integrity @EventHandler(priority = EventPriority.NORMAL) public void onBlockFade(BlockFadeEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (isInProtectedArea(event.getBlock().getLocation())) - { - event.setCancelled(true); - } + if (ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + && isInProtectedArea(event.getBlock().getLocation())) + event.setCancelled(true); } // Sign text editing (Minecraft 1.20+ allows editing signs after placement) @@ -572,20 +623,14 @@ public void onBlockFade(BlockFadeEvent event) public void onSignChange(SignChangeEvent event) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return; - } final Player player = event.getPlayer(); if (plugin.al.isAdmin(player)) - { return; - } if (isInProtectedArea(event.getBlock().getLocation())) - { event.setCancelled(true); - } } // Player interact (crop trampling, etc.) @@ -595,15 +640,11 @@ public void onPlayerInteract(PlayerInteractEvent event) final Player player = event.getPlayer(); Block block = event.getClickedBlock(); if (block == null) - { return; - } final Location location = block.getLocation(); if (!shouldBlockInteraction(player, location)) - { return; - } if (event.getAction() == org.bukkit.event.block.Action.PHYSICAL) { @@ -612,13 +653,9 @@ public void onPlayerInteract(PlayerInteractEvent event) } // block right-click interactions - if (event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK) - { - if (event.getItem() != null) - { - event.setCancelled(true); - } - } + if ((event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK) + && event.getItem() != null) + event.setCancelled(true); } @EventHandler(priority = EventPriority.NORMAL) @@ -628,137 +665,88 @@ public void onPlayerInteractEntity(PlayerInteractEntityEvent event) final Location location = event.getRightClicked().getLocation(); if (shouldBlockInteraction(player, location)) - { event.setCancelled(true); - } } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onPlayerDamage(EntityDamageEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_PROTECT_PLAYERS.getBoolean() + || !(event.getEntity() instanceof Player)) return; - } - - if (!ConfigEntry.PROTECTAREA_PROTECT_PLAYERS.getBoolean()) - { - return; - } - - if (!(event.getEntity() instanceof Player)) - { - return; - } if (isInProtectedArea(event.getEntity().getLocation())) - { event.setCancelled(true); - } } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onPotionSplash(PotionSplashEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (!ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) - { - return; - } + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) + return; for (LivingEntity affected : event.getAffectedEntities()) - { if (affected instanceof Player && isInProtectedArea(affected.getLocation())) - { event.setIntensity(affected, 0.0D); - } - } } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onLingeringPotionSplash(LingeringPotionSplashEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (!ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) - { - return; - } + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) + return; if (isInProtectedArea(event.getEntity().getLocation())) - { event.setCancelled(true); - } } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onAreaEffectCloudApply(AreaEffectCloudApplyEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { - return; - } - - if (!ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) - { - return; - } - - event.getAffectedEntities().removeIf( - entity -> entity instanceof Player && isInProtectedArea(entity.getLocation())); + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_BLOCK_POTIONS.getBoolean()) + return; + + event.getAffectedEntities() + .removeIf(entity -> + entity instanceof Player && isInProtectedArea(entity.getLocation()) + ); } @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onItemPickup(EntityPickupItemEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() || !ConfigEntry.PROTECTAREA_BLOCK_ITEMS.getBoolean()) - { - return; - } - - if (event.getEntity() instanceof Player player && plugin.al.isAdmin(player)) - { - return; - } + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_BLOCK_ITEMS.getBoolean() + || (event.getEntity() instanceof Player player + && plugin.al.isAdmin(player))) + return; if (isInProtectedArea(event.getItem().getLocation())) - { event.setCancelled(true); - } } @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onInventoryPickupItem(InventoryPickupItemEvent event) { - if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() || !ConfigEntry.PROTECTAREA_BLOCK_ITEMS.getBoolean()) - { - return; - } + if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean() + || !ConfigEntry.PROTECTAREA_BLOCK_ITEMS.getBoolean()) + return; if (isInProtectedArea(event.getItem().getLocation())) - { event.setCancelled(true); - } } private boolean shouldBlockInteraction(Player player, Location location) { if (!ConfigEntry.PROTECTAREA_ENABLED.getBoolean()) - { return false; - } if (player != null && plugin.al.isAdmin(player)) - { return false; - } return isInProtectedArea(location); } @@ -859,14 +847,12 @@ private void sweepItems() public static class ProtectedRegion { - @Getter private UUID uuid; - @Getter private String name; private Vector min; private Vector max; private UUID worldUUID; - private World world; + private transient World world; public ProtectedRegion(final UUID uuid, final String name, final Location min, final Location max, final World world) { @@ -904,6 +890,43 @@ public ProtectedRegion(final UUID uuid, this.max = new Vector(maxX, maxY, maxZ); } + public UUID getUuid() + { + return uuid; + } + + public String getName() + { + return name; + } + + /** + * Raw world reference, usable for persistence without requiring the world to + * currently be loaded in Bukkit (unlike {@link #getWorld()}). + */ + public UUID getWorldUUID() + { + return worldUUID; + } + + /** + * Raw minimum corner, usable for persistence without requiring the world to + * currently be loaded in Bukkit (unlike {@link #getMinimumPoint()}). + */ + public Vector getMinVector() + { + return min; + } + + /** + * Raw maximum corner, usable for persistence without requiring the world to + * currently be loaded in Bukkit (unlike {@link #getMaximumPoint()}). + */ + public Vector getMaxVector() + { + return max; + } + public World getWorld() throws CantFindWorldException { if (this.world != null) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java index 18d7a9077..54a439e67 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SavedFlags.java @@ -1,21 +1,45 @@ package me.totalfreedom.totalfreedommod; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.ObjectInputStream; +import java.io.*; +import java.lang.reflect.Type; +import java.util.Collections; import java.util.HashMap; import java.util.Map; -import me.totalfreedom.totalfreedommod.util.FLog; + import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import com.google.gson.reflect.TypeToken; + public class SavedFlags extends FreedomService { - public static final String DATA_FILENAME = "savedflags.yml"; + public static final String DATA_FILENAME = "savedflags.json"; + public static final String LEGACY_YAML_FILENAME = "savedflags.yml"; public static final String LEGACY_DATA_FILENAME = "savedflags.dat"; + private static final Type FLAGS_MAP_TYPE = new TypeToken>() {}.getType(); + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; + + private final PersistenceQueue writes = new PersistenceQueue("saved flag"); + + /** + * Authoritative in-memory view. Kept current so reads never touch JDBC on the calling thread. + */ + private final Map flags = new HashMap<>(); + + private boolean usingSql = false; + public SavedFlags(TotalFreedomMod plugin) { super(plugin); @@ -24,38 +48,57 @@ public SavedFlags(TotalFreedomMod plugin) @Override protected void onStart() { - File ymlFile = new File(plugin.getDataFolder(), DATA_FILENAME); - File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); + final File dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); + final File legacyFile = new File(plugin.getDataFolder(), LEGACY_DATA_FILENAME); - if (legacyFile.exists() && !ymlFile.exists()) + if (legacyFile.exists() && !dataFile.exists()) { - migrateLegacyData(legacyFile, ymlFile); + migrateLegacyData(legacyFile, dataFile); } + + flags.putAll(readJsonFlags(dataFile)); + plugin.dm.whenReady(this::load); } @Override protected void onStop() { + writes.await(SHUTDOWN_FLUSH_TIMEOUT_MS); + } + + /** + * Refresh the in-memory view from SQL, then reconcile the JSON snapshot back into it if the + * file is the newer of the two. Never blocks. + */ + public void load() + { + if (plugin.dm == null || !plugin.dm.isInitialized()) + return; + + final SavedFlagRepository repo = plugin.dm.getSavedFlagRepository(); + plugin.dm.readAsync("SavedFlags/loadFromSql", repo.loadAllAsync(), + loaded -> + { + usingSql = true; + flags.clear(); + flags.putAll(loaded); + reconcileFromJsonIfNewer(repo); + }, + () -> usingSql = false); } @SuppressWarnings("unchecked") - private void migrateLegacyData(File legacyFile, File ymlFile) + private void migrateLegacyData(File legacyFile, File dataFile) { - FLog.info("Migrating saved flags from legacy .dat format to .yml format..."); + FLog.info("Migrating saved flags from legacy .dat format..."); try (FileInputStream fis = new FileInputStream(legacyFile); ObjectInputStream ois = new ObjectInputStream(fis)) { - HashMap legacyFlags = (HashMap) ois.readObject(); - - YamlConfiguration config = new YamlConfiguration(); - ConfigurationSection flagsSection = config.createSection("flags"); + final HashMap legacyFlags = (HashMap) ois.readObject(); - for (Map.Entry entry : legacyFlags.entrySet()) - { - flagsSection.set(entry.getKey(), entry.getValue()); - } - - config.save(ymlFile); + // Writing the snapshot is enough: the database is still coming up, and the + // reconcile pass on ready folds this file into SQL because it is the newer of the two. + saveToJson(legacyFlags); File oldFile = new File(legacyFile.getParent(), LEGACY_DATA_FILENAME + ".old"); if (legacyFile.renameTo(oldFile)) @@ -74,11 +117,13 @@ private void migrateLegacyData(File legacyFile, File ymlFile) } } - public Map getSavedFlags() + /** + * Reads the pre-JSON {@code savedflags.yml} format. Retained only for the one-time + * legacy-install migration path (not called during normal startup). + */ + private Map loadLegacyYaml(File file) { Map flags = new HashMap<>(); - - File file = new File(PluginProvider.get().getDataFolder(), DATA_FILENAME); if (!file.exists()) { return flags; @@ -99,65 +144,149 @@ public Map getSavedFlags() } catch (Exception ex) { - FLog.severe("Failed to load saved flags: " + ex.getMessage()); + FLog.severe("Failed to load legacy saved flags: " + ex.getMessage()); FLog.severe(ex); } return flags; } - public boolean getSavedFlag(String flag) throws Exception + /** + * If savedflags.json was written more recently than the database's last update, re-import it + * into SQL. The comparison and the re-import both ride the write queue off the main thread. + */ + private void reconcileFromJsonIfNewer(final SavedFlagRepository repo) { - Boolean flagValue = null; - - Map flags = getSavedFlags(); - - if (flags != null) + final File dataFile = new File(plugin.getDataFolder(), DATA_FILENAME); + if (!dataFile.exists()) { - if (flags.containsKey(flag)) - { - flagValue = flags.get(flag); - } + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + writes.enqueue(writeJsonAsync()); + return; } - if (flagValue != null) + final Map jsonFlags = readJsonFlags(dataFile); + if (jsonFlags.isEmpty()) { - return flagValue; - } - else - { - throw new Exception(); + writes.enqueue(writeJsonAsync()); + return; } + + final long fileModified = dataFile.lastModified(); + + writes.enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMap(ignored -> + { + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d flag(s).", + DATA_FILENAME, jsonFlags.size())); + return repo.loadAllAsync() + .flatMapMany(existing -> Flux.fromIterable(jsonFlags.entrySet()) + .concatMap(entry -> repo.upsertAsync(entry.getKey(), entry.getValue())) + .thenMany(Flux.fromIterable(existing.keySet()) + .filter(flag -> !jsonFlags.containsKey(flag)) + .concatMap(repo::deleteAsync))) + .then(Mono.fromRunnable(() -> plugin.dm.sync("SavedFlags/applyReconciled", + () -> + { + flags.clear(); + flags.putAll(jsonFlags); + }))); + }) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + DATA_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); } - public void setSavedFlag(String flag, boolean value) + private Map readJsonFlags(File file) { - Map flags = getSavedFlags(); + Map flags = new HashMap<>(); + if (!file.exists()) + return flags; - if (flags == null) + try (FileReader reader = new FileReader(file)) { - flags = new HashMap<>(); + Map loaded = JsonUtil.GSON.fromJson(reader, FLAGS_MAP_TYPE); + if (loaded != null) + flags.putAll(loaded); } - - flags.put(flag, value); - - try + catch (Exception ex) { - YamlConfiguration config = new YamlConfiguration(); - ConfigurationSection flagsSection = config.createSection("flags"); + FLog.severe("Failed to load saved flags: " + ex.getMessage()); + FLog.severe(ex); + } - for (Map.Entry entry : flags.entrySet()) - { - flagsSection.set(entry.getKey(), entry.getValue()); - } + return flags; + } - config.save(new File(plugin.getDataFolder(), DATA_FILENAME)); + private void saveToJson(final Map snapshot) + { + final File file = new File(plugin.getDataFolder(), DATA_FILENAME); + try (FileWriter writer = new FileWriter(file)) + { + JsonUtil.GSON.toJson(snapshot, FLAGS_MAP_TYPE, writer); } catch (IOException ex) { - FLog.severe("Failed to save saved flags: " + ex.getMessage()); + FLog.severe(String.format("Failed to save saved flags: %s", ex.getMessage())); FLog.severe(ex); } } + private Mono writeJsonAsync() + { + final Map snapshot = new HashMap<>(flags); + return Mono.fromRunnable(() -> saveToJson(snapshot)) + .subscribeOn(Schedulers.boundedElastic()); + } + + /** + * The current flags, served from memory. Never touches JDBC on the calling thread. + */ + public Map getSavedFlags() + { + return Collections.unmodifiableMap(flags); + } + + public boolean getSavedFlag(String flag) throws Exception + { + final Boolean flagValue = flags.get(flag); + if (flagValue == null) + throw new Exception(); + + return flagValue; + } + + /** + * Set a flag in memory and queue the SQL write plus a refresh of the JSON snapshot. Safe + * from a command handler: the round trip runs off the main thread. + */ + public void setSavedFlag(String flag, boolean value) + { + flags.put(flag, value); + + if (!usingSql || plugin.dm == null || !plugin.dm.isInitialized()) + { + writes.enqueue(writeJsonAsync()); + return; + } + + writes.enqueue(plugin.dm.getSavedFlagRepository().upsertAsync(flag, value) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save flag '%s' to SQL: %s", flag, ex.getMessage())); + return Mono.empty(); + }) + .then(writeJsonAsync())); + } + } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ServerPing.java b/src/main/java/me/totalfreedom/totalfreedommod/ServerPing.java index c7f65eca6..4ab28ea6a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ServerPing.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ServerPing.java @@ -1,15 +1,17 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.server.ServerListPingEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class ServerPing extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java index 663dc542a..7ad8f59ee 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SignSpy.java @@ -1,27 +1,10 @@ package me.totalfreedom.totalfreedommod; import java.time.Duration; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.util.*; import io.papermc.paper.math.Position; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Displayable; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickCallback; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.DyeColor; -import org.bukkit.Location; -import org.bukkit.World; +import org.bukkit.*; import org.bukkit.block.Sign; import org.bukkit.block.TileState; import org.bukkit.block.data.BlockData; @@ -34,6 +17,18 @@ import org.bukkit.event.block.SignChangeEvent; import org.bukkit.event.player.PlayerQuitEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class SignSpy extends FreedomService { private static final int LINES_PER_SIDE = 4; @@ -141,8 +136,10 @@ public void onSignChange(SignChangeEvent event) final String context = bothSides ? (event.getSide() == Side.FRONT ? " (front)" : " (back)") : ""; + final boolean editorIsAdmin = plugin.al.isAdmin(editor); + Component message = Component.empty(); - if (plugin.al.isAdmin(editor)) + if (editorIsAdmin) { final Displayable display = plugin.rm.getDisplay(editor); String prefix = AdventureUtil.componentToPlainText(display.getColoredTag()).trim(); @@ -170,7 +167,7 @@ public void onSignChange(SignChangeEvent event) final String text = change.text(); final int room = Math.min(budget, MAX_LINE_CHARS); shown.add(text.length() > room - ? new LineChange(change.added(), text.substring(0, room)) : change); + ? new LineChange(change.added(), String.format("%s[...]", text.substring(0, room))) : change); budget -= Math.min(text.length(), room); } @@ -209,9 +206,10 @@ else if (bothSides) else { final SignSnapshot snapshot = snapshot(event, oldState); - message = message.append(Component.text(" ", NamedTextColor.GRAY)) - .append(viewButton("[View]", "Click to view the full sign", - snapshot, snapshot.side(), false)); + message = message.append(Component.text(" [", NamedTextColor.GRAY)) + .append(viewButton("View", "Click to view the full sign", + snapshot, snapshot.side(), false)) + .append(Component.text("]", NamedTextColor.GRAY)); } for (final Player admin : plugin.al.getOnlineAdmins()) @@ -221,7 +219,7 @@ else if (bothSides) continue; } final PlayerData data = plugin.pl.getData(admin); - if (data == null || !data.isSignSpy()) + if (data == null || !data.getSignSpyMode().shows(editorIsAdmin)) { continue; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SpawnManager.java b/src/main/java/me/totalfreedom/totalfreedommod/SpawnManager.java index 67f19b6ce..982475a9c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SpawnManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SpawnManager.java @@ -1,7 +1,5 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.World; @@ -11,6 +9,9 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerRespawnEvent; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + public class SpawnManager extends FreedomService { public SpawnManager(TotalFreedomMod plugin) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/SpectatorBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/SpectatorBlocker.java index 90dcf2130..8091c3c14 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/SpectatorBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/SpectatorBlocker.java @@ -1,23 +1,12 @@ package me.totalfreedom.totalfreedommod; -import com.destroystokyo.paper.event.player.PlayerStartSpectatingEntityEvent; -import com.github.retrooper.packetevents.PacketEvents; -import com.github.retrooper.packetevents.event.PacketListenerAbstract; -import com.github.retrooper.packetevents.event.PacketListenerCommon; -import com.github.retrooper.packetevents.event.PacketListenerPriority; -import com.github.retrooper.packetevents.event.PacketSendEvent; -import com.github.retrooper.packetevents.protocol.packettype.PacketType; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + +import com.destroystokyo.paper.event.player.PlayerStartSpectatingEntityEvent; import org.bukkit.GameMode; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; @@ -30,6 +19,21 @@ import org.bukkit.plugin.Plugin; import org.bukkit.scheduler.BukkitTask; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import com.github.retrooper.packetevents.PacketEvents; +import com.github.retrooper.packetevents.event.PacketListenerAbstract; +import com.github.retrooper.packetevents.event.PacketListenerCommon; +import com.github.retrooper.packetevents.event.PacketListenerPriority; +import com.github.retrooper.packetevents.event.PacketSendEvent; +import com.github.retrooper.packetevents.protocol.packettype.PacketType; +import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerPlayerInfoUpdate; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; + public class SpectatorBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java index a23cffdd8..1510e771e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TFMLibraryLoader.java @@ -3,6 +3,7 @@ import io.papermc.paper.plugin.loader.PluginClasspathBuilder; import io.papermc.paper.plugin.loader.PluginLoader; import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver; + import org.eclipse.aether.artifact.DefaultArtifact; import org.eclipse.aether.graph.Dependency; import org.eclipse.aether.repository.RemoteRepository; @@ -16,24 +17,30 @@ public class TFMLibraryLoader implements PluginLoader { private static final String[] LIBRARIES = { - "net.dv8tion:JDA:5.6.1", + "com.discord4j:discord4j-core:3.3.2", "org.apache.sshd:sshd-core:2.17.1", - "net.i2p.crypto:eddsa:0.3.0" + "net.i2p.crypto:eddsa:0.3.0", + "org.postgresql:postgresql:42.7.7", + "org.xerial:sqlite-jdbc:3.49.1.0", + "com.mysql:mysql-connector-j:9.3.0", + "io.projectreactor:reactor-core:3.8.3", + "com.zaxxer:HikariCP:6.3.0" }; @Override public void classloader(@NotNull PluginClasspathBuilder classpathBuilder) { MavenLibraryResolver resolver = new MavenLibraryResolver(); + for (String coordinates : LIBRARIES) - { resolver.addDependency(new Dependency(new DefaultArtifact(coordinates), null)); - } + resolver.addRepository(new RemoteRepository.Builder( - "central", - "default", - MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR - ).build()); + "central", + "default", + MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR) + .build()); + classpathBuilder.addLibrary(resolver); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java b/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java index 770f2ccbc..1c1e753dc 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TextFilterService.java @@ -1,22 +1,28 @@ package me.totalfreedom.totalfreedommod; -import io.papermc.paper.event.player.AsyncChatEvent; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import org.bukkit.event.block.SignChangeEvent; import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerEditBookEvent; +import org.bukkit.inventory.meta.BookMeta; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.banning.Ban; +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; public class TextFilterService extends FreedomService { @@ -42,16 +48,14 @@ protected void onStop() @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) public void onAsyncChat(AsyncChatEvent event) { - if (!shouldFilter()) + if (isFilterDisabled()) { return; } final String message = MessageUtils.toPlainText(event.message()); if (!matchesFilter(message)) - { return; - } event.setCancelled(true); Bukkit.getScheduler().runTask(plugin, () -> temporarilyBan(event.getPlayer())); @@ -60,16 +64,64 @@ public void onAsyncChat(AsyncChatEvent event) @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) public void onPlayerCommandPreprocess(PlayerCommandPreprocessEvent event) { - if (!shouldFilter()) - { + if (isFilterDisabled()) return; - } if (!matchesFilter(event.getMessage())) + return; + + event.setCancelled(true); + temporarilyBan(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onSignEdit(SignChangeEvent event) + { + if (isFilterDisabled()) + return; + + final StringBuilder builder = new StringBuilder(); + + for (int i = 0; i < event.lines().size(); i++) { + if (!builder.isEmpty()) + { + builder.append(Component.newline()); + } + builder.append(event.line(i)); + } + + if (!matchesFilter(builder.toString())) return; + + event.setCancelled(true); + temporarilyBan(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onBookEdit(PlayerEditBookEvent event) + { + final BookMeta meta = event.getNewBookMeta(); + final StringBuilder builder = new StringBuilder(); + + if (isFilterDisabled()) + return; + + if (meta.hasTitle()) + { + builder.append(MessageUtils.toPlainText(meta.title())); + } + + for (Component page : meta.pages()) + { + if (!builder.isEmpty()) + builder.append(Component.newline()); + builder.append(MessageUtils.toPlainText(page)); } + if (!matchesFilter(builder.toString())) + return; + event.setCancelled(true); temporarilyBan(event.getPlayer()); } @@ -98,9 +150,9 @@ private void reloadFilters() FLog.info("Loaded " + filters.size() + " text filter regex pattern(s)."); } - private boolean shouldFilter() + private boolean isFilterDisabled() { - return ConfigEntry.TEXT_FILTER_ENABLED.getBoolean(true) && !filters.isEmpty(); + return !ConfigEntry.TEXT_FILTER_ENABLED.getBoolean(true) || filters.isEmpty(); } private boolean matchesFilter(String text) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java index a86880807..e3d96426d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/TotalFreedomMod.java @@ -1,32 +1,25 @@ package me.totalfreedom.totalfreedommod; -import me.totalfreedom.totalfreedommod.fun.Trailer; -import me.totalfreedom.totalfreedommod.tablist.TabList; -import me.totalfreedom.totalfreedommod.world.CleanroomChunkGenerator; import java.io.File; import java.io.InputStream; import java.util.Properties; + +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.plugin.java.JavaPlugin; + import me.totalfreedom.totalfreedommod.admin.AdminList; import me.totalfreedom.totalfreedommod.banning.BanManager; import me.totalfreedom.totalfreedommod.banning.PermbanList; import me.totalfreedom.totalfreedommod.banning.StrikeList; -import me.totalfreedom.totalfreedommod.blocking.BlockBlocker; -import me.totalfreedom.totalfreedommod.blocking.EventBlocker; -import me.totalfreedom.totalfreedommod.blocking.InteractBlocker; -import me.totalfreedom.totalfreedommod.blocking.MobBlocker; -import me.totalfreedom.totalfreedommod.blocking.PotionBlocker; +import me.totalfreedom.totalfreedommod.blocking.*; import me.totalfreedom.totalfreedommod.blocking.command.CommandBlocker; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepScheduler; -import me.totalfreedom.totalfreedommod.blocking.entity.EntityNameValidator; -import me.totalfreedom.totalfreedommod.blocking.entity.EntitySizeGuard; -import me.totalfreedom.totalfreedommod.blocking.entity.TextDisplayGuard; -import me.totalfreedom.totalfreedommod.blocking.entity.ProjectileGuard; -import me.totalfreedom.totalfreedommod.blocking.entity.WaypointGuard; +import me.totalfreedom.totalfreedommod.blocking.entity.*; import me.totalfreedom.totalfreedommod.blocking.item.ConsoleSpamFilter; -import me.totalfreedom.totalfreedommod.blocking.packet.CrashPacketService; import me.totalfreedom.totalfreedommod.blocking.item.ItemValidator; +import me.totalfreedom.totalfreedommod.blocking.packet.CrashPacketService; import me.totalfreedom.totalfreedommod.blocking.sign.SignValidator; import me.totalfreedom.totalfreedommod.blocking.spawner.SpawnerValidator; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepScheduler; import me.totalfreedom.totalfreedommod.bridge.CoreProtectBridge; import me.totalfreedom.totalfreedommod.bridge.EssentialsBridge; import me.totalfreedom.totalfreedommod.bridge.LibsDisguisesBridge; @@ -36,26 +29,23 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.config.MainConfig; import me.totalfreedom.totalfreedommod.discord.DiscordBridge; +import me.totalfreedom.totalfreedommod.framework.ServiceManager; import me.totalfreedom.totalfreedommod.freeze.Freezer; -import me.totalfreedom.totalfreedommod.fun.ItemFun; -import me.totalfreedom.totalfreedommod.fun.Jumppads; -import me.totalfreedom.totalfreedommod.fun.Landminer; -import me.totalfreedom.totalfreedommod.fun.MP44; +import me.totalfreedom.totalfreedommod.fun.*; import me.totalfreedom.totalfreedommod.httpd.HTTPDaemon; -import me.totalfreedom.totalfreedommod.ssh.SshDaemon; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.player.PlayerList; import me.totalfreedom.totalfreedommod.rank.ConsoleSenderRegistry; import me.totalfreedom.totalfreedommod.rank.RankManager; import me.totalfreedom.totalfreedommod.sql.FreedomDatabase; -import me.totalfreedom.totalfreedommod.sql.YamlMigrationService; +import me.totalfreedom.totalfreedommod.ssh.SshDaemon; +import me.totalfreedom.totalfreedommod.tablist.TabList; +import me.totalfreedom.totalfreedommod.title.TitleManager; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; import me.totalfreedom.totalfreedommod.util.MethodTimer; -import me.totalfreedom.totalfreedommod.framework.ServiceManager; +import me.totalfreedom.totalfreedommod.world.CleanroomChunkGenerator; import me.totalfreedom.totalfreedommod.world.WorldManager; -import org.bukkit.generator.ChunkGenerator; -import org.bukkit.plugin.java.JavaPlugin; public class TotalFreedomMod extends JavaPlugin { @@ -76,8 +66,8 @@ public class TotalFreedomMod extends JavaPlugin public WorldManager wm; // WorldManager - Manages world operations public AdminList al; // AdminList - Manages admin list and permissions public RankManager rm; // RankManager - Handles player ranks and display + public TitleManager tm; // TitleManager - Flat, non-inheriting capability grants shown alongside ranks public ConsoleSenderRegistry csr; // ConsoleSenderRegistry - Maps console senders to appropriate rank - // public CommandLoader cl; // CommandLoader - Loads and registers commands (LEGACY) public CommandLoader cmdl; // CmdLoader - Loads and registers Brigadier commands public CommandBlocker cb; // CommandBlocker - Blocks specific commands public SweepScheduler sweepScheduler; // SweepScheduler - Shared budgeted world/chunk sweep walker @@ -107,6 +97,7 @@ public class TotalFreedomMod extends JavaPlugin public CommandSpy cs; // CommandSpy - Logs and monitors command usage public PotionSpy ps; // PotionSpy - Logs and monitors potion usage public SignSpy ss; // SignSpy - Logs and monitors sign edits + public BookSpy bs; // BookSpy - Logs and monitors book edits public Cager ca; // Cager - Creates cages around players public Freezer fm; // Freezer - Freezes players in place public Orbiter or; // Orbiter - Makes players orbit around a point @@ -181,20 +172,23 @@ public void onEnable() // Start services services = new ServiceManager<>(this); - sf = services.registerService(SavedFlags.class); - - // Initialize database manager first (before services that depend on it) + + // Initialize database manager first (before services that depend on it). Services stop + // in reverse registration order, so everything registered after this also flushes its + // pending writes before the connection pool closes. dm = services.registerService(FreedomDatabase.class); - + + sf = services.registerService(SavedFlags.class); wm = services.registerService(WorldManager.class); al = services.registerService(AdminList.class); configConverter.convertAdminConsoleRanks(); - // Run YAML to SQL migrations after database and admin list are ready - runYamlMigrations(); - rm = services.registerService(RankManager.class); + tm = services.registerService(TitleManager.class); + + // Runs after both registries exist: it reads ranks and titles to decide what to move. + configConverter.convertCosmeticRankHolders(); // Console sender whitelist. This first read only resolves bindings that name a legacy // rank, since registerService constructs RankManager without starting it and no custom @@ -244,6 +238,7 @@ public void onEnable() cs = services.registerService(CommandSpy.class); ps = services.registerService(PotionSpy.class); ss = services.registerService(SignSpy.class); + bs = services.registerService(BookSpy.class); ca = services.registerService(Cager.class); fm = services.registerService(Freezer.class); or = services.registerService(Orbiter.class); @@ -377,33 +372,4 @@ public String formattedVersion() } } - /** - * Run YAML to SQL migrations for admins, bans, and permbans. - * This converts existing YAML files to the new SQL database format. - */ - private void runYamlMigrations() - { - if (dm == null || !dm.isInitialized()) - { - FLog.info("Database not initialized, skipping YAML migrations"); - return; - } - - try - { - YamlMigrationService migrationService = new YamlMigrationService(this, dm); - migrationService.runMigrations().join(); - - // Reload admin list after migration to pick up SQL data - if (al != null) - { - al.load(); - } - } - catch (Exception ex) - { - FLog.warning("Error during YAML migrations: " + ex.getMessage()); - } - } - } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java index 9eb789451..e256e3456 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/Admin.java @@ -1,27 +1,33 @@ package me.totalfreedom.totalfreedommod.admin; -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; import java.util.Date; import java.util.List; import java.util.UUID; -import me.totalfreedom.totalfreedommod.rank.Rank; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigLoadable; -import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigSavable; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.Validatable; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.entity.Player; -public class Admin implements ConfigLoadable, ConfigSavable, Validatable +import com.google.common.collect.Lists; + +public class Admin implements ConfigLoadable, Validatable { private UUID uuid; private String configKey; private String name; private boolean active = true; - private Rank rank = Rank.SUPER_ADMIN; - private String customRankId = null; + + /** + * The id of the rank this admin holds, resolved against {@code ranks.json}. Stored as an id + * rather than as a fixed tier so that defined ranks are first-class: an admin may hold + * any rank the registry knows, not just one of a handful the plugin ships with. + */ + private String rankId = null; + private final List ips = Lists.newArrayList(); private Date lastLogin = new Date(); private String loginMessage = null; @@ -48,8 +54,7 @@ public String toString() .append("- IPs: ").append(String.join(", ", ips)).append("\n") .append("- Last Login: ").append(FUtil.dateToString(lastLogin)).append("\n") .append("- Custom Login Message: ").append(loginMessage).append("\n") - .append("- Rank: ").append(rank.getName()).append("\n") - .append("- Custom Rank: ").append(customRankId != null ? customRankId : "none").append("\n") + .append("- Rank: ").append(rankId).append("\n") .append("- Is Active: ").append(active); return output.toString(); @@ -68,31 +73,30 @@ public void loadFrom(ConfigurationSection cs) { name = cs.getString("username", configKey); active = cs.getBoolean("active", true); - rank = Rank.findRank(cs.getString("rank")); + rankId = normaliseRankId(cs.getString("custom_rank"), cs.getString("rank")); ips.clear(); ips.addAll(cs.getStringList("ips")); lastLogin = FUtil.stringToDate(cs.getString("last_login")); loginMessage = cs.getString("login_message", null); - customRankId = cs.getString("custom_rank", null); } - @Override - public void saveTo(ConfigurationSection cs) + /** + * Folds the two rank fields records used to carry into the single id used now. + *

+ * {@code custom_rank} was the assigned rank and took precedence over {@code rank}, + * which held one of the fixed tiers, so it is preferred here too. A tier name is lowercased to + * become an id, which is the convention the shipped {@code ranks.json} follows. + */ + private static String normaliseRankId(final String customRank, final String legacyRank) { - Preconditions.checkArgument(isValid(), "Could not save admin entry: " + name + ". Entry not valid!"); - cs.set("username", name); - cs.set("active", active); - cs.set("rank", rank.toString()); - cs.set("ips", Lists.newArrayList(ips)); - cs.set("last_login", FUtil.dateToString(lastLogin)); - cs.set("login_message", loginMessage); - cs.set("custom_rank", customRankId); - } + if (customRank != null && !customRank.isBlank()) + return customRank.toLowerCase(); - public boolean isAtLeast(Rank pRank) - { - return rank.isAtLeast(pRank); + if (legacyRank != null && !legacyRank.isBlank()) + return legacyRank.toLowerCase(); + + return null; } public boolean hasLoginMessage() @@ -141,19 +145,14 @@ public void setUuid(UUID uuid) this.uuid = uuid; } - public Rank getRank() - { - return rank; - } - - public String getCustomRankId() + public String getRankId() { - return customRankId; + return rankId; } - public void setCustomRankId(String customRankId) + public void setRankId(String rankId) { - this.customRankId = customRankId; + this.rankId = rankId == null ? null : rankId.toLowerCase(); } public String getName() @@ -196,11 +195,6 @@ public String getLoginMessage() return loginMessage; } - public void setRank(Rank rank) - { - this.rank = rank; - } - public void setLoginMessage(String loginMessage) { this.loginMessage = loginMessage; @@ -214,9 +208,10 @@ public void setActive(boolean active) @Override public boolean isValid() { + // rankId is deliberately not required: an unset id means "whatever fills the admin-default + // role", which the registry resolves, so an older record without one is still valid. return configKey != null && name != null - && rank != null && !ips.isEmpty() && lastLogin != null; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java index 8631ed127..2b99c7a49 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/admin/AdminList.java @@ -1,32 +1,23 @@ package me.totalfreedom.totalfreedommod.admin; -import com.google.common.base.Function; -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.time.Duration; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; -import java.nio.charset.StandardCharsets; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; + import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -34,10 +25,39 @@ import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.plugin.ServicePriority; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.rank.RankRole; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +import com.google.common.base.Function; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.google.gson.reflect.TypeToken; + public class AdminList extends FreedomService { - public static final String CONFIG_FILENAME = "admins.yml"; + public static final String CONFIG_FILENAME = "admins.json"; + + /** + * The node that marks a rank as senior. Senior standing is a capability granted by + * {@code ranks.json} rather than a fixed tier, so a defined rank can hold it and a + * rename or re-tier of the shipped ranks does not strand this check. + */ + public static final String SENIOR_STATUS_NODE = "tfm.admin.senior.status"; + + private static final Type ADMIN_MAP_TYPE = new TypeToken>() {}.getType(); private static final long LAST_LOGIN_DEBOUNCE_MS = 5L * 60L * 1000L; @@ -46,7 +66,7 @@ public class AdminList extends FreedomService private final Map allAdmins = Maps.newHashMap(); // Includes disabled admins // Only active admins below private final Set activeAdmins = Sets.newHashSet(); - + // UUID-based lookup table private final Map uuidTable = Maps.newHashMap(); private final Map nameTable = Maps.newHashMap(); @@ -54,26 +74,25 @@ public class AdminList extends FreedomService private final Set onlineAdminPlayers = Sets.newHashSet(); // private final File configFile; - private YamlConfiguration config; - + + // Serialises every queued write so a stale snapshot can never land after a newer one. + private final PersistenceQueue writes = new PersistenceQueue("admin"); + // Flag to track if SQL is available private boolean usingSql = false; - private final Object persistenceLock = new Object(); - private final Object fileLock = new Object(); - private CompletableFuture persistenceChain = CompletableFuture.completedFuture(null); public AdminList(TotalFreedomMod plugin) { super(plugin); this.configFile = new File(plugin.getDataFolder(), CONFIG_FILENAME); - this.config = YamlConfiguration.loadConfiguration(configFile); } @Override protected void onStart() { load(); + plugin.dm.whenReady(this::load); server.getServicesManager().register(Function.class, new Function() { @@ -96,182 +115,25 @@ protected void onStop() save(); } - public void load() - { - // Try to load from SQL database first - if (plugin.dm != null && plugin.dm.isInitialized()) - { - loadFromSql(); - } - else - { - loadFromYaml(); - } - - if (ConfigEntry.ADMINLIST_USE_UUID_ONLY.getBoolean()) - { - getMissingUuids(); - } - } - /** - * Best-effort UUID backfill for admin records loaded without a stored UUID. + * Populate the list from SQL where it is available and the JSON snapshot otherwise. Never + * blocks: the SQL read runs off-thread and its result is applied back on the main thread, + * so this is safe from a command handler as well as from startup. */ - private void getMissingUuids() + public void load() { - int resolved = 0; - int offlineDerived = 0; - boolean mojangLookup = ConfigEntry.ADMINLIST_MOJANG_UUID_LOOKUP.getBoolean(); - final List backfilled = new ArrayList<>(); - - for (Admin admin : allAdmins.values()) - { - if (admin.getUuid() != null) - { - continue; - } - - UUID uuid = FUtil.usernameToUuid(admin.getName()); - if (uuid != null) - { - resolved++; - } - else - { - uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + admin.getName().toLowerCase()).getBytes(StandardCharsets.UTF_8)); - offlineDerived++; - } - admin.setUuid(uuid); - backfilled.add(admin); - } - - if (backfilled.isEmpty()) + if (plugin.dm != null && plugin.dm.isInitialized()) { + loadFromSqlAsync(); return; } - FLog.info("UUID backfill: " + resolved + " resolved via Mojang, " + offlineDerived + " offline-derived"); - if (offlineDerived > 0 && !mojangLookup) - { - FLog.warning("use_uuid_only is enabled but mojang_uuid_lookup is disabled; " - + offlineDerived + " admin record(s) fell back to offline-derived UUIDs and " - + "will not match premium accounts on login"); - } - - updateTables(); - - // SQL can persist just the rows we touched; YAML is a whole-file format - // so one bulk write beats N rewrites of the same file. - if (usingSql) - { - backfilled.forEach(this::saveAdminAsync); - } - else - { - saveAsync(); - } - } - - /** - * Load admins from SQL database. - */ - private void loadFromSql() - { - try - { - AdminRepository repo = plugin.dm.getAdminRepository(); - List admins = repo.findAll().join(); - - allAdmins.clear(); - for (Admin admin : admins) - { - String key = admin.getName().toLowerCase(); - admin = fixConfigKey(admin, key); - allAdmins.put(key, admin); - } - - usingSql = true; - updateTables(); - FLog.info("Loaded " + allAdmins.size() + " admins from SQL database (" + nameTable.size() + " active, " + ipTable.size() + " IPs)"); - } - catch (Exception ex) - { - FLog.warning("Failed to load admins from SQL, falling back to YAML: " + ex.getMessage()); - loadFromYaml(); - } - } - - /** - * Fix the config key on an admin (needed when loading from SQL). - */ - private Admin fixConfigKey(Admin admin, String key) - { - // Use reflection or create new admin to set configKey - // Since configKey is private with no setter, we need to recreate - if (admin.getConfigKey() == null || !admin.getConfigKey().equals(key)) - { - Admin fixed = new Admin(key); - fixed.setUuid(admin.getUuid()); - fixed.setName(admin.getName()); - fixed.setRank(admin.getRank()); - fixed.setActive(admin.isActive()); - fixed.setLastLogin(admin.getLastLogin()); - fixed.setLoginMessage(admin.getLoginMessage()); - fixed.setCustomRankId(admin.getCustomRankId()); - fixed.addIps(admin.getIps()); - return fixed; - } - return admin; - } - - /** - * Load admins from YAML file (fallback). - */ - private void loadFromYaml() - { - if (!configFile.exists()) - { - try - { - configFile.getParentFile().mkdirs(); - configFile.createNewFile(); - } - catch (IOException ex) - { - FLog.severe("Could not create " + CONFIG_FILENAME); - } - } - config = YamlConfiguration.loadConfiguration(configFile); - - allAdmins.clear(); - for (String key : config.getKeys(false)) - { - ConfigurationSection section = config.getConfigurationSection(key); - if (section == null) - { - FLog.warning("Invalid admin list format: " + key); - continue; - } - - Admin admin = new Admin(key); - admin.loadFrom(section); - - if (!admin.isValid()) - { - FLog.warning("Could not load admin: " + key + ". Missing details!"); - continue; - } - - allAdmins.put(key, admin); - } - - usingSql = false; - updateTables(); - FLog.info("Loaded " + allAdmins.size() + " admins from YAML (" + nameTable.size() + " active, " + ipTable.size() + " IPs)"); + loadFromJson(); + backfillUuidsIfEnabled(); } /** - * Blocking write of every admin record. This is the shutdown flush - it is + * Blocking write of every admin record. This is the shutdown flush which is * called from {@link #onStop()} so nothing is lost when the server stops. *

* Do not call this from a command or event handler: under SQL it is a @@ -287,75 +149,55 @@ public synchronized void save() } else { - saveToYaml(); + saveToJson(); } } /** - * Wait for queued {@link #saveAdminAsync(Admin)} writes to land, up to - * {@code timeoutMs}. Without this a shutdown flush can race the queue and - * let an older queued snapshot overwrite the state we just wrote. + * Wait for queued {@link #saveAdminAsync(Admin)} writes to land, up to {@code timeoutMs}. */ public void awaitPendingWrites(long timeoutMs) { - final CompletableFuture pending; - synchronized (persistenceLock) - { - pending = persistenceChain; - } - - try - { - pending.get(timeoutMs, TimeUnit.MILLISECONDS); - } - catch (TimeoutException ex) - { - FLog.warning("Timed out after " + timeoutMs + "ms waiting for pending admin writes; flushing anyway"); - } - catch (InterruptedException ex) - { - Thread.currentThread().interrupt(); - } - catch (Exception ex) - { - FLog.warning("A queued admin write failed before shutdown: " + ex.getMessage()); - } + writes.await(timeoutMs); } /** - * Persist every admin record on a worker thread. Under SQL this is a - * fan-out: one queued write per admin, whether or not it changed. + * Persist every admin record off the main thread. Under SQL the whole + * list goes out as one queued batch, written serially so the connection pool + * is not swamped. *

- * Only call this when the whole list is genuinely dirty, or when running on - * YAML (a whole-file format that cannot be written piecemeal). If a single - * entry changed - a login, an IP edit, a rank change - call - * {@link #saveAdminAsync(Admin)} instead, which queues just that row. + * Only call this when the whole list is genuinely dirty. If only a single entry + * changed, call {@link #saveAdminAsync(Admin)} instead, which queues just that row. */ public void saveAsync() { if (usingSql) { - for (Admin admin : List.copyOf(allAdmins.values())) - { - saveAdminAsync(admin); - } + queueSqlWrites(allAdmins.values() + .stream() + .map(this::pendingWrite) + .toList()); return; } // Render on this thread while we still own the maps, then hand the // finished text to the worker. Serialising inside the async task would // read allAdmins off-thread while the main thread is free to mutate it. - final String data = serialiseAdmins(); + final String json = serialiseAdmins(); if (!plugin.isEnabled()) { - writeYaml(data); + writeJson(json); return; } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> writeYaml(data)); + enqueue(writeJsonAsync(json)); } + /** + * Queue a single admin row for an off-thread write, followed by a refresh of + * the JSON snapshot. Safe to call from commands and event handlers. + */ public void saveAdminAsync(Admin admin) { if (admin == null) @@ -369,261 +211,81 @@ public void saveAdminAsync(Admin admin) return; } - if (plugin.dm == null || !plugin.dm.isInitialized()) - { - FLog.warning("SQL not available; admin change was not saved for " + admin.getName()); - return; - } - - final Admin snapshot = copyAdmin(admin); - - synchronized (persistenceLock) - { - persistenceChain = persistenceChain - .handle((ignored, throwable) -> null) - // Async: resolveUuid may hit Mojang. A plain thenCompose would - // run it on this thread whenever the chain is already complete. - .thenComposeAsync(ignored -> resolveUuid(admin, snapshot)) - .thenCompose(uuid -> plugin.dm.getAdminRepository().save(uuid, snapshot).thenAccept(id -> - { - })) - .exceptionally(ex -> - { - FLog.warning("Failed to save admin " + snapshot.getName() + " to SQL: " + ex.getMessage()); - return null; - }); - } + queueSqlWrites(List.of(pendingWrite(admin))); } - /** - * Resolve the UUID for a queued write. Runs on the persistence chain rather - * than the caller, because {@link FUtil#usernameToUuid} can make a blocking - * Mojang request with a 5s connect and 5s read timeout - that must never - * land on the main thread during play. The resolved value is handed back to - * the live entry on the main thread, which owns the lookup tables. - */ - private CompletableFuture resolveUuid(Admin live, Admin snapshot) + public synchronized boolean isAdminSync(CommandSender sender) { - if (snapshot.getUuid() != null) - { - return CompletableFuture.completedFuture(snapshot.getUuid()); - } + return isAdmin(sender); + } - UUID resolved = FUtil.usernameToUuid(snapshot.getName()); - if (resolved == null) + public boolean isAdmin(CommandSender sender) + { + if (!(sender instanceof Player)) { - resolved = UUID.nameUUIDFromBytes(("OfflinePlayer:" + snapshot.getName().toLowerCase()).getBytes(StandardCharsets.UTF_8)); + return true; } - snapshot.setUuid(resolved); - - final UUID finalResolved = resolved; - try - { - if (plugin.isEnabled()) - { - plugin.getServer().getScheduler().runTask(plugin, () -> - { - if (live.getUuid() == null) - { - live.setUuid(finalResolved); - uuidTable.put(finalResolved, live); - } - }); - } - } - catch (RuntimeException ex) - { - // Plugin disabled between the check and the schedule; Bukkit answers - // that with IllegalPluginAccessException. The snapshot already holds - // the UUID, so let the write below proceed regardless. - FLog.debug("Could not sync resolved UUID back to " + snapshot.getName() + "; server is stopping"); - } + Admin admin = getAdmin((Player) sender); - return CompletableFuture.completedFuture(finalResolved); + return admin != null && admin.isActive(); } - private Admin copyAdmin(Admin admin) + /** + * Whether {@code sender} counts as a senior admin. + *

+ * Asked as a capability rather than as a rank comparison, because no rank is named in code any + * more: whichever ranks {@code ranks.json} grants {@link #SENIOR_STATUS_NODE} to are the senior + * ones, including any custom definitions. + */ + public boolean isSeniorAdmin(CommandSender sender) { - Admin copy = new Admin(admin.getConfigKey()); - copy.setUuid(admin.getUuid()); - copy.setName(admin.getName()); - copy.setRank(admin.getRank()); - copy.setActive(admin.isActive()); - copy.setLastLogin(admin.getLastLogin() == null ? null : new Date(admin.getLastLogin().getTime())); - copy.setLoginMessage(admin.getLoginMessage()); - copy.setCustomRankId(admin.getCustomRankId()); - copy.addIps(new ArrayList<>(admin.getIps())); - return copy; + return isAdmin(sender) && plugin.rm.hasPermission(sender, SENIOR_STATUS_NODE); } - + /** - * Save all admins to SQL database. + * The same test applied to a stored profile rather than to a live sender, for the cleanup pass + * that runs over admins who are not online to be asked. */ - private void saveToSql() + public boolean grantsSeniorStatus(Admin admin) { - if (plugin.dm == null || !plugin.dm.isInitialized()) - { - FLog.warning("SQL not available, falling back to YAML save"); - saveToYaml(); - return; - } - - final AdminRepository repo = plugin.dm.getAdminRepository(); - int saved = 0; - int failed = 0; + return plugin.rm.getRegistry() + .byId(admin.getRankId()) + .map(rank -> plugin.rm.getRegistry().satisfies(rank, SENIOR_STATUS_NODE)) + .orElse(false); + } - // Isolate per admin: this is the shutdown flush, so one unwritable row - // must not take the rest of the list down with it. - for (Admin admin : allAdmins.values()) + public Admin getAdmin(CommandSender sender) + { + if (sender instanceof Player player) // this instead of two separate methods. { - try + if (ConfigEntry.ADMINLIST_USE_UUID_ONLY.getBoolean()) { - UUID uuid = admin.getUuid(); - if (uuid == null) + Admin uuidAdmin = uuidTable.get(player.getUniqueId()); + if (uuidAdmin == null || !uuidAdmin.isActive()) + { + return null; + } + // Rewrite the stored display name if Mojang has changed it. + if (!uuidAdmin.getName().equalsIgnoreCase(player.getName())) { - // Generate UUID if not present. Blocking Mojang lookup is - // acceptable here - this only runs at startup/shutdown. - uuid = FUtil.usernameToUuid(admin.getName()); - if (uuid == null) + final String oldKey = uuidAdmin.getName().toLowerCase(); + uuidAdmin.setName(player.getName()); + final String newKey = uuidAdmin.getName().toLowerCase(); + if (!oldKey.equals(newKey)) { - uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + admin.getName().toLowerCase()).getBytes(StandardCharsets.UTF_8)); + nameTable.remove(oldKey); + nameTable.put(newKey, uuidAdmin); } - admin.setUuid(uuid); + saveAdminAsync(uuidAdmin); } - repo.save(uuid, admin).join(); - saved++; - } - catch (Exception ex) - { - failed++; - FLog.warning("Failed to save admin " + admin.getName() + " to SQL: " + ex.getMessage()); + return uuidAdmin; } - } - // Don't fall back to YAML on failure - we don't want conflicting data. - if (failed > 0) - { - FLog.warning("Saved " + saved + " admins to SQL database, " + failed + " failed"); - } - else - { - FLog.debug("Saved " + saved + " admins to SQL database"); - } - } - - /** - * Render the whole admin list to YAML text. - *

- * Reads {@code allAdmins} and mutates {@code config}, so it must run on the - * thread that owns them - the main thread. Callers wanting an off-thread - * write should call this first and hand the result to {@link #writeYaml}. - */ - private String serialiseAdmins() - { - // Clear the config - for (String key : config.getKeys(false)) - { - config.set(key, null); - } + // Find admin + final String ip = player.getAddress().getAddress().getHostAddress(); + Admin admin = getEntryByName(player.getName()); - for (Admin admin : allAdmins.values()) - { - ConfigurationSection section = config.createSection(admin.getConfigKey()); - admin.saveTo(section); - } - - return config.saveToString(); - } - - /** - * Write pre-rendered YAML to disk. Touches no shared state beyond the file, - * so it is safe from any thread; the lock only serialises concurrent writers - * so two saves cannot interleave into a half-written file. - */ - private void writeYaml(String data) - { - synchronized (fileLock) - { - try - { - configFile.getParentFile().mkdirs(); - Files.writeString(configFile.toPath(), data, StandardCharsets.UTF_8); - } - catch (IOException ex) - { - FLog.severe("Could not save " + CONFIG_FILENAME + ": " + ex.getMessage()); - } - } - } - - /** - * Save all admins to YAML file (fallback). Blocking - startup/shutdown only. - */ - private void saveToYaml() - { - writeYaml(serialiseAdmins()); - } - - public synchronized boolean isAdminSync(CommandSender sender) - { - return isAdmin(sender); - } - - public boolean isAdmin(CommandSender sender) - { - if (!(sender instanceof Player)) - { - return true; - } - - Admin admin = getAdmin((Player) sender); - - return admin != null && admin.isActive(); - } - - public boolean isSeniorAdmin(CommandSender sender) - { - Admin admin = getAdmin(sender); - if (admin == null) - { - return false; - } - - return admin.getRank().ordinal() >= Rank.SENIOR_ADMIN.ordinal(); - } - - public Admin getAdmin(CommandSender sender) - { - if (sender instanceof Player player) // this instead of two separate methods. - { - if (ConfigEntry.ADMINLIST_USE_UUID_ONLY.getBoolean()) - { - Admin uuidAdmin = uuidTable.get(player.getUniqueId()); - if (uuidAdmin == null || !uuidAdmin.isActive()) - { - return null; - } - // Rewrite the stored display name if Mojang has changed it. - if (!uuidAdmin.getName().equalsIgnoreCase(player.getName())) - { - final String oldKey = uuidAdmin.getName().toLowerCase(); - uuidAdmin.setName(player.getName()); - final String newKey = uuidAdmin.getName().toLowerCase(); - if (!oldKey.equals(newKey)) - { - nameTable.remove(oldKey); - nameTable.put(newKey, uuidAdmin); - } - saveAdminAsync(uuidAdmin); - } - return uuidAdmin; - } - - // Find admin - final String ip = player.getAddress().getAddress().getHostAddress(); - Admin admin = getEntryByName(player.getName()); - // Admin by name if (admin != null) { @@ -640,10 +302,11 @@ public Admin getAdmin(CommandSender sender) } return admin; } - - // Impostor + + // Impostor: the name is ours but the IP is not. Fall through to + // the IP lookup, which will not match this entry. } - + // Admin by ip admin = getEntryByIp(ip); if (admin != null) @@ -662,8 +325,8 @@ public Admin getAdmin(CommandSender sender) } saveAdminAsync(admin); } - - return null; + + return admin; } return getEntryByName(sender.getName()); @@ -687,15 +350,12 @@ public Admin getEntryByIpFuzzy(String needleIp) return directAdmin; } - for (String ip : ipTable.keySet()) - { - if (FUtil.fuzzyIpMatch(needleIp, ip, 3)) - { - return ipTable.get(ip); - } - } - - return null; + return ipTable.entrySet() + .stream() + .filter(entry -> FUtil.fuzzyIpMatch(needleIp, entry.getKey(), 3)) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); } public void updateLastLogin(Player player) @@ -733,14 +393,14 @@ public boolean isIdentityMatched(Player player) } Admin admin = getAdmin(player); - return admin == null ? false : admin.getName().equalsIgnoreCase(player.getName()); + return admin != null && admin.getName().equalsIgnoreCase(player.getName()); } public boolean addAdmin(Admin admin) { if (!admin.isValid()) { - FLog.warning("Could not add admin: " + admin.getConfigKey() + " Admin is missing details!"); + FLog.warning(String.format("Could not add admin: %s Admin is missing details!", admin.getConfigKey())); return false; } @@ -753,33 +413,17 @@ public boolean addAdmin(Admin admin) // Save admin if (usingSql) { - saveAdminToSql(admin); + saveAdminAsync(admin); } else { - admin.saveTo(config.createSection(key)); - try - { - config.save(configFile); - } - catch (IOException ex) - { - FLog.severe("Could not save " + CONFIG_FILENAME); - } + saveAsync(); } refreshWorldEditBypassForAdmin(admin); return true; } - /** - * Save a single admin to SQL database. - */ - private void saveAdminToSql(Admin admin) - { - saveAdminAsync(admin); - } - public boolean removeAdmin(Admin admin) { // Remove admin, update views @@ -796,96 +440,13 @@ public boolean removeAdmin(Admin admin) } else { - config.set(admin.getConfigKey(), null); - try - { - config.save(configFile); - } - catch (IOException ex) - { - FLog.severe("Could not save " + CONFIG_FILENAME); - } + saveAsync(); } refreshWorldEditBypassForAdmin(admin); return true; } - private void refreshWorldEditBypassForAdmin(Admin admin) - { - if (plugin.web == null) - { - return; - } - try - { - org.bukkit.entity.Player online = null; - final UUID uuid = admin.getUuid(); - if (uuid != null) - { - online = plugin.getServer().getPlayer(uuid); - } - if (online == null && admin.getName() != null) - { - online = plugin.getServer().getPlayerExact(admin.getName()); - } - if (online != null) - { - plugin.web.refreshBypassNegation(online); - } - } - catch (Throwable t) - { - FLog.warning("Failed to refresh WorldEdit bypass negation: " + t.getMessage()); - } - } - - /** - * Remove admin from SQL database. - */ - private void removeAdminFromSql(Admin admin) - { - if (plugin.dm == null || !plugin.dm.isInitialized()) - { - return; - } - - UUID uuid = admin.getUuid(); - String name = admin.getName(); - - synchronized (persistenceLock) - { - persistenceChain = persistenceChain - .handle((ignored, throwable) -> null) - .thenCompose(ignored -> - { - if (uuid != null) - { - return plugin.dm.getAdminRepository().deleteByUuid(uuid).thenAccept(deleted -> - { - }); - } - - return CompletableFuture.runAsync(() -> - { - try - { - plugin.dm.getAdminRepository().deleteByUsername(name); - } - catch (Exception ex) - { - throw new RuntimeException(ex); - } - }); - }) - .exceptionally(ex -> - { - FLog.warning("Failed to remove admin " + name + " from SQL: " + ex.getMessage()); - return null; - }); - } - } - /** * Refresh the IP lookup table for a single admin. Use this instead of * {@link #updateTables()} when only one entry's IP list has changed. @@ -915,42 +476,36 @@ public void updateTables() uuidTable.clear(); onlineAdminPlayers.clear(); - for (Admin admin : allAdmins.values()) + allAdmins.values().forEach(admin -> { // Always populate UUID table if (admin.getUuid() != null) { uuidTable.put(admin.getUuid(), admin); } - + if (!admin.isActive()) { - continue; + return; } activeAdmins.add(admin); nameTable.put(admin.getName().toLowerCase(), admin); - - for (String ip : admin.getIps()) - { - ipTable.put(ip, admin); - } - - } + admin.getIps().forEach(ip -> ipTable.put(ip, admin)); + }); // Re-populate online-admin cache from currently-online players. - for (Player online : Bukkit.getOnlinePlayers()) + Bukkit.getOnlinePlayers() + .stream() + .filter(this::isAdmin) + .forEach(onlineAdminPlayers::add); + + if (plugin.wm != null && plugin.wm.adminworld != null) { - if (isAdmin(online)) - { - onlineAdminPlayers.add(online); - } + plugin.wm.adminworld.wipeAccessCache(); } - - plugin.wm.adminworld.wipeAccessCache(); - } - + public Map getAllAdmins() { return allAdmins; @@ -1002,30 +557,554 @@ public void onPlayerQuit(PlayerQuitEvent event) public void deactivateOldEntries(boolean verbose) { - for (Admin admin : allAdmins.values()) + final long threshold = ConfigEntry.ADMINLIST_CLEAN_THESHOLD_HOURS.getInteger(); + + allAdmins.values() + .stream() + .filter(Admin::isActive) + .filter(admin -> !grantsSeniorStatus(admin)) + // A record with no recorded login has nothing to age out against. + .filter(admin -> admin.getLastLogin() != null) + .filter(admin -> inactiveHours(admin) >= threshold) + .forEach(admin -> + { + if (verbose) + { + FUtil.adminAction("TotalFreedomMod", String.format( + "Deactivating superadmin %s, inactive for %d hours", + admin.getName(), inactiveHours(admin)), true); + } + + admin.setActive(false); + saveAdminAsync(admin); + }); + + updateTables(); + } + + /** + * Best-effort UUID backfill for admin records loaded without a stored UUID. + * Runs at startup only: {@link FUtil#usernameToUuid} may make a blocking + * Mojang request, which must never happen during play. + */ + private void getMissingUuids() + { + final List backfilled = allAdmins.values() + .stream() + .filter(admin -> admin.getUuid() == null) + .map(AdminList::backfillUuid) + .toList(); + + if (backfilled.isEmpty()) { - if (!admin.isActive() || admin.getRank().isAtLeast(Rank.SENIOR_ADMIN)) - { - continue; - } + return; + } + + final long resolved = backfilled.stream() + .filter(UuidBackfill::fromLookup) + .count(); + final long offlineDerived = backfilled.size() - resolved; + + FLog.info(String.format("UUID backfill: %d resolved via lookup, %d offline-derived", resolved, offlineDerived)); + + if (offlineDerived > 0 && !ConfigEntry.ADMINLIST_MOJANG_UUID_LOOKUP.getBoolean()) + { + FLog.warning(String.format("use_uuid_only is enabled but mojang_uuid_lookup is disabled; " + + "%d admin record(s) fell back to offline-derived UUIDs and " + + "will not match premium accounts on login", offlineDerived)); + } + + updateTables(); + saveAsync(); + } + + /** + * Read every admin off-thread and apply the result on the main thread, dropping back to + * the JSON snapshot if the read fails. + */ + private void loadFromSqlAsync() + { + final AdminRepository repo = plugin.dm.getAdminRepository(); + plugin.dm.readAsync("AdminList/loadFromSql", repo.findAll(), + admins -> applyLoadedAdmins(repo, admins), + () -> + { + loadFromJson(); + backfillUuidsIfEnabled(); + }); + } + + private void applyLoadedAdmins(final AdminRepository repo, final List admins) + { + allAdmins.clear(); + admins.forEach(admin -> + { + final String key = admin.getName().toLowerCase(); + allAdmins.put(key, fixConfigKey(admin, key)); + }); + + usingSql = true; + updateTables(); + FLog.info(String.format("Loaded %d admins from SQL database (%d active, %d IPs)", + allAdmins.size(), nameTable.size(), ipTable.size())); + + reconcileFromJsonIfNewer(repo); + backfillUuidsIfEnabled(); + } + + private void backfillUuidsIfEnabled() + { + if (ConfigEntry.ADMINLIST_USE_UUID_ONLY.getBoolean()) + getMissingUuids(); + } + + /** + * If admins.json was written more recently than the database's last update (e.g. edited + * by hand, or restored from backup while SQL was unavailable), re-import it into SQL. + * The comparison and the re-import both ride the write queue off the main thread. + *

+ * The import replaces the table rather than merging into it, so an entry removed from the file + * by hand is removed from SQL too instead of reappearing on the next start. An empty or + * unreadable file is ignored, so a truncated snapshot cannot empty the table. + */ + private void reconcileFromJsonIfNewer(final AdminRepository repo) + { + if (!configFile.exists()) + { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + enqueue(writeJsonAsync(serialiseAdmins())); + return; + } + + final Map jsonAdmins; + try + { + jsonAdmins = readJsonAdmins(); + } + catch (IOException ex) + { + FLog.warning(String.format("Failed to read %s: %s", CONFIG_FILENAME, ex.getMessage())); + return; + } + + if (jsonAdmins.isEmpty()) + { + // An empty snapshot described nothing, but SQL may hold rows it should be covering. + // Refresh it rather than leaving the fallback with no admins. + enqueue(writeJsonAsync(serialiseAdmins())); + return; + } + + final long fileModified = configFile.lastModified(); + + enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMap(ignored -> + { + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d admin(s).", + CONFIG_FILENAME, jsonAdmins.size())); + return Flux.fromIterable(jsonAdmins.values()) + .filter(Admin::isValid) + .concatMap(admin -> repo.save(resolveUuidFor(admin), copyAdmin(admin))) + .then(repo.findAll()) + .flatMapMany(Flux::fromIterable) + .filter(existing -> existing.getUuid() != null + && !jsonAdmins.containsKey(existing.getName().toLowerCase())) + .concatMap(stale -> repo.deleteByUuid(stale.getUuid())) + .then(Mono.fromRunnable(() -> plugin.dm.sync("AdminList/applyReconciled", + () -> applyReconciledAdmins(jsonAdmins)))); + }) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + CONFIG_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); + } - final Date lastLogin = admin.getLastLogin(); - final long lastLoginHours = TimeUnit.HOURS.convert(new Date().getTime() - lastLogin.getTime(), TimeUnit.MILLISECONDS); + private void applyReconciledAdmins(final Map jsonAdmins) + { + allAdmins.clear(); + allAdmins.putAll(jsonAdmins); + updateTables(); + } + + private Map readJsonAdmins() throws IOException + { + try (FileReader reader = new FileReader(configFile)) + { + Map admins = JsonUtil.GSON.fromJson(reader, ADMIN_MAP_TYPE); + return admins != null ? admins : Maps.newHashMap(); + } + } + + /** + * Fix the config key on an admin (needed when loading from SQL). + */ + private Admin fixConfigKey(Admin admin, String key) + { + if (admin.getConfigKey() == null || !admin.getConfigKey().equals(key)) + { + Admin fixed = new Admin(key); + fixed.setUuid(admin.getUuid()); + fixed.setName(admin.getName()); + fixed.setRankId(admin.getRankId()); + fixed.setActive(admin.isActive()); + fixed.setLastLogin(admin.getLastLogin()); + fixed.setLoginMessage(admin.getLoginMessage()); + fixed.addIps(admin.getIps()); + return fixed; + } + return admin; + } - if (lastLoginHours < ConfigEntry.ADMINLIST_CLEAN_THESHOLD_HOURS.getInteger()) + /** + * Load admins from JSON file (fallback). + */ + private void loadFromJson() + { + if (!configFile.exists()) + { + try { - continue; + configFile.getParentFile().mkdirs(); + configFile.createNewFile(); } - - if (verbose) + catch (IOException ex) { - FUtil.adminAction("TotalFreedomMod", "Deactivating superadmin " + admin.getName() + ", inactive for " + lastLoginHours + " hours", true); + FLog.severe(String.format("Could not create %s", CONFIG_FILENAME)); } + } - admin.setActive(false); - saveAdminAsync(admin); + allAdmins.clear(); + try + { + readJsonAdmins().forEach((key, admin) -> + { + if (admin == null || !admin.isValid()) + { + FLog.warning(String.format("Could not load admin: %s. Missing details!", key)); + return; + } + allAdmins.put(key, admin); + }); + } + catch (IOException ex) + { + FLog.severe(String.format("Could not read %s: %s", CONFIG_FILENAME, ex.getMessage())); } + usingSql = false; updateTables(); + FLog.info(String.format("Loaded %d admins from JSON (%d active, %d IPs)", + allAdmins.size(), nameTable.size(), ipTable.size())); + } + + private void enqueue(Mono work) + { + writes.enqueue(work); + } + + /** + * Queue a batch of admin rows for an off-thread SQL write, followed by one + * refresh of the JSON snapshot. + */ + private void queueSqlWrites(List batch) + { + if (batch.isEmpty()) + { + return; + } + + if (plugin.dm == null || !plugin.dm.isInitialized()) + { + FLog.warning(String.format("SQL not available; %d admin change(s) were not saved", batch.size())); + return; + } + + final AdminRepository repo = plugin.dm.getAdminRepository(); + + // Render the snapshot here, while we still own the maps on the calling + // thread. Serialising inside the queued task would read allAdmins + // off-thread while the main thread is free to mutate it. + final String json = serialiseAdmins(); + + enqueue(Flux.fromIterable(batch) + .concatMap(pending -> resolveUuidAsync(pending.live(), pending.snapshot()) + .flatMap(uuid -> repo.save(uuid, pending.snapshot())) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to save admin %s to SQL: %s", + pending.snapshot().getName(), ex.getMessage())); + return Mono.empty(); + })) + .then(writeJsonAsync(json))); + } + + /** + * Remove admin from SQL database. + */ + private void removeAdminFromSql(Admin admin) + { + if (plugin.dm == null || !plugin.dm.isInitialized()) + { + FLog.warning(String.format("SQL not available; removal of admin %s was not saved", admin.getName())); + return; + } + + final AdminRepository repo = plugin.dm.getAdminRepository(); + final UUID uuid = admin.getUuid(); + final String name = admin.getName(); + final String json = serialiseAdmins(); + + final Mono delete = uuid != null + ? repo.deleteByUuid(uuid).then() + : Mono.fromRunnable(() -> + { + try + { + repo.deleteByUsername(name); + } + catch (SQLException ex) + { + throw new IllegalStateException(ex); + } + }).subscribeOn(Schedulers.boundedElastic()); + + enqueue(delete + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to remove admin %s from SQL: %s", name, ex.getMessage())); + return Mono.empty(); + }) + .then(writeJsonAsync(json))); } + + /** + * Blocking write of every admin record to SQL. Startup/shutdown only. + */ + private void saveToSql() + { + if (plugin.dm == null || !plugin.dm.isInitialized()) + { + FLog.warning("SQL not available, falling back to the JSON snapshot"); + saveToJson(); + return; + } + + final AdminRepository repo = plugin.dm.getAdminRepository(); + + final Long failed = Flux.fromIterable(List.copyOf(allAdmins.values())) + .concatMap(admin -> Mono.fromCallable(() -> resolveUuidFor(admin)) + .flatMap(uuid -> repo.save(uuid, admin)) + .thenReturn(Boolean.TRUE) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to save admin %s to SQL: %s", + admin.getName(), ex.getMessage())); + return Mono.just(Boolean.FALSE); + })) + .filter(Boolean.FALSE::equals) + .count() + .block(); + + FLog.debug(String.format("Flushed %d admin(s) to SQL (%d failed)", + allAdmins.size(), failed == null ? 0L : failed)); + + // Write the snapshot either way, so a later SQL-less start has something to read. + saveToJson(); + } + + /** + * Blocking write of the JSON snapshot. Startup/shutdown and main-thread + * fallbacks only; queued writes go through {@link #writeJsonAsync(String)}. + */ + private void saveToJson() + { + writeJson(serialiseAdmins()); + } + + private String serialiseAdmins() + { + return JsonUtil.GSON.toJson(allAdmins, ADMIN_MAP_TYPE); + } + + private Mono writeJsonAsync(String json) + { + return Mono.fromRunnable(() -> writeJson(json)) + .subscribeOn(Schedulers.boundedElastic()); + } + + private void writeJson(String json) + { + try (FileWriter writer = new FileWriter(configFile)) + { + writer.write(json); + } + catch (IOException ex) + { + FLog.severe(String.format("Could not save %s: %s", CONFIG_FILENAME, ex.getMessage())); + } + } + + private PendingWrite pendingWrite(Admin live) + { + return new PendingWrite(live, copyAdmin(live)); + } + + private Admin copyAdmin(Admin admin) + { + Admin copy = new Admin(admin.getConfigKey()); + copy.setUuid(admin.getUuid()); + copy.setName(admin.getName()); + copy.setRankId(effectiveRankId(admin)); + copy.setActive(admin.isActive()); + copy.setLastLogin(admin.getLastLogin() == null ? null : new Date(admin.getLastLogin().getTime())); + copy.setLoginMessage(admin.getLoginMessage()); + copy.addIps(new ArrayList<>(admin.getIps())); + return copy; + } + + /** + * The rank id to store for an admin. An unset id means "whatever fills the default admin role", + * which a NOT NULL column cannot express, so the role is resolved to a concrete id at write time. + */ + private String effectiveRankId(final Admin admin) + { + if (admin.getRankId() != null) + return admin.getRankId(); + + return plugin.rm.getRegistry() + .byRole(RankRole.ADMIN_DEFAULT) + .map(CustomRank::getId) + .orElse(null); + } + + /** + * Resolve the UUID for a queued write. The lookup runs on the persistence + * chain rather than the caller, because {@link FUtil#usernameToUuid} can make + * a blocking Mojang request with a 5s connect and 5s read timeout. The resolved value is handed + * back to the live entry on the main thread, which owns the lookup tables. + */ + private Mono resolveUuidAsync(Admin live, Admin snapshot) + { + if (snapshot.getUuid() != null) + { + return Mono.just(snapshot.getUuid()); + } + + return Mono.fromCallable(() -> resolveUuidFor(snapshot)) + .subscribeOn(Schedulers.boundedElastic()) + .doOnNext(resolved -> syncResolvedUuid(live, resolved)); + } + + /** + * Hand a UUID resolved off-thread back to the live entry and the lookup + * table, both of which belong to the main thread. + */ + private void syncResolvedUuid(Admin live, UUID resolved) + { + if (!plugin.isEnabled()) + { + return; + } + + try + { + plugin.getServer().getScheduler().runTask(plugin, () -> + { + if (live.getUuid() == null) + { + live.setUuid(resolved); + } + uuidTable.putIfAbsent(resolved, live); + }); + } + catch (RuntimeException ex) + { + // Plugin disabled between the check and the schedule; Bukkit answers + // that with IllegalPluginAccessException. The snapshot already holds + // the UUID, so the write itself is unaffected. + FLog.debug(String.format("Could not sync resolved UUID back to %s; server is stopping", live.getName())); + } + } + + /** + * Resolve and store a UUID on {@code admin}: Mojang lookup by name, falling + * back to an offline-derived UUID. Blocking - never call this on the main + * thread outside startup or shutdown. + */ + private UUID resolveUuidFor(Admin admin) + { + if (admin.getUuid() != null) + { + return admin.getUuid(); + } + + final UUID looked = FUtil.usernameToUuid(admin.getName()); + final UUID resolved = looked != null ? looked : offlineUuid(admin.getName()); + admin.setUuid(resolved); + return resolved; + } + + private void refreshWorldEditBypassForAdmin(Admin admin) + { + if (plugin.web == null) + { + return; + } + try + { + Player online = null; + final UUID uuid = admin.getUuid(); + if (uuid != null) + { + online = plugin.getServer().getPlayer(uuid); + } + if (online == null && admin.getName() != null) + { + online = plugin.getServer().getPlayerExact(admin.getName()); + } + if (online != null) + { + plugin.web.refreshBypassNegation(online); + } + } + catch (Throwable t) + { + FLog.warning(String.format("Failed to refresh WorldEdit bypass negation: %s", t.getMessage())); + } + } + + private long inactiveHours(Admin admin) + { + return TimeUnit.HOURS.convert(new Date().getTime() - admin.getLastLogin().getTime(), TimeUnit.MILLISECONDS); + } + + private static UuidBackfill backfillUuid(Admin admin) + { + final UUID looked = FUtil.usernameToUuid(admin.getName()); + admin.setUuid(looked != null ? looked : offlineUuid(admin.getName())); + return new UuidBackfill(admin, looked != null); + } + + private static UUID offlineUuid(String name) + { + return UUID.nameUUIDFromBytes(String.format("OfflinePlayer:%s", name.toLowerCase()) + .getBytes(StandardCharsets.UTF_8)); + } + + /** + * A live entry paired with the immutable snapshot that will actually be + * written, so a mutation on the main thread cannot change a row mid-write. + */ + private record PendingWrite(Admin live, Admin snapshot) {} + + private record UuidBackfill(Admin admin, boolean fromLookup) {} } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java index 51cee87a6..8ab55743d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/Ban.java @@ -1,6 +1,5 @@ package me.totalfreedom.totalfreedommod.banning; -import com.google.common.collect.Lists; import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Date; @@ -9,59 +8,111 @@ import java.util.List; import java.util.Set; import java.util.UUID; -import lombok.Getter; -import lombok.Setter; + +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigLoadable; -import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigSavable; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.Validatable; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.command.CommandSender; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.entity.Player; -public class Ban implements ConfigLoadable, ConfigSavable, Validatable +import com.google.common.collect.Lists; + +public class Ban implements ConfigLoadable, Validatable { public static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd \'at\' HH:mm:ss z"); // UUID support for SQL storage - @Getter - @Setter private UUID uuid = null; - - @Getter - @Setter private UUID bannedByUuid = null; - - @Getter - @Setter private String username = null; - @Getter private final List ips = Lists.newArrayList(); - @Getter - @Setter private String by = null; - @Getter - @Setter private String reason = null; // Unformatted, &[0-9,a-f] instead of ChatColor - @Getter - @Setter private long expiryUnix = -1; + public UUID getUuid() + { + return uuid; + } + + public void setUuid(UUID uuid) + { + this.uuid = uuid; + } + + public UUID getBannedByUuid() + { + return bannedByUuid; + } + + public void setBannedByUuid(UUID bannedByUuid) + { + this.bannedByUuid = bannedByUuid; + } + + public String getUsername() + { + return username; + } + + public void setUsername(String username) + { + this.username = username; + } + + public List getIps() + { + return ips; + } + + public String getBy() + { + return by; + } + + public void setBy(String by) + { + this.by = by; + } + + public String getReason() + { + return reason; + } + + public void setReason(String reason) + { + this.reason = reason; + } + + public long getExpiryUnix() + { + return expiryUnix; + } + + public void setExpiryUnix(long expiryUnix) + { + this.expiryUnix = expiryUnix; + } + // SQL repository alias accessors public String getBannedBy() { return by; } - + public void setBannedBy(String bannedBy) { this.by = bannedBy; } - + public Date getExpireAt() { return expiryUnix > 0 ? FUtil.getUnixDate(expiryUnix) : null; @@ -286,17 +337,6 @@ public void loadFrom(ConfigurationSection cs) dedupeIps(); } - @Override - public void saveTo(ConfigurationSection cs) - { - dedupeIps(); - cs.set("username", username); - cs.set("ips", ips.isEmpty() ? null : ips); - cs.set("by", by); - cs.set("reason", reason); - cs.set("expiry_unix", expiryUnix > 0 ? expiryUnix : null); - } - @Override public boolean isValid() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java index 2c18a992f..7118a28b8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/BanManager.java @@ -1,102 +1,237 @@ package me.totalfreedom.totalfreedommod.banning; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Set; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; -import org.bukkit.configuration.file.YamlConfiguration; +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; + import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.AsyncPlayerPreLoginEvent; import org.bukkit.event.player.PlayerJoinEvent; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.google.gson.reflect.TypeToken; + public class BanManager extends FreedomService { + private static final Type BAN_LIST_TYPE = new TypeToken>() {}.getType(); + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; private final Set bans = Sets.newHashSet(); private final Map ipBans = Maps.newHashMap(); private final Map nameBans = Maps.newHashMap(); private final List unbannableUsernames = Lists.newArrayList(); - // private final File configFile; - private final Object lock = new Object(); - private final Object persistenceLock = new Object(); + private final PersistenceQueue writes = new PersistenceQueue("ban"); - // Flag to track if SQL is available private boolean usingSql = false; public BanManager(TotalFreedomMod plugin) { super(plugin); - this.configFile = new File(plugin.getDataFolder(), "bans.yml"); + this.configFile = new File(plugin.getDataFolder(), "bans.json"); } @Override + @SuppressWarnings("unchecked") protected void onStart() { - // Try to load from SQL database first + load(); + plugin.dm.whenReady(this::load); + + unbannableUsernames.clear(); + unbannableUsernames.addAll((Collection) ConfigEntry.FAMOUS_PLAYERS.getList()); + FLog.info(String.format("Loaded %d unbannable usernames.", unbannableUsernames.size())); + } + + /** + * Populate the ban list from SQL where it is available and the JSON snapshot otherwise. + * Never blocks: the SQL read runs off-thread and is applied back on the main thread. + */ + public void load() + { if (plugin.dm != null && plugin.dm.isInitialized()) { - loadFromSql(); + loadFromSqlAsync(); + return; } - else + + loadFromJson(); + } + + private void loadFromSqlAsync() + { + final BanRepository repo = plugin.dm.getBanRepository(); + plugin.dm.readAsync("BanManager/loadFromSql", repo.findAll(), + loaded -> applyLoadedBans(repo, loaded), + this::loadFromJson); + } + + private void applyLoadedBans(final BanRepository repo, final List loaded) + { + synchronized (lock) { - loadFromYaml(); + bans.clear(); + bans.addAll(loaded); + usingSql = true; + updateViews(); + FLog.info(String.format("Loaded %d IP bans and %d username bans from SQL database.", + ipBans.size(), nameBans.size())); } - - // Load unbannable usernames - unbannableUsernames.clear(); - unbannableUsernames.addAll((Collection) ConfigEntry.FAMOUS_PLAYERS.getList()); - FLog.info("Loaded " + unbannableUsernames.size() + " unbannable usernames."); + + reconcileFromJsonIfNewer(repo); } - + /** - * Load bans from SQL database. + * If bans.json was written more recently than the database's last update, re-import it into + * SQL. The comparison and the re-import both ride the write queue off the main thread. + *

+ * The import replaces the table rather than merging into it, so a ban deleted from the file by + * hand does not come back. An empty or unreadable file is ignored. */ - private void loadFromSql() + private void reconcileFromJsonIfNewer(final BanRepository repo) { + if (!configFile.exists()) + { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + enqueue(writeJsonAsync(new ArrayList<>(bans))); + return; + } + + final List jsonBans; try { - BanRepository repo = plugin.dm.getBanRepository(); - List loadedBans = repo.findAll().join(); + jsonBans = readJsonBans(); + } + catch (IOException ex) + { + FLog.warning(String.format("Failed to read bans.json: %s", ex.getMessage())); + return; + } - synchronized (lock) - { - bans.clear(); - bans.addAll(loadedBans); - usingSql = true; - updateViews(); - FLog.info("Loaded " + ipBans.size() + " IP bans and " + nameBans.size() + " username bans from SQL database."); - } + if (jsonBans.isEmpty()) + { + enqueue(writeJsonAsync(new ArrayList<>(bans))); + return; } - catch (Exception ex) + + final long fileModified = configFile.lastModified(); + + enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMap(ignored -> + { + FLog.info(String.format("bans.json is newer than the database; rebuilding it from the file's %d ban(s).", + jsonBans.size())); + return syncToSql(repo, jsonBans).then(Mono.fromRunnable(() -> plugin.dm.sync("BanManager/applyReconciled", + () -> applyReconciledBans(jsonBans)))); + }) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile bans.json into the database: %s", ex.getMessage())); + return Mono.empty(); + }) + .then()); + } + + /** + * Whether a row in the database is still described by the snapshot. A ban with + * no uuid is an IP ban, so it is matched on its addresses instead. A row with + * neither is left alone, since there is no key by which to remove it safely. + */ + private static boolean isKept(final Ban existing, final Set keepUuids, final Set keepIps) + { + if (existing.getUuid() != null) + return keepUuids.contains(existing.getUuid()); + + if (existing.getIps().isEmpty()) + return true; + + return existing.getIps().stream().anyMatch(keepIps::contains); + } + + /** + * Make the database match {@code desired}: write every record, then delete + * only the rows it no longer describes. The table is never emptied first, + * so a failed write leaves the existing rows untouched. A ban with no uuid + * is an IP ban, so it is matched and removed by address. + */ + private Mono syncToSql(final BanRepository repo, final Collection desired) + { + final Set keepUuids = desired.stream() + .map(Ban::getUuid) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + final Set keepIps = desired.stream() + .flatMap(ban -> ban.getIps().stream()) + .collect(Collectors.toSet()); + + return repo.loadAllAsync() + .flatMapMany(existing -> + { + return Flux.fromIterable(desired) + .filter(Ban::isValid) + .concatMap(repo::save) + .thenMany(Flux.fromIterable(existing) + .filter(row -> !isKept(row, keepUuids, keepIps)) + .concatMap(stale -> stale.getUuid() != null + ? repo.deleteAsync(stale.getUuid()) + : repo.deleteByIpAsync(stale.getIps().get(0)))); + }) + .then(); + } + + private void applyReconciledBans(final List jsonBans) + { + synchronized (lock) + { + bans.clear(); + bans.addAll(jsonBans); + updateViews(); + } + } + + private List readJsonBans() throws IOException + { + try (FileReader reader = new FileReader(configFile)) { - FLog.warning("Failed to load bans from SQL, falling back to YAML: " + ex.getMessage()); - loadFromYaml(); + List loaded = JsonUtil.GSON.fromJson(reader, BAN_LIST_TYPE); + return loaded != null ? loaded : new ArrayList<>(); } } - + /** - * Load bans from YAML file (fallback). + * Load bans from the JSON file (fallback). */ - private void loadFromYaml() + private void loadFromJson() { if (!configFile.exists()) { @@ -107,47 +242,54 @@ private void loadFromYaml() } catch (IOException ex) { - FLog.severe("Could not create bans.yml"); + FLog.severe("Could not create bans.json"); } } - final YamlConfiguration loaded = YamlConfiguration.loadConfiguration(configFile); synchronized (lock) { bans.clear(); - for (String id : loaded.getKeys(false)) + try { - if (!loaded.isConfigurationSection(id)) + for (Ban ban : readJsonBans()) { - FLog.warning("Could not load username ban: " + id + ". Invalid format!"); - continue; - } - - Ban ban = new Ban(); - ban.loadFrom(loaded.getConfigurationSection(id)); - - if (!ban.isValid()) - { - FLog.warning("Not adding username ban: " + id + ". Missing information."); - continue; + if (!ban.isValid()) + { + FLog.warning("Not adding username ban: " + ban.getUsername() + ". Missing information."); + continue; + } + bans.add(ban); } - - bans.add(ban); + } + catch (IOException ex) + { + FLog.severe("Could not read bans.json: " + ex.getMessage()); } usingSql = false; updateViews(); - FLog.info("Loaded " + ipBans.size() + " IP bans and " + nameBans.size() + " username bans from YAML."); + FLog.info("Loaded " + ipBans.size() + " IP bans and " + nameBans.size() + " username bans from JSON."); } } @Override protected void onStop() { + awaitPendingWrites(SHUTDOWN_FLUSH_TIMEOUT_MS); saveAll(); FLog.info("Saved " + bans.size() + " player bans"); } + public void awaitPendingWrites(long timeoutMs) + { + writes.await(timeoutMs); + } + + private void enqueue(Mono work) + { + writes.enqueue(work); + } + public Set getAllBans() { return Collections.unmodifiableSet(bans); @@ -163,6 +305,11 @@ public Collection getUsernameBans() return Collections.unmodifiableCollection(nameBans.values()); } + /** + * Blocking write of every ban record. Startup/shutdown only. Single-entry changes belong + * on {@link #saveBanToSqlAsync(Ban)}; whole-list changes reachable from a command or event + * handler belong on {@link #saveAllAsync()}. + */ public void saveAll() { final boolean sql; @@ -174,71 +321,138 @@ public void saveAll() snapshot = new ArrayList<>(bans); } - synchronized (persistenceLock) + if (sql) { - if (sql) - { - writeAllToSql(snapshot); - } - else - { - writeAllToYaml(snapshot); - } + writeAllToSql(snapshot); + } + else + { + writeAllToJson(snapshot); } } public void saveAllAsync() { - if (!plugin.isEnabled()) + final boolean sql; + final List snapshot; + synchronized (lock) + { + updateViews(); + sql = usingSql; + snapshot = new ArrayList<>(bans); + } + + if (!sql) + { + if (!plugin.isEnabled()) + { + writeAllToJson(snapshot); + return; + } + enqueue(writeJsonAsync(snapshot)); + return; + } + + if (plugin.dm == null || !plugin.dm.isInitialized()) { - saveAll(); + FLog.warning("SQL not available, falling back to JSON save"); + enqueue(writeJsonAsync(snapshot)); return; } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, this::saveAll); + + final BanRepository repo = plugin.dm.getBanRepository(); + + enqueue(syncToSql(repo, snapshot) + .onErrorResume(ex -> + { + FLog.warning("Failed to write bans to SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync(snapshot))); } + /** + * Queue a single ban for an off-thread SQL write, followed by a refresh of the JSON + * snapshot. Safe to call from commands and event handlers. + */ private void saveBanToSqlAsync(Ban ban) { - if (!plugin.isEnabled()) + if (plugin.dm == null || !plugin.dm.isInitialized()) { - saveBanToSql(ban); + FLog.warning("SQL not available; ban change was not saved"); return; } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> saveBanToSql(ban)); + + final BanRepository repo = plugin.dm.getBanRepository(); + final List snapshot = currentBansSnapshot(); + + enqueue(repo.save(ban) + .onErrorResume(ex -> + { + FLog.warning("Failed to save ban to SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync(snapshot))); } + /** + * Queue a single ban removal for an off-thread SQL write, followed by a refresh of the + * JSON snapshot. Safe to call from commands and event handlers. + */ private void removeBanFromSqlAsync(Ban ban) { - if (!plugin.isEnabled()) + if (plugin.dm == null || !plugin.dm.isInitialized()) { - removeBanFromSql(ban); + FLog.warning("SQL not available; ban removal was not saved"); return; } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> removeBanFromSql(ban)); + + final BanRepository repo = plugin.dm.getBanRepository(); + final List snapshot = currentBansSnapshot(); + + final Mono delete; + if (ban.getUuid() != null) + { + delete = repo.deleteByUuid(ban.getUuid()); + } + else if (ban.hasUsername()) + { + delete = Mono.fromCallable(() -> repo.deleteByUsername(ban.getUsername())) + .subscribeOn(Schedulers.boundedElastic()); + } + else if (ban.hasIps()) + { + delete = repo.deleteByIpAsync(ban.getIps().get(0)); + } + else + { + delete = Mono.just(Boolean.FALSE); + } + + enqueue(delete + .onErrorResume(ex -> + { + FLog.warning("Failed to remove ban from SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync(snapshot))); } - /** - * Write the given snapshot of bans to the SQL database. Must be called under persistenceLock. - */ private void writeAllToSql(List snapshot) { if (plugin.dm == null || !plugin.dm.isInitialized()) { - FLog.warning("SQL not available, falling back to YAML save"); - writeAllToYaml(snapshot); + FLog.warning("SQL not available, falling back to JSON save"); + writeAllToJson(snapshot); return; } try { BanRepository repo = plugin.dm.getBanRepository(); - // Clear and re-add all (simple approach for now) - repo.deleteAll().join(); - for (Ban ban : snapshot) - { - repo.save(ban).join(); - } + syncToSql(repo, snapshot).block(); FLog.debug("Saved " + snapshot.size() + " bans to SQL database"); + writeAllToJson(snapshot); } catch (Exception ex) { @@ -247,26 +461,26 @@ private void writeAllToSql(List snapshot) } /** - * Write the given snapshot of bans to the YAML file. Must be called under persistenceLock. + * Write the given snapshot of bans to the JSON file (fallback, and the write-through snapshot when using SQL). */ - private void writeAllToYaml(List snapshot) + private void writeAllToJson(List snapshot) { - final YamlConfiguration out = new YamlConfiguration(); - for (Ban ban : snapshot) - { - ban.saveTo(out.createSection(String.valueOf(ban.hashCode()))); - } - - try + try (FileWriter writer = new FileWriter(configFile)) { - out.save(configFile); + JsonUtil.GSON.toJson(snapshot, BAN_LIST_TYPE, writer); } catch (IOException ex) { - FLog.severe("Could not save bans.yml"); + FLog.severe("Could not save bans.json"); } } + private Mono writeJsonAsync(List snapshot) + { + return Mono.fromRunnable(() -> writeAllToJson(snapshot)) + .subscribeOn(Schedulers.boundedElastic()); + } + public Ban getByIp(String ip) { synchronized (lock) @@ -441,59 +655,6 @@ private void cancelWorldEditFor(Ban ban) } } - /** - * Save a single ban to SQL database. - */ - private void saveBanToSql(Ban ban) - { - if (plugin.dm == null || !plugin.dm.isInitialized()) - { - return; - } - - synchronized (persistenceLock) - { - try - { - plugin.dm.getBanRepository().save(ban).join(); - } - catch (Exception ex) - { - FLog.warning("Failed to save ban to SQL: " + ex.getMessage()); - } - } - } - - /** - * Remove a ban from SQL database. - */ - private void removeBanFromSql(Ban ban) - { - if (plugin.dm == null || !plugin.dm.isInitialized()) - { - return; - } - - synchronized (persistenceLock) - { - try - { - if (ban.getUuid() != null) - { - plugin.dm.getBanRepository().deleteByUuid(ban.getUuid()).join(); - } - else if (ban.hasUsername()) - { - plugin.dm.getBanRepository().deleteByUsername(ban.getUsername()); - } - } - catch (Exception ex) - { - FLog.warning("Failed to remove ban from SQL: " + ex.getMessage()); - } - } - } - public boolean removeBan(Ban ban) { final boolean removed; @@ -579,6 +740,14 @@ public void onPlayerJoin(PlayerJoinEvent event) player.setOp(true); } + private List currentBansSnapshot() + { + synchronized (lock) + { + return new ArrayList<>(bans); + } + } + // Must be called while holding 'lock'. private void updateViews() { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermBan.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermBan.java index a49b6a288..97e0f525f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermBan.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermBan.java @@ -1,12 +1,12 @@ package me.totalfreedom.totalfreedommod.banning; -import lombok.Getter; -import lombok.Setter; - import java.util.ArrayList; import java.util.List; import java.util.UUID; +import lombok.Getter; +import lombok.Setter; + /** * Model class representing a permanent ban. * Used for SQL database storage. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java index e6681016e..e5e88bfe8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/PermbanList.java @@ -1,103 +1,232 @@ package me.totalfreedom.totalfreedommod.banning; -import com.google.common.collect.Maps; -import com.google.common.collect.Sets; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.*; +import java.util.stream.Collectors; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.AsyncPlayerPreLoginEvent; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; -import java.io.File; -import java.io.IOException; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.player.AsyncPlayerPreLoginEvent; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.google.gson.reflect.TypeToken; public class PermbanList extends FreedomService { - public static final String CONFIG_FILENAME = "permbans.yml"; + public static final String CONFIG_FILENAME = "permbans.json"; + private static final Type PERMBAN_MAP_TYPE = new TypeToken>() {}.getType(); + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; private final Set permbannedNames = Sets.newHashSet(); private final Set permbannedIps = Sets.newHashSet(); - - // Store full PermBan objects for SQL operations private final Map permbansByName = Maps.newHashMap(); + private final File configFile; private final Object lock = new Object(); - private final Object persistenceLock = new Object(); + private final PersistenceQueue writes = new PersistenceQueue("permban"); - // Flag to track if SQL is available private boolean usingSql = false; public PermbanList(TotalFreedomMod plugin) { super(plugin); + this.configFile = new File(plugin.getDataFolder(), CONFIG_FILENAME); } @Override protected void onStart() { - // Try to load from SQL database first + load(); + plugin.dm.whenReady(this::load); + } + + /** + * Populate the permban list from SQL where it is available and the JSON snapshot otherwise. + * Never blocks: the SQL read runs off-thread and is applied back on the main thread. + */ + public void load() + { if (plugin.dm != null && plugin.dm.isInitialized()) { - loadFromSql(); + loadFromSqlAsync(); + return; } - else + + loadFromJson(); + } + + private void loadFromSqlAsync() + { + final PermbanRepository repo = plugin.dm.getPermbanRepository(); + plugin.dm.readAsync("PermbanList/loadFromSql", repo.findAll(), + loaded -> applyLoadedPermbans(repo, loaded), + this::loadFromJson); + } + + private void applyLoadedPermbans(final PermbanRepository repo, final List loaded) + { + synchronized (lock) { - loadFromYaml(); + replaceViews(loaded); + usingSql = true; + FLog.info(String.format("Loaded %d perm IP bans and %d perm username bans from SQL database.", + permbannedIps.size(), permbannedNames.size())); } + + reconcileFromJsonIfNewer(repo); } - + /** - * Load permbans from SQL database. + * If permbans.json was written more recently than the database's last update, re-import it + * into SQL. The comparison and the re-import both ride the write queue off the main thread. */ - private void loadFromSql() + private void reconcileFromJsonIfNewer(final PermbanRepository repo) { + if (!configFile.exists()) + { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + enqueue(writeJsonAsync()); + return; + } + + final Map jsonPermbans; try { - PermbanRepository repo = plugin.dm.getPermbanRepository(); - List loadedPermbans = repo.findAll().join(); + jsonPermbans = readJsonPermbans(); + } + catch (IOException ex) + { + FLog.warning(String.format("Failed to read %s: %s", CONFIG_FILENAME, ex.getMessage())); + return; + } - synchronized (lock) - { - permbannedNames.clear(); - permbannedIps.clear(); - permbansByName.clear(); + if (jsonPermbans.isEmpty()) + { + enqueue(writeJsonAsync()); + return; + } - for (PermBan permban : loadedPermbans) - { - String name = permban.getUsername().toLowerCase().trim(); - permbannedNames.add(name); - permbannedIps.addAll(permban.getIps()); - permbansByName.put(name, permban); - } + final long fileModified = configFile.lastModified(); - usingSql = true; - FLog.info("Loaded " + permbannedIps.size() + " perm IP bans and " + permbannedNames.size() + " perm username bans from SQL database."); - } + enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMap(ignored -> + { + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d permban(s).", + CONFIG_FILENAME, jsonPermbans.size())); + final Set keepUuids = jsonPermbans.values().stream() + .map(PermBan::getUuid) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + final Set keepIps = jsonPermbans.values().stream() + .flatMap(permban -> permban.getIps().stream()) + .collect(Collectors.toSet()); + return Flux.fromIterable(jsonPermbans.values()) + .concatMap(repo::save) + .then(repo.loadAllAsync()) + .flatMapMany(Flux::fromIterable) + .filter(existing -> !isKept(existing, keepUuids, keepIps)) + .concatMap(stale -> stale.getUuid() != null + ? repo.deleteAsync(stale.getUuid()) + : repo.deleteByIpAsync(stale.getIps().get(0))) + .then(Mono.fromRunnable(() -> plugin.dm.sync("PermbanList/applyReconciled", + () -> applyReconciledPermbans(jsonPermbans.values())))); + }) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + CONFIG_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); + } + + /** + * Whether a row in the database is still described by the snapshot. A permban with + * no uuid is an IP permban, so it is matched on its addresses instead. A row with + * neither is left alone, since there is no key to remove it safely. + */ + private static boolean isKept(final PermBan existing, final Set keepUuids, final Set keepIps) + { + if (existing.getUuid() != null) + return keepUuids.contains(existing.getUuid()); + + if (existing.getIps().isEmpty()) + return true; + + return existing.getIps().stream().anyMatch(keepIps::contains); + } + + private void applyReconciledPermbans(final Collection jsonPermbans) + { + synchronized (lock) + { + replaceViews(jsonPermbans); } - catch (Exception ex) + } + + /** + * Rebuild the name/IP lookup views from {@code source}. Callers hold {@link #lock}. + */ + private void replaceViews(final Collection source) + { + permbannedNames.clear(); + permbannedIps.clear(); + permbansByName.clear(); + + source.forEach(permban -> { - FLog.warning("Failed to load permbans from SQL, falling back to YAML: " + ex.getMessage()); - loadFromYaml(); + final String name = permban.hasUsername() ? permban.getUsername().toLowerCase().trim() + : null; + if (name != null) + { + permbannedNames.add(name); + permbansByName.put(name, permban); + } + permbannedIps.addAll(permban.getIps()); + }); + } + + private Map readJsonPermbans() throws IOException + { + try (FileReader reader = new FileReader(configFile)) + { + Map loaded = JsonUtil.GSON.fromJson(reader, PERMBAN_MAP_TYPE); + return loaded != null ? loaded : Maps.newHashMap(); } } - + /** - * Load permbans from YAML file (fallback). + * Load permbans from the JSON file (fallback). */ - private void loadFromYaml() + private void loadFromJson() { - final File configFile = new File(plugin.getDataFolder(), CONFIG_FILENAME); if (!configFile.exists()) { try @@ -110,7 +239,6 @@ private void loadFromYaml() FLog.severe("Could not create " + CONFIG_FILENAME); } } - final YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); synchronized (lock) { @@ -118,44 +246,82 @@ private void loadFromYaml() permbannedIps.clear(); permbansByName.clear(); - for (String name : config.getKeys(false)) + try { - String lowerName = name.toLowerCase().trim(); - permbannedNames.add(lowerName); - List ips = config.getStringList(name); - permbannedIps.addAll(ips); - - // Create PermBan object - PermBan permban = new PermBan(); - permban.setUsername(lowerName); - permban.setIps(ips); - permbansByName.put(lowerName, permban); + for (Map.Entry entry : readJsonPermbans().entrySet()) + { + PermBan permban = entry.getValue(); + permbannedNames.add(entry.getKey()); + permbannedIps.addAll(permban.getIps()); + permbansByName.put(entry.getKey(), permban); + } + } + catch (IOException ex) + { + FLog.severe("Could not read " + CONFIG_FILENAME + ": " + ex.getMessage()); } usingSql = false; - FLog.info("Loaded " + permbannedIps.size() + " perm IP bans and " + permbannedNames.size() + " perm username bans from YAML."); + FLog.info("Loaded " + permbannedIps.size() + " perm IP bans and " + permbannedNames.size() + " perm username bans from JSON."); + } + } + + private void saveToJson() + { + final Map snapshot; + synchronized (lock) + { + snapshot = Maps.newHashMap(permbansByName); + } + + try (FileWriter writer = new FileWriter(configFile)) + { + JsonUtil.GSON.toJson(snapshot, PERMBAN_MAP_TYPE, writer); + } + catch (IOException ex) + { + FLog.severe("Could not save " + CONFIG_FILENAME); } } @Override protected void onStop() { - // Save if using SQL + awaitPendingWrites(SHUTDOWN_FLUSH_TIMEOUT_MS); + if (usingSql) - { saveAllToSql(); - } + else + saveToJson(); + + } + + /** + * Wait for queued async writes to land, up to {@code timeoutMs}. + */ + public void awaitPendingWrites(long timeoutMs) + { + writes.await(timeoutMs); + } + + private void enqueue(Mono work) + { + writes.enqueue(work); } - + + private Mono writeJsonAsync() + { + return Mono.fromRunnable(this::saveToJson) + .subscribeOn(Schedulers.boundedElastic()); + } + /** - * Save all permbans to SQL database. + * Blocking write of every permban record to SQL. Startup/shutdown only. */ private void saveAllToSql() { if (plugin.dm == null || !plugin.dm.isInitialized()) - { return; - } final List snapshot; synchronized (lock) @@ -163,30 +329,61 @@ private void saveAllToSql() snapshot = new ArrayList<>(permbansByName.values()); } - synchronized (persistenceLock) + try { - try - { - PermbanRepository repo = plugin.dm.getPermbanRepository(); - for (PermBan permban : snapshot) - { - repo.save(permban).join(); - } - FLog.debug("Saved " + snapshot.size() + " permbans to SQL database"); - } - catch (Exception ex) - { - FLog.warning("Failed to save permbans to SQL: " + ex.getMessage()); - } + PermbanRepository repo = plugin.dm.getPermbanRepository(); + for (PermBan permban : snapshot) + repo.save(permban).block(); + + FLog.debug("Saved " + snapshot.size() + " permbans to SQL database"); + saveToJson(); + } + catch (Exception ex) + { + FLog.warning("Failed to save permbans to SQL: " + ex.getMessage()); } } + /** + * Flush pending changes and re-read the list. Safe from a command handler: the flush is + * queued rather than awaited, and {@link #load()} applies its result asynchronously. + */ public void reload() { - onStop(); - onStart(); + saveAsync(); + load(); } - + + /** + * Queue a write of every permban record, followed by a refresh of the JSON snapshot. + */ + public void saveAsync() + { + if (!usingSql || plugin.dm == null || !plugin.dm.isInitialized()) + { + enqueue(writeJsonAsync()); + return; + } + + final List snapshot; + synchronized (lock) + { + snapshot = new ArrayList<>(permbansByName.values()); + } + + final PermbanRepository repo = plugin.dm.getPermbanRepository(); + enqueue(Flux.fromIterable(snapshot) + .concatMap(permban -> repo.save(permban) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to save permban %s to SQL: %s", + permban.getUsername(), ex.getMessage())); + return Mono.empty(); + })) + .then(writeJsonAsync())); + } + + /** * Add a permban. */ @@ -203,9 +400,9 @@ public void addPermban(PermBan permban) } if (sql) - { savePermbanToSqlAsync(permban); - } + else + saveToJson(); } /** @@ -220,20 +417,17 @@ public boolean removePermban(String username) { permban = permbansByName.remove(name); if (permban == null) - { return false; - } permbannedNames.remove(name); - // Remove IPs associated with this permban permbannedIps.removeAll(permban.getIps()); sql = usingSql; } if (sql) - { removePermbanFromSqlAsync(name); - } + else + saveToJson(); return true; } @@ -260,9 +454,7 @@ public List removePermbansByIp(String ip) } if (!matches) - { continue; - } final String name = permban.getUsername().toLowerCase().trim(); permbansByName.remove(name); @@ -273,20 +465,17 @@ public List removePermbansByIp(String ip) // Rebuild the IP view so IPs shared with surviving permbans are retained. permbannedIps.clear(); for (PermBan permban : permbansByName.values()) - { permbannedIps.addAll(permban.getIps()); - } sql = usingSql; } if (sql) - { for (String name : removedNames) - { - removePermbanFromSqlAsync(name.toLowerCase().trim()); - } - } + removePermbanFromSqlAsync(name.toLowerCase().trim()); // why does this look so nice without brackets lmao + + else if (!removedNames.isEmpty()) + saveToJson(); return removedNames; } @@ -325,69 +514,50 @@ public Set getPermbannedIps() } /** - * Save a single permban to SQL. + * Queue a single permban for an off-thread SQL write, followed by a refresh of the JSON + * snapshot. Safe to call from commands and event handlers. */ private void savePermbanToSqlAsync(PermBan permban) - { - if (!plugin.isEnabled()) - { - savePermbanToSql(permban); - return; - } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> savePermbanToSql(permban)); - } - - private void savePermbanToSql(PermBan permban) { if (plugin.dm == null || !plugin.dm.isInitialized()) { + FLog.warning("SQL not available; permban change was not saved"); return; } - synchronized (persistenceLock) - { - try - { - plugin.dm.getPermbanRepository().save(permban).join(); - } - catch (Exception ex) - { - FLog.warning("Failed to save permban to SQL: " + ex.getMessage()); - } - } + final PermbanRepository repo = plugin.dm.getPermbanRepository(); + + enqueue(repo.save(permban) + .onErrorResume(ex -> + { + FLog.warning("Failed to save permban to SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync())); } - + /** - * Remove a single permban from SQL. + * Queue a single permban removal for an off-thread SQL write, followed by a refresh of + * the JSON snapshot. Safe to call from commands and event handlers. */ private void removePermbanFromSqlAsync(String name) - { - if (!plugin.isEnabled()) - { - removePermbanFromSql(name); - return; - } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> removePermbanFromSql(name)); - } - - private void removePermbanFromSql(String name) { if (plugin.dm == null || !plugin.dm.isInitialized()) { + FLog.warning("SQL not available; permban removal was not saved"); return; } - synchronized (persistenceLock) - { - try - { - plugin.dm.getPermbanRepository().deleteByUsername(name); - } - catch (Exception ex) - { - FLog.warning("Failed to remove permban from SQL: " + ex.getMessage()); - } - } + final PermbanRepository repo = plugin.dm.getPermbanRepository(); + + enqueue(Mono.fromCallable(() -> repo.deleteByUsername(name)) + .subscribeOn(Schedulers.boundedElastic()) + .onErrorResume(ex -> + { + FLog.warning("Failed to remove permban from SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync())); } @EventHandler(priority = EventPriority.LOWEST) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java index f4c2379ab..18341f43a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeList.java @@ -1,34 +1,49 @@ package me.totalfreedom.totalfreedommod.banning; -import com.google.common.collect.Maps; import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; +import java.lang.reflect.Type; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; +import java.util.List; import java.util.Map; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; +import me.totalfreedom.totalfreedommod.util.JsonUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; public class StrikeList extends FreedomService { + private static final Type STRIKE_MAP_TYPE = new TypeToken>() {}.getType(); + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; private final Map strikes = Maps.newHashMap(); private final File configFile; - private YamlConfiguration config; + private final PersistenceQueue writes = new PersistenceQueue("strike"); + private boolean usingSql = false; private boolean persistEnabled = true; public StrikeList(TotalFreedomMod plugin) { super(plugin); - this.configFile = new File(plugin.getDataFolder(), "strikes.yml"); - this.config = YamlConfiguration.loadConfiguration(configFile); + this.configFile = new File(plugin.getDataFolder(), "strikes.json"); } @Override @@ -46,49 +61,164 @@ protected void onStart() return; } + load(); + plugin.dm.whenReady(this::load); + } + + /** + * Populate the strike map from SQL where it is available and the JSON snapshot otherwise. + * Never blocks: the SQL read runs off-thread and is applied back on the main thread. + */ + public void load() + { + if (!persistEnabled) + return; + if (plugin.dm != null && plugin.dm.isInitialized()) { - loadFromSql(); - } - else - { - loadFromYaml(); + loadFromSqlAsync(); + return; } - pruneDecayed(); - FLog.info("Loaded " + strikes.size() + " strike records."); + loadFromJson(); + finishLoad(); } @Override protected void onStop() { - if (!persistEnabled) + if (!persistEnabled) + return; + + awaitPendingWrites(SHUTDOWN_FLUSH_TIMEOUT_MS); + + if (!usingSql) + saveToJson(); + + } + + /** + * Wait for queued async writes to land, up to {@code timeoutMs}. + */ + public void awaitPendingWrites(long timeoutMs) + { + writes.await(timeoutMs); + } + + private void enqueue(Mono work) + { + writes.enqueue(work); + } + + private Mono writeJsonAsync() + { + return Mono.fromRunnable(this::saveToJson) + .subscribeOn(Schedulers.boundedElastic()); + } + + private void loadFromSqlAsync() + { + final StrikeRepository repo = plugin.dm.getStrikeRepository(); + plugin.dm.readAsync("StrikeList/loadFromSql", repo.loadAllAsync(), + loaded -> applyLoadedStrikes(repo, loaded), + () -> + { + loadFromJson(); + finishLoad(); + }); + } + + private void applyLoadedStrikes(final StrikeRepository repo, final Map loaded) + { + strikes.clear(); + strikes.putAll(loaded); + usingSql = true; + + reconcileFromJsonIfNewer(repo); + finishLoad(); + } + + private void finishLoad() + { + pruneDecayed(); + FLog.info(String.format("Loaded %d strike records.", strikes.size())); + } + + /** + * If strikes.json was written more recently than the database's last update, re-import it + * into SQL. The comparison and the re-import both ride the write queue off the main thread. + */ + private void reconcileFromJsonIfNewer(final StrikeRepository repo) + { + if (!configFile.exists()) + { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + enqueue(writeJsonAsync()); + return; + } + + final Map jsonStrikes; + try + { + jsonStrikes = readJsonStrikes(); + } + catch (IOException ex) { + FLog.warning(String.format("Failed to read strikes.json: %s", ex.getMessage())); return; } - if (!usingSql) + + if (jsonStrikes.isEmpty()) { - saveAllToYaml(); + enqueue(writeJsonAsync()); + return; } + + final long fileModified = configFile.lastModified(); + + enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMap(ignored -> + { + FLog.info(String.format("strikes.json is newer than the database; rebuilding it from the file's %d strike record(s).", + jsonStrikes.size())); + return repo.loadAllAsync() + .flatMapMany(existing -> Flux.fromIterable(jsonStrikes.values()) + .concatMap(repo::upsertAsync) + .thenMany(Flux.fromIterable(existing.keySet()) + .filter(ip -> !jsonStrikes.containsKey(ip)) + .concatMap(repo::deleteByIpAsync))) + .then(Mono.fromRunnable(() -> plugin.dm.sync("StrikeList/applyReconciled", + () -> + { + strikes.clear(); + strikes.putAll(jsonStrikes); + }))); + }) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile strikes.json into the database: %s", + ex.getMessage())); + return Mono.empty(); + }) + .then()); } - private void loadFromSql() + private Map readJsonStrikes() throws IOException { - try - { - StrikeRepository repo = plugin.dm.getStrikeRepository(); - Map loaded = repo.loadAllAsync().join(); - strikes.putAll(loaded); - usingSql = true; - } - catch (Exception ex) + try (FileReader reader = new FileReader(configFile)) { - FLog.warning("Failed to load strikes from SQL, falling back to YAML: " + ex.getMessage()); - loadFromYaml(); + Map loaded = JsonUtil.GSON.fromJson(reader, STRIKE_MAP_TYPE); + return loaded != null ? loaded : Maps.newHashMap(); } } - private void loadFromYaml() + private void loadFromJson() { if (!configFile.exists()) { @@ -99,21 +229,17 @@ private void loadFromYaml() } catch (IOException ex) { - FLog.severe("Could not create strikes.yml"); + FLog.severe("Could not create strikes.json"); } } - config = YamlConfiguration.loadConfiguration(configFile); - for (String ip : config.getKeys(false)) + try { - if (!config.isConfigurationSection(ip)) - { - continue; - } - ConfigurationSection cs = config.getConfigurationSection(ip); - StrikeRecord r = new StrikeRecord(ip); - r.loadFrom(cs); - strikes.put(ip, r); + strikes.putAll(readJsonStrikes()); + } + catch (IOException ex) + { + FLog.severe("Could not read strikes.json: " + ex.getMessage()); } usingSql = false; } @@ -122,38 +248,46 @@ private void pruneDecayed() { final int decayHours = decayHours(); if (decayHours <= 0) - { return; - } - int removed = 0; + + final List prunedIps = new ArrayList<>(); for (Iterator> it = strikes.entrySet().iterator(); it.hasNext(); ) { Map.Entry e = it.next(); if (e.getValue().effectiveCount(decayHours) == 0) { it.remove(); - removed++; - if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) - { - try - { - plugin.dm.getStrikeRepository().deleteByIpAsync(e.getKey()); - } - catch (Exception ex) - { - FLog.warning("Failed to prune decayed strike for " + e.getKey() + ": " + ex.getMessage()); - } - } + prunedIps.add(e.getKey()); } } - if (removed > 0 && !usingSql) + + if (prunedIps.isEmpty()) + return; + + if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - saveAllToYamlAsync(); + final StrikeRepository repo = plugin.dm.getStrikeRepository(); + enqueue(Flux.fromIterable(prunedIps) + .concatMap(ip -> repo.deleteByIpAsync(ip) + .onErrorResume(ex -> + { + FLog.warning(String.format( + "Failed to prune decayed strike for %s: %s", + ip, + ex.getMessage() + ) + ); + return Mono.empty(); + }) + ) + .then(writeJsonAsync())); } - if (removed > 0) + else { - FLog.info("Pruned " + removed + " decayed strike record(s)."); + enqueue(writeJsonAsync()); } + + FLog.info("Pruned " + prunedIps.size() + " decayed strike record(s)."); } private int decayHours() @@ -168,25 +302,22 @@ public synchronized int recordStrikeAndGet(String ip, String username) final int decay = decayHours(); int base = 0; if (r != null) - { base = r.effectiveCount(decay); - } + if (r == null) { r = new StrikeRecord(ip); strikes.put(ip, r); } + r.setCount(base + 1); r.setLastStrikeUnix(System.currentTimeMillis() / 1000L); if (username != null) - { r.setLastUsername(username); - } if (persistEnabled) - { persist(r); - } + return r.getCount(); } @@ -194,9 +325,8 @@ public synchronized int peek(String ip) { StrikeRecord r = strikes.get(ip); if (r == null) - { return 0; - } + return r.effectiveCount(decayHours()); } @@ -204,28 +334,24 @@ public synchronized boolean clear(String ip) { StrikeRecord removed = strikes.remove(ip); if (removed == null) - { return false; - } + if (!persistEnabled) - { return true; - } + if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - try - { - plugin.dm.getStrikeRepository().deleteByIpAsync(ip); - } - catch (Exception ex) - { - FLog.warning("Failed to clear strike from SQL: " + ex.getMessage()); - } - } - else - { - saveAllToYamlAsync(); + final StrikeRepository repo = plugin.dm.getStrikeRepository(); + enqueue(repo.deleteByIpAsync(ip) + .onErrorResume(ex -> + { + FLog.warning("Failed to clear strike from SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync())); } + else enqueue(writeJsonAsync()); // else on same line maybe??? that is nice tbh + return true; } @@ -238,48 +364,27 @@ private void persist(StrikeRecord r) { if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - try - { - plugin.dm.getStrikeRepository().upsertAsync(r); - } - catch (Exception ex) - { - FLog.warning("Failed to persist strike to SQL: " + ex.getMessage()); - } - } - else - { - saveAllToYamlAsync(); - } - } - - private void saveAllToYamlAsync() - { - if (!plugin.isEnabled()) - { - saveAllToYaml(); - return; + final StrikeRepository repo = plugin.dm.getStrikeRepository(); + enqueue(repo.upsertAsync(r) + .onErrorResume(ex -> + { + FLog.warning("Failed to persist strike to SQL: " + ex.getMessage()); + return Mono.empty(); + }) + .then(writeJsonAsync())); } - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, this::saveAllToYaml); + else enqueue(writeJsonAsync()); } - private synchronized void saveAllToYaml() + private synchronized void saveToJson() { - for (String key : config.getKeys(false)) - { - config.set(key, null); - } - for (StrikeRecord r : strikes.values()) - { - r.saveTo(config.createSection(r.getIp())); - } - try + try (FileWriter writer = new FileWriter(configFile)) { - config.save(configFile); + JsonUtil.GSON.toJson(strikes, STRIKE_MAP_TYPE, writer); } catch (IOException ex) { - FLog.severe("Could not save strikes.yml"); + FLog.severe("Could not save strikes.json"); } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeRecord.java b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeRecord.java index 3c111226a..cd73f284a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeRecord.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/banning/StrikeRecord.java @@ -1,22 +1,13 @@ package me.totalfreedom.totalfreedommod.banning; -import lombok.Getter; -import lombok.Setter; import org.bukkit.configuration.ConfigurationSection; public class StrikeRecord { - @Getter private final String ip; - @Getter - @Setter private int count; - @Getter - @Setter private long lastStrikeUnix; - @Getter - @Setter private String lastUsername; public StrikeRecord(String ip) @@ -24,6 +15,41 @@ public StrikeRecord(String ip) this.ip = ip; } + public String getIp() + { + return ip; + } + + public int getCount() + { + return count; + } + + public void setCount(int count) + { + this.count = count; + } + + public long getLastStrikeUnix() + { + return lastStrikeUnix; + } + + public void setLastStrikeUnix(long lastStrikeUnix) + { + this.lastStrikeUnix = lastStrikeUnix; + } + + public String getLastUsername() + { + return lastUsername; + } + + public void setLastUsername(String lastUsername) + { + this.lastUsername = lastUsername; + } + public StrikeRecord(String ip, int count, long lastStrikeUnix, String lastUsername) { this.ip = ip; @@ -53,10 +79,4 @@ public void loadFrom(ConfigurationSection cs) this.lastUsername = cs.getString("last_username", null); } - public void saveTo(ConfigurationSection cs) - { - cs.set("count", count); - cs.set("last_strike_unix", lastStrikeUnix); - cs.set("last_username", lastUsername); - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java index d062bdedd..66b3e637e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/BlockBlocker.java @@ -1,15 +1,6 @@ package me.totalfreedom.totalfreedommod.blocking; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.blocking.sign.SignBlocks; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Material; -import org.bukkit.entity.Entity; import org.bukkit.entity.FallingBlock; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -19,6 +10,16 @@ import org.bukkit.event.entity.EntitySpawnEvent; import org.bukkit.inventory.ItemStack; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sign.SignBlocks; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class BlockBlocker extends FreedomService { @@ -142,6 +143,7 @@ public void onBlockPlace(BlockPlaceEvent event) event.setCancelled(true); break; } + default: break; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java index 791e134cb..d10bacef1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/EventBlocker.java @@ -1,23 +1,9 @@ package me.totalfreedom.totalfreedommod.blocking; import io.papermc.paper.event.block.BlockPreDispenseEvent; -import me.totalfreedom.totalfreedommod.EntityWiper; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; import org.bukkit.GameMode; import org.bukkit.Material; -import org.bukkit.entity.ArmorStand; -import org.bukkit.entity.Boat; -import org.bukkit.entity.Entity; -import org.bukkit.entity.EntityType; -import org.bukkit.entity.FallingBlock; -import org.bukkit.entity.Hanging; -import org.bukkit.entity.Item; -import org.bukkit.entity.Minecart; -import org.bukkit.entity.Player; -import org.bukkit.entity.Projectile; -import org.bukkit.entity.Tameable; +import org.bukkit.entity.*; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.block.BlockBurnEvent; @@ -30,21 +16,17 @@ import org.bukkit.event.block.BlockRedstoneEvent; import org.bukkit.event.block.BlockSpreadEvent; import org.bukkit.event.block.LeavesDecayEvent; -import org.bukkit.event.entity.EntityCombustEvent; -import org.bukkit.event.entity.EntityDamageEvent; -import org.bukkit.event.entity.EntityDeathEvent; -import org.bukkit.event.entity.EntityRegainHealthEvent; -import org.bukkit.event.entity.EntityExplodeEvent; -import org.bukkit.event.entity.EntitySpawnEvent; -import org.bukkit.event.entity.ExplosionPrimeEvent; -import org.bukkit.event.entity.ProjectileHitEvent; -import org.bukkit.event.entity.SpawnerSpawnEvent; -import org.bukkit.event.entity.TrialSpawnerSpawnEvent; +import org.bukkit.event.entity.*; import org.bukkit.event.inventory.InventoryMoveItemEvent; import org.bukkit.event.inventory.InventoryPickupItemEvent; import org.bukkit.event.player.PlayerDropItemEvent; import org.bukkit.event.world.PortalCreateEvent; +import me.totalfreedom.totalfreedommod.EntityWiper; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; + public class EventBlocker extends FreedomService { @@ -196,6 +178,7 @@ public void onEntityDamage(EntityDamageEvent event) return; } } + default: break; } if (ConfigEntry.ENABLE_PET_PROTECT.getBoolean()) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/InteractBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/InteractBlocker.java index 97bdb3782..70b3a32dd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/InteractBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/InteractBlocker.java @@ -1,11 +1,5 @@ package me.totalfreedom.totalfreedommod.blocking; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -13,6 +7,14 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.ItemStack; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; + public class InteractBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/MobBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/MobBlocker.java index 7bad99b49..4b59d04fa 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/MobBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/MobBlocker.java @@ -1,12 +1,7 @@ package me.totalfreedom.totalfreedommod.blocking; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; +import java.util.*; + import org.bukkit.Location; import org.bukkit.block.Block; import org.bukkit.entity.*; @@ -21,7 +16,14 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.SpawnEggMeta; -import java.util.*; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; public class MobBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/PotionBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/PotionBlocker.java index b51aeb13a..cb8dde654 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/PotionBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/PotionBlocker.java @@ -1,10 +1,7 @@ package me.totalfreedom.totalfreedommod.blocking; import java.util.Collection; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.entity.Arrow; import org.bukkit.entity.Player; import org.bukkit.entity.ThrownPotion; @@ -19,6 +16,12 @@ import org.bukkit.potion.PotionEffectType; import org.bukkit.projectiles.ProjectileSource; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public class PotionBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlocker.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlocker.java index ab15989e2..4698bfb9e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlocker.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlocker.java @@ -1,13 +1,20 @@ package me.totalfreedom.totalfreedommod.blocking.command; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; + +import org.bukkit.command.*; +import org.bukkit.entity.Player; +import org.bukkit.entity.minecart.CommandMinecart; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.server.ServerCommandEvent; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.command.CommandBlockerEntry.PatternToken; @@ -15,18 +22,9 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.command.Command; -import org.bukkit.command.CommandMap; -import org.bukkit.command.BlockCommandSender; -import org.bukkit.command.CommandSender; -import org.bukkit.command.ConsoleCommandSender; -import org.bukkit.command.RemoteConsoleCommandSender; -import org.bukkit.entity.minecart.CommandMinecart; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.server.ServerCommandEvent; -import org.bukkit.event.player.PlayerCommandPreprocessEvent; -import org.bukkit.entity.Player; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; public class CommandBlocker extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerEntry.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerEntry.java index 3f96e3d31..ea8f4c2f9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerEntry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerEntry.java @@ -3,13 +3,17 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import lombok.Getter; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import lombok.Getter; + public class CommandBlockerEntry { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java index 6bbc04fe9..9fdad871e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/command/CommandBlockerRank.java @@ -1,11 +1,11 @@ package me.totalfreedom.totalfreedommod.blocking.command; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.rank.Rank; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.admin.Admin; + public enum CommandBlockerRank { @@ -43,7 +43,7 @@ public static CommandBlockerRank fromSender(CommandSender sender) Admin admin = PluginProvider.get().al.getAdmin(sender); if (admin != null) { - if (admin.getRank() == Rank.SENIOR_ADMIN) + if (PluginProvider.get().al.isSeniorAdmin(sender)) { return SENIOR; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityMetaPacketGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityMetaPacketGuard.java index 44194ab74..b150c8aa6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityMetaPacketGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityMetaPacketGuard.java @@ -1,16 +1,19 @@ package me.totalfreedom.totalfreedommod.blocking.entity; +import java.util.List; +import java.util.Optional; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; + import com.github.retrooper.packetevents.protocol.attribute.Attribute; import com.github.retrooper.packetevents.protocol.attribute.Attributes; import com.github.retrooper.packetevents.protocol.entity.data.EntityData; import com.github.retrooper.packetevents.protocol.entity.data.EntityDataType; import com.github.retrooper.packetevents.protocol.entity.data.EntityDataTypes; import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerUpdateAttributes; -import java.util.List; -import java.util.Optional; + import me.totalfreedom.totalfreedommod.util.ComponentScanner; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; public final class EntityMetaPacketGuard { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityNameValidator.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityNameValidator.java index 15e4d653d..428b41a23 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityNameValidator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntityNameValidator.java @@ -1,7 +1,17 @@ package me.totalfreedom.totalfreedommod.blocking.entity; -import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; import java.util.regex.Pattern; + +import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; +import org.bukkit.entity.Entity; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.server.ServerCommandEvent; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; @@ -11,13 +21,6 @@ import me.totalfreedom.totalfreedommod.util.DetectionReporter; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.entity.Entity; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.player.PlayerCommandPreprocessEvent; -import org.bukkit.event.server.ServerCommandEvent; /** * Strips format bomb / oversized custom names from entities before the client diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntitySizeGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntitySizeGuard.java index 911ee5316..6b8bb9804 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntitySizeGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/EntitySizeGuard.java @@ -1,13 +1,6 @@ package me.totalfreedom.totalfreedommod.blocking.entity; import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.DetectionReporter; -import me.totalfreedom.totalfreedommod.util.FLog; import org.bukkit.Art; import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeInstance; @@ -20,6 +13,14 @@ import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.event.entity.EntityTransformEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.DetectionReporter; +import me.totalfreedom.totalfreedommod.util.FLog; + /** * Hard cap on Slime#setSize and the SCALE attribute. * Prevents tick stalls in Entity#checkInsideBlocks. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/ProjectileGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/ProjectileGuard.java index 21e71d077..74309f72a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/ProjectileGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/ProjectileGuard.java @@ -1,7 +1,18 @@ package me.totalfreedom.totalfreedommod.blocking.entity; -import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; import java.util.Set; + +import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; +import org.bukkit.entity.*; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.entity.EntityMountEvent; +import org.bukkit.event.entity.EntitySpawnEvent; +import org.bukkit.event.entity.ProjectileLaunchEvent; +import org.bukkit.util.Vector; + +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; @@ -11,18 +22,6 @@ import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.entity.AbstractWindCharge; -import org.bukkit.entity.EntityType; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Player; -import org.bukkit.entity.Projectile; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.entity.EntityMountEvent; -import org.bukkit.event.entity.EntitySpawnEvent; -import org.bukkit.event.entity.ProjectileLaunchEvent; -import org.bukkit.util.Vector; /** * Removes projectiles that load chunks or otherwise threaten server stability diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/TextDisplayGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/TextDisplayGuard.java index 3e65d5dd4..b88420b05 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/TextDisplayGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/TextDisplayGuard.java @@ -1,6 +1,16 @@ package me.totalfreedom.totalfreedommod.blocking.entity; import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; +import org.bukkit.entity.Display; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Interaction; +import org.bukkit.entity.TextDisplay; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.entity.EntitySpawnEvent; + +import net.kyori.adventure.text.Component; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; @@ -9,14 +19,6 @@ import me.totalfreedom.totalfreedommod.util.ComponentScanner; import me.totalfreedom.totalfreedommod.util.DetectionReporter; import me.totalfreedom.totalfreedommod.util.FLog; -import net.kyori.adventure.text.Component; -import org.bukkit.entity.Display; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Interaction; -import org.bukkit.entity.TextDisplay; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.entity.EntitySpawnEvent; /** * Removes certain text display entities that can be used to crash clients. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/WaypointGuard.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/WaypointGuard.java index b3faa3418..fe6f08c9c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/WaypointGuard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/entity/WaypointGuard.java @@ -1,14 +1,8 @@ package me.totalfreedom.totalfreedommod.blocking.entity; -import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; import java.util.ArrayList; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.DetectionReporter; -import me.totalfreedom.totalfreedommod.util.FLog; + +import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent; import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeInstance; import org.bukkit.attribute.AttributeModifier; @@ -18,6 +12,14 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.DetectionReporter; +import me.totalfreedom.totalfreedommod.util.FLog; + public class WaypointGuard extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ConsoleSpamFilter.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ConsoleSpamFilter.java index 84a116bf2..8e8b823f8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ConsoleSpamFilter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ConsoleSpamFilter.java @@ -3,6 +3,7 @@ import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; + import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Marker; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/EntityDataRules.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/EntityDataRules.java index 16e1b1243..81d698ff0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/EntityDataRules.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/EntityDataRules.java @@ -4,6 +4,7 @@ import java.util.Locale; import java.util.Optional; import java.util.Set; + import org.bukkit.Material; final class EntityDataRules diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemScanner.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemScanner.java index d29ac97eb..8232d49f3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemScanner.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemScanner.java @@ -1,31 +1,13 @@ package me.totalfreedom.totalfreedommod.blocking.item; +import java.util.List; + import io.papermc.paper.datacomponent.DataComponentTypes; -import io.papermc.paper.datacomponent.item.BannerPatternLayers; -import io.papermc.paper.datacomponent.item.BlocksAttacks; -import io.papermc.paper.datacomponent.item.BundleContents; -import io.papermc.paper.datacomponent.item.ChargedProjectiles; -import io.papermc.paper.datacomponent.item.CustomModelData; -import io.papermc.paper.datacomponent.item.Equippable; -import io.papermc.paper.datacomponent.item.Fireworks; -import io.papermc.paper.datacomponent.item.ItemAttributeModifiers; -import io.papermc.paper.datacomponent.item.ItemContainerContents; -import io.papermc.paper.datacomponent.item.ItemLore; -import io.papermc.paper.datacomponent.item.JukeboxPlayable; -import io.papermc.paper.datacomponent.item.PotionContents; -import io.papermc.paper.datacomponent.item.SuspiciousStewEffects; -import io.papermc.paper.datacomponent.item.UseRemainder; -import io.papermc.paper.datacomponent.item.WritableBookContent; -import io.papermc.paper.datacomponent.item.WrittenBookContent; +import io.papermc.paper.datacomponent.item.*; import io.papermc.paper.datacomponent.item.attribute.AttributeModifierDisplay; import io.papermc.paper.datacomponent.item.blocksattacks.DamageReduction; import io.papermc.paper.registry.set.RegistryKeySet; import io.papermc.paper.text.Filtered; -import java.util.List; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.ComponentScanner; -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; import org.bukkit.FireworkEffect; import org.bukkit.JukeboxSong; import org.bukkit.MusicInstrument; @@ -37,6 +19,12 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemType; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.ComponentScanner; + final class ItemScanner { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemValidator.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemValidator.java index 26437a8d6..6b05d4353 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemValidator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/ItemValidator.java @@ -1,24 +1,14 @@ package me.totalfreedom.totalfreedommod.blocking.item; -import io.papermc.paper.datacomponent.DataComponentTypes; -import io.papermc.paper.event.block.BlockPreDispenseEvent; import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; -import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.DetectionReporter; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; + +import io.papermc.paper.datacomponent.DataComponentTypes; +import io.papermc.paper.event.block.BlockPreDispenseEvent; import org.apache.commons.lang3.exception.ExceptionUtils; import org.bukkit.Chunk; @@ -32,7 +22,6 @@ import org.bukkit.entity.Item; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; -import org.bukkit.inventory.EntityEquipment; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.block.Action; @@ -40,29 +29,24 @@ import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityPickupItemEvent; import org.bukkit.event.entity.ItemSpawnEvent; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.inventory.InventoryCreativeEvent; -import org.bukkit.event.inventory.InventoryDragEvent; -import org.bukkit.event.inventory.InventoryMoveItemEvent; -import org.bukkit.event.inventory.InventoryOpenEvent; -import org.bukkit.event.inventory.InventoryPickupItemEvent; -import org.bukkit.event.inventory.InventoryType; -import org.bukkit.event.inventory.PrepareAnvilEvent; -import org.bukkit.event.inventory.PrepareItemCraftEvent; -import org.bukkit.event.player.PlayerCommandPreprocessEvent; -import org.bukkit.event.player.PlayerDropItemEvent; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.event.player.PlayerItemConsumeEvent; -import org.bukkit.event.player.PlayerItemHeldEvent; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.event.player.PlayerSwapHandItemsEvent; +import org.bukkit.event.inventory.*; +import org.bukkit.event.player.*; import org.bukkit.event.server.ServerCommandEvent; import org.bukkit.event.world.LootGenerateEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.InventoryHolder; -import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.*; + +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sweep.EntityVisitor; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; +import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.DetectionReporter; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; public class ItemValidator extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/RawNbtInspector.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/RawNbtInspector.java index 41ae8bf6e..0d5e37294 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/RawNbtInspector.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/item/RawNbtInspector.java @@ -7,6 +7,7 @@ import java.util.Collection; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; + import org.bukkit.Material; import org.bukkit.inventory.ItemStack; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketListener.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketListener.java index afffd4f53..72a9eb790 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketListener.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketListener.java @@ -1,9 +1,22 @@ package me.totalfreedom.totalfreedommod.blocking.packet; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.format.NamedTextColor; + import com.github.retrooper.packetevents.event.PacketListenerAbstract; import com.github.retrooper.packetevents.event.PacketListenerPriority; import com.github.retrooper.packetevents.event.PacketReceiveEvent; import com.github.retrooper.packetevents.event.PacketSendEvent; +import com.github.retrooper.packetevents.protocol.component.ComponentTypes; +import com.github.retrooper.packetevents.protocol.component.builtin.item.ItemAttributeModifiers; import com.github.retrooper.packetevents.protocol.entity.data.EntityData; import com.github.retrooper.packetevents.protocol.nbt.NBTCompound; import com.github.retrooper.packetevents.protocol.packettype.PacketType; @@ -13,32 +26,11 @@ import com.github.retrooper.packetevents.protocol.recipe.data.MerchantOffer; import com.github.retrooper.packetevents.protocol.world.chunk.Column; import com.github.retrooper.packetevents.util.Vector3d; +import com.github.retrooper.packetevents.wrapper.play.client.*; +import com.github.retrooper.packetevents.wrapper.play.server.*; + import io.netty.buffer.ByteBuf; -import com.github.retrooper.packetevents.protocol.component.ComponentTypes; -import com.github.retrooper.packetevents.protocol.component.builtin.item.ItemAttributeModifiers; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientChatCommand; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientChatCommandUnsigned; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientChatMessage; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientCreativeInventoryAction; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientPlayerPosition; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientPlayerPositionAndRotation; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientTabComplete; -import com.github.retrooper.packetevents.wrapper.play.client.WrapperPlayClientVehicleMove; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerBlockEntityData; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerChunkData; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityEquipment; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityMetadata; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSetSlot; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSpawnLivingEntity; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerUpdateAttributes; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerMerchantOffers; -import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerWindowItems; -import io.github.retrooper.packetevents.util.SpigotConversionUtil; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.entity.EntityMetaPacketGuard; import me.totalfreedom.totalfreedommod.blocking.item.ContainerPacketGuard; @@ -49,9 +41,8 @@ import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FSync; import me.totalfreedom.totalfreedommod.util.FTask; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; + +import io.github.retrooper.packetevents.util.SpigotConversionUtil; final class CrashPacketListener extends PacketListenerAbstract { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketService.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketService.java index 9577342b8..4972c44d7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/packet/CrashPacketService.java @@ -1,17 +1,19 @@ package me.totalfreedom.totalfreedommod.blocking.packet; +import org.bukkit.event.EventHandler; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.plugin.Plugin; + import com.github.retrooper.packetevents.PacketEvents; import com.github.retrooper.packetevents.event.PacketListenerCommon; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.entity.EntityMetaPacketGuard; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.event.EventHandler; -import org.bukkit.event.player.PlayerQuitEvent; -import org.bukkit.event.player.PlayerRespawnEvent; -import org.bukkit.event.player.PlayerTeleportEvent; -import org.bukkit.plugin.Plugin; public class CrashPacketService extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sign/SignValidator.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sign/SignValidator.java index ca2b335d6..aa1239a5e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sign/SignValidator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sign/SignValidator.java @@ -1,18 +1,9 @@ package me.totalfreedom.totalfreedommod.blocking.sign; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; -import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.ComponentScanner; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; import java.util.HashSet; import java.util.Set; import java.util.function.Predicate; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.Material; @@ -31,6 +22,18 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.scheduler.BukkitTask; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; +import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.ComponentScanner; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class SignValidator extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java index b48bdf7b3..e95c6a998 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/spawner/SpawnerValidator.java @@ -1,13 +1,7 @@ package me.totalfreedom.totalfreedommod.blocking.spawner; import java.util.function.Predicate; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; -import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.DetectionReporter; -import me.totalfreedom.totalfreedommod.util.FLog; + import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.BlockState; @@ -23,6 +17,14 @@ import org.bukkit.event.entity.SpawnerSpawnEvent; import org.bukkit.event.entity.TrialSpawnerSpawnEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; +import me.totalfreedom.totalfreedommod.blocking.sweep.TileEntityVisitor; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.DetectionReporter; +import me.totalfreedom.totalfreedommod.util.FLog; + public class SpawnerValidator extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/SweepScheduler.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/SweepScheduler.java index 9c1e1068f..41c232d07 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/SweepScheduler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/SweepScheduler.java @@ -8,10 +8,7 @@ import java.util.Map; import java.util.Set; import java.util.function.BooleanSupplier; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; + import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.World; @@ -21,6 +18,11 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.world.ChunkLoadEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; + public class SweepScheduler extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/TileEntityVisitor.java b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/TileEntityVisitor.java index 51b262bd7..561098cc9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/TileEntityVisitor.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/blocking/sweep/TileEntityVisitor.java @@ -1,6 +1,7 @@ package me.totalfreedom.totalfreedommod.blocking.sweep; import java.util.function.Predicate; + import org.bukkit.block.Block; import org.bukkit.block.BlockState; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/bridge/CoreProtectBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/bridge/CoreProtectBridge.java index 4133872f0..98516318f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/bridge/CoreProtectBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/bridge/CoreProtectBridge.java @@ -3,15 +3,6 @@ import java.util.Collections; import java.util.List; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.coreprotect.CoreProtect; -import net.coreprotect.CoreProtectAPI; -import net.coreprotect.utility.Util; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; @@ -20,6 +11,18 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.plugin.Plugin; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import net.coreprotect.CoreProtect; +import net.coreprotect.CoreProtectAPI; +import net.coreprotect.utility.Util; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class CoreProtectBridge extends FreedomService { private static final int ROLLBACK_TIME = 2592000; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/bridge/EssentialsBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/bridge/EssentialsBridge.java index 878167f54..6ce4fc0ae 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/bridge/EssentialsBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/bridge/EssentialsBridge.java @@ -1,10 +1,11 @@ package me.totalfreedom.totalfreedommod.bridge; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.Bukkit; -import org.bukkit.plugin.Plugin; public class EssentialsBridge extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/bridge/LibsDisguisesBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/bridge/LibsDisguisesBridge.java index fc2272e16..92f21ab1f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/bridge/LibsDisguisesBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/bridge/LibsDisguisesBridge.java @@ -1,13 +1,15 @@ package me.totalfreedom.totalfreedommod.bridge; import java.lang.reflect.Method; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.disguise.DisallowedDisguises; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Player; -import org.bukkit.plugin.Plugin; /** * Bridge to LibsDisguises plugin. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditBridge.java index f7c1867c6..becfdb0b3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditBridge.java @@ -1,10 +1,5 @@ package me.totalfreedom.totalfreedommod.bridge; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; - import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -13,6 +8,11 @@ import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + public class WorldEditBridge extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditHook.java b/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditHook.java index 1e5355e37..0c94c37d5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditHook.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/bridge/WorldEditHook.java @@ -1,13 +1,31 @@ package me.totalfreedom.totalfreedommod.bridge; -import com.google.common.eventbus.Subscribe; -import com.sk89q.worldedit.EditSession; -import com.sk89q.worldedit.EmptyClipboardException; -import com.sk89q.worldedit.IncompleteRegionException; -import com.sk89q.worldedit.LocalSession; -import com.sk89q.worldedit.MaxChangedBlocksException; -import com.sk89q.worldedit.WorldEdit; -import com.sk89q.worldedit.WorldEditException; +import java.io.IOException; +import java.io.OutputStream; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.permissions.PermissionAttachment; +import org.bukkit.scheduler.BukkitTask; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import com.sk89q.worldedit.*; import com.sk89q.worldedit.bukkit.BukkitAdapter; import com.sk89q.worldedit.bukkit.WorldEditPlugin; import com.sk89q.worldedit.event.extent.EditSessionEvent; @@ -24,40 +42,15 @@ import com.sk89q.worldedit.world.block.BlockType; import com.sk89q.worldedit.world.registry.BlockMaterial; import com.sk89q.worldedit.world.registry.LegacyMapper; -import java.io.IOException; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.regex.Matcher; -import java.util.regex.Pattern; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.blocking.sweep.SweepContext; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.World; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.HandlerList; -import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerCommandPreprocessEvent; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.event.player.PlayerQuitEvent; -import org.bukkit.permissions.PermissionAttachment; -import org.bukkit.scheduler.BukkitTask; + +import com.google.common.eventbus.Subscribe; /** * Loaded only after WorldEditBridge has confirmed the WorldEdit plugin is diff --git a/src/main/java/me/totalfreedom/totalfreedommod/caging/CageData.java b/src/main/java/me/totalfreedom/totalfreedommod/caging/CageData.java index 788a12792..1d2ed6710 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/caging/CageData.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/caging/CageData.java @@ -1,9 +1,9 @@ package me.totalfreedom.totalfreedommod.caging; -import io.papermc.paper.datacomponent.item.ResolvableProfile; import java.util.ArrayList; import java.util.List; -import me.totalfreedom.totalfreedommod.player.FPlayer; + +import io.papermc.paper.datacomponent.item.ResolvableProfile; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -11,6 +11,8 @@ import org.bukkit.block.Skull; import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.player.FPlayer; + public class CageData { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/caging/Cager.java b/src/main/java/me/totalfreedom/totalfreedommod/caging/Cager.java index afa559a6d..79e71a08a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/caging/Cager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/caging/Cager.java @@ -1,10 +1,5 @@ package me.totalfreedom.totalfreedommod.caging; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -16,6 +11,13 @@ import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerQuitEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class Cager extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/BanCommandUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/BanCommandUtil.java index 53131c5e3..f3a147e29 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/BanCommandUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/BanCommandUtil.java @@ -5,12 +5,13 @@ import java.util.List; import java.util.Set; +import org.bukkit.entity.Player; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.entity.Player; final class BanCommandUtil { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandCandidates.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandCandidates.java new file mode 100644 index 000000000..8ebb1719a --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandCandidates.java @@ -0,0 +1,40 @@ +package me.totalfreedom.totalfreedommod.cmd; + +import java.util.List; + +import org.bukkit.Server; +import org.bukkit.command.CommandSender; + +/** + * Tab-completion source for arguments that are themselves a command line, as in {@code /wildcard} + * and {@code /gcmd}. + *

+ * Completion is delegated to the server's own command map, so the inner command gets the same + * suggestions it would get if it had been typed directly, arguments included. + */ +final class CommandCandidates +{ + + private CommandCandidates() + { + } + + /** + * Completions for the final word of {@code typed}, an entire inner command line, as seen by + * {@code sender}. + *

+ * The whole line has to be passed in because that is what decides the candidates: the words + * before the cursor are what tells the command map whether a command name or one of that + * command's arguments is being typed. The completions come back covering the final word only, + * which pairs with + * {@link me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer.Scope#ARGUMENT_TO_WORD}. + * + * @param sender the sender the suggestions are filtered for, not the player the command would + * eventually run as + */ + static List inner(Server server, CommandSender sender, String typed) + { + final List completions = server.getCommandMap().tabComplete(sender, typed); + return completions == null ? List.of() : completions; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandLoader.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandLoader.java index 28891d69b..fc9bd4835 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandLoader.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandLoader.java @@ -14,6 +14,18 @@ import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.Registry; +import org.bukkit.World; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.EntityType; +import org.bukkit.plugin.Plugin; +import org.bukkit.potion.PotionEffectType; + +import net.kyori.adventure.key.Key; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; import me.totalfreedom.totalfreedommod.TotalFreedomMod; @@ -22,18 +34,8 @@ import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.world.WorldTime; import me.totalfreedom.totalfreedommod.world.WorldWeather; -import net.kyori.adventure.key.Key; import org.apache.commons.lang3.exception.ExceptionUtils; -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.OfflinePlayer; -import org.bukkit.Registry; -import org.bukkit.World; -import org.bukkit.enchantments.Enchantment; -import org.bukkit.entity.EntityType; -import org.bukkit.plugin.Plugin; -import org.bukkit.potion.PotionEffectType; /** * Registers the custom argument resolvers into the {@link ResolverRegistry} and auto-discovers {@link FCommand} declarations. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandRegistry.java index 12a7f35c7..fe1e97652 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/CommandRegistry.java @@ -1,6 +1,5 @@ package me.totalfreedom.totalfreedommod.cmd; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java index b5506f155..fc3511fcb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminchat.java @@ -1,22 +1,26 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import java.util.List; + import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.FPlayer; + + @Command(name = "adminchat", - description = "AdminChat - Talk privately with other admins. Using the command by itself will toggle AdminChat on and off for all messages.", - usage = "/ [message...]", - aliases = {"o", "ac"}) -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.BOTH, permission = "tfm.admin.adminchat") + description = "AdminChat - Talk privately with other admins. Using the command by itself will toggle AdminChat on and off for all messages.", + usage = "/ [message...]", + aliases = {"o", "ac"}) +@Permission(source = SourceType.BOTH, permission = "tfm.admin.adminchat") public class Command_adminchat extends FCommand { @Callback - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.adminchat", source = SourceType.ONLY_IN_GAME) + @Permission(permission = "tfm.admin.adminchat", source = SourceType.ONLY_IN_GAME) public void toggle(Player sender) { final FPlayer fplayer = plugin().pl.getPlayer(sender); @@ -26,6 +30,12 @@ public void toggle(Player sender) msg(sender, "Toggled Admin Chat .", Formatter.booleanChoice("mode", mode)); } + @Completer(value = "", position = 0) + public List completeMessage(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void sendMessage(CommandSender sender, @Greedy String message) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java index 3b6a66f6d..986673998 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_admininfo.java @@ -4,15 +4,15 @@ import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.JoinConfiguration; -@Command(name = "admininfo", description = "Information on how to apply for admin.", usage = "/admininfo", aliases={"si", "ai", "staffinfo"}) -@Permission(level = Rank.OP, source = SourceType.BOTH, permission = "tfm.player.admininfo") +@Command(name = "admininfo", description = "Information on how to apply for admin.", usage = "/admininfo", aliases = {"si", "ai", "staffinfo"}) +@Permission(source = SourceType.BOTH, permission = "tfm.player.admininfo") public class Command_admininfo extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java index fc25c616c..876facc3a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adminworld.java @@ -1,12 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.World; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.world.WorldTime; import me.totalfreedom.totalfreedommod.world.WorldWeather; @@ -44,7 +44,7 @@ else if (plugin().wm.adminworld.canAccessWorld(player)) @Callback @Subcommand("guest add") - @Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME, permission = "tfm.world.adminworld") + @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.world.adminworld.manage") public void addGuest(Player sender, Player target) { if (plugin().wm.adminworld.addGuest(target, sender)) @@ -74,7 +74,7 @@ public void listGuests(CommandSender sender) @Callback @Subcommand("guest remove") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.world.adminworld") + @Permission(permission = "tfm.world.adminworld.manage") public void removeGuest(CommandSender sender, Player target) { if (plugin().wm.adminworld.removeGuest(target)) @@ -91,7 +91,7 @@ public void removeGuest(CommandSender sender, Player target) @Callback @Subcommand("guest purge") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.world.adminworld") + @Permission(permission = "tfm.world.adminworld.manage") public void purgeGuests(CommandSender sender) { plugin().wm.adminworld.purgeGuestList(); @@ -100,7 +100,7 @@ public void purgeGuests(CommandSender sender) @Callback @Subcommand("time") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.world.adminworld") + @Permission(permission = "tfm.world.adminworld.manage") public void setTime(CommandSender sender, WorldTime time) { plugin().wm.adminworld.setTimeOfDay(time); @@ -110,7 +110,7 @@ public void setTime(CommandSender sender, WorldTime time) @Callback @Subcommand("weather") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.world.adminworld") + @Permission(permission = "tfm.world.adminworld.manage") public void setWeather(CommandSender sender, WorldWeather weather) { plugin().wm.adminworld.setWeatherMode(weather); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java index ef88ad1e5..df3a46909 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_adventure.java @@ -1,13 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "adventure", description = "Quickly change your own gamemode to adventure, or define someone's username to change theirs.", usage = "/adventure [-a | name]", aliases = {"gma"}) @Permission(permission = "tfm.admin.gamemode") @@ -23,7 +23,7 @@ public void changeGamemodeSelf(Player player) @Callback @Subcommand("-a") // a switch wouldn't be really appropriate here due to the nature of the @Permission annotation - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeAll(CommandSender sender) { adminAction(sender, "Changing everyone's gamemode to Adventure"); @@ -31,7 +31,7 @@ public void changeGamemodeAll(CommandSender sender) } @Callback - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeOther(CommandSender sender, Player target) { target.setGameMode(GameMode.ADVENTURE); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java index 8e91af000..c472cf566 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_aeclear.java @@ -1,17 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.AreaEffectCloud; import org.bukkit.entity.Entity; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Command(name = "aeclear", description = "Removes all area-of-effect clouds on the server.", usage = "/aeclear", aliases = {"aec"}) -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.aeclear") +@Permission(permission = "tfm.admin.aeclear") public class Command_aeclear extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java index 21a1cf925..344716643 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_announce.java @@ -1,14 +1,21 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "announce", description = "Make an announcement", usage = "/announce ") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.announce") +@Permission(permission = "tfm.admin.announce") public class Command_announce extends FCommand { + @Completer(value = "", position = 0) + public List completeContent(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void broadcast(CommandSender sender, @Greedy String content) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_attributelist.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_attributelist.java index 8b53d12fe..d526942f8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_attributelist.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_attributelist.java @@ -3,14 +3,15 @@ import java.util.Comparator; import java.util.List; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.command.CommandSender; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.JoinConfiguration; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.NamespacedKey; -import org.bukkit.Registry; -import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java index 10837eee2..47a0e45f7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autoclear.java @@ -1,15 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "autoclear", description = "Toggle whether or not a player has their inventory automatically cleared when they join.", usage = "/autoclear ") -@Permission(permission = "tfm.admin.autoclear", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.autoclear") public class Command_autoclear extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java index 5fbc7e6ad..72612db9b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_autotp.java @@ -1,15 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "autotp", description = "Toggle whether or not a player is automatically teleported when they join.", usage = "/autotp ") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.autotp") +@Permission(permission = "tfm.admin.autotp") public class Command_autotp extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java index 44749343a..ba7f96323 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ban.java @@ -2,21 +2,21 @@ import java.util.List; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.GameMode; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; @Command(name = "ban", description = "Bans an online or previously known player and their known IP addresses.", usage = "/ [-s] [-nrb] [reason]", aliases = {"gtfo"}) -@Permission(permission = "tfm.admin.ban", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.ban") public class Command_ban extends FCommand { @@ -26,6 +26,12 @@ public List completeTarget(CommandSender sender, String partial) return NameCandidates.online(server(), partial); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void ban(CommandSender sender, String name, @Greedy String reason, @Switch("s") boolean silent, @Switch("nrb") boolean noRollback) { @@ -51,6 +57,9 @@ private void doBan(CommandSender sender, String name, String reason, boolean sil name = BanCommandUtil.getCanonicalName(name, player, data); + if (isProtectedAdminByName(sender, name)) + return; + if (plugin().bm.getByUsername(name) != null) { msg(sender, " is already banned.", diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java index 50162c33e..015878f20 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banip.java @@ -4,17 +4,17 @@ import java.util.List; import java.util.Objects; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "banip", description = "Bans an IP address or all known IP addresses for a player.", usage = "/banip [reason]") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.ban") +@Permission(permission = "tfm.admin.ban") public class Command_banip extends FCommand { @@ -25,9 +25,25 @@ public void banIps(CommandSender sender, @Resolve(value = "IPs", strategy = "all banIpsWithReason(sender, addressList, null); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void banIpsWithReason(CommandSender sender, @Resolve(value = "IPs", strategy = "allowPlayers,all") List addressList, @Greedy String reason) { + final boolean reachesAdmin = addressList.stream() + .map(InetAddress::getHostAddress) + .anyMatch(ip -> isProtectedAdminByIp(sender, ip)); + + if (reachesAdmin) + { + msg(sender, "You cannot IP-ban another admin."); + return; + } + adminAction(sender, "Banning address\":\"\">", Formatter.number("count", addressList.size()), Formatter.booleanChoice("plural", addressList.size() != 1), diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java index e8ba98eee..7abd68518 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banlist.java @@ -4,13 +4,13 @@ import java.util.List; import java.util.TreeSet; +import org.bukkit.command.CommandSender; + import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; @Command(name = "banlist", description = "Shows all banned players and IP addresses. Senior Admins may optionally use 'purge' to clear the list.", usage = "/banlist [purge]") @@ -62,7 +62,7 @@ public void showBanList(CommandSender sender) @Callback @Subcommand("purge") - @Permission(level = Rank.SENIOR_ADMIN, permission = "tfm.admin.banlist") + @Permission(permission = "tfm.admin.banlist.purge") public void purgeBans(CommandSender sender) { // Ok so apparently plugin().bm.purge() purges the banlist then returns an int to count how many bans were purged. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java index b612dfefb..e018d49aa 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_banname.java @@ -1,17 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import java.util.List; + import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.banning.Ban; -import java.util.List; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "banname", description = "Bans the specified name.", usage = "/banname [reason]") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.ban") +@Permission(permission = "tfm.admin.ban") public class Command_banname extends FCommand { @Completer(value = "", position = 0) @@ -26,14 +26,27 @@ public void banName(CommandSender sender, String name) banNameWithReason(sender, name, null); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void banNameWithReason(CommandSender sender, String name, @Greedy String reason) { + if (isProtectedAdminByName(sender, name)) + return; + if (plugin().bm.getByUsername(name) != null) { msg(sender, " is already banned.", Placeholder.unparsed("name", name)); return; } + + if (reason == null || reason.isEmpty()) + reason = "This username has been banned."; + final Ban ban = Ban.forPlayerName(name, sender, null, reason); plugin().bm.addBan(ban); @@ -41,6 +54,7 @@ public void banNameWithReason(CommandSender sender, String name, @Greedy String adminAction(sender, "Banning the username ", Placeholder.unparsed("name", name)); + final Player player = server().getPlayer(name); if (player != null) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java index 745acfe50..cb46094ce 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_blockcmd.java @@ -1,16 +1,16 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; -@Command(name = "blockcmd", description = "Block all commands for a specific player.", usage = "/ <-a | purge | >", aliases = {"blockcommands","blockcommand","bc","bcmd"}) -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.blockcmd") +@Command(name = "blockcmd", description = "Block all commands for a specific player.", usage = "/ <-a | purge | >", aliases = {"blockcommands", "blockcommand", "bc", "bcmd"}) +@Permission(permission = "tfm.admin.blockcmd") public class Command_blockcmd extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_bookspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_bookspy.java new file mode 100644 index 000000000..27a5b0890 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_bookspy.java @@ -0,0 +1,50 @@ +package me.totalfreedom.totalfreedommod.cmd; + +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +import org.bukkit.entity.Player; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.player.SpyMode; + +@Command(name = "bookspy", description = "Spy on book edits", usage = "/bookspy [ops | admins | all | off]", aliases = {"bspy"}) +@Permission(permission = "tfm.admin.bookspy", source = SourceType.ONLY_IN_GAME) +public class Command_bookspy extends FCommand +{ + @Callback + public void toggle(final Player player) + { + final PlayerData data = plugin().pl.getData(player); + bookSpy(player, data.isBookSpy() ? SpyMode.OFF : SpyMode.OPS); + } + + @Callback + public void bookSpy(final Player player, final SpyMode mode) + { + final PlayerData data = plugin().pl.getData(player); + + data.setBookSpyMode(mode); + plugin().pl.saveAsync(); + + switch (mode) + { + case OFF -> msg(player, "BookSpy disabled."); + case OPS -> msg(player, "BookSpy set to OPS mode. You will only see non-admins' book edits."); + case ADMINS -> msg(player, "BookSpy set to ADMINS mode. You will only see admins' book edits."); + case ALL -> msg(player, "BookSpy set to ALL mode. You will see both non-admins' and admins' book edits."); + } + } + + @Completer(value = "", position = 0) + public List completeMode(final Player player, final String partial) + { + final String lower = partial.toLowerCase(Locale.ROOT); + + return Stream.of("ops", "admins", "all", "off") + .filter(mode -> mode.startsWith(lower)) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java index 257b44d0a..c03bfcd0b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cage.java @@ -5,14 +5,14 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.caging.CageData; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "cage", description = "Place a cage around someone.", usage = "/cage (-s) [ ] | purge") -@Permission(permission = "tfm.admin.cage", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.cage") public class Command_cage extends FCommand { @Callback // /cage [-s] | Will auto-toggle, but switch -s can be used to guarantee uncaging. @@ -46,6 +46,9 @@ public void purge(final CommandSender sender) @Callback // /cage - No support for switch here because why would you supply a -s and then define materials? would default to switch above anyways. public void cage(final CommandSender sender, final Player player, final Material outer, final Material inner) { + if (isProtectedAdmin(sender, player)) + return; + final CageData data = plugin().pl.getPlayer(player).getCageData(); final Location loc = player.getLocation().clone().add(0, 1, 0); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cake.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cake.java index 8bb568dc3..d632950fb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cake.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cake.java @@ -10,10 +10,9 @@ import org.bukkit.inventory.meta.ItemMeta; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "cake", description = "For the people that are still alive.", usage = "/cake") -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.BOTH, permission = "tfm.fun.cake") +@Permission(source = SourceType.BOTH, permission = "tfm.fun.cake") public class Command_cake extends FCommand { public static final String CAKE_LYRICS = "But there's no sense crying over every mistake. You just keep on trying till you run out of cake."; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java index 3393e18d7..4af5f321f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_chat.java @@ -1,18 +1,25 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command( name = "chat", description = "Send a chat message without needing to verify your ID with Microsoft.", usage = "/chat ", aliases = {"c","fuckofcom"}) -@Permission(permission = "tfm.player.chat", level = Rank.NON_OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.player.chat", source = SourceType.ONLY_IN_GAME) public final class Command_chat extends FCommand { + @Completer(value = "", position = 0) + public List completeMessage(final Player player, final String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void chat(final Player player, final @Greedy String message) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cleanchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cleanchat.java index 70a6fa236..c67a0872f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cleanchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cleanchat.java @@ -3,10 +3,9 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "cleanchat", description = "Clears the chat.", usage = "/cleanchat", aliases = {"cc", "clearchat"}) -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.cleanchat") +@Permission(permission = "tfm.admin.cleanchat") public class Command_cleanchat extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java index d08f55bb2..02dbce3e6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cmdspy.java @@ -7,11 +7,10 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.player.CommandSpyMode; -import me.totalfreedom.totalfreedommod.rank.Rank; +import me.totalfreedom.totalfreedommod.player.SpyMode; -@Command(name = "cmdspy", description = "Spy on commands", usage = "/ [admins | ops | all]", aliases = {"commandspy", "cspy"}) -@Permission(permission = "tfm.admin.cmdspy", source = SourceType.ONLY_IN_GAME, level = Rank.SUPER_ADMIN) +@Command(name = "cmdspy", description = "Spy on commands", usage = "/cmdspy [ops | admins | all | off]", aliases = {"commandspy", "cspy"}) +@Permission(permission = "tfm.admin.cmdspy", source = SourceType.ONLY_IN_GAME) public class Command_cmdspy extends FCommand { // doing this to show ajax why i don't like using var keyword :) @@ -19,11 +18,11 @@ public class Command_cmdspy extends FCommand public void toggle(final Player player) { final var pd = plugin().pl.getPlayer(player); - commandSpy(player, pd.cmdspyEnabled() ? CommandSpyMode.OFF : CommandSpyMode.ALL); + commandSpy(player, pd.cmdspyEnabled() ? SpyMode.OFF : SpyMode.ALL); } @Callback - public void commandSpy(final Player player, final CommandSpyMode mode) // should auto resolve enums + public void commandSpy(final Player player, final SpyMode mode) // should auto resolve enums { final var fp = plugin().pl.getPlayer(player); final var pd = plugin().pl.getData(player); @@ -34,7 +33,7 @@ public void commandSpy(final Player player, final CommandSpyMode mode) // should switch (mode) { - case OFF -> msg(player, "CommandSpy disabled."); + case OFF -> msg(player, "CommandSpy disabled."); case ADMINS -> msg(player, "CommandSpy set to ADMINS mode. You will only see admins' commands."); case OPS -> msg(player, "CommandSpy set to OPS mode. You will only see OPs' commands."); case ALL -> msg(player, "CommandSpy set to ALL mode. You will see both OPs' and admins' commands."); @@ -46,7 +45,7 @@ public List completeMode(final Player player, final String partial) { final String lower = partial.toLowerCase(Locale.ROOT); - return Stream.of("admins", "ops", "all", "off") + return Stream.of("ops", "admins", "all", "off") .filter(mode -> mode.startsWith(lower)) .toList(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java index d9ba7d2e8..f4c927522 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_consolesay.java @@ -2,14 +2,14 @@ import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "consolesay", description = "Send a chat message with chat formatting over SSH.", usage = "/ ", aliases = {"csay"}) -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.consolesay") +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.consolesay") public class Command_consolesay extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java index 1b8048d9a..30229da06 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_cookie.java @@ -10,13 +10,13 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import net.kyori.adventure.text.Component; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; @Command(name = "cookie", description = "For those who have no friends.", usage = "/cookie") -@Permission(permission = "tfm.fun.cookie", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.fun.cookie") public class Command_cookie extends FCommand { public static final String COOKIE_LYRICS = "Imagine that you have zero cookies and you split them evenly among zero friends. How many cookies does each person get? See? It doesn't make sense. And Cookie Monster is sad that there are no cookies, and you are sad that you have no friends."; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java index 7f6f1b3ca..453bd6b1c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_crash.java @@ -1,26 +1,23 @@ package me.totalfreedom.totalfreedommod.cmd; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Particle; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Command(name = "crash", description = "Crashes the specified player", usage = "/crash ", aliases = {"fuckup"}) -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.fuckup") +@Permission(permission = "tfm.admin.fuckup") public class Command_crash extends FCommand { @Callback public void crash(final CommandSender sender, final Player player) { - if (plugin().al.isAdmin(player)) - { - msg(sender, "This command cannot be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } player.spawnParticle( Particle.ASH, diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java index 3a7a2caa0..eb58f9a6f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_creative.java @@ -5,15 +5,15 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command( - name = "creative", - description = "Quickly change your own gamemode to creative, or define someone's username to change theirs.", - usage = "/creative <-a | [partialname]>", - aliases = {"gmc"} + name = "creative", + description = "Quickly change your own gamemode to creative, or define someone's username to change theirs.", + usage = "/creative <-a | [partialname]>", + aliases = {"gmc"} ) @Permission(permission = "tfm.player.creative") public class Command_creative extends FCommand @@ -28,7 +28,7 @@ public void changeGamemodeSelf(Player player) @Callback @Subcommand("-a") - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeAll(CommandSender sender) { Bukkit.getOnlinePlayers().forEach(player -> player.setGameMode(GameMode.CREATIVE)); @@ -36,7 +36,7 @@ public void changeGamemodeAll(CommandSender sender) } @Callback - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeOther(CommandSender sender, Player target) { target.setGameMode(GameMode.CREATIVE); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java index 71f0bcf18..3132e2950 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deafen.java @@ -1,17 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; +import java.util.List; +import java.util.Random; + import org.bukkit.Location; import org.bukkit.Registry; import org.bukkit.Sound; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import java.util.List; -import java.util.Random; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -@Permission(level = Rank.SENIOR_ADMIN, permission = "tfm.admin.senior.deafen") +@Permission(permission = "tfm.admin.senior.deafen") @Command(name = "deafen", description = "Make some noise.", usage = "/") public class Command_deafen extends FCommand { @@ -23,8 +23,8 @@ public class Command_deafen extends FCommand private static Location randomOffset(Location a, double magnitude) { return a.clone().add(random.nextDouble(-magnitude, magnitude), - random.nextDouble(-magnitude, magnitude), - random.nextDouble(-magnitude, magnitude)); + random.nextDouble(-magnitude, magnitude), + random.nextDouble(-magnitude, magnitude)); } private static Sound getRandomSound() @@ -48,13 +48,10 @@ public void deafenNoArgument(CommandSender sender) server().getOnlinePlayers().forEach(this::playNoiseSequence); } - // cuz why not make it able to target one player? @Callback public void deafenPlayer(CommandSender sender, Player player) { - for (int x = 0; x <= server().getOnlinePlayers().size(); x++) // using player size since that's how the other method functions - { + for (int x = 0; x <= server().getOnlinePlayers().size(); x++) playNoiseSequence(player); - } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java index b99c1bc9b..acd83cac0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_deop.java @@ -3,17 +3,20 @@ import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "deop", description = "Deop a player.", usage = "/deop ") -@Permission(permission = "tfm.admin.deop", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.deop") public class Command_deop extends FCommand { @Callback public void deop(CommandSender sender, OfflinePlayer player) { + if (isProtectedAdminByName(sender, player.getName())) + return; + adminAction(sender, "De-opping ", Placeholder.unparsed("player", player.getName())); player.setOp(false); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java index 6ea414259..843dfd68a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_disguisetoggle.java @@ -2,12 +2,12 @@ import org.bukkit.command.CommandSender; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "disguisetoggle", description = "Toggle the disguise plugin", usage = "/disguisetoggle", aliases = {"dtoggle"}) -@Permission(permission = "tfm.admin.disguisetoggle", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.disguisetoggle") public class Command_disguisetoggle extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java index 83aa95b51..274297148 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_doom.java @@ -1,18 +1,23 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; - import org.bukkit.GameMode; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.util.Vector; -@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.fun.doom") +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; + +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.banning.Ban; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + +/** + * Every other disciplinary or destructive command refuses an admin target through + * {@link FCommand#isProtectedAdmin} or {@link FCommand#isProtectedAdminByName}. + * This one is the way an admin is removed, so it carries no such guard and is fenced by reach instead at console only. + */ +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.senior.doom") @Command(name = "doom", description = "For the bad admins", usage = "/doom ") public class Command_doom extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_enchant.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_enchant.java index b0bb55cbc..0574fbac7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_enchant.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_enchant.java @@ -1,17 +1,18 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import org.bukkit.Material; import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; -import java.util.List; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.player.enchant") @Command(name = "enchant", description = "Enchant items.", usage = "/ [level] | remove >") diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java index 5f977ec3f..cec1ab740 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_entitywipe.java @@ -3,13 +3,13 @@ import org.bukkit.World; import org.bukkit.command.CommandSender; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "entitywipe", description = "Remove various server entities that may cause lag, such as dropped items, minecarts, and boats.", usage = "/entitywipe [world]", aliases = {"ew", "rd"}) -@Permission(permission = "tfm.server.entitywipe", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.server.entitywipe") public class Command_entitywipe extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java index f34310a5a..7dc3557e0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_expel.java @@ -1,23 +1,24 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.util.FUtil; +import java.util.List; + +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.JoinConfiguration; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import org.bukkit.Location; -import org.bukkit.entity.Player; -import org.bukkit.util.Vector; -import java.util.List; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; +import me.totalfreedom.totalfreedommod.util.FUtil; @Command(name = "expel", description = "Push people away from you.", usage = "/expel [radius] [strength]") -@Permission(permission = "tfm.fun.expel", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.fun.expel", source = SourceType.ONLY_IN_GAME) public class Command_expel extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java index dbc64f747..6626278ff 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_findip.java @@ -3,13 +3,13 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "findip", description = "Shows all IPs registered to a player.", usage = "/findip [player]", aliases = {"ips", "ip"}) -@Permission(permission = "tfm.admin.findip", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.findip") public class Command_findip extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_flatlands.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_flatlands.java index 3bcf210a4..df4c7909b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_flatlands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_flatlands.java @@ -4,10 +4,9 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "flatlands", description = "Goto the flatlands.", usage = "/flatlands") -@Permission(permission = "tfm.world.flatlands", level = Rank.NON_OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.world.flatlands", source = SourceType.ONLY_IN_GAME) public class Command_flatlands extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java index 34cf6083c..b4745117a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_freeze.java @@ -1,20 +1,20 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Resolve; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "freeze", description = "Freeze players. Append \"on\" or \"off\" at the end to set a specific state.", usage = "/freeze <[on | off] | [on | off]>", aliases = {"fr"}) -@Permission(permission = "tfm.admin.freeze", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.freeze") public class Command_freeze extends FCommand { @@ -73,6 +73,9 @@ public void togglePlayerFreeze(CommandSender sender, Player player) @Callback public void setFreezeForPlayer(CommandSender sender, Player player, @Resolve("Boolean") boolean value) { + if (value && isProtectedAdmin(sender, player)) + return; + plugin().pl.getPlayer(player).getFreezeData().setFrozen(value); msg(sender, " has been .", diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java index a2dc82874..9db486145 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_fuckoff.java @@ -1,12 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; +import org.bukkit.entity.Player; + import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.senior.fuckoff") +@Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.senior.fuckoff") @Command(name = "fuckoff", description = "You'll never even see it coming.", usage = "/fuckoff ") public class Command_fuckoff extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java index 649cbd7af..d9abd6ed8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gchat.java @@ -1,18 +1,28 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "gchat", description = "Send a chat message as someone else.", usage = "/gchat ") -@Permission(permission = "tfm.admin.gchat", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.gchat") public class Command_gchat extends FCommand { + @Completer(value = "", position = 1) + public List completeMessage(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void sendMessageAsSomeoneElse(CommandSender sender, Player player, @Greedy String message) { @@ -28,11 +38,8 @@ public void sendMessageAsSomeoneElse(CommandSender sender, Player player, @Greed return; } - if (isAdmin(player)) - { - msg(sender, "This command cannot be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } msg(sender, "Sending chat as : ", Placeholder.unparsed("name", player.getName()), diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java index 6364e6079..3d9852672 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_gcmd.java @@ -1,32 +1,39 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "gcmd", description = "Send a command as someone else.", usage = "/gcmd ") -@Permission(permission = "tfm.admin.gcmd", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.gcmd") public class Command_gcmd extends FCommand { + @Completer(value = "", position = 1, scope = Completer.Scope.ARGUMENT_TO_WORD) + public List completeCommand(CommandSender sender, String partial) + { + return CommandCandidates.inner(server(), sender, partial); + } + @Callback public void runAsOtherPlayer(CommandSender sender, Player player, @Greedy String command) { if (plugin().cb.isCommandBlocked(command, sender)) { - msg(sender, "Did you really think that was going to work?"); + msg(sender, "You cannot run blocked commands on another player."); return; } - if (isAdmin(player) && !plugin().rm.getRank(sender).isAtLeast(Rank.SENIOR_ADMIN)) - { - msg(sender, "This command can't be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } msg(sender, "Sending command as : ", Placeholder.unparsed("player", player.getName()), @@ -34,14 +41,10 @@ public void runAsOtherPlayer(CommandSender sender, Player player, @Greedy String try { - if (server().getCommandMap().dispatch(player, command)) - { + if (server().getCommandMap().dispatch(player, command)) msg(sender, "Command sent."); - } - else - { + else msg(sender, "Unknown error sending command."); - } } catch (Throwable ex) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java index 151eddba1..69d5af31d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_invis.java @@ -2,22 +2,22 @@ import java.util.List; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; -import me.totalfreedom.totalfreedommod.rank.Rank; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.potion.PotionEffectType; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.bukkit.potion.PotionEffectType; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; @Command(name = "invis", description = "Shows (and optionally clears) invisisible players", usage = "/invis [clear]") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.invis") +@Permission(permission = "tfm.admin.invis") public class Command_invis extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_joinmessages.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_joinmessages.java index ddad03283..6764599d9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_joinmessages.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_joinmessages.java @@ -5,10 +5,9 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "joinmessages", description = "Toggle visibility of other players' join/leave messages.", usage = "/joinmessages", aliases = {"jlm", "togglejoinmessages"}) -@Permission(permission = "tfm.player.joinmessages", level = Rank.NON_OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.player.joinmessages", source = SourceType.ONLY_IN_GAME) public class Command_joinmessages extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java index 31e556c6d..4e0230507 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_jumppads.java @@ -1,16 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.fun.Jumppads; -import me.totalfreedom.totalfreedommod.rank.Rank; +import java.util.stream.Stream; + +import org.bukkit.command.CommandSender; + import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import java.util.stream.Stream; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.fun.Jumppads; -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.BOTH, permission = "tfm.fun.jumppads") +@Permission(source = SourceType.BOTH, permission = "tfm.fun.jumppads") @Command(name = "jumppads", description = "Manage jumppads", usage = "/ < | info | mode | strength >", aliases = "launchpads,jp") public class Command_jumppads extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java index 58484ad0b..2e238d7c1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_kick.java @@ -1,14 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import java.util.List; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.kick") +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; + + +@Permission(permission = "tfm.admin.kick") @Command(name = "kick", aliases = "k", description = "Kick a player.", usage = "/ [-s] [reason]") public class Command_kick extends FCommand { @@ -18,14 +21,17 @@ public void kickNoReason(CommandSender sender, Player player, @Switch("s") boole kick(sender, player, null, silent); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void kick(CommandSender sender, Player player, @Greedy String reason, @Switch("s") boolean silent) { - if (isAdmin(player)) - { - msg(sender, "This command cannot be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } final String kickMessage = reason != null ? "You have been kicked from the server.\nKicked by: \nReason: " diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java index 8723ae942..c4d9b7d0b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_landmine.java @@ -1,21 +1,21 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.fun.Landminer.Landmine; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; - -import org.bukkit.Material; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; -import org.bukkit.entity.Player; @Command(name = "landmine", description = "Set a landmine trap.", usage = "/") -@Permission(level = Rank.OP, source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.landmine") +@Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.landmine") public class Command_landmine extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java index c77ccb1f2..55842b70a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_link.java @@ -2,14 +2,14 @@ import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "link", description = "Generate a one-time code for admins to link their Discord account.", usage = "/link") -@Permission(permission = "tfm.admin.discordlink", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.admin.discordlink", source = SourceType.ONLY_IN_GAME) public class Command_link extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java index ec0d601cb..c6ffc8dea 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_list.java @@ -7,19 +7,19 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; -import me.totalfreedom.totalfreedommod.rank.Displayable; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.util.PlayerListUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.util.PlayerListUtil; + @Command(name = "list", description = "Lists the real names of all online players.", usage = "/list [-a | -i | -f]", aliases = {"who"}) -@Permission(permission = "tfm.player.list", level = Rank.IMPOSTOR) +@Permission(permission = "tfm.player.list") public class Command_list extends FCommand { private enum ListFilter diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java index d87f1c8b7..66079ddb6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_localspawn.java @@ -2,12 +2,12 @@ import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "localspawn", description = "Teleport to the spawn point for the current world.", usage = "/localspawn", aliases = {"worldspawn", "gotospawn"}) -@Permission(permission = "tfm.player.localspawn", level = Rank.NON_OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.player.localspawn", source = SourceType.ONLY_IN_GAME) public class Command_localspawn extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java index d65543735..3f36388b0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_lockup.java @@ -2,6 +2,13 @@ import java.util.List; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitRunnable; +import org.bukkit.scheduler.BukkitTask; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; @@ -9,17 +16,10 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FTask; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitRunnable; -import org.bukkit.scheduler.BukkitTask; @Command(name = "lockup", description = "Block target's minecraft input. This is evil, and I never should have wrote it.", usage = "/ on | off>>") -@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.senior.lockup") +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.senior.lockup") public class Command_lockup extends FCommand { @Callback @@ -28,7 +28,10 @@ public void lockAll(CommandSender sender) { adminAction(sender, "Locking up all players"); - server().getOnlinePlayers().forEach(this::startLockup); + server().getOnlinePlayers() + .stream() + .filter(player -> !isAdmin(player)) + .forEach(this::startLockup); msg(sender, "Locked up all players."); } @@ -69,6 +72,9 @@ public void toggle(CommandSender sender, String name, String state) if (state.equalsIgnoreCase("on")) { + if (isProtectedAdmin(sender, player)) + return; + adminAction(sender, "Locking up ", Placeholder.unparsed("player", player.getName())); startLockup(player); msg(sender, "Locked up .", Placeholder.unparsed("player", player.getName())); @@ -110,13 +116,9 @@ public void run() FTask.run("Command_lockup/lockup", () -> { if (player.isOnline()) - { player.openInventory(player.getInventory()); - } else - { cancelLockup(playerdata); - } }); } }.runTaskTimer(plugin(), 0L, 5L)); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java index fa6221127..ff787a78d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_moblimiter.java @@ -1,17 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; +import org.bukkit.GameRules; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.EntityType; + import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.GameRules; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.EntityType; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.moblimiter") +@Permission(permission = "tfm.server.moblimiter") @Command(name = "moblimiter", description = "Control the MobLimiter.", usage = "/ < | limit | >>") public class Command_moblimiter extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java index 289342b82..5652f5f67 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mobpurge.java @@ -5,21 +5,16 @@ import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.command.CommandSender; -import org.bukkit.entity.Ambient; -import org.bukkit.entity.Creature; -import org.bukkit.entity.EnderDragon; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Ghast; -import org.bukkit.entity.Slime; +import org.bukkit.entity.*; import org.bukkit.scheduler.BukkitRunnable; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "mobpurge", description = "Purge all mobs in all worlds.", usage = "/mobpurge [world] [chunkX chunkZ | batchSize]", aliases = {"mp"}) -@Permission(permission = "tfm.server.mobpurge", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.server.mobpurge") public class Command_mobpurge extends FCommand { public static final int DEFAULT_BATCH_SIZE = 200; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java index 84539e78b..321581947 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_mp44.java @@ -1,18 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; - -import org.bukkit.Material; -import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; @Command(name = "mp44", description = "Modern weaponry, FTW. Use 'draw' to start firing, 'sling' to stop firing.", usage = "/ ") -@Permission(level = Rank.OP, source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.mp44") +@Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.mp44") public class Command_mp44 extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java index 468966971..a64692f79 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_myadmin.java @@ -3,13 +3,14 @@ import java.net.InetAddress; import java.util.List; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; + import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import org.bukkit.entity.Player; -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.myadmin") +@Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.myadmin") @Command(name = "myadmin", description = "Manage my admin entry", usage = "/myadmin | setlogin | clearlogin>") public class Command_myadmin extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java index cd0b04c7a..427457b36 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nickname.java @@ -9,13 +9,6 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.Style; @@ -23,11 +16,18 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; + @Command( - name = "nickname", - description = "Manages player nicknames", - usage = "/nickname < | set | clean | clear [player] | clearall>", - aliases = {"nick"} + name = "nickname", + description = "Manages player nicknames", + usage = "/nickname < | set | clean | clear [player] | clearall>", + aliases = {"nick"} ) @Permission(permission = "tfm.player.nickname") public class Command_nickname extends FCommand @@ -187,7 +187,7 @@ public void clearAll(CommandSender sender) @Callback @Subcommand("clean") - @Permission(permission = "tfm.player.nickname", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.nickclean") public void clean(CommandSender sender) { adminAction(sender, "Cleaning all nicknames"); @@ -262,11 +262,6 @@ public static boolean containsForbidden(String plainText) { final List terms = new ArrayList<>(FORBIDDEN_WORDS); - Stream.of(Rank.values()) - .filter(r -> r.isAdmin()) - .filter(r -> !r.getTag().isEmpty()) - .forEach(r -> terms.add(r.getTag())); - PluginProvider.get() .rm .getCustomRanksSorted() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java index 5b91525f6..d05d2a38a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_nicknyan.java @@ -3,21 +3,21 @@ import java.util.ArrayList; import java.util.List; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.JoinConfiguration; - -import org.bukkit.entity.Player; @Command(name = "nicknyan", description = "Essentials Interface Command - Nyanify your nickname.", usage = "/ < | off>") -@Permission(level = Rank.OP, source = SourceType.ONLY_IN_GAME, permission = "tfm.player.nicknyan") +@Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.player.nicknyan") public class Command_nicknyan extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_op.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_op.java index 38ebf399b..1d506771a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_op.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_op.java @@ -3,9 +3,10 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "op", description = "Makes a player operator", usage = "/op [player]") @Permission(permission = "tfm.player.op") public class Command_op extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java index 58aa2d608..654beda6d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_opall.java @@ -3,9 +3,10 @@ import org.bukkit.GameMode; import org.bukkit.command.CommandSender; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "opall", description = "Op everyone on the server, optionally changing everyone's gamemode at the same time.", usage = "/opall [-c | -s]") @Permission(permission = "tfm.player.opall") public class Command_opall extends FCommand @@ -13,6 +14,11 @@ public class Command_opall extends FCommand @Callback public void opall(CommandSender sender, @Switch("c") boolean creative, @Switch("s") boolean survival) { + if ((creative || survival) && !isAdmin(sender)) + { + throw new CommandFailException("Only admins may change everyone's gamemode with -c or -s."); + } + if (creative && survival) { throw new CommandFailException("Cannot use both -c and -s at the same time."); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ops.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ops.java index 4def8cdc7..f6327b955 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ops.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ops.java @@ -3,9 +3,10 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "ops", description = "Manage operators", usage = "/ops") @Permission(permission = "tfm.player.ops") public class Command_ops extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java index 1abfef834..f4935e495 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_orbit.java @@ -1,19 +1,19 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; - -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.GameMode; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.util.Vector; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; +import me.totalfreedom.totalfreedommod.player.FPlayer; + @Command(name = "orbit", description = "POW!!! Right in the kisser! One of these days Alice, straight to the Moon!", usage = "/orbit ") -@Permission(permission = "tfm.fun.orbit", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.fun.orbit") public class Command_orbit extends FCommand { @Callback @@ -22,6 +22,9 @@ public void setOrbit(CommandSender sender, Player player) final FPlayer target = plugin().pl.getPlayer(player); if (!target.isOrbiting()) { + if (isProtectedAdmin(sender, player)) + return; + player.setGameMode(GameMode.SURVIVAL); final double strength = 10.0; target.startOrbiting(strength); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java index b95df40ba..8f153a4eb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permban.java @@ -10,18 +10,18 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.banning.PermBan; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "permban", description = "Manage permanently banned players and IPs.", usage = "/permban [ip...] | remove | reload>") -@Permission(permission = "tfm.admin.ban.perm", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_CONSOLE) +@Permission(permission = "tfm.admin.ban.perm", source = SourceType.ONLY_CONSOLE) public class Command_permban extends FCommand { @Callback @@ -85,21 +85,19 @@ public void add(CommandSender sender, String name, @Greedy String extraIps) final PlayerData data = BanCommandUtil.getData(plugin(), name, online); final String canonicalName = BanCommandUtil.getCanonicalName(name, online, data); + if (isProtectedAdminByName(sender, canonicalName)) + return; + final Set ips = new LinkedHashSet<>(BanCommandUtil.getIps(online, data)); for (String ip : extraIps.trim().split("\\s+")) { if (ip.isEmpty()) - { continue; - } + if (isValidIpOrRange(ip)) - { ips.add(ip); - } else - { msg(sender, "Ignoring invalid IP/range: ", Placeholder.unparsed("ip", ip)); - } } UUID uuid = online != null ? online.getUniqueId() : FUtil.usernameToUuid(canonicalName); @@ -109,13 +107,9 @@ public void add(CommandSender sender, String name, @Greedy String extraIps) final boolean existed = permban != null; if (permban == null) // this caused a semantic issue with nullability. replaced with a proper null check instead of the cached boolean. - { permban = new PermBan(uuid, canonicalName, "Permbanned by " + sender.getName()); - } else if (uuid != null && !permban.hasUuid()) - { permban.setUuid(uuid); - } final int before = permban.getIps().size(); permban.addIps(new ArrayList<>(ips)); @@ -152,9 +146,7 @@ else if (uuid != null && !permban.hasUuid()) } if (online != null) - { kickPlayer(online, permbanKickMessage()); - } } @Callback @@ -178,10 +170,9 @@ private void doRemove(CommandSender sender, String target) if (isValidIpOrRange(target)) { final List removed = plugin().pm.removePermbansByIp(target); + if (removed.isEmpty()) - { msg(sender, "No permbans matched the IP .", Placeholder.unparsed("target", target)); - } else { adminAction( @@ -215,9 +206,7 @@ private void doRemove(CommandSender sender, String target) private void requireLocalConsole(CommandSender sender) { if (RemoteDispatchContext.isActive() || !sender.getName().equalsIgnoreCase("CONSOLE")) - { throw new CommandFailException("This command can only be used from the server panel console."); - } } private String permbanKickMessage() @@ -231,13 +220,11 @@ private static boolean isValidIpOrRange(String ip) { final String[] parts = ip.split("\\."); if (parts.length != 4) - { return false; - } return Stream.of(parts) - .filter(part -> !part.equals("*")) - .allMatch(Command_permban::isValidOctet); + .filter(part -> !part.equals("*")) + .allMatch(Command_permban::isValidOctet); } private static boolean isValidOctet(String part) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java index 3936a0c6d..24838c23f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_permbanlist.java @@ -3,16 +3,16 @@ import java.util.ArrayList; import java.util.List; +import org.bukkit.command.CommandSender; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; - -import org.bukkit.command.CommandSender; @Command(name = "permbanlist", aliases = "pbanlist", description = "Shows all permanently banned players and IP addresses.", usage = "/ [page]") -@Permission(level = Rank.OP, permission = "tfm.admin.banlist") +@Permission(permission = "tfm.admin.banlist") public class Command_permbanlist extends FCommand { private static final int ENTRIES_PER_PAGE = 10; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java index a6178bb9f..bc475ffe6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_plugincontrol.java @@ -1,17 +1,17 @@ package me.totalfreedom.totalfreedommod.cmd; import io.papermc.paper.plugin.configuration.PluginMeta; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; + import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.plugin.Plugin; -import org.bukkit.plugin.PluginManager; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.telnet.plugincontrol") +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.admin.senior.plugincontrol") @Command(name = "plugincontrol", aliases = "plc", description = "Manage plugins", usage = "/ < > | list>") public class Command_plugincontrol extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potion.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potion.java index de368a536..cfe7c3be6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potion.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potion.java @@ -7,7 +7,6 @@ import org.bukkit.entity.Player; import org.bukkit.potion.PotionEffectType; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; @@ -15,10 +14,12 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command( - name = "potion", - description = "Manipulate potion effects. Duration is measured in server ticks (~20 ticks per second).", - usage = "/potion [player] | remove [player]>" + name = "potion", + description = "Manipulate potion effects. Duration is measured in server ticks (~20 ticks per second).", + usage = "/potion [player] | remove [player]>" ) @Permission(permission = "tfm.player.potion") public class Command_potion extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java index a01e4b862..c622e12da 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_potionspy.java @@ -1,25 +1,50 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import me.totalfreedom.totalfreedommod.player.SpyMode; -@Command(name = "potionspy", description = "Spy on potion usage", usage = "/potionspy", aliases = {"potspy"}) -@Permission(permission = "tfm.admin.potspy", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Command(name = "potionspy", description = "Spy on potion usage", usage = "/potionspy [ops | admins | all | off]", aliases = {"potspy"}) +@Permission(permission = "tfm.admin.potspy", source = SourceType.ONLY_IN_GAME) public class Command_potionspy extends FCommand { @Callback - public void toggle(Player player) + public void toggle(final Player player) + { + final PlayerData data = plugin().pl.getData(player); + potionSpy(player, data.isPotionSpy() ? SpyMode.OFF : SpyMode.ALL); + } + + @Callback + public void potionSpy(final Player player, final SpyMode mode) { final PlayerData data = plugin().pl.getData(player); - data.setPotionSpy(!data.isPotionSpy()); - msg( - player, - "PotionSpy .", - Formatter.booleanChoice("status", data.isPotionSpy()) - ); + + data.setPotionSpyMode(mode); + plugin().pl.saveAsync(); + + switch (mode) + { + case OFF -> msg(player, "PotionSpy disabled."); + case OPS -> msg(player, "PotionSpy set to OPS mode. You will only see non-admins' potions."); + case ADMINS -> msg(player, "PotionSpy set to ADMINS mode. You will only see admins' potions."); + case ALL -> msg(player, "PotionSpy set to ALL mode. You will see both non-admins' and admins' potions."); + } + } + + @Completer(value = "", position = 0) + public List completeMode(final Player player, final String partial) + { + final String lower = partial.toLowerCase(Locale.ROOT); + + return Stream.of("ops", "admins", "all", "off") + .filter(mode -> mode.startsWith(lower)) + .toList(); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java index a36e40795..1b6a50f3c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_premium.java @@ -5,19 +5,18 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; - import java.util.List; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.util.FLog; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.util.FLog; -@Permission(level = Rank.SUPER_ADMIN, source = SourceType.BOTH, permission = "tfm.admin.premium") +@Permission(source = SourceType.BOTH, permission = "tfm.admin.premium") @Command(name = "premium", description = "Validates if a given account is premium.", usage = "/premium ", aliases = "prem") public class Command_premium extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java index feb889dd6..161a40bbb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_protectarea.java @@ -2,6 +2,12 @@ import java.util.List; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; import me.totalfreedom.totalfreedommod.bridge.WorldEditBridge; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; @@ -9,18 +15,12 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; - -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "protectarea", - description = "Manage protected regions so that only superadmins can directly modify blocks within them. WorldEdit and other such plugins might bypass this.", - usage = "/ | info | update | delete >", - aliases = {"protectregion", "protect"}) -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.protectregion") + description = "Manage protected regions so that only superadmins can directly modify blocks within them. WorldEdit and other such plugins might bypass this.", + usage = "/ | info | update | delete >", + aliases = {"protectregion", "protect"}) +@Permission(permission = "tfm.admin.protectregion") public class Command_protectarea extends FCommand { @Callback @@ -54,7 +54,7 @@ public void clear(final CommandSender sender) @Callback @Subcommand("create") - @Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.protectregion") + @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.protectregion") public void create(final Player sender, final String name) { checkEnabled(); @@ -76,7 +76,7 @@ public void create(final Player sender, final String name) @Callback @Subcommand("update") - @Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.protectregion") + @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.admin.protectregion") public void update(final Player sender, final ProtectedRegion region) { checkEnabled(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java index ada15bb68..562e425b0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_purgeall.java @@ -1,16 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.command.CommandSender; +import org.bukkit.potion.PotionEffect; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; - -import org.bukkit.command.CommandSender; -import org.bukkit.potion.PotionEffect; @Command(name = "purgeall", description = "Superadmin command - Purge everything! (except for bans).", usage = "/") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.purgeall") +@Permission(permission = "tfm.admin.purgeall") public class Command_purgeall extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java index 67eccb835..52d4ab8f5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_radar.java @@ -1,19 +1,20 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import java.util.Comparator; +import java.util.List; + import org.bukkit.Location; import org.bukkit.entity.Player; -import java.util.Comparator; -import java.util.List; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; @Command(name = "radar", description = "Shows nearby people sorted by distance.", usage = "/radar [radius]") -@Permission(permission = "tfm.player.radar", level = Rank.OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.player.radar", source = SourceType.ONLY_IN_GAME) public class Command_radar extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java index cff402ec5..bfcc8deee 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rank.java @@ -1,18 +1,19 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Displayable; -import me.totalfreedom.totalfreedommod.rank.Rank; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.rank.CustomRank; @Command(name = "rank", description = "Shows ranks", usage = "/ [player]") -@Permission(level = Rank.NON_OP, permission = "tfm.player.rank") +@Permission(permission = "tfm.player.rank") public class Command_rank extends FCommand { @Callback @@ -31,12 +32,14 @@ public void querySelfOrAll(CommandSender sender) public void queryPlayer(CommandSender sender, Player player) { final Displayable display = plugin().rm.getDisplay(player); - final Rank rank = plugin().rm.getRank(player); + final CustomRank rank = plugin().rm.getEffectiveRank(player); Component result = Component.text(player.getName() + " is ", NamedTextColor.AQUA) .append(display.getColoredLoginMessage()); - if (rank != display) + // The display may be a cosmetic rank (developer, owner) that differs from the rank the + // player actually acts at, in which case both are worth showing. + if (rank != null && rank != display) { result = result.append(Component.text(" (", NamedTextColor.AQUA)) .append(rank.getColoredName()) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java index e685a8af6..dcb388744 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rankconfig.java @@ -1,27 +1,37 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.rank.RankRole; @Command( - name = "rankconfig", - description = "Configure custom ranks.", - usage = "/rankconfig [list | create | edit | delete | set | setrank | reload | save]", - aliases = {"rankconf", "rankcfg"} + name = "rankconfig", + description = "Configure custom ranks.", + usage = "/rankconfig [list | create | edit | delete | set | setrank | reload | save]", + aliases = {"rankconf", "rankcfg"} ) -@Permission(permission = "tfm.manage.ranks", level = Rank.SENIOR_ADMIN) +@Permission(permission = "tfm.manage.ranks") public class Command_rankconfig extends FCommand { + private static final List COLOR_NAMES = Stream.concat( + NamedTextColor.NAMES.keys().stream(), + Stream.of("purple", "orange", "grey", "dark_grey", "cyan", "dark_cyan", "pink", "magenta")) + .sorted() + .toList(); + @Callback public void menu(CommandSender sender) { @@ -125,7 +135,6 @@ public void set(CommandSender sender, String rank, Property property, @Greedy St case DETERMINER -> target.setDeterminer(value); case COLOR -> target.setColor(parseColor(value)); case ADMIN -> target.setAdmin(isTruthy(value)); - case CONSOLE -> target.setConsoleOnly(isTruthy(value)); case ADDPERM -> target.addPermission(value); case REMPERM -> target.removePermission(value); case LEVEL -> @@ -179,6 +188,51 @@ public List completeSetRank(CommandSender sender, String partial) return rankIdCandidates(partial); } + @Completer(value = "set", position = 2, scope = Completer.Scope.ARGUMENT) + public List completeSetValue(CommandSender sender, String partial, List priorArgs) + { + final Property property = parseProperty(priorArgs.get(1)); + if (property == null) + { + return List.of(); + } + + return switch (property) + { + case COLOR -> FuzzyMatch.filter(COLOR_NAMES, partial); + case ADMIN -> FuzzyMatch.filter(List.of("true", "false"), partial); + case INHERIT -> FuzzyMatch.filter(inheritCandidates(priorArgs.get(0)), partial); + case REMPERM -> FuzzyMatch.filter(heldPermissions(priorArgs.get(0)), partial); + default -> List.of(); + }; + } + + private List inheritCandidates(String rankId) + { + final List candidates = new ArrayList<>(List.of("none")); + plugin().rm.getCustomRanksSorted() + .stream() + .map(CustomRank::getId) + .filter(id -> !id.equalsIgnoreCase(rankId.trim())) + .forEach(candidates::add); + + return candidates; + } + + private List heldPermissions(String rankId) + { + final CustomRank rank = plugin().rm.getCustomRank(rankId.trim().toLowerCase()); + return rank == null ? List.of() : rank.getPermissions().stream().sorted().toList(); + } + + private static Property parseProperty(String typed) + { + return Arrays.stream(Property.values()) + .filter(property -> property.name().equalsIgnoreCase(typed.trim())) + .findFirst() + .orElse(null); + } + @Callback @Subcommand("setrank") public void setRank(CommandSender sender, Player target, String rank) @@ -195,9 +249,11 @@ public void setRank(CommandSender sender, Player target, String rank) return; } - admin.setCustomRankId(null); - plugin().al.save(); - msg(sender, "Cleared custom rank for ", Placeholder.unparsed("player", target.getName())); + admin.setRankId(plugin().rm.getRegistry().byRole(RankRole.ADMIN_DEFAULT) + .map(CustomRank::getId) + .orElse(null)); + plugin().al.saveAsync(); + msg(sender, "Reset to the baseline admin rank.", Placeholder.unparsed("player", target.getName())); return; } @@ -216,8 +272,8 @@ public void setRank(CommandSender sender, Player target, String rank) return; } - admin.setCustomRankId(rankId); - plugin().al.save(); + admin.setRankId(rankId); + plugin().al.saveAsync(); adminAction( sender, @@ -305,6 +361,6 @@ private static NamedTextColor parseColor(String name) private enum Property { - NAME, ABBREVIATION, LEVEL, COLOR, DETERMINER, ADMIN, CONSOLE, PREFIX, INHERIT, ADDPERM, REMPERM + NAME, ABBREVIATION, LEVEL, COLOR, DETERMINER, ADMIN, PREFIX, INHERIT, ADDPERM, REMPERM } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java index 26434bfeb..67736f42a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_rawsay.java @@ -1,15 +1,22 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; @Command(name = "rawsay", description = "Broadcasts the given message. Supports colors.", usage = "/rawsay ") -@Permission(permission = "tfm.admin.senior.rawsay", level = Rank.SENIOR_ADMIN) +@Permission(permission = "tfm.admin.senior.rawsay") public class Command_rawsay extends FCommand { + @Completer(value = "", position = 0) + public List completeMessage(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void rawsay(CommandSender sender, @Greedy String message) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java index 861282066..ad8242cd0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_realname.java @@ -1,20 +1,29 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + +import org.bukkit.command.CommandSender; + +import net.kyori.adventure.text.Component; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import net.kyori.adventure.text.Component; - -import org.bukkit.command.CommandSender; @Command(name = "realname", description = "Finds the real name of a nicknamed player", usage = "/ ") -@Permission(level = Rank.OP, permission = "tfm.player.realname") +@Permission(permission = "tfm.player.realname") public class Command_realname extends FCommand { + @Completer(value = "", position = 0, scope = Completer.Scope.ARGUMENT) + public List completeNickname(CommandSender sender, String partial) + { + return NameCandidates.onlineNicknames(plugin(), server(), partial); + } + @Callback public void realname(CommandSender sender, @Greedy String nickname) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java index 2c705e3ce..5aca7da52 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_report.java @@ -1,10 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import java.util.List; + import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.player.report") @Command(name = "report", description = "Report a player for admins to see.", usage = "/report ") public class Command_report extends FCommand @@ -50,6 +53,18 @@ private void doReport(Player player, OfflinePlayer target, String reason) } } + @Completer(value = "", position = 0) + public List completeTarget(Player sender, String partial) + { + return NameCandidates.online(server(), partial); + } + + @Completer(value = "", position = 1) + public List completeReason(Player sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void report(Player sender, String playerName, @Greedy String reason) { Player player = getPlayer(playerName); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java index 0bec796cd..b6b883716 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_ro.java @@ -4,18 +4,18 @@ import java.util.List; import java.util.Set; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.ro") +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + +@Permission(permission = "tfm.admin.ro") @Command(name = "ro", description = "Remove all blocks of a certain type in the radius of certain players.", usage = "/ [radius] [players]") public class Command_ro extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java index 563b2c834..024c09ed8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_saconfig.java @@ -11,6 +11,13 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; @@ -20,79 +27,30 @@ import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.rank.Rank; - -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.Bukkit; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "saconfig", description = "Manage admins.", - usage = "/ | >") -// The read-only subcommands sit at SUPER_ADMIN, so they cannot be gated behind a tfm.manage.* -// node: RankManager#checkLegacyPermission maps that whole namespace to SENIOR_ADMIN for ranks -// that hold no explicit grant, which vetoes the level declared here and hides the command from -// super admins entirely. tfm.admin.* legacy-maps to SUPER_ADMIN and matches this gate. The -// mutating handlers below keep tfm.manage.saconfig, which now differs from this node and so is -// actually tested rather than skipped as a repeat of the parent. -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.saconfig") + usage = "/ | >") +@Permission(permission = "tfm.admin.saconfig") public class Command_saconfig extends FCommand { @Callback @Subcommand("setrank") - @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE, level = Rank.SENIOR_ADMIN) - public void setRank(CommandSender sender, Player target, String rankInput) + @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE) + public void setRank(CommandSender sender, Player target, String rankInput) { - final CustomRank custom = plugin().rm != null ? plugin().rm.getCustomRank(rankInput) : null; - final Rank rank; - final String displayName; + final CustomRank rank = plugin().rm == null ? null : plugin().rm.getCustomRank(rankInput); - if (custom != null) - { - if (custom.isConsoleOnly()) - { - msg(sender, "You cannot set players to a console rank"); - return; - } - if (!custom.isAdmin()) - { - msg(sender, "Rank \"\" is not an admin rank.", - Placeholder.unparsed("rank", custom.getName())); - return; - } - rank = resolveLegacyTier(custom); - if (rank == null) - { - msg(sender, "Rank \"\" has no legacy tier; set or inherit from super_admin or senior_admin.", - Placeholder.unparsed("rank", custom.getName())); - return; - } - displayName = custom.getName(); - } - else + if (rank == null) { - try - { - rank = Rank.valueOf(rankInput.toUpperCase()); - } - catch (IllegalArgumentException ignored) - { - msg(sender, "Unknown rank: ", Placeholder.unparsed("rank", rankInput)); - return; - } - if (rank.isConsole()) - { - msg(sender, "You cannot set players to a console rank"); - return; - } - displayName = rank.getName(); + msg(sender, "Unknown rank: ", Placeholder.unparsed("rank", rankInput)); + return; } - if (!rank.isAtLeast(Rank.SUPER_ADMIN)) + if (!rank.isAdmin()) { - msg(sender, "Rank must be superadmin or higher."); + msg(sender, "Rank \"\" is not an admin rank.", + Placeholder.unparsed("rank", rank.getName())); return; } @@ -106,17 +64,16 @@ public void setRank(CommandSender sender, Player target, String rankInput) adminAction(sender, "Setting 's rank to ", Placeholder.unparsed("player", admin.getName()), - Placeholder.unparsed("rank", displayName)); + Placeholder.unparsed("rank", rank.getName())); - admin.setRank(rank); - admin.setCustomRankId(custom != null ? custom.getId() : null); + admin.setRankId(rank.getId()); plugin().al.updateTables(); plugin().al.saveAdminAsync(admin); plugin().rm.updatePlayerTeam(target); msg(sender, "Set 's rank to .", Placeholder.unparsed("player", admin.getName()), - Placeholder.unparsed("rank", displayName)); + Placeholder.unparsed("rank", rank.getName())); } @Callback @@ -140,7 +97,7 @@ public boolean getInfo(CommandSender sender, Player target) @Callback @Subcommand("add") - @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE, level = Rank.SENIOR_ADMIN) + @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE) public boolean addUser(CommandSender sender, Player target) { if (plugin().al.isAdmin(target)) @@ -200,7 +157,7 @@ public boolean addUser(CommandSender sender, Player target) @Callback @Subcommand("remove") - @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE, level = Rank.SENIOR_ADMIN) + @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE) public boolean removeUser(CommandSender sender, String username) { final Admin admin = plugin().al.getEntryByName(username); @@ -239,7 +196,7 @@ public boolean reload(CommandSender sender) @Callback @Subcommand("clean") - @Permission(permission = "tfm.manage.saconfig", level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE) + @Permission(permission = "tfm.manage.saconfig", source = SourceType.ONLY_CONSOLE) public boolean clean(CommandSender sender) { adminAction(sender, "Cleaning admin list"); @@ -265,17 +222,12 @@ public List completeSetrankAdmin(CommandSender sender, String partial) @Completer(value = "setrank", position = 1) public List completeSetrankRank(CommandSender sender, String partial) { - final Stream rankNames = Stream.of(Rank.values()) - .filter(rank -> rank.isAdmin() && !rank.isConsole()) - .map(rank -> rank.name().toLowerCase(Locale.ROOT)); - - final Stream customRankIds = plugin().rm.getCustomRanks() - .values() - .stream() - .filter(custom -> custom.isAdmin() && !custom.isConsoleOnly()) - .map(CustomRank::getId); - - final List candidates = Stream.concat(rankNames, customRankIds).toList(); + final List candidates = plugin().rm.getCustomRanks() + .values() + .stream() + .filter(CustomRank::isAdmin) + .map(CustomRank::getId) + .toList(); return FuzzyMatch.filter(candidates, partial); } @@ -319,52 +271,28 @@ private void getAdminList(CommandSender sender) return; } - final Map> byRank = activeAdmins.stream().collect( - Collectors.groupingBy(a -> a.getRank(), () -> new EnumMap<>(Rank.class), Collectors.toList()) - ); - - Stream.of(Rank.values()) - .filter(r -> r.isAdmin() && !r.isConsole()) - .filter(byRank::containsKey) - .sorted(Comparator.comparingInt(Rank::getLevel).reversed()) - .forEach(rank -> { - List bucket = byRank.get(rank); - String joinedAdmins = bucket.stream() - .sorted(Comparator.comparing(a -> a.getName().toLowerCase())) - .map(admin -> { - CustomRank customRank = admin.getCustomRankId() == null - ? null - : plugin().rm.getCustomRank(admin.getCustomRankId()); - - if (customRank != null) { - String tagText = customRank.getName(); - String colorTag = customRank.getColor().asHexString(); - return String.format("<%s>%s %s", colorTag, tagText, admin.getName()); - } - return String.format("%s", admin.getName()); - }) - .collect(Collectors.joining(", ")); - - String rankColorTag = "<" + rank.getColor().asHexString() + ">"; - String line = rankColorTag + rank.getName() + "s: " + joinedAdmins; - - msg(sender, line); - }); + // Group by the rank each admin actually holds. Ranks come from the registry rather than a + // fixed set, so a custom defined rank gets its own line instead of being folded into a + // tier that happens to sit near it. + final Map> byRank = activeAdmins.stream() + .filter(admin -> plugin().rm.getCustomRank(admin.getRankId()) != null) + .collect(Collectors.groupingBy(admin -> plugin().rm.getCustomRank(admin.getRankId()), + Collectors.toList())); + + byRank.keySet() + .stream() + .sorted(Comparator.comparingInt(CustomRank::getLevel).reversed()) + .forEach(rank -> + { + final String joinedAdmins = byRank.get(rank) + .stream() + .sorted(Comparator.comparing(admin -> admin.getName().toLowerCase())) + .map(admin -> String.format("%s", admin.getName())) + .collect(Collectors.joining(", ")); + + msg(sender, String.format("<%s>%ss: %s", + rank.getColor().asHexString(), rank.getName(), joinedAdmins)); + }); } - private Rank resolveLegacyTier(CustomRank custom) - { - return Stream.iterate(custom, Objects::nonNull, current -> plugin().rm.getCustomRank(current.getInheritFrom())) - .limit(32) - .map(current -> { - try { - return Rank.valueOf(current.getId().toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException ignored) { - return null; - } - }) - .filter(Objects::nonNull) - .findFirst() - .orElse(null); - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java index aee009869..510e6cdc1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_say.java @@ -1,20 +1,28 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.ChatMentionUtil; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "say", description = "Broadcasts the given message as the console, includes sender name.", usage = "/say ") -@Permission(permission = "tfm.admin.say", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.say") public class Command_say extends FCommand { + @Completer(value = "", position = 0) + public List completeMessage(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void say(CommandSender sender, @Greedy String message) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java index a57967334..ede7cd927 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_setspawn.java @@ -3,13 +3,13 @@ import org.bukkit.Location; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "setspawn", description = "Set the server spawn to your current location.", usage = "/setspawn") -@Permission(permission = "tfm.world.setspawn", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.world.setspawn", source = SourceType.ONLY_IN_GAME) public class Command_setspawn extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java index bdad0775b..4c11e19e2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_settings.java @@ -8,17 +8,17 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import static me.totalfreedom.totalfreedommod.config.ConfigEntry.*; @Command(name = "settings", description = "Modify TotalFreedom Server Settings / Flags", usage = "/settings [toggle | set] [ | ]", aliases = {"toggle", "set", "tfset"}) -@Permission(permission = "tfm.server.settings", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.server.settings") public class Command_settings extends FCommand { private static final double DEFAULT_EXPLOSION_RADIUS = 4.0; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java index e206548f9..26af8aa29 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_signspy.java @@ -1,26 +1,50 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import me.totalfreedom.totalfreedommod.player.SpyMode; -@Command(name = "signspy", description = "Spy on sign edits", usage = "/signspy", aliases = {"sspy"}) -@Permission(permission = "tfm.admin.signspy", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Command(name = "signspy", description = "Spy on sign edits", usage = "/signspy [ops | admins | all | off]", aliases = {"sspy"}) +@Permission(permission = "tfm.admin.signspy", source = SourceType.ONLY_IN_GAME) public class Command_signspy extends FCommand { @Callback public void toggle(final Player player) { final PlayerData data = plugin().pl.getData(player); - data.setSignSpy(!data.isSignSpy()); + signSpy(player, data.isSignSpy() ? SpyMode.OFF : SpyMode.OPS); + } + + @Callback + public void signSpy(final Player player, final SpyMode mode) + { + final PlayerData data = plugin().pl.getData(player); + + data.setSignSpyMode(mode); plugin().pl.saveAsync(); - msg( - player, - "SignSpy .", - Formatter.booleanChoice("status", data.isSignSpy()) - ); + + switch (mode) + { + case OFF -> msg(player, "SignSpy disabled."); + case OPS -> msg(player, "SignSpy set to OPS mode. You will only see non-admins' sign edits."); + case ADMINS -> msg(player, "SignSpy set to ADMINS mode. You will only see admins' sign edits."); + case ALL -> msg(player, "SignSpy set to ALL mode. You will see both non-admins' and admins' sign edits."); + } + } + + @Completer(value = "", position = 0) + public List completeMode(final Player player, final String partial) + { + final String lower = partial.toLowerCase(Locale.ROOT); + + return Stream.of("ops", "admins", "all", "off") + .filter(mode -> mode.startsWith(lower)) + .toList(); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java index 69ca37ffb..92ef70e86 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_smite.java @@ -1,21 +1,26 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.GameMode; -import org.bukkit.Location; -import org.bukkit.World; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; + @Command(name = "smite", description = "Someone being a little bitch? Smite them down...", usage = "/smite [reason]") -@Permission(permission = "tfm.fun.smite", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.fun.smite") public class Command_smite extends FCommand { @Callback @@ -24,15 +29,22 @@ public void smiteNoReason(CommandSender sender, Player player) smite(sender, player, null); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void smite(CommandSender sender, Player player, @Greedy String reason) { + if (isProtectedAdmin(sender, player)) + return; + FUtil.bcastMsg(" has been a naughty, naughty boy.", Placeholder.unparsed("player", player.getName())); if (reason != null) - { FUtil.bcastMsg(" Reason: ", Placeholder.unparsed("reason", reason)); - } plugin().db.sendActionMessage(sender.getName(), player.getName(), reason, ConfigEntry.DISCORD_PLAYER_SMITE_MESSAGE); @@ -49,13 +61,11 @@ public void smite(CommandSender sender, Player player, @Greedy String reason) final Location targetPos = player.getLocation(); final World world = player.getWorld(); for (int x = -1; x <= 1; x++) - { for (int z = -1; z <= 1; z++) { final Location strike_pos = new Location(world, targetPos.getBlockX() + x, targetPos.getBlockY(), targetPos.getBlockZ() + z); world.strikeLightning(strike_pos); } - } // Kill player.setHealth(0.0); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java index d40dc2189..aa7b868f7 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawn.java @@ -2,12 +2,12 @@ import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command(name = "spawn", description = "Teleport to the server spawn.", usage = "/spawn [player]") -@Permission(permission = "tfm.player.spawn", level = Rank.NON_OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.player.spawn", source = SourceType.ONLY_IN_GAME) public class Command_spawn extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java index 7fd23bb99..29b0103da 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_spawnmob.java @@ -1,18 +1,19 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.Location; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.event.entity.CreatureSpawnEvent; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; + @Command(name = "spawnmob", description = "Spawns any mob.", usage = "/spawnmob [amount]") -@Permission(permission = "tfm.fun.spawnmob", level = Rank.OP, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.fun.spawnmob", source = SourceType.ONLY_IN_GAME) public class Command_spawnmob extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java new file mode 100644 index 000000000..f10d2baee --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sqlstatus.java @@ -0,0 +1,48 @@ +package me.totalfreedom.totalfreedommod.cmd; + +import org.bukkit.command.CommandSender; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.sql.ConnectionHandler.PoolStats; + +@Command(name = "sqlstatus", description = "Show database connection pool health.", usage = "/sqlstatus") +@Permission(source = SourceType.BOTH, permission = "tfm.admin.sqlstatus") +public class Command_sqlstatus extends FCommand +{ + @Callback + public void info(CommandSender sender) + { + if (plugin().dm == null || !plugin().dm.isInitialized()) + { + msg(sender, "Database is not initialized; every domain is running on its JSON fallback."); + return; + } + + final PoolStats stats = plugin().dm.getPoolStats(); + if (stats == null) + { + msg(sender, "Could not read connection pool stats."); + return; + } + + msg(sender, + """ + Database: + Pool: active connections, idle connections, total connections (max ) + Threads waiting on a connection: + Fairness queue: permit(s) available, permit(s) waiting. + """, + Placeholder.unparsed("db_type", stats.databaseType()), + Formatter.number("db_actives", stats.activeConnections()), + Formatter.number("db_idle", stats.idleConnections()), + Formatter.number("db_total", stats.totalConnections()), + Formatter.number("db_max_pool_size", stats.maxPoolSize()), + Formatter.number("thread_await", stats.threadsAwaitingConnection()), + Formatter.number("permits_open", stats.availablePermits()), + Formatter.number("permits_waiting", stats.queueLength()) + ); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java index 71ab3d3ee..c2c26839e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_sshtotp.java @@ -1,23 +1,23 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.UUID; + +import org.bukkit.command.CommandSender; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.ssh.SshIdentity; import me.totalfreedom.totalfreedommod.ssh.SshQrServer; import me.totalfreedom.totalfreedommod.ssh.TotpUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - -import org.bukkit.command.CommandSender; - -import java.util.UUID; -@Permission(level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.ssh.totp") +@Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.ssh.totp") @Command( name = "sshtotp", description = "Generate a TOTP secret for an SSH identity and serve a one-time QR setup page.", usage = "/sshtotp " - ) +) public class Command_sshtotp extends FCommand { /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java index 9c5c7452f..536a1003c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stfu.java @@ -1,19 +1,19 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import java.util.List; +import java.util.Objects; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import java.util.List; -import java.util.Objects; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.FPlayer; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.mute") +@Permission(permission = "tfm.admin.mute") @Command(name = "stfu", aliases = "mute", description = "Mutes a player with brute force.", usage = "/ < [reason] | list | purge | all>") public class Command_stfu extends FCommand { @@ -83,16 +83,19 @@ public void mutePlayer(CommandSender sender, Player player) mutePlayerWithReason(sender, player, null); } + @Completer(value = "", position = 1) + public List completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void mutePlayerWithReason(CommandSender sender, Player player, @Greedy String reason) { final FPlayer fplayer = fplayer(player); - if (isAdmin(player)) - { - msg(sender, "This command cannot be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } if (fplayer.isMuted()) { @@ -103,28 +106,20 @@ public void mutePlayerWithReason(CommandSender sender, Player player, @Greedy St else { if (reason != null) - { - adminAction( - sender, "Muting Reason: ", + adminAction(sender, "Muting Reason: ", Placeholder.unparsed("player", player.getName()), - MessageUtils.parsed("reason", reason) - ); - } + MessageUtils.parsed("reason", reason)); else - { - adminAction(sender, "Muting ", Placeholder.unparsed("player", player.getName())); - } + adminAction(sender, "Muting ", + Placeholder.unparsed("player", player.getName())); fplayer.setMuted(true); if (reason != null) - { - msg(player, "You have been muted. Reason: ", MessageUtils.parsed("reason", reason)); - } + msg(player, "You have been muted. Reason: ", + MessageUtils.parsed("reason", reason)); else - { msg(player, "You have been muted."); - } } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java index 9828efd1f..996b6b570 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_stop.java @@ -1,11 +1,11 @@ package me.totalfreedom.totalfreedommod.cmd; import org.bukkit.command.CommandSender; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "stop", description = "Kicks everyone and stops the server.", usage = "/stop") -@Permission(permission = "tfm.server.stop", level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE) +@Permission(permission = "tfm.server.stop", source = SourceType.ONLY_CONSOLE) public class Command_stop extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java index 240dcda8d..47290a9e8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_strikes.java @@ -1,15 +1,15 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.player.PlayerData; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.strike") +@Permission(permission = "tfm.admin.strike") @Command(name = "strikes", aliases = "strike", description = "Manages the strikes for a player.", usage = "/ ") public class Command_strikes extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java index 1eb8b3eb4..8b643d5c0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_survival.java @@ -5,15 +5,15 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Command( - name = "survival", - description = "Quickly change your own gamemode to survival, or define someone's username to change theirs.", - usage = "/survival <-a | [player]>", - aliases = {"gms"} + name = "survival", + description = "Quickly change your own gamemode to survival, or define someone's username to change theirs.", + usage = "/survival <-a | [player]>", + aliases = {"gms"} ) @Permission(permission = "tfm.player.survival") public class Command_survival extends FCommand @@ -28,7 +28,7 @@ public void changeGamemodeSelf(Player player) @Callback @Subcommand("-a") - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeAll(CommandSender sender) { Bukkit.getOnlinePlayers().forEach(player -> player.setGameMode(GameMode.SURVIVAL)); @@ -36,7 +36,7 @@ public void changeGamemodeAll(CommandSender sender) } @Callback - @Permission(permission = "tfm.admin.gamemode", level = Rank.SUPER_ADMIN) + @Permission(permission = "tfm.admin.gamemode") public void changeGamemodeOther(CommandSender sender, Player target) { target.setGameMode(GameMode.SURVIVAL); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java index fb9740383..d44ba3730 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tag.java @@ -9,17 +9,17 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.player.PlayerData; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "tag", description = "Sets yourself a prefix", usage = "/tag [-s[ave]] | list | off | clear | clearall>") @Permission(permission = "tfm.player.tag") @@ -189,11 +189,6 @@ public static boolean containsForbidden(String plainText) { final List terms = new ArrayList<>(FORBIDDEN_WORDS); - Stream.of(Rank.values()) - .filter(Rank::isAdmin) - .filter(r -> !r.getTag().isEmpty()) - .forEach(r -> terms.add(r.getTag())); - PluginProvider.get() .rm .getCustomRanksSorted() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java index 75923c0c0..a2d01c72c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tempban.java @@ -5,25 +5,24 @@ import java.util.List; import java.util.Objects; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.cmd.resolver.DateOffsetArgumentResolver; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - @Command(name = "tempban", aliases = {"tban", "noob"}, - description = "Temporarily bans an online or previously known player.", - usage = "/ [-s] [-rb] [duration] [reason]") -@Permission(permission = "tfm.admin.ban", level = Rank.SUPER_ADMIN) + description = "Temporarily bans an online or previously known player.", + usage = "/ [-s] [-rb] [duration] [reason]") +@Permission(permission = "tfm.admin.ban") public class Command_tempban extends FCommand { private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd 'at' HH:mm:ss z"); @@ -95,6 +94,9 @@ private void tempBan(CommandSender sender, String name, Date expiry, String reas final PlayerData data = BanCommandUtil.getData(plugin(), name, player); final String canonicalName = BanCommandUtil.getCanonicalName(name, player, data); + if (isProtectedAdminByName(sender, canonicalName)) + return; + if (plugin().bm.getByUsername(canonicalName) != null) { msg(sender, " is already banned.", Placeholder.unparsed("player", canonicalName)); @@ -137,9 +139,8 @@ private void tempBan(CommandSender sender, String name, Date expiry, String reas .forEach(target -> { if (!silent) - { smitePlayer(target); - } + target.kick(ban.bakeKickMessage()); }); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java new file mode 100644 index 000000000..8b5a01653 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_title.java @@ -0,0 +1,215 @@ +package me.totalfreedom.totalfreedommod.cmd; + +import java.util.List; +import java.util.Set; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Subcommand; +import me.totalfreedom.totalfreedommod.title.Title; + +/** + * Grants, revokes and inspects titles. + *

+ * Granting is guarded by {@code tfm.manage.titles} rather than by the node the title itself + * carries: a title is a way to hand out a capability, so being able to hand out any of them is a + * strictly larger privilege than holding one. + */ +@Command( + name = "title", + description = "View and manage player titles.", + usage = "/title [list | info | of <player> | grant <player> <title> | revoke <player> <title>]", + aliases = {"titles"} +) +@Permission(permission = "tfm.player.titles") +public class Command_title extends FCommand +{ + + @Callback + public void list(CommandSender sender) + { + final List<Title> titles = plugin().tm.getTitlesSorted(); + + if (titles.isEmpty()) + { + msg(sender, "<gray>No titles are configured."); + return; + } + + msg(sender, "<gold>Titles:"); + titles.forEach(title -> msg(sender, "<line>", + MessageUtils.component("line", Component.text(" ") + .append(title.getColoredTag()) + .append(Component.text(" ")) + .append(title.getColoredName()) + .append(Component.text(" (" + title.getId() + ")", NamedTextColor.DARK_GRAY))))); + } + + @Callback + @Subcommand("list") + public void listExplicit(CommandSender sender) + { + list(sender); + } + + @Callback + @Subcommand("info") + public void info(CommandSender sender, String titleId) + { + final Title title = plugin().tm.getTitle(titleId); + + if (title == null) + { + msg(sender, "<red>No such title: <title>", Placeholder.unparsed("title", titleId)); + return; + } + + msg(sender, "<line>", MessageUtils.component("line", Component.text("Title: ", NamedTextColor.GOLD) + .append(title.getColoredTag()) + .append(Component.text(" ")) + .append(title.getColoredName()))); + msg(sender, "<gray> ID: <id>", Placeholder.unparsed("id", title.getId())); + msg(sender, "<gray> Display weight: <weight>", Placeholder.unparsed("weight", String.valueOf(title.getWeight()))); + + if (title.getPermissions().isEmpty()) + { + msg(sender, "<gray> Grants: <dark_gray><italic>nothing (display only)"); + return; + } + + msg(sender, "<gray> Grants:"); + title.getPermissions() + .stream() + .sorted() + .forEach(permission -> msg(sender, "<gray> - <node>", Placeholder.unparsed("node", permission))); + } + + @Callback + @Subcommand("of") + public void of(CommandSender sender, Player target) + { + final List<Title> held = plugin().tm.getHeldTitles(target); + + if (held.isEmpty()) + { + msg(sender, "<gray><player> holds no titles.", Placeholder.unparsed("player", target.getName())); + return; + } + + msg(sender, "<gray><player>'s titles:", Placeholder.unparsed("player", target.getName())); + held.forEach(title -> msg(sender, "<line>", + MessageUtils.component("line", Component.text(" ") + .append(title.getColoredTag()) + .append(Component.text(" ")) + .append(title.getColoredName())))); + } + + @Callback + @Subcommand("grant") + @Permission(permission = "tfm.manage.titles") + public void grant(CommandSender sender, Player target, String titleId) + { + if (sender instanceof Player granter && granter.getUniqueId().equals(target.getUniqueId())) + { + msg(sender, "<red>You cannot grant yourself a title."); + return; + } + + final Title title = plugin().tm.getTitle(titleId); + + if (title == null) + { + msg(sender, "<red>No such title: <title>", Placeholder.unparsed("title", titleId)); + return; + } + + if (!plugin().tm.grantTitle(target, title.getId())) + { + msg(sender, "<red><player> already holds that title.", + Placeholder.unparsed("player", target.getName())); + return; + } + + adminAction(sender, "<aqua>Granted <title> to <player>", + Placeholder.unparsed("title", title.getName()), + Placeholder.unparsed("player", target.getName())); + + msg(target, "<green>You have been granted the <title> title.", + Placeholder.unparsed("title", title.getName())); + } + + @Callback + @Subcommand("revoke") + @Permission(permission = "tfm.manage.titles") + public void revoke(CommandSender sender, Player target, String titleId) + { + if (!plugin().tm.revokeTitle(target, titleId)) + { + msg(sender, "<red><player> does not hold that title.", + Placeholder.unparsed("player", target.getName())); + return; + } + + adminAction(sender, "<aqua>Revoked <title> from <player>", + Placeholder.unparsed("title", titleId), + Placeholder.unparsed("player", target.getName())); + + msg(target, "<gray>Your <title> title has been revoked.", + Placeholder.unparsed("title", titleId)); + } + + @Completer(value = "info", position = 0) + public List<String> completeInfo(CommandSender sender, String partial) + { + return matching(plugin().tm.getTitleIds(), partial); + } + + @Completer(value = "grant", position = 1) + public List<String> completeGrant(CommandSender sender, String partial, List<String> priorArgs) + { + final Player target = server().getPlayerExact(priorArgs.get(0)); + + if (target == null) + return List.of(); + + final Set<String> held = plugin().tm.getHeldTitleIds(target); + final List<String> grantable = plugin().tm.getTitleIds() + .stream() + .filter(id -> !held.contains(id)) + .toList(); + + return matching(grantable, partial); + } + + /** + * Completes only titles the target already holds, so revoking offers the set that can actually + * be revoked rather than every title that exists. + */ + @Completer(value = "revoke", position = 1) + public List<String> completeRevoke(CommandSender sender, String partial, List<String> priorArgs) + { + final Player target = server().getPlayerExact(priorArgs.get(0)); + + return target == null ? List.of() : matching(plugin().tm.getHeldTitleIds(target), partial); + } + + private static List<String> matching(Iterable<String> candidates, String partial) + { + final String prefix = partial == null ? "" : partial.toLowerCase(); + + return java.util.stream.StreamSupport.stream(candidates.spliterator(), false) + .filter(id -> id.startsWith(prefix)) + .sorted() + .toList(); + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tossmob.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tossmob.java index 1d5a6b9a8..b1e88d6e1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tossmob.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_tossmob.java @@ -1,10 +1,5 @@ package me.totalfreedom.totalfreedommod.cmd; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import java.util.List; import java.util.Locale; import java.util.stream.Stream; @@ -14,9 +9,15 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; + @Permission(source = SourceType.ONLY_IN_GAME, permission = "tfm.fun.tossmob") @Command( - name = "tossmob", + name = "tossmob", description = "Throw a mob in the direction you are facing when you left click with a stick.", usage = "/<command> <mobtype [speed] | off | list>") public class Command_tossmob extends FCommand diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java index fae072722..af12fde1f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_totalfreedommod.java @@ -1,5 +1,9 @@ package me.totalfreedom.totalfreedommod.cmd; +import org.bukkit.command.CommandSender; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; @@ -8,19 +12,16 @@ import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.discord.DiscordBridge; import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; /* * See https://github.com/TotalFreedom/License - This file may not be edited or removed. */ @Command(name = "totalfreedommod", description = "Shows information about TotalFreedomMod or reloads it", usage = "/totalfreedommod [reload]", aliases = {"tfm"}) -@Permission(permission = "tfm.server.info", level = Rank.NON_OP) +@Permission(permission = "tfm.server.info") public class Command_totalfreedommod extends FCommand { @Subcommand("reload") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.info") + @Permission(permission = "tfm.server.reload") @Callback public void reloadPlugin(CommandSender sender) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_trail.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_trail.java index 53c9ea32c..8be7658a3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_trail.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_trail.java @@ -3,10 +3,9 @@ import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "trail", description = "Pretty rainbow trails.", usage = "/trail [on | off]") -@Permission(permission = "tfm.fun.trail", level = Rank.SUPER_ADMIN, source = SourceType.ONLY_IN_GAME) +@Permission(permission = "tfm.fun.trail", source = SourceType.ONLY_IN_GAME) public class Command_trail extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java index 7905cf98b..cb8506101 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unban.java @@ -6,14 +6,14 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "unban", description = "Unbans an online or offline player and linked IP addresses.", usage = "/unban [-s] [-r] <player>") -@Permission(permission = "tfm.admin.ban", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.ban") public class Command_unban extends FCommand { @Completer(value = "", position = 0) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java index d1dfc9846..ff1c42fa5 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_unbanip.java @@ -4,13 +4,13 @@ import org.bukkit.command.CommandSender; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @Command(name = "unbanip", description = "Unbans an IP address.", usage = "/unbanip <ip>") -@Permission(permission = "tfm.admin.ban", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.ban") public class Command_unbanip extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_undisguiseall.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_undisguiseall.java index 3a6c402c4..baab6c238 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_undisguiseall.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_undisguiseall.java @@ -3,10 +3,9 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "undisguiseall", description = "Undisguise all players on the server", usage = "/undisguiseall", aliases = {"uall"}) -@Permission(permission = "tfm.admin.undisguiseall", level = Rank.SUPER_ADMIN) +@Permission(permission = "tfm.admin.undisguiseall") public class Command_undisguiseall extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java index 8d53a0439..567e77bf8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_warn.java @@ -1,20 +1,29 @@ package me.totalfreedom.totalfreedommod.cmd; +import java.util.List; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Callback; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Command; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Completer; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Greedy; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; @Command(name = "warn", description = "Warns a player.", usage = "/<command> <player> <reason>") -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.warn") +@Permission(permission = "tfm.admin.warn") public class Command_warn extends FCommand { + @Completer(value = "", position = 1) + public List<String> completeReason(CommandSender sender, String partial) + { + return NameCandidates.onlineTyped(server(), partial); + } + @Callback public void warnPlayer(CommandSender sender, Player player, @Greedy String reason) { @@ -24,11 +33,8 @@ public void warnPlayer(CommandSender sender, Player player, @Greedy String reaso return; } - if (isAdmin(player)) - { - msg(sender, "<gray>This command cannot be used on other admins."); + if (isProtectedAdmin(sender, player)) return; - } adminAction(sender, "<red>Warning <player>", Placeholder.unparsed("player", player.getName())); MessageUtils.broadcast("<red> Reason: <yellow><reason>", MessageUtils.parsed("reason", reason)); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java index bc424f9a3..1964ad62a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whitelist.java @@ -2,17 +2,17 @@ import java.util.List; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -@Permission(level = Rank.OP, permission = "tfm.server.whitelist") +import net.kyori.adventure.text.minimessage.tag.resolver.Formatter; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; +import me.totalfreedom.totalfreedommod.util.FUtil; + +@Permission(permission = "tfm.server.whitelist") @Command(name = "whitelist", description = "Manage the whitelist.", usage = "/<command> <on | off | list | count | add <player> | remove <player> | addall | purge>") public class Command_whitelist extends FCommand { @@ -49,7 +49,7 @@ public void count(CommandSender sender) @Callback @Subcommand("on") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.whitelist") + @Permission(permission = "tfm.server.whitelist.manage") public void on(CommandSender sender) { adminAction(sender, "<aqua>Turning the whitelist on."); @@ -58,7 +58,7 @@ public void on(CommandSender sender) @Callback @Subcommand("off") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.whitelist") + @Permission(permission = "tfm.server.whitelist.manage") public void off(CommandSender sender) { adminAction(sender, "<aqua>Turning the whitelist off."); @@ -83,7 +83,7 @@ public List<String> completeRemove(CommandSender sender, String partial) @Callback @Subcommand("add") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.whitelist") + @Permission(permission = "tfm.server.whitelist.manage") public void add(CommandSender sender, String name) { final OfflinePlayer player = resolveOfflinePlayer(name); @@ -94,7 +94,7 @@ public void add(CommandSender sender, String name) @Callback @Subcommand("remove") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.whitelist") + @Permission(permission = "tfm.server.whitelist.manage") public void remove(CommandSender sender, String name) { final OfflinePlayer player = resolveOfflinePlayer(name); @@ -111,7 +111,7 @@ public void remove(CommandSender sender, String name) @Callback @Subcommand("addall") - @Permission(level = Rank.SUPER_ADMIN, permission = "tfm.server.whitelist") + @Permission(permission = "tfm.server.whitelist.manage") public void addAll(CommandSender sender) { adminAction(sender, "<aqua>Adding all online players to the whitelist."); @@ -131,7 +131,7 @@ public void addAll(CommandSender sender) @Callback @Subcommand("purge") - @Permission(level = Rank.SUPER_ADMIN, source = SourceType.ONLY_CONSOLE, permission = "tfm.server.whitelist") + @Permission(source = SourceType.ONLY_CONSOLE, permission = "tfm.server.whitelist.manage") public void purge(CommandSender sender) { adminAction(sender, "<aqua>Whitelist purging is temporarily disabled."); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java index 57395105f..d86143b84 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_whohas.java @@ -2,18 +2,18 @@ import java.util.List; -import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; +import org.bukkit.Material; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Material; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.whohas") +@Permission(permission = "tfm.admin.whohas") @Command(name = "whohas", aliases = "wh", description = "See who has a block and optionally clears the item.", usage = "/<command> [-clear] <item>") public class Command_whohas extends FCommand { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java index ca1deaf97..37192be74 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wildcard.java @@ -4,16 +4,17 @@ import java.util.List; import java.util.Objects; +import org.bukkit.command.CommandSender; + +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.ssh.AttributedConsoleSender; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import org.bukkit.command.CommandSender; -@Permission(level = Rank.SUPER_ADMIN, permission = "tfm.admin.wildcard") +@Permission(permission = "tfm.admin.wildcard") @Command( name = "wildcard", - description = "Run any command on all users, username placeholder = ?.", + description = "Run any command on all users, username placeholder = ?.", usage = "/wildcard <command> (use ? to insert each player's username)") public class Command_wildcard extends FCommand { @@ -25,6 +26,12 @@ public class Command_wildcard extends FCommand "crash" ); + @Completer(value = "", position = 0, scope = Completer.Scope.ARGUMENT_TO_WORD) + public List<String> completeCommand(CommandSender sender, String partial) + { + return CommandCandidates.inner(server(), sender, partial); + } + @Callback public void wildcard(CommandSender sender, @Greedy String command) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeflatlands.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeflatlands.java index e124d7237..ab6e397be 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeflatlands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeflatlands.java @@ -3,10 +3,9 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; @Command(name = "wipeflatlands", description = "Wipe the flatlands map. Requires manual restart after command is used.", usage = "/wipeflatlands") -@Permission(permission = "tfm.admin.senior.wipeflatlands", level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE) +@Permission(permission = "tfm.admin.senior.wipeflatlands", source = SourceType.ONLY_CONSOLE) public class Command_wipeflatlands extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeuserdata.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeuserdata.java index 85e1f15df..e53877155 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeuserdata.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/Command_wipeuserdata.java @@ -5,11 +5,10 @@ import org.bukkit.command.CommandSender; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.*; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FUtil; @Command(name = "wipeuserdata", description = "Removes essentials playerdata", usage = "/wipeuserdata") -@Permission(permission = "tfm.admin.senior.wipeuserdata", level = Rank.SENIOR_ADMIN, source = SourceType.ONLY_CONSOLE) +@Permission(permission = "tfm.admin.senior.wipeuserdata", source = SourceType.ONLY_CONSOLE) public class Command_wipeuserdata extends FCommand { @Callback diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java index 0a64e7775..9ead97b5e 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/FCommand.java @@ -7,27 +7,26 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; + import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.player.PlayerData; -import me.totalfreedom.totalfreedommod.rank.Rank; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; - -import org.bukkit.Location; -import org.bukkit.Server; -import org.bukkit.World; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitTask; - -import io.papermc.paper.threadedregions.scheduler.ScheduledTask; /** * Base class for command declarations in the new command framework. @@ -50,6 +49,9 @@ public abstract class FCommand public static final Component NOT_FROM_CONSOLE = Component.text("This command may not be used from the console.", NamedTextColor.GRAY); public static final Component PLAYER_NOT_FOUND = Component.text("Player not found!", NamedTextColor.GRAY); + // Eventually the above components will be converted to use this same MM string format. + public static final String ADMIN_PROTECTED_MESSAGE = "<red>This command cannot be used on other admins."; + protected final TotalFreedomMod plugin() { return PluginProvider.get(); @@ -84,15 +86,6 @@ protected void checkPlayer(CommandSender sender) } } - @Deprecated - protected void checkRank(CommandSender sender, Rank rank) - { - if (!plugin().rm.getRank(sender).isAtLeast(rank)) - { - noPerms(); - } - } - protected void adminAction(CommandSender sender, String action, Object... refs) { FUtil.adminAction(sender, MessageUtils.parse(String.format(action, refs))); @@ -187,6 +180,55 @@ protected boolean isAdmin(CommandSender sender) return plugin().al.isAdmin(sender); } + /** + * Whether {@code target} is an admin, and so out of reach of a disciplinary or destructive + * command. Tells {@code sender} why when it is, so a caller only has to return. + */ + protected boolean isProtectedAdmin(final CommandSender sender, final Player target) + { + if (target == null || !plugin().al.isAdmin(target)) + return false; + + msg(sender, ADMIN_PROTECTED_MESSAGE); + return true; + } + + /** + * The same test for the commands whose target is a bare name and may be offline or unknown. + */ + protected boolean isProtectedAdminByName(final CommandSender sender, final String targetName) + { + if (targetName == null || targetName.isEmpty()) + return false; + + final Player online = server().getPlayerExact(targetName); + if (online != null) + return isProtectedAdmin(sender, online); + + final Admin listed = plugin().al.getEntryByName(targetName); + if (listed == null || !listed.isActive()) + return false; + + msg(sender, ADMIN_PROTECTED_MESSAGE); + return true; + } + + /** + * The same test for an address, so that an IP ban cannot reach an admin through one of theirs. + */ + protected boolean isProtectedAdminByIp(final CommandSender sender, final String ip) + { + if (ip == null || ip.isEmpty()) + return false; + + final Admin listed = plugin().al.getEntryByIpFuzzy(ip); + if (listed == null || !listed.isActive()) + return false; + + msg(sender, ADMIN_PROTECTED_MESSAGE); + return true; + } + protected Admin getAdmin(CommandSender sender) { return plugin().al.getAdmin(sender); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/MessageUtils.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/MessageUtils.java index 8ccd4eb42..da65e80e8 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/MessageUtils.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/MessageUtils.java @@ -8,7 +8,6 @@ import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.JoinConfiguration; import net.kyori.adventure.text.format.NamedTextColor; @@ -17,6 +16,8 @@ import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; + /** * Utility class for formatting messages using Kyori Adventure MiniMessage. * Supports named colors, hex colors, click/hover events, and placeholders. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java index 5fc5857e3..787af1575 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/NameCandidates.java @@ -2,15 +2,17 @@ import java.util.List; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; - import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.Server; import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.banning.Ban; +import me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; + + /** * Shared tab-completion sources for commands that take a player name as a plain {@code String}. * <p> @@ -23,10 +25,28 @@ final class NameCandidates { + private static final int MIN_TYPED_PREFIX = 3; + private NameCandidates() { } + static List<String> onlineTyped(Server server, String partial) + { + if (partial.length() < MIN_TYPED_PREFIX) + { + return List.of(); + } + + final String prefix = partial.toLowerCase(); + return server.getOnlinePlayers() + .stream() + .map(Player::getName) + .filter(name -> name.toLowerCase().startsWith(prefix)) + .sorted() + .toList(); + } + static List<String> online(Server server, String partial) { return FuzzyMatch.filter( @@ -38,6 +58,24 @@ static List<String> online(Server server, String partial) partial); } + /** + * Plain-text nicknames of the online players who have one, for arguments that are matched + * against nicknames rather than usernames. + */ + static List<String> onlineNicknames(TotalFreedomMod plugin, Server server, String partial) + { + return FuzzyMatch.filter( + server.getOnlinePlayers() + .stream() + .map(player -> plugin.pl.getData(player).getNickname()) + .filter(nickname -> nickname != null) + .map(nickname -> AdventureUtil.componentToPlainText(nickname).trim()) + .filter(nickname -> !nickname.isEmpty()) + .sorted() + .toList(), + partial); + } + static List<String> banned(TotalFreedomMod plugin, String partial) { return FuzzyMatch.filter( diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/ArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/ArgumentResolver.java index 458d40044..296d4f6ab 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/ArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/ArgumentResolver.java @@ -1,12 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal; -import com.mojang.brigadier.arguments.ArgumentType; -import com.mojang.brigadier.arguments.BoolArgumentType; -import com.mojang.brigadier.arguments.DoubleArgumentType; -import com.mojang.brigadier.arguments.FloatArgumentType; -import com.mojang.brigadier.arguments.IntegerArgumentType; -import com.mojang.brigadier.arguments.LongArgumentType; -import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.arguments.*; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java index ed389ddde..43a145c6c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/CommandProcessor.java @@ -1,5 +1,19 @@ package me.totalfreedom.totalfreedommod.cmd.internal; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import java.util.stream.IntStream; + import com.mojang.brigadier.arguments.ArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.ArgumentBuilder; @@ -7,11 +21,18 @@ import com.mojang.brigadier.builder.RequiredArgumentBuilder; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; -import io.papermc.paper.command.brigadier.Commands; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.Commands; import io.papermc.paper.command.brigadier.argument.ArgumentTypes; import io.papermc.paper.command.brigadier.argument.resolvers.selector.PlayerSelectorArgumentResolver; import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.cmd.CommandFailException; import me.totalfreedom.totalfreedommod.cmd.FCommand; @@ -28,26 +49,8 @@ import me.totalfreedom.totalfreedommod.cmd.resolver.AbstractArgumentResolver; import me.totalfreedom.totalfreedommod.cmd.resolver.ArgumentResolutionException; import me.totalfreedom.totalfreedommod.util.FLog; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.apache.commons.lang3.exception.ExceptionUtils; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Supplier; -import java.util.stream.IntStream; /** * Builds Brigadier command node trees from {@link FCommand} declarations and wires them @@ -312,7 +315,7 @@ private LiteralArgumentBuilder<CommandSourceStack> buildBranch(SubcommandNode no private static boolean isValidCompleterSignature(Method method) { Class<?>[] types = method.getParameterTypes(); - return types.length == 2 + return (types.length == 2 || (types.length == 3 && types[2] == List.class)) && ArgumentResolver.isSenderType(types[0]) && types[1] == String.class && method.getReturnType() == List.class; @@ -444,8 +447,17 @@ private int sendUsage(CommandContext<CommandSourceStack> ctx) return 1; } - private SuggestionProvider<CommandSourceStack> buildSuggestionProvider(Method completerMethod) + /** + * @param priorArgNames argument-node names of the positional parameters ahead of this one, in + * order, supplied to completers that declare the third parameter + */ + private SuggestionProvider<CommandSourceStack> buildSuggestionProvider(Method completerMethod, boolean greedy, List<String> priorArgNames) { + Completer.Scope scope = completerMethod.getAnnotation(Completer.class).scope(); + boolean replacesWord = greedy && scope != Completer.Scope.ARGUMENT; + boolean seesWholeArgument = greedy && scope != Completer.Scope.WORD; + boolean wantsPriorArgs = completerMethod.getParameterCount() == 3; + return (ctx, builder) -> { CommandSender sender = ctx.getSource().getSender(); @@ -453,21 +465,67 @@ private SuggestionProvider<CommandSourceStack> buildSuggestionProvider(Method co { return builder.buildFuture(); } + + SuggestionsBuilder target = currentWord(builder, replacesWord); + String typed = seesWholeArgument ? builder.getRemaining() : target.getRemaining(); try { - List<String> suggestions = (List<String>) completerMethod.invoke(command, sender, builder.getRemaining()); - suggestions.forEach(builder::suggest); + List<String> suggestions = wantsPriorArgs + ? (List<String>) completerMethod.invoke(command, sender, typed, priorArgs(ctx, priorArgNames)) + : (List<String>) completerMethod.invoke(command, sender, typed); + suggestions.forEach(target::suggest); } catch (Exception e) { Throwable cause = e.getCause() != null ? e.getCause() : e; FLog.severe(String.format("Error in completer %s: \n%s", completerMethod.getName(), ExceptionUtils.getRootCauseMessage(cause))); } - return builder.buildFuture(); + return target.buildFuture(); }; } + private static SuggestionsBuilder currentWord(SuggestionsBuilder builder, boolean greedy) + { + if (!greedy) + { + return builder; + } + + int lastSpace = builder.getRemaining().lastIndexOf(' '); + return lastSpace < 0 ? builder : builder.createOffset(builder.getStart() + lastSpace + 1); + } + + /** + * Text typed for the arguments named in {@code names}, in that order, for a completer that + * asked to see them. + * <p> + * Values are read back off the parsed nodes rather than out of the resolved arguments, so a + * completer sees the raw input even for arguments whose type would fail to resolve it. A name + * that has not been parsed yet yields and empty string, which happens only when the client asks + * about a position it has not reached. + */ /** + * Argument-node names of the positional parameters ahead of {@code position}, in order + */ + private static List<String> argumentNames(List<Parameter> positionalParams, int position) + { + return positionalParams.subList(0, position) + .stream() + .map(Parameter::getName) + .toList(); + } + + private static List<String> priorArgs(CommandContext<CommandSourceStack> ctx, List<String> names) + { + Map<String, String> typed = new HashMap<>(); + ctx.getNodes().forEach(parsed -> typed.put(parsed.getNode().getName(), parsed.getRange().get(ctx.getInput()))); + + return names.stream() + .map(name -> typed.getOrDefault(name,"")) + .toList(); + } + + /** * Default suggester for an argument with no explicit {@link Completer}: fuzzy-matches the partial * input against {@code candidates}, falling back to the enum's constant names when the parameter * is enum-typed and no candidates were produced. @@ -696,7 +754,7 @@ private void attachSwitchLevel( Method completer = completers.get(new CompleterKey(subPath, position)); Supplier<List<String>> candidates = candidatesFor(param); if (completer != null) { - arg.suggests(buildSuggestionProvider(completer)); + arg.suggests(buildSuggestionProvider(completer, greedy, argumentNames(positionalParams, position))); } else if (candidates != null || type.isEnum()) { arg.suggests(buildCandidateSuggestionProvider(candidates, type)); } else if (ArgumentResolver.isPlayerArgType(type)) { @@ -732,7 +790,7 @@ private com.mojang.brigadier.Command<CommandSourceStack> buildDispatch( CommandSourceStack source = ctx.getSource(); CommandSender sender = PermissionGate.resolveSender(source.getSender()); - if (!PermissionGate.test(plugin, sender, methodPermission, classPermission, true)) + if (!PermissionGate.test(plugin, sender, methodPermission, true)) { return 0; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java index f5694874a..d2568e817 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/PermissionGate.java @@ -1,19 +1,27 @@ package me.totalfreedom.totalfreedommod.cmd.internal; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.cmd.SourceType; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; -import me.totalfreedom.totalfreedommod.rank.CustomRank; import me.totalfreedom.totalfreedommod.ssh.AttributedConsoleSender; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; /** * Enforces {@link Permission} declarations. + * <p> + * A declaration carries only a permission node, never a rank. The tier a node requires is derived + * from {@code ranks.json}, so this class does not compare rank levels itself: it asks + * {@code RankManager} whether the sender satisfies the node and lets the rank registry decide what + * that means. A handler that needs to be stricter than its class does so by declaring a distinct + * node, which removes the old special case where a repeated node had to be ignored so that a + * stricter {@code level()} could take over. */ public final class PermissionGate { @@ -25,158 +33,110 @@ public final class PermissionGate private PermissionGate() {} - public static CommandSender resolveSender(CommandSender raw) + public static CommandSender resolveSender(final CommandSender raw) { - RemoteDispatchSession session = RemoteDispatchContext.getActiveSession(); + final RemoteDispatchSession session = RemoteDispatchContext.getActiveSession(); if (session != null && !(raw instanceof Player)) - { return new AttributedConsoleSender(raw, session.getDisplayName()); - } + return raw; } /** * Tests whether {@code sender} may execute a handler guarded by {@code perm}. * - * @param sendMsg whether to message the sender on failure (pass {@code false} for - * silent checks such as Brigadier {@code requires()} / tab visibility) + * @param sendMsg whether to message the sender on failure (pass {@code false} for silent checks + * such as Brigadier {@code requires()} / tab visibility) */ - public static boolean test(TotalFreedomMod plugin, CommandSender sender, Permission perm, boolean sendMsg) + public static boolean test(final TotalFreedomMod plugin, final CommandSender sender, + final Permission perm, final boolean sendMsg) { - return test(plugin, sender, perm, null, sendMsg); + if (perm == null) + return true; + + if (!passesChannelGate(plugin, sender, sendMsg)) + return false; + + if (!passesSourceGate(sender, perm, sendMsg)) + return false; + + final boolean allowed = plugin.rm.hasPermission(sender, perm.permission()); + if (!allowed && sendMsg) + sender.sendMessage(Component.text(perm.message(), NamedTextColor.RED)); + + return allowed; } /** - * Tests whether {@code sender} may execute a subcommand handler guarded by {@code perm}, - * declared inside a command whose class-level guard is {@code parent}. - * <p> - * When a handler repeats its parent's permission node, that node cannot tell the two apart: - * anyone who passed the parent gate holds it already, so checking it again always succeeds - * and a stricter {@code level()} on the handler is silently ignored. In that case the node is - * skipped and {@link Permission#level()} decides, which is the only field that still - * discriminates. The node itself is not lost - the parent's own gate already enforced it - * before the sender could reach this handler. - * - * @param parent the class-level guard, or {@code null} when {@code perm} is itself the - * class-level guard and there is no outer scope to compare against + * Whether the sender is permitted to issue commands over the channel it arrived on at all, + * independently of what the command itself requires. */ - public static boolean test(TotalFreedomMod plugin, CommandSender sender, Permission perm, Permission parent, boolean sendMsg) + private static boolean passesChannelGate(final TotalFreedomMod plugin, final CommandSender sender, + final boolean sendMsg) { - if (perm == null) - { - return true; - } + final RemoteDispatchSession dispatch = RemoteDispatchContext.getActiveSession(); - RemoteDispatchSession dispatch = RemoteDispatchContext.getActiveSession(); if (dispatch != null) { - if (dispatch.isIdentified()) + if (!dispatch.isIdentified()) + return true; + + final String node = switch (dispatch.getChannel()) { - String permNode = switch (dispatch.getChannel()) - { - case SSH -> "tfm.manage.ssh"; - case DISCORD -> "tfm.manage.discord"; - }; - String channelLabel = switch (dispatch.getChannel()) - { - case SSH -> "SSH"; - case DISCORD -> "Discord"; - }; - if (!plugin.rm.hasPermission(sender, permNode)) - { - if (sendMsg) - { - sender.sendMessage(Component.text("You do not have permission to run commands via " + channelLabel + ".", NamedTextColor.RED)); - } - return false; - } - } - } - else if (!(sender instanceof Player) && plugin.al.getEntryByName(sender.getName()) != null) - { - if (!plugin.rm.hasPermission(sender, "tfm.manage.telnet")) + case SSH -> "tfm.manage.ssh"; + case DISCORD -> "tfm.manage.discord"; + }; + final String label = switch (dispatch.getChannel()) { - if (sendMsg) - { - sender.sendMessage(Component.text("You do not have permission to run commands via telnet.", NamedTextColor.RED)); - } - return false; - } + case SSH -> "SSH"; + case DISCORD -> "Discord"; + }; + + return requireChannelNode(plugin, sender, node, label, sendMsg); } - final Player player = sender instanceof Player p ? p : null; + if (!(sender instanceof Player) && plugin.al.getEntryByName(sender.getName()) != null) + return requireChannelNode(plugin, sender, "tfm.manage.telnet", "telnet", sendMsg); + + return true; + } + + private static boolean requireChannelNode(final TotalFreedomMod plugin, final CommandSender sender, + final String node, final String label, final boolean sendMsg) + { + if (plugin.rm.hasPermission(sender, node)) + return true; - if (perm.source() == SourceType.ONLY_CONSOLE && player != null) + if (sendMsg) + sender.sendMessage(Component.text( + String.format("You do not have permission to run commands via %s.", label), + NamedTextColor.RED)); + + return false; + } + + private static boolean passesSourceGate(final CommandSender sender, final Permission perm, + final boolean sendMsg) + { + final boolean isPlayer = sender instanceof Player; + + if (perm.source() == SourceType.ONLY_CONSOLE && isPlayer) { if (sendMsg) - { sender.sendMessage(ONLY_CONSOLE_MESSAGE); - } + return false; } - if (perm.source() == SourceType.ONLY_IN_GAME && player == null) + if (perm.source() == SourceType.ONLY_IN_GAME && !isPlayer) { if (sendMsg) - { sender.sendMessage(ONLY_PLAYER_MESSAGE); - } - return false; - } - - String tfmPermission = perm.permission(); - if (tfmPermission != null && !tfmPermission.isEmpty() && !repeatsParentNode(perm, parent)) - { - boolean result = plugin.rm.hasPermission(sender, tfmPermission); - if (!result && sendMsg) - { - sender.sendMessage(Component.text(perm.message(), NamedTextColor.RED)); - } - return result; - } - if (player != null) - { - boolean result = plugin.rm.getRank(player).isAtLeast(perm.level()); - if (!result && sendMsg) - { - sender.sendMessage(Component.text(perm.message(), NamedTextColor.RED)); - } - return result; + return false; } - // getEffectiveRank resolves how this sender earned its rank: an identified SSH or Discord - // session becomes that admin's own profile rank, custom rank included, while a host - // channel becomes its host_senders binding. Falling back to getRank keeps senders it - // cannot place working on the legacy scale. - final CustomRank effective = plugin.rm.getEffectiveRank(sender); - - // A CustomRank's level is an operator-defined scale that need not line up with - // Rank.ordinal(), so testing one straight against perm.level() can be off by a tier and - // deny a sender its own rank. Resolve the requirement through the same registry the - // sender's rank came from, so both sides are read off one scale. - final CustomRank required = plugin.rm.getCustomRankForLegacy(perm.level()); - final boolean result = effective != null && required != null - ? effective.isAtLeast(required) - : plugin.rm.getRank(sender).isAtLeast(perm.level()); - if (!result && sendMsg) - { - sender.sendMessage(Component.text(perm.message(), NamedTextColor.RED)); - } - return result; + return true; } - /** - * Whether {@code perm} is a handler-level guard that declares the same node as its parent. - * <p> - * The identity check matters: a handler with no {@code @Permission} of its own is handed the - * class-level annotation itself, and that is not a repeat - there is no distinct handler - * level to defer to, so the node check must stand. - */ - private static boolean repeatsParentNode(Permission perm, Permission parent) - { - return parent != null - && parent != perm - && perm.permission().equals(parent.permission()); - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Callback.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Callback.java index a7f47c8c1..55bec4408 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Callback.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Callback.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Marks a method as a command handler. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Command.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Command.java index 9a7d12e55..41085be41 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Command.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Command.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java index 049a54430..c2eb4133a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Completer.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Overrides tab-completion for a specific argument position on a subcommand. @@ -15,9 +11,13 @@ * parameters: the sender and any {@link Switch}-annotated parameters are excluded, since switches * become literal branches rather than argument nodes. * <p> - * The annotated method must return {@code List<String>} and accept exactly two parameters: - * the same sender type as the handler, followed by the partially-typed input ({@code String}). - * The returned list is used as-is. Consider {@link me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch#filter FuzzyMatch#filter(List<String>, String)} + * The annotated method must return {@code List<String>} and accept the same sender type as the + * handler, followed by the partially-typed input ({@code String}), and optionally a third + * {@code List<String>} parameter receiving the text already typed for the preceding positional + * arguments (see {@link #position()} for what counts as one). That third parameter is how a + * completer whose candidates depend on an earlier argument gets at it: {@code /rankconfig set + * <rank> <property> <value>} completes {@code value} differently per {@code property}. + * The returned list is used as is. Consider {@link me.totalfreedom.totalfreedommod.cmd.internal.FuzzyMatch#filter FuzzyMatch#filter(List<String>, String)} * for subsequence fuzzy matching against a candidate list, which also happens to be automatically applied to enum-typed arguments with no {@code @Completer}. */ @Retention(RetentionPolicy.RUNTIME) @@ -27,4 +27,40 @@ { String value(); int position(); + + /** + * What a completer is shown and what its suggestions overwrite, for an argument that holds the + * rest of the line. + * <p> + * The two are worth choosing separately because a suggestion is displayed as the text it would + * replace: widening the replacement to the whole argument also makes the popup spell out the + * whole argument, which ready badly when only the last word is really being completed. + */ + enum Scope + { + /** + * The word under the cursor, replacing just that word. Right for free text that merely + * mentions completable things, e.g. a chat message naming a player. + */ + WORD, + + /** + * The whole argument, replacing all of it. Right when the argument is a single value that + * happens to tolerate spaces, e.g. a nickname. + */ + ARGUMENT, + + /** + * The whole argument, replacing only its final word. Right when the argument is itself + * structured, e.g. a command line whose completions depend on the words before the one + * being typed. + */ + ARGUMENT_TO_WORD + } + + /** + * How much of a {@link Greedy} argument the completer works on. Ignored for non-greedy + * arguments, which are one word wide already. + */ + Scope scope() default Scope.WORD; } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Cooldown.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Cooldown.java index 06acaadc7..e8834d7a4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Cooldown.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Cooldown.java @@ -1,12 +1,8 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import me.totalfreedom.totalfreedommod.cmd.internal.CooldownUnit; +import java.lang.annotation.*; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import me.totalfreedom.totalfreedommod.cmd.internal.CooldownUnit; /** * Applies a per-player cooldown to the annotated subcommand handler. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Greedy.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Greedy.java index 270234f49..2d99c0735 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Greedy.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Greedy.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Marks the final handler parameter as variable-length. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java index f4ffca437..18bcd3bb1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Permission.java @@ -1,27 +1,34 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; + import me.totalfreedom.totalfreedommod.cmd.SourceType; -import me.totalfreedom.totalfreedommod.rank.Rank; /** * Declares who may run a command (or a single subcommand handler) and from where. * <p> - * Place on an {@code FCommand} class. The check is also wired into the Brigadier root node's {@code requires()}, - * so unauthorized senders don't see the command in tab completion. - * Place on a handler method to indicate a permission for that specific subcommand (this overrides the parent on a permissible check). + * Place on an {@code FCommand} class. The check is also wired into the Brigadier root node's + * {@code requires()}, so unauthorized senders don't see the command in tab completion. Place on a + * handler method to guard that specific subcommand, which overrides the class-level declaration. + * <p> + * A declaration names only the internal permission node it needs; it never names a rank. The rank + * required to reach a handler is derived from {@code ranks.json} by resolving which is the least + * privileged rank that grants the node, so re-tiering a command is a config edit rather than a code + * change. Two consequences follow, and both matter when adding a node: + * <ul> + * <li>A handler that must be stricter than its parent needs its own <em>distinct</em> node. A node + * shared with the parent resolves to the same rank for both, silently dropping the raise.</li> + * <li>A node no rank grants is unreachable rather than open. A typo therefore denies everyone, + * which is the safe direction, but it will not surface until someone tries the command.</li> + * </ul> + * These nodes are internal to TFM and are deliberately never registered as Bukkit permissions: + * every player on a TotalFreedom server is opped, so a Bukkit node would effectively grant itself. */ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) @Documented public @interface Permission { - Rank level() default Rank.OP; - SourceType source() default SourceType.BOTH; String permission(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Resolve.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Resolve.java index f1c56412d..7abcfdf05 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Resolve.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Resolve.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Routes a handler parameter through a custom argument resolver. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Subcommand.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Subcommand.java index 300a764f8..ffc0a309a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Subcommand.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Subcommand.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Switch.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Switch.java index 67b595c65..8be92f40a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Switch.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/internal/annotation/Switch.java @@ -1,10 +1,6 @@ package me.totalfreedom.totalfreedommod.cmd.internal.annotation; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import java.lang.annotation.*; /** * Marks a {@code boolean} handler parameter as an optional command-line switch (e.g. {@code -s}). diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/AbstractParameterizedArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/AbstractParameterizedArgumentResolver.java index 71c5cbd2b..06a3766ec 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/AbstractParameterizedArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/AbstractParameterizedArgumentResolver.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import com.google.common.collect.ImmutableMap; -import me.totalfreedom.totalfreedommod.util.FUtil; - import java.util.Arrays; import java.util.Map; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import com.google.common.collect.ImmutableMap; + public interface AbstractParameterizedArgumentResolver<T> extends AbstractArgumentResolver<T> { T resolve(String arg, Map<String, Object> parameters); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/DateOffsetArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/DateOffsetArgumentResolver.java index dbaf75258..b565d120b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/DateOffsetArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/DateOffsetArgumentResolver.java @@ -1,10 +1,10 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import me.totalfreedom.totalfreedommod.util.FUtil; - import java.util.Date; import java.util.List; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class DateOffsetArgumentResolver implements AbstractArgumentResolver<Date> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnchantmentArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnchantmentArgumentResolver.java index e3ee1c013..a6e1ea4ec 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnchantmentArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnchantmentArgumentResolver.java @@ -2,11 +2,12 @@ import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; -import net.kyori.adventure.key.InvalidKeyException; -import net.kyori.adventure.key.Key; import org.bukkit.NamespacedKey; import org.bukkit.enchantments.Enchantment; +import net.kyori.adventure.key.InvalidKeyException; +import net.kyori.adventure.key.Key; + public class EnchantmentArgumentResolver implements AbstractArgumentResolver<Enchantment> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EntityTypeArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EntityTypeArgumentResolver.java index a17864705..1fa9a3d16 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EntityTypeArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EntityTypeArgumentResolver.java @@ -2,12 +2,13 @@ import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; -import net.kyori.adventure.key.InvalidKeyException; -import net.kyori.adventure.key.Key; import org.bukkit.NamespacedKey; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; +import net.kyori.adventure.key.InvalidKeyException; +import net.kyori.adventure.key.Key; + public class EntityTypeArgumentResolver implements AbstractArgumentResolver<EntityType> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnumArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnumArgumentResolver.java index ca42d8696..59fdf7fb3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnumArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/EnumArgumentResolver.java @@ -1,13 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import com.google.common.base.Enums; -import com.google.common.base.Optional; - import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Map; import java.util.function.Function; +import com.google.common.base.Enums; +import com.google.common.base.Optional; + public class EnumArgumentResolver implements AbstractParameterizedArgumentResolver<Enum<?>> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressListResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressListResolver.java index 066f6f4d9..a7eb11140 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressListResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressListResolver.java @@ -1,16 +1,18 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import com.google.common.net.InetAddresses; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.cmd.internal.ResolverRegistry; -import me.totalfreedom.totalfreedommod.player.PlayerData; -import org.bukkit.entity.Player; - import java.net.InetAddress; import java.util.List; import java.util.Map; import java.util.Objects; +import org.bukkit.entity.Player; + +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.cmd.internal.ResolverRegistry; +import me.totalfreedom.totalfreedommod.player.PlayerData; + +import com.google.common.net.InetAddresses; + public class InetAddressListResolver implements AbstractParameterizedArgumentResolver<List<InetAddress>> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressResolver.java index ccbae5638..0dbcd656d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/InetAddressResolver.java @@ -1,9 +1,9 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import com.google.common.net.InetAddresses; - import java.net.InetAddress; +import com.google.common.net.InetAddresses; + public class InetAddressResolver implements AbstractArgumentResolver<InetAddress> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/KeyArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/KeyArgumentResolver.java index 9989c386c..bfbaab66b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/KeyArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/KeyArgumentResolver.java @@ -1,8 +1,9 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; +import org.bukkit.NamespacedKey; + import net.kyori.adventure.key.InvalidKeyException; import net.kyori.adventure.key.Key; -import org.bukkit.NamespacedKey; public class KeyArgumentResolver implements AbstractArgumentResolver<Key> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialArgumentResolver.java index 3a3def1d9..9f8faece3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialArgumentResolver.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import net.kyori.adventure.key.InvalidKeyException; -import net.kyori.adventure.key.Key; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.Registry; +import net.kyori.adventure.key.InvalidKeyException; +import net.kyori.adventure.key.Key; + public class MaterialArgumentResolver implements AbstractArgumentResolver<Material> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialQueryArgumentProvider.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialQueryArgumentProvider.java index a46431c5a..56b3fe86c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialQueryArgumentProvider.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/MaterialQueryArgumentProvider.java @@ -1,13 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import org.bukkit.Material; -import org.bukkit.Registry; - import java.util.List; import java.util.Map; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; +import org.bukkit.Material; +import org.bukkit.Registry; + public class MaterialQueryArgumentProvider implements AbstractParameterizedArgumentResolver<List<Material>> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/OfflinePlayerArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/OfflinePlayerArgumentResolver.java index 5d2b8a73f..59f1408e2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/OfflinePlayerArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/OfflinePlayerArgumentResolver.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import me.totalfreedom.totalfreedommod.cmd.FCommand; +import java.util.List; +import java.util.UUID; + import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; -import java.util.List; -import java.util.UUID; +import me.totalfreedom.totalfreedommod.cmd.FCommand; public class OfflinePlayerArgumentResolver implements AbstractArgumentResolver<OfflinePlayer> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerArgumentResolver.java index 287debcdb..356768200 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerArgumentResolver.java @@ -1,13 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import java.util.List; +import java.util.UUID; + import org.bukkit.Bukkit; import org.bukkit.entity.Player; import me.totalfreedom.totalfreedommod.cmd.FCommand; - -import java.util.List; -import java.util.UUID; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; public class PlayerArgumentResolver implements AbstractArgumentResolver<Player> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerListArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerListArgumentResolver.java index 7e186734a..6607e259c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerListArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PlayerListArgumentResolver.java @@ -1,12 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; - import java.util.ArrayList; import java.util.List; import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + public class PlayerListArgumentResolver implements AbstractArgumentResolver<List<Player>> { private List<Player> resolveDefault(String arg) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PotionEffectTypeArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PotionEffectTypeArgumentResolver.java index 7699b0fb3..6dbe35d6c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PotionEffectTypeArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/PotionEffectTypeArgumentResolver.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import net.kyori.adventure.key.InvalidKeyException; -import net.kyori.adventure.key.Key; import org.bukkit.NamespacedKey; import org.bukkit.Registry; import org.bukkit.potion.PotionEffectType; +import net.kyori.adventure.key.InvalidKeyException; +import net.kyori.adventure.key.Key; + public class PotionEffectTypeArgumentResolver implements AbstractArgumentResolver<PotionEffectType> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WeatherArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WeatherArgumentResolver.java index 7d02e4bc0..9811a4b01 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WeatherArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WeatherArgumentResolver.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.world.WorldWeather; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import java.util.Arrays; import java.util.List; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.world.WorldWeather; + public class WeatherArgumentResolver implements AbstractArgumentResolver<WorldWeather> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WorldTimeArgumentResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WorldTimeArgumentResolver.java index 4f0fcd4bf..d3fc4a7df 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WorldTimeArgumentResolver.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/cmd/resolver/WorldTimeArgumentResolver.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.cmd.resolver; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.world.WorldTime; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; - import java.util.Arrays; import java.util.List; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; + +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.world.WorldTime; + public class WorldTimeArgumentResolver implements AbstractArgumentResolver<WorldTime> { @Override diff --git a/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java b/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java index e667a9314..8609877cd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/config/ConfigEntry.java @@ -3,9 +3,11 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import me.totalfreedom.totalfreedommod.PluginProvider; + import org.bukkit.configuration.ConfigurationSection; +import me.totalfreedom.totalfreedommod.PluginProvider; + public enum ConfigEntry { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/config/MainConfig.java b/src/main/java/me/totalfreedom/totalfreedommod/config/MainConfig.java index 2f95c425b..7851e5f32 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/config/MainConfig.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/config/MainConfig.java @@ -7,12 +7,15 @@ import java.io.InputStreamReader; import java.util.EnumMap; import java.util.List; + +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.framework.PluginComponent; +import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.commons.io.FileUtils; -import org.bukkit.configuration.InvalidConfigurationException; -import org.bukkit.configuration.file.YamlConfiguration; public class MainConfig extends PluginComponent<TotalFreedomMod> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java index ef260e679..d7a2458cd 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/AbstractDiscordChatRelay.java @@ -1,27 +1,14 @@ package me.totalfreedom.totalfreedommod.discord; -import java.awt.Color; -import java.util.Collections; +import java.time.Duration; import java.util.List; import java.util.Locale; import java.util.Optional; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.regex.Matcher; import java.util.regex.Pattern; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.ChatMentionUtil; -import me.totalfreedom.totalfreedommod.util.FLog; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.Role; -import net.dv8tion.jda.api.entities.User; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; @@ -29,22 +16,42 @@ import net.kyori.adventure.text.format.Style; import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextDecoration; -import org.bukkit.Bukkit; -import org.jetbrains.annotations.NotNull; + +import discord4j.common.util.Snowflake; +import discord4j.core.GatewayDiscordClient; +import discord4j.core.event.domain.message.MessageCreateEvent; +import discord4j.core.object.entity.Attachment; +import discord4j.core.object.entity.Member; +import discord4j.core.object.entity.Message; +import discord4j.core.object.entity.Role; +import discord4j.core.object.entity.User; +import discord4j.core.spec.MessageCreateSpec; +import discord4j.rest.util.AllowedMentions; +import discord4j.rest.util.Color; +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.ChatMentionUtil; +import me.totalfreedom.totalfreedommod.util.FLog; /** * Bidirectional chat relay for the chat channel chosen by the extending subclass. */ -public abstract class AbstractDiscordChatRelay extends ListenerAdapter +public abstract class AbstractDiscordChatRelay { protected static final int DISCORD_MAX_MESSAGE_LENGTH = 1900; protected static final int MINECRAFT_MAX_MESSAGE_LENGTH = 200; - private static final Pattern URL_PATTERN = Pattern.compile( - "(?i)\\b(?:https?://|www\\.)[-a-z0-9+&@#/%?=~_|!:,.;]*[-a-z0-9+&@#/%=~_|]"); + private static final int NO_ROLE_COLOR = Role.DEFAULT_COLOR.getRGB(); + private static final String DEFAULT_CHAT_FORMAT = "&9[Discord] &r{user}{reply}&7: &f{message}"; + private static final Pattern TEMPLATE_PLACEHOLDER = Pattern.compile("\\{(user|role|rolecolor|message|reply)}"); private static final Pattern MEDIA_EXTENSION = Pattern.compile( - "(?i)\\.(?:png|jpe?g|gif|webp|bmp|tiff?|svg|mp4|m4v|mov|webm|avi|mkv|mp3|wav|ogg|oga|m4a|flac)(?:$|[?#])"); + "(?i)\\.(?:png|jpe?g|gif|webp|bmp|tiff?|svg|mp4|m4v|mov|webm|avi|mkv|mp3|wav|ogg|oga|m4a|flac)(?:$|[?#])"); + private static final Pattern URL_PATTERN = Pattern.compile( + "(?i)\\b(?:https?://|www\\.)[-a-z0-9+&@#/%?=~_|!:,.;]*[-a-z0-9+&@#/%=~_|]"); + private static final NamedTextColor[] MINECRAFT_COLORS = { NamedTextColor.BLACK, NamedTextColor.DARK_BLUE, @@ -65,173 +72,204 @@ public abstract class AbstractDiscordChatRelay extends ListenerAdapter }; /** - * Resolved per send rather than captured at construction, so a reconnect that produces new - * channel handles does not leave this relay writing into a dead session's objects. + * Resolved per send rather than captured at construction, so a reconnect that produces a new + * session does not leave this relay writing into a dead connection. */ - private final Supplier<Optional<TextChannel>> channelSupplier; - private final String channelFormat; - private final String chatFormat; + private final Supplier<Optional<Snowflake>> channelSupplier; + private final Optional<String> channelFormat; + private final Optional<String> chatFormat; private final Consumer<Component> chatAction; private final TotalFreedomMod plugin; private final DiscordBridge bridge; - public AbstractDiscordChatRelay(Supplier<Optional<TextChannel>> channelSupplier, String channelFormat, String chatFormat, Consumer<Component> chatAction, TotalFreedomMod plugin, DiscordBridge bridge) + public AbstractDiscordChatRelay(Supplier<Optional<Snowflake>> channelSupplier, String channelFormat, String chatFormat, Consumer<Component> chatAction, TotalFreedomMod plugin, DiscordBridge bridge) { this.channelSupplier = channelSupplier; - this.channelFormat = channelFormat; - this.chatFormat = chatFormat; + this.channelFormat = configured(channelFormat); + this.chatFormat = configured(chatFormat); this.chatAction = chatAction; this.plugin = plugin; this.bridge = bridge; } - public void sendMessageToDiscord(Component rendered) + private static Optional<String> configured(final String value) { - String body = DiscordMarkdown.render(rendered); - if (body.isBlank()) - { - return; - } + return Optional.ofNullable(value).filter(text -> !text.isBlank()); + } - if (channelFormat != null && !channelFormat.isBlank()) - { - body = channelFormat.replace("{message}", body); - } + /** + * Every message is handled inside its own {@code onErrorResume}. + * An error reaching the outer {@link reactor.core.publisher.Flux} terminates it, and a + * terminated event stream is a relay that has silently stopped while the gateway stays up. + * Recovering per message means one malformed message costs one message. + */ + public Mono<Void> bind(final GatewayDiscordClient gateway) + { + return gateway.on(MessageCreateEvent.class) + .filter(this::isRelayable) + .flatMap(event -> handleMessage(event) + .onErrorResume(thrown -> + { + FLog.warning(String.format("[Discord] Dropped an inbound message: %s", + DiscordConnection.describeFailure(thrown))); + return Mono.empty(); + })) + .then(); + } - if (body.length() > DISCORD_MAX_MESSAGE_LENGTH) - { - body = body.substring(0, DISCORD_MAX_MESSAGE_LENGTH) + "…"; - } + public void sendMessageToDiscord(Component rendered) + { + configured(DiscordMarkdown.render(rendered)) + .map(body -> channelFormat + .map(format -> format.replace("{message}", body)) + .orElse(body)) + .map(AbstractDiscordChatRelay::truncateForDiscord) + .ifPresent(body -> sendToRelayChannel(body, "forward chat to Discord")); + } - sendToRelayChannel(body, "forward chat to Discord"); + private static String truncateForDiscord(final String body) + { + return body.length() > DISCORD_MAX_MESSAGE_LENGTH + ? body.substring(0, DISCORD_MAX_MESSAGE_LENGTH) + "…" + : body; } public void sendSystemMessageToDiscord(String message) { - if (message == null || message.isBlank()) - { - return; - } - - sendToRelayChannel(sanitizeForDiscord(message), "send system message to Discord"); + configured(message).ifPresent(text -> + sendToRelayChannel(sanitizeForDiscord(text), "send system message to Discord")); } /** - * Blocking send used on shutdown, where a queued message would be dropped when the client - * stops. Targets this relay's own channel: it previously hardcoded the public channel, so an - * adminchat relay calling it would have posted into public chat. + * Blocking send used on shutdown, where a queued message would be dropped when the connection + * goes away. + * <p> + * This is the one place the bridge blocks deliberately. */ - public void sendSystemMessageToDiscordNow(String message, long timeout, TimeUnit unit) + public void sendSystemMessageNow(String message, Duration timeout) { - if (message == null || message.isBlank()) + configured(message).ifPresent(text -> { - return; - } + try + { + createMessage(sanitizeForDiscord(text)).block(timeout); + } + catch (Exception ex) + { + FLog.warning("[Discord] Failed to send system message to Discord: " + ex.getMessage()); + } + }); + } - Optional<TextChannel> target = channelSupplier.get(); - if (target.isEmpty()) - { - return; - } + private boolean isRelayable(final MessageCreateEvent event) + { + if (event.getGuildId().isEmpty()) + return false; - try - { - target.get().sendMessage(sanitizeForDiscord(message)) - .setAllowedMentions(Collections.emptyList()) - .submit().get(timeout, unit); - } - catch (InterruptedException ex) - { - Thread.currentThread().interrupt(); - FLog.warning("[Discord] Interrupted while sending system message to Discord."); - } - catch (Exception ex) - { - FLog.warning("[Discord] Failed to send system message to Discord: " + ex.getMessage()); - } + final Optional<User> author = event.getMessage().getAuthor(); + if (author.isEmpty() || author.get().isBot()) + return false; + + final Optional<Snowflake> target = channelSupplier.get(); + return target.isPresent() && target.get().equals(event.getMessage().getChannelId()); } - @Override - public void onMessageReceived(@NotNull MessageReceivedEvent event) + private Mono<Void> handleMessage(final MessageCreateEvent event) { - if (event.getAuthor().isBot() || event.getAuthor().isSystem()) - { - return; - } - if (!event.isFromGuild()) - { - return; - } + final Message discordMessage = event.getMessage(); + final String content = discordMessage.getContent(); + final List<Attachment> attachments = List.copyOf(discordMessage.getAttachments()); + if (content.isBlank() && attachments.isEmpty()) + return Mono.empty(); + + final String finalTemplate = chatFormat.orElse(DEFAULT_CHAT_FORMAT); + final Optional<Member> member = event.getMember(); + final String displayName = resolveDisplayName(member, discordMessage.getAuthor()); + final String truncatedContent = content.length() > MINECRAFT_MAX_MESSAGE_LENGTH + ? content.substring(0, MINECRAFT_MAX_MESSAGE_LENGTH) + : content; + final Component discordContent = buildDiscordContent(truncatedContent, attachments); + + return Mono.zip(resolveRole(member), resolveReplyName(discordMessage)) + .flatMap(resolved -> relayToMinecraft(finalTemplate, + displayName, + resolved.getT1(), + discordContent, + resolved.getT2())); + } - Optional<TextChannel> target = channelSupplier.get(); - if (target.isEmpty() || !event.getChannel().getId().equals(target.get().getId())) - { - return; - } + /** + * Function to thread hop to main thread + */ + private Mono<Void> relayToMinecraft(String template, String displayName, DiscordRole role, Component discordContent, Optional<String> replyName) + { + return Mono.fromRunnable(() -> + { + Component mentionedContent = ChatMentionUtil.highlightAndPing(plugin, discordContent, false); + Component component = buildDiscordMessage( + template, + displayName, + role, + mentionedContent, + replyName); + chatAction.accept(component); + }) + .subscribeOn(bridge.mainThread()) + .then(); + } - Message discordMessage = event.getMessage(); - String content = discordMessage.getContentDisplay(); - List<Message.Attachment> attachments = discordMessage.getAttachments(); - if (content.isBlank() && attachments.isEmpty()) - { - return; - } + /** + * The display name of whoever wrote the message being replied to, absent when this message is + * not a reply or the referenced author cannot be resolved. + */ + private static Mono<Optional<String>> resolveReplyName(final Message message) + { + return message.getReferencedMessage() + .map(referenced -> referenced.getAuthorAsMember() + .map(Member::getDisplayName) + .switchIfEmpty(Mono.justOrEmpty(referenced.getAuthor().map(User::getUsername))) + .map(Optional::of) + .defaultIfEmpty(Optional.<String>empty())) + .orElseGet(() -> Mono.just(Optional.empty())); + } - String template = chatFormat; - if (template == null || template.isBlank()) - { - template = "&9[Discord] &r{user}{reply}&7: &f{message}"; - } + private static Mono<DiscordRole> resolveRole(final Optional<Member> member) + { + return member.map(present -> present.getRoles() + .collectList() + .map(AbstractDiscordChatRelay::pickRole)) + .orElseGet(() -> Mono.just(defaultRole())) + .onErrorReturn(defaultRole()); + } - User author = event.getAuthor(); - String displayName = resolveDisplayName(event.getMember(), author); - DiscordRole role = resolveRole(event.getMember()); - String truncatedContent = content.length() > MINECRAFT_MAX_MESSAGE_LENGTH - ? content.substring(0, MINECRAFT_MAX_MESSAGE_LENGTH) - : content; - Component discordContent = buildDiscordContent(truncatedContent, attachments); - String finalTemplate = template; - Message referencedMessage = discordMessage.getReferencedMessage(); - - if (referencedMessage == null) - { - relayToMinecraft(finalTemplate, displayName, role, discordContent, ""); - return; - } + private static DiscordRole defaultRole() + { + return new DiscordRole("everyone", NamedTextColor.GRAY); + } - Member referencedMember = referencedMessage.getMember(); - if (referencedMember == null) - { - referencedMember = event.getGuild().getMemberById(referencedMessage.getAuthor().getIdLong()); - } + private static DiscordRole pickRole(final List<Role> roles) + { + if (roles.isEmpty()) + return defaultRole(); - if (referencedMember != null) - { - relayToMinecraft( - finalTemplate, - displayName, - role, - discordContent, - resolveDisplayName(referencedMember, referencedMessage.getAuthor())); - return; - } + final Role selectedRole = roles.stream() + .filter(role -> colorOf(role).isPresent()) + .findFirst() + .orElseGet(() -> roles.get(0)); + + final NamedTextColor minecraftColor = colorOf(selectedRole).map(color -> closestMinecraftColor(color.getRGB() & 0xFFFFFF)) + .orElse(NamedTextColor.GRAY); - event.getGuild().retrieveMemberById(referencedMessage.getAuthor().getIdLong()).queue( - member -> relayToMinecraft( - finalTemplate, - displayName, - role, - discordContent, - resolveDisplayName(member, referencedMessage.getAuthor())), - failure -> relayToMinecraft( - finalTemplate, - displayName, - role, - discordContent, - referencedMessage.getAuthor().getName())); + return new DiscordRole(selectedRole.getName(), minecraftColor); } - private static Component buildDiscordContent(String content, List<Message.Attachment> attachments) + private static Optional<Color> colorOf(final Role role) + { + return Optional.ofNullable(role.getPrimaryColor()) + .filter(color -> color.getRGB() != NO_ROLE_COLOR); + } + + private static Component buildDiscordContent(String content, List<Attachment> attachments) { Component result = Component.empty(); Matcher matcher = URL_PATTERN.matcher(content); @@ -240,9 +278,7 @@ private static Component buildDiscordContent(String content, List<Message.Attach while (matcher.find()) { if (matcher.start() > last) - { result = result.append(Component.text(content.substring(last, matcher.start()))); - } String displayedUrl = matcher.group(); String href = displayedUrl.regionMatches(true, 0, "http", 0, 4) @@ -253,16 +289,13 @@ private static Component buildDiscordContent(String content, List<Message.Attach } if (last < content.length()) - { result = result.append(Component.text(content.substring(last))); - } - for (Message.Attachment attachment : attachments) + for (Attachment attachment : attachments) { if (!AdventureUtil.componentToPlainText(result).isBlank()) - { result = result.append(Component.space()); - } + result = result.append(maskedLink(attachment.getUrl(), true)); } @@ -272,8 +305,8 @@ private static Component buildDiscordContent(String content, List<Message.Attach private static Component maskedLink(String url, boolean media) { return Component.text(media ? "[Media]" : "[Link]", NamedTextColor.YELLOW) - .clickEvent(ClickEvent.openUrl(url)) - .hoverEvent(HoverEvent.showText(Component.text(url))); + .clickEvent(ClickEvent.openUrl(url)) + .hoverEvent(HoverEvent.showText(Component.text(url))); } private static boolean isMediaUrl(String url) @@ -281,56 +314,11 @@ private static boolean isMediaUrl(String url) return MEDIA_EXTENSION.matcher(url).find(); } - private static String resolveDisplayName(Member member, User user) + private static String resolveDisplayName(Optional<Member> member, Optional<User> user) { - if (member != null && member.getNickname() != null && !member.getNickname().isBlank()) - { - return member.getNickname(); - } - return user.getName(); - } - - private void relayToMinecraft(String template, String displayName, DiscordRole role, Component discordContent, String replyName) - { - Bukkit.getScheduler().runTask(plugin, () -> - { - Component mentionedContent = ChatMentionUtil.highlightAndPing(plugin, discordContent, false); - Component component = buildDiscordMessage( - template, - displayName, - role, - mentionedContent, - replyName); - chatAction.accept(component); - }); - } - - private static DiscordRole resolveRole(Member member) - { - if (member == null || member.getRoles().isEmpty()) - { - return new DiscordRole("everyone", NamedTextColor.GRAY); - } - - Role selectedRole = null; - for (Role role : member.getRoles()) - { - if (role.getColor() != null) - { - selectedRole = role; - break; - } - } - if (selectedRole == null) - { - selectedRole = member.getRoles().get(0); - } - - Color discordColor = selectedRole.getColor(); - NamedTextColor minecraftColor = discordColor == null - ? NamedTextColor.GRAY - : closestMinecraftColor(discordColor.getRGB() & 0xFFFFFF); - return new DiscordRole(selectedRole.getName(), minecraftColor); + return member.map(Member::getDisplayName) + .or(() -> user.map(User::getUsername)) + .orElse("unknown"); } private static NamedTextColor closestMinecraftColor(int rgb) @@ -364,15 +352,16 @@ private static NamedTextColor closestMinecraftColor(int rgb) return closest; } - private static Component buildDiscordMessage(String template, String user, DiscordRole role, Component message, String replyName) + private static Component buildDiscordMessage(String template, String user, DiscordRole role, Component message, Optional<String> replyName) { Matcher matcher = TEMPLATE_PLACEHOLDER.matcher(template); LegacyStyleState styleState = new LegacyStyleState(); Component result = Component.empty(); - Component reply = replyName == null || replyName.isBlank() - ? Component.empty() - : Component.text(" \u21aa Replying to ", NamedTextColor.GRAY) - .append(Component.text(replyName, NamedTextColor.AQUA)); + Component reply = replyName.filter(name -> !name.isBlank()) + .map(name -> Component.text(" ↪ Replying to ", NamedTextColor.GRAY) + .append(Component.text(name, NamedTextColor.AQUA))) + .map(Component.class::cast) + .orElseGet(Component::empty); boolean hasReplyPlaceholder = template.contains("{reply}"); boolean hasMessagePlaceholder = false; int last = 0; @@ -387,9 +376,8 @@ private static Component buildDiscordMessage(String template, String user, Disco case "user": result = result.append(Component.text(user).style(styleState.style())); if (!hasReplyPlaceholder) - { result = result.append(reply); - } + break; case "role": result = result.append(Component.text(role.name).style(styleState.style())); @@ -413,18 +401,15 @@ private static Component buildDiscordMessage(String template, String user, Disco result = result.append(styledLiteral(template.substring(last), styleState)); if (!hasMessagePlaceholder) - { result = result.append(Component.text().style(styleState.style()).append(message).build()); - } + return result; } private static Component styledLiteral(String literal, LegacyStyleState state) { if (literal.isEmpty()) - { return Component.empty(); - } Component component = Component.text().style(state.style()).append(AdventureUtil.format(literal)).build(); state.consume(literal); @@ -436,36 +421,38 @@ private static String sanitizeForDiscord(String input) return input.replace("`", "'"); } - private void failRelayChannelSend(final String failureDescription, final Throwable err) + private void failRelayChannelSend(final String failureDescription, final Optional<Throwable> err) { - FLog.warning("[Discord] Failed to " + failureDescription + (err != null ? ": " + err.getMessage() : "")); + FLog.warning(String.format("[Discord] Failed to %s: %s", + failureDescription, + err.map(thrown -> ": " + thrown.getMessage()).orElse(""))); } - private void sendToRelayChannel(final String body, final String failureDescription) + /** + * Build the send for {@code body} against whichever channel this relay currently points at. + */ + private Mono<Message> createMessage(final String body) { - final Optional<TextChannel> target = channelSupplier.get(); - if (target.isEmpty() || body == null) - { - failRelayChannelSend(failureDescription, null); - return; - } + final Optional<Snowflake> target = channelSupplier.get(); + + return Mono.justOrEmpty(configured(body)) + .flatMap(text -> Mono.justOrEmpty(bridge.getSession()) + .flatMap(current -> current.channel(target)) + .flatMap(channel -> channel.createMessage(MessageCreateSpec.builder() + .content(text) + .allowedMentions(AllowedMentions.suppressAll()) + .build()) + )); + } - try - { - target.get().sendMessage(body) - .setAllowedMentions(Collections.emptyList()) - .queue( - null, - err -> failRelayChannelSend(failureDescription, err) - ); - } - catch (RejectedExecutionException ex) - { - // The client underneath us is gone, so no gateway event is coming to announce it. - // Tell the supervisor directly or the bridge would sit here failing every send. - failRelayChannelSend(failureDescription, ex); - bridge.reportTransportFailure(failureDescription, ex); - } + /** + * Fire-and-forget send with an explicit error consumer. + */ + private void sendToRelayChannel(final String body, final String failureDescription) + { + // The no-op onNext is a placeholder for Reactor's three-argument subscribe, which has no overload taking only an error consumer. + createMessage(body).subscribe(sent -> {}, + err -> failRelayChannelSend(failureDescription, Optional.of(err))); } private static final class DiscordRole @@ -482,7 +469,8 @@ private DiscordRole(String name, NamedTextColor color) private static final class LegacyStyleState { - private TextColor color; + private Optional<TextColor> color = Optional.empty(); + private boolean obfuscated; private boolean bold; private boolean strikethrough; @@ -491,37 +479,30 @@ private static final class LegacyStyleState private void setColor(TextColor color) { - this.color = color; + this.color = Optional.of(color); clearDecorations(); } private Style style() { Style.Builder builder = Style.style(); - if (color != null) - { - builder.color(color); - } + color.ifPresent(builder::color); + if (obfuscated) - { builder.decoration(TextDecoration.OBFUSCATED, TextDecoration.State.TRUE); - } + if (bold) - { builder.decoration(TextDecoration.BOLD, TextDecoration.State.TRUE); - } + if (strikethrough) - { builder.decoration(TextDecoration.STRIKETHROUGH, TextDecoration.State.TRUE); - } + if (underlined) - { builder.decoration(TextDecoration.UNDERLINED, TextDecoration.State.TRUE); - } + if (italic) - { builder.decoration(TextDecoration.ITALIC, TextDecoration.State.TRUE); - } + return builder.build(); } @@ -531,9 +512,7 @@ private void consume(String text) { char marker = text.charAt(i); if (marker != '&' && marker != '§') - { continue; - } char code = Character.toLowerCase(text.charAt(i + 1)); if (code == '#' && i + 7 < text.length()) @@ -541,7 +520,7 @@ private void consume(String text) String hex = text.substring(i + 2, i + 8); if (hex.matches("[0-9a-fA-F]{6}")) { - color = TextColor.color(Integer.parseInt(hex, 16)); + color = Optional.of(TextColor.color(Integer.parseInt(hex, 16))); clearDecorations(); i += 7; } @@ -566,15 +545,15 @@ private void consume(String text) } if (valid) { - color = TextColor.color(Integer.parseInt(hex.toString(), 16)); + color = Optional.of(TextColor.color(Integer.parseInt(hex.toString(), 16))); clearDecorations(); i += 13; } continue; } - NamedTextColor namedColor = legacyColor(code); - if (namedColor != null) + final Optional<TextColor> namedColor = legacyColor(code); + if (namedColor.isPresent()) { color = namedColor; clearDecorations(); @@ -600,7 +579,7 @@ private void consume(String text) italic = true; break; case 'r': - color = null; + color = Optional.empty(); clearDecorations(); break; default: @@ -619,28 +598,31 @@ private void clearDecorations() italic = false; } - private static NamedTextColor legacyColor(char code) + /** + * The colour a legacy code selects, absent when the character is not a colour code at all. + */ + private static Optional<TextColor> legacyColor(char code) { - switch (code) + return switch (code) { - case '0': return NamedTextColor.BLACK; - case '1': return NamedTextColor.DARK_BLUE; - case '2': return NamedTextColor.DARK_GREEN; - case '3': return NamedTextColor.DARK_AQUA; - case '4': return NamedTextColor.DARK_RED; - case '5': return NamedTextColor.DARK_PURPLE; - case '6': return NamedTextColor.GOLD; - case '7': return NamedTextColor.GRAY; - case '8': return NamedTextColor.DARK_GRAY; - case '9': return NamedTextColor.BLUE; - case 'a': return NamedTextColor.GREEN; - case 'b': return NamedTextColor.AQUA; - case 'c': return NamedTextColor.RED; - case 'd': return NamedTextColor.LIGHT_PURPLE; - case 'e': return NamedTextColor.YELLOW; - case 'f': return NamedTextColor.WHITE; - default: return null; - } + case '0' -> Optional.of(NamedTextColor.BLACK); + case '1' -> Optional.of(NamedTextColor.DARK_BLUE); + case '2' -> Optional.of(NamedTextColor.DARK_GREEN); + case '3' -> Optional.of(NamedTextColor.DARK_AQUA); + case '4' -> Optional.of(NamedTextColor.DARK_RED); + case '5' -> Optional.of(NamedTextColor.DARK_PURPLE); + case '6' -> Optional.of(NamedTextColor.GOLD); + case '7' -> Optional.of(NamedTextColor.GRAY); + case '8' -> Optional.of(NamedTextColor.DARK_GRAY); + case '9' -> Optional.of(NamedTextColor.BLUE); + case 'a' -> Optional.of(NamedTextColor.GREEN); + case 'b' -> Optional.of(NamedTextColor.AQUA); + case 'c' -> Optional.of(NamedTextColor.RED); + case 'd' -> Optional.of(NamedTextColor.LIGHT_PURPLE); + case 'e' -> Optional.of(NamedTextColor.YELLOW); + case 'f' -> Optional.of(NamedTextColor.WHITE); + default -> Optional.empty(); + }; } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java index 34618b077..efdef4482 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordBridge.java @@ -1,33 +1,15 @@ package me.totalfreedom.totalfreedommod.discord; -import io.papermc.paper.event.player.AsyncChatEvent; import java.security.SecureRandom; +import java.time.Duration; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.discord.acquisition.DiscordAcquisition; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.JDABuilder; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.events.session.ReadyEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.Commands; -import net.dv8tion.jda.api.requests.GatewayIntent; -import net.dv8tion.jda.api.utils.MemberCachePolicy; -import net.dv8tion.jda.api.utils.cache.CacheFlag; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.text.Component; +import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -36,22 +18,43 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.scheduler.BukkitTask; -import org.jetbrains.annotations.NotNull; + +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; + +import discord4j.common.util.Snowflake; +import discord4j.core.GatewayDiscordClient; +import discord4j.core.event.domain.lifecycle.ReadyEvent; +import discord4j.core.object.entity.channel.GuildMessageChannel; +import discord4j.discordjson.json.ApplicationCommandOptionData; +import discord4j.discordjson.json.ApplicationCommandRequest; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FTask; /** - * Built-in Discord bridge: owns the JDA client, the chat/console relays and the slash-command - * listeners. + * Owns the Discord4J client, the chat/console relays and the slash-command handlers. * <p> - * The connection itself is owned by {@link DiscordAcquisition}, which guarantees this server holds - * at most one gateway session on the token and that a failed start never leaves an orphan - * connected. Startup runs off the main thread, so a slow or unreachable Discord no longer blocks - * server boot the way {@code awaitReady()} on the main thread used to. + * The connection itself is owned by {@link DiscordConnection}, which scopes one gateway session to + * one pipeline and reconnects within a budget. Startup is a subscription rather than a blocking + * call, so a slow or unreachable Discord never blocks server boot. * <p> - * The live connection is published as one immutable {@link DiscordSession} behind a volatile - * field. Readers on gateway and scheduler threads take a snapshot and either use a whole + * The live connection is published as one immutable {@link DiscordSession} behind a volatile field. + * Readers on Reactor threads and the console flush task take a snapshot and either use a whole * connection or find none, and reconnecting swaps the lot in a single assignment. - * - * @see DiscordConnectionSupervisor + * <p> + * Everything downstream of a gateway event runs on a Reactor thread. Anything that touches the + * server has to hop to the main thread first, and the single convention for that is + * {@link #mainThread()}, applied with {@code subscribeOn} to a {@code fromRunnable} or + * {@code fromCallable} holding the server call. + * + * @see DiscordConnection */ public class DiscordBridge extends FreedomService { @@ -61,40 +64,50 @@ public class DiscordBridge extends FreedomService private static final SecureRandom CODE_RANDOM = new SecureRandom(); private static final String CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private static final int CODE_LENGTH = 8; + private static final int DEFAULT_LINK_CODE_TTL_SECONDS = 300; + private static final Duration SHUTDOWN_MESSAGE_TIMEOUT = Duration.ofSeconds(5); + + private static final List<ApplicationCommandRequest> SLASH_COMMANDS = List.of( + ApplicationCommandRequest.builder() + .name("list") + .description("Show a list of players on the server.") + .build(), + ApplicationCommandRequest.builder() + .name("link") + .description("Link your Minecraft account by entering a code from the in-game /link command.") + .addOption(ApplicationCommandOptionData.builder() + .name("code") + .description("The 8-character code shown in-game.") + .type(3) + .required(true) + .build()) + .build(), + ApplicationCommandRequest.builder() + .name("unlink") + .description("Unlink your Minecraft account from your current Discord account.") + .build()); private final Map<String, PendingLink> pendingLinks = new ConcurrentHashMap<>(); - - private final DiscordAcquisition acquisition = new DiscordAcquisition(); - - /** - * Held for the duration of one connect attempt. The initial attempt and a supervisor retry can - * overlap, and the acquisition layer would refuse the second anyway; this keeps it from - * getting that far and logging a refusal for something benign. - */ - private final AtomicBoolean connecting = new AtomicBoolean(); + private final DiscordConnection connection = new DiscordConnection(); + private final Scheduler mainThread; /** - * The live connection, or {@code null} when disconnected. Volatile because it is written by - * the connect task and read from JDA's gateway threads and the console flush task. + * The live connection, empty while disconnected. Volatile because it is written by the connect + * pipeline and read from Reactor threads and the console flush task. */ - private volatile DiscordSession session; - - private volatile DiscordConnectionSupervisor supervisor; + private volatile Optional<DiscordSession> session = Optional.empty(); private volatile boolean started; - - // Written by the connect task, read from JDA's gateway threads and the server main thread. - private volatile DiscordChatRelay chatRelay; - private volatile DiscordAdminchatRelay adminchatRelay; - private volatile DiscordConsoleRelay consoleRelay; - private volatile DiscordCommands commands; - - private volatile BukkitTask cleanupTask; + private volatile Optional<DiscordChatRelay> chatRelay = Optional.empty(); + private volatile Optional<DiscordAdminchatRelay> adminchatRelay = Optional.empty(); + private volatile Optional<DiscordConsoleRelay> consoleRelay = Optional.empty(); + private volatile Optional<BukkitTask> cleanupTask = Optional.empty(); private volatile int linkCodeTtlSeconds; private volatile boolean startedWhileReloading; public DiscordBridge(TotalFreedomMod plugin) { super(plugin); + this.mainThread = Schedulers.fromExecutor(Bukkit.getScheduler().getMainThreadExecutor(plugin)); } @Override @@ -105,169 +118,145 @@ protected void onStart() if (!Boolean.TRUE.equals(ConfigEntry.DISCORD_ENABLED.getBoolean())) return; - if (isBlank(ConfigEntry.DISCORD_TOKEN.getString())) + plugin.dm.whenReady(() -> + DiscordLinkJsonSync.reconcileFromJsonIfNewer(plugin, plugin.dm.getDiscordLinkRepository())); + + final Optional<String> token = configured(ConfigEntry.DISCORD_TOKEN.getString()); + if (token.isEmpty()) { FLog.warning("[Discord] discord.enabled is true but discord.token is empty; bridge will not start."); return; } - if (isBlank(ConfigEntry.DISCORD_GUILD_ID.getString())) + if (configured(ConfigEntry.DISCORD_GUILD_ID.getString()).isEmpty()) { FLog.warning("[Discord] discord.guild_id is empty; bridge will not start."); return; } - final Integer ttl = ConfigEntry.DISCORD_LINK_CODE_TTL.getInteger(); - linkCodeTtlSeconds = ttl == null || ttl <= 0 ? 300 : ttl; + linkCodeTtlSeconds = Optional.ofNullable(ConfigEntry.DISCORD_LINK_CODE_TTL.getInteger()) + .filter(ttl -> ttl > 0) + .orElse(DEFAULT_LINK_CODE_TTL_SECONDS); started = true; - supervisor = new DiscordConnectionSupervisor(plugin, this::connect, this::teardownConnection); - - cleanupTask = plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, + cleanupTask = Optional.of(plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, FTask.guard("DiscordBridge/cleanupPendingLinks", this::cleanupPendingLinks), - 20L * 60L, 20L * 60L); + 20L * 60L, 20L * 60L)); - runAsync("DiscordBridge/connect", this::connect); + connection.start(token.get(), this::onConnected); } @Override protected void onStop() { started = false; - cancel(cleanupTask); - cleanupTask = null; - - final DiscordConnectionSupervisor currentSupervisor = supervisor; - if (currentSupervisor != null) - currentSupervisor.beginIntentionalShutdown(); + cleanupTask.ifPresent(BukkitTask::cancel); + cleanupTask = Optional.empty(); - if (consoleRelay != null) - consoleRelay.detachAppender(); + consoleRelay.ifPresent(DiscordConsoleRelay::detachAppender); - if (chatRelay != null && !reloading && currentPublicChannel().isPresent()) + if (!reloading) { - chatRelay.sendSystemMessageToDiscordNow(getConfiguredMessage(ConfigEntry.DISCORD_SERVER_SHUTDOWN_MESSAGE), - 5L, TimeUnit.SECONDS); + chatRelay.ifPresent(relay -> getConfiguredMessage(ConfigEntry.DISCORD_SERVER_SHUTDOWN_MESSAGE) + .ifPresent(message -> relay.sendSystemMessageNow(message, SHUTDOWN_MESSAGE_TIMEOUT))); } - teardownConnection(); + connection.stop(); - supervisor = null; + session = Optional.empty(); + chatRelay = Optional.empty(); + adminchatRelay = Optional.empty(); + consoleRelay = Optional.empty(); pendingLinks.clear(); } - // ============================================ - // Connection lifecycle - // ============================================ - /** - * One connect attempt: open the gateway through the acquisition layer, resolve the guild and - * channels, and publish the session. Runs off the main thread. + * Resolves the guild and channels, publishes the session, then returns the relay pipelines + * joined together. Those never complete on their own, which is what holds the gateway open; + * when the connection drops they terminate, the returned {@link Mono} completes, and + * {@link DiscordConnection} turns that into a retry. * <p> - * Cleanup of a half-opened client is the acquisition layer's job, and the supervisor releases - * whatever a failed attempt left behind before it retries, so the failure path here only has - * to report the attempt. + * A guild that cannot be resolved is an error rather than a quiet exit, so a bot that was + * removed from the guild spends its reconnect budget and reports instead of sitting connected + * and idle. */ - private void connect() + private Mono<Void> onConnected(final GatewayDiscordClient gateway) { - final DiscordConnectionSupervisor currentSupervisor = supervisor; - - if (!started || currentSupervisor == null || currentSupervisor.hasGivenUp()) - return; - - if (session != null || !connecting.compareAndSet(false, true)) - return; + if (!started) + return Mono.empty(); - try - { - final Optional<JDA> opened = acquisition.acquire(() -> buildClient(currentSupervisor)); - if (opened.isEmpty()) - return; - - final JDA client = opened.get(); - final String guildId = ConfigEntry.DISCORD_GUILD_ID.getString(); - final Guild guild = client.getGuildById(guildId); - if (guild == null) - { - acquisition.release(); - currentSupervisor.reportFailure(String.format("bot is not a member of guild %s", guildId)); - return; - } + final String rawGuildId = ConfigEntry.DISCORD_GUILD_ID.getString(); - publishSession(new DiscordSession(client, guild, - resolveChannel(guild, ConfigEntry.DISCORD_PUBLIC_CHANNEL_ID.getString(), "public_channel_id"), - resolveChannel(guild, ConfigEntry.DISCORD_ADMINCHAT_CHANNEL_ID.getString(), "adminchat_channel_id"), - resolveChannel(guild, ConfigEntry.DISCORD_CONSOLE_CHANNEL_ID.getString(), "console_channel_id")), - currentSupervisor); - } - catch (Throwable thrown) - { - currentSupervisor.reportFailure(String.format("connect failed: %s", - DiscordConnectionSupervisor.describeFailure(thrown))); - } - finally - { - connecting.set(false); - } + return Mono.justOrEmpty(parseSnowflake(rawGuildId, "guild_id")) + .switchIfEmpty(Mono.error(() -> + new IllegalStateException(String.format("guild_id '%s' is not a valid id", rawGuildId)))) + .flatMap(guildId -> gateway.getGuildById(guildId) + .switchIfEmpty(Mono.error(() -> new IllegalStateException( + String.format("bot is not a member of guild %s", rawGuildId)))) + .flatMap(guild -> resolveSession(gateway, guildId, guild.getName()))) + .flatMap(resolved -> publishSession(gateway, resolved)); } - /** - * The supervisor is attached here, before {@code build()} opens the gateway, so there is no - * point in the client's life at which a drop can go unseen: a connection that dies during the - * readiness wait, or in the moments between it and the session being published, still produces - * a shutdown event with the supervisor listening. Closes the bridge performs itself do not - * reach it, because the acquisition layer detaches listeners before closing anything. - */ - private JDA buildClient(final DiscordConnectionSupervisor currentSupervisor) + private Mono<DiscordSession> resolveSession(final GatewayDiscordClient gateway, final Snowflake guildId, + final String guildName) { - return JDABuilder.createDefault(ConfigEntry.DISCORD_TOKEN.getString()) - .enableIntents(GatewayIntent.GUILD_MESSAGES, - GatewayIntent.MESSAGE_CONTENT, - GatewayIntent.DIRECT_MESSAGES) - .disableCache(CacheFlag.VOICE_STATE, CacheFlag.EMOJI, CacheFlag.STICKER, - CacheFlag.SCHEDULED_EVENTS, CacheFlag.ACTIVITY, - CacheFlag.CLIENT_STATUS, CacheFlag.ONLINE_STATUS) - .setMemberCachePolicy(MemberCachePolicy.NONE) - .addEventListeners(new ReadyListener(), currentSupervisor) - .build(); + return Mono.zip( + resolveChannel(gateway, ConfigEntry.DISCORD_PUBLIC_CHANNEL_ID.getString(), "public_channel_id"), + resolveChannel(gateway, ConfigEntry.DISCORD_ADMINCHAT_CHANNEL_ID.getString(), "adminchat_channel_id"), + resolveChannel(gateway, ConfigEntry.DISCORD_CONSOLE_CHANNEL_ID.getString(), "console_channel_id")) + .map(resolved -> new DiscordSession( + gateway, + guildId, + guildName, + resolved.getT1().id(), + resolved.getT2().id(), + resolved.getT3().id(), + String.format("public: %s | adminchat: %s | console: %s", + resolved.getT1().name(), resolved.getT2().name(), resolved.getT3().name()))); } - /** - * Attach the relays to a freshly opened connection and make it visible to the rest of the - * plugin. Ordering matters: the session is published before the console appender starts, so - * the first flush already has somewhere to send. - */ - private void publishSession(final DiscordSession opened, final DiscordConnectionSupervisor currentSupervisor) + private Mono<Void> publishSession(final GatewayDiscordClient gateway, final DiscordSession opened) { if (!started) - { - acquisition.release(); - return; - } + return Mono.empty(); + + final DiscordCommands openedCommands = new DiscordCommands(plugin, this); + final DiscordChatRelay openedChat = new DiscordChatRelay(plugin, this); + final DiscordAdminchatRelay openedAdminchat = new DiscordAdminchatRelay(plugin, this); + final DiscordConsoleRelay openedConsole = new DiscordConsoleRelay(plugin, this); - commands = new DiscordCommands(plugin, this); - chatRelay = new DiscordChatRelay(plugin, this); - adminchatRelay = new DiscordAdminchatRelay(plugin, this); - consoleRelay = new DiscordConsoleRelay(plugin, this); + chatRelay = Optional.of(openedChat); + adminchatRelay = Optional.of(openedAdminchat); + consoleRelay = Optional.of(openedConsole); - session = opened; - // Only the relays here; the supervisor has been attached since buildClient(). - opened.jda().addEventListener(commands, chatRelay, adminchatRelay, consoleRelay); + session = Optional.of(opened); - opened.guild().updateCommands().addCommands( - Commands.slash("list", "Show a list of players on the server."), - Commands.slash("link", "Link your Minecraft account by entering a code from the in-game /link command.") - .addOption(OptionType.STRING, "code", "The 8-character code shown in-game.", true), - Commands.slash("unlink", "Unlink your Minecraft account from your current Discord account.") - ).queue( - ok -> FLog.info(String.format("[Discord] Registered slash commands on guild %s.", opened.guild().getName())), - err -> FLog.warning(String.format("[Discord] Failed to register slash commands: %s", err.getMessage())) - ); + final Mono<Void> ready = gateway.on(ReadyEvent.class) + .doOnNext(event -> + { + connection.reportConnected(); + FLog.info(String.format("[Discord] Gateway ready. Bot: %s", + event.getSelf().getUsername())); + }) + .then(); + + return Mono.when( + registerSlashCommands(gateway, opened) + .then(Mono.fromRunnable(() -> announceReady(opened))), + ready, + openedCommands.bind(gateway), + openedChat.bind(gateway), + openedAdminchat.bind(gateway), + openedConsole.bind(gateway)); + } + + private void announceReady(final DiscordSession opened) + { + connection.reportConnected(); - consoleRelay.attachAppender(); - currentSupervisor.reportConnected(); + consoleRelay.ifPresent(DiscordConsoleRelay::attachAppender); FLog.info(String.format("[Discord] Bridge ready. Guild: %s | %s", - opened.guild().getName(), opened.describeChannels())); + opened.guildName(), opened.describeChannels())); final ConfigEntry greeting = startedWhileReloading ? ConfigEntry.DISCORD_PLUGIN_RELOAD_MESSAGE @@ -275,91 +264,108 @@ private void publishSession(final DiscordSession opened, final DiscordConnection startedWhileReloading = false; if (opened.publicChannel().isPresent()) - chatRelay.sendSystemMessageToDiscord(getConfiguredMessage(greeting)); - } - - /** - * Take the live connection down and detach everything hanging off it. Safe to call when - * nothing is connected, and safe to call repeatedly. - * <p> - * Blocks for as long as the acquisition layer takes to close the client, so callers reaching - * here from a gateway event have to hop off that thread first. - */ - private void teardownConnection() - { - session = null; - - if (consoleRelay != null) { - consoleRelay.detachAppender(); - consoleRelay = null; + chatRelay.ifPresent(relay -> getConfiguredMessage(greeting) + .ifPresent(relay::sendSystemMessageToDiscord)); } - - chatRelay = null; - adminchatRelay = null; - commands = null; - - acquisition.release(); } - // ============================================ - // Relay entry points - // ============================================ + private Mono<Void> registerSlashCommands(final GatewayDiscordClient gateway, final DiscordSession opened) + { + return gateway.getRestClient().getApplicationId() + .flatMapMany(applicationId -> gateway.getRestClient().getApplicationService() + .bulkOverwriteGuildApplicationCommand(applicationId, opened.guildId().asLong(), SLASH_COMMANDS)) + .then(Mono.fromRunnable(() -> FLog.info( + String.format("[Discord] Registered slash commands on guild %s.", opened.guildName())))) + .onErrorResume(thrown -> + { + FLog.warning(String.format("[Discord] Failed to register slash commands: %s", + DiscordConnection.describeFailure(thrown))); + return Mono.empty(); + }) + .then(); + } + + private Mono<ResolvedChannel> resolveChannel(final GatewayDiscordClient gateway, final String rawId, + final String configKey) + { + if (configured(rawId).isEmpty()) + return Mono.just(ResolvedChannel.none()); + + return Mono.justOrEmpty(parseSnowflake(rawId, configKey)) + .flatMap(id -> gateway.getChannelById(id) + .ofType(GuildMessageChannel.class) + .map(channel -> new ResolvedChannel(Optional.of(id), channel.getName())) + .switchIfEmpty(Mono.fromSupplier(() -> + { + FLog.warning(String.format( + "[Discord] %s '%s' is not a text channel in the configured guild.", + configKey, rawId)); + return ResolvedChannel.none(); + }))) + .defaultIfEmpty(ResolvedChannel.none()) + .onErrorResume(thrown -> + { + FLog.warning(String.format( + "[Discord] Could not resolve %s '%s': %s", + configKey, rawId, DiscordConnection.describeFailure(thrown))); + return Mono.just(ResolvedChannel.none()); + }); + } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAsyncChat(AsyncChatEvent event) { - final DiscordChatRelay relay = chatRelay; - if (relay == null || currentPublicChannel().isEmpty()) + if (currentPublicChannel().isEmpty()) return; - final Player player = event.getPlayer(); - Component rendered; - try - { - rendered = event.renderer().render(player, player.displayName(), event.message(), Audience.empty()); - } - catch (Exception ex) + chatRelay.ifPresent(relay -> { - FLog.warning(String.format("[Discord] Chat renderer threw, falling back to plain: %s", ex.getMessage())); - rendered = Component.text(player.getName() + ": ").append(event.message()); - } - relay.sendMessageToDiscord(rendered); + final Player player = event.getPlayer(); + Component rendered; + try + { + rendered = event.renderer().render(player, player.displayName(), event.message(), Audience.empty()); + } + catch (Exception ex) + { + FLog.warning(String.format( + "[Discord] Chat renderer threw, falling back to plain: %s", + ex.getMessage())); + rendered = Component.text(player.getName() + ": ").append(event.message()); + } + relay.sendMessageToDiscord(rendered); + }); } public void sendBroadcastMessage(String senderName, String message, ConfigEntry configEntry) { - final String template = getConfiguredMessage(configEntry); - if (template == null) - return; - - sendToPublicRelay(template.replace("{sender}", senderName).replace("{message}", message)); + getConfiguredMessage(configEntry).ifPresent(template -> sendToPublicRelay(template.replace("{sender}", senderName).replace("{message}", message))); } public void sendActionMessage(String senderName, String playerName, String reason, ConfigEntry configEntry) { - final String template = getConfiguredMessage(configEntry); - if (template == null) - return; - - sendToPublicRelay(template.replace("{sender}", senderName == null ? "CONSOLE" : senderName) - .replace("{player}", playerName == null ? "null" : playerName) - .replace("{reason}", reason == null || reason.isBlank() ? "No reason provided." : reason)); + getConfiguredMessage(configEntry).ifPresent(template -> sendToPublicRelay(template.replace("{sender}", Optional.ofNullable(senderName).orElse("CONSOLE")) + .replace("{player}", Optional.ofNullable(playerName).orElse("null")) + .replace("{reason}", configured(reason).orElse("No reason provided.")))); } public void relayAdminchatMessage(CommandSender sender, Component tag, Component message) { - final DiscordAdminchatRelay relay = adminchatRelay; - if (relay == null || currentAdminchatChannel().isEmpty()) + if (currentAdminchatChannel().isEmpty()) return; - final Component rendered = sender instanceof Player - ? tag.append(Component.text(" " + sender.getName() + ": ")).append(message) - : Component.text(sender.getName() + " ") - .append(tag) - .append(Component.text(": ")) - .append(message); - relay.sendMessageToDiscord(rendered); + adminchatRelay.ifPresent(relay -> + { + final Component rendered = sender instanceof Player + ? tag.append(Component.text(" " + sender.getName() + ": ")).append(message) + : Component.text(sender.getName() + " ") + .append(tag) + .append(Component.text(": ")) + .append(message); + + relay.sendMessageToDiscord(rendered); + }); } @EventHandler(priority = EventPriority.MONITOR) @@ -376,78 +382,54 @@ public void onPlayerQuit(PlayerQuitEvent event) public void relayLoginMessage(Component message) { - if (message == null) - return; - - final String rendered = DiscordMarkdown.render(message); - if (rendered.isBlank()) - return; - - sendToPublicRelay(rendered); + Optional.ofNullable(message) + .map(DiscordMarkdown::render) + .filter(rendered -> !rendered.isBlank()) + .ifPresent(this::sendToPublicRelay); + } + + public Optional<DiscordSession> getSession() + { + return session; } - - // ============================================ - // Connection accessors - // ============================================ /** - * The live connection, absent while disconnected. + * The server main thread as a Reactor scheduler. Every pipeline that ends in a call touching + * the server publishes onto this first. */ - public Optional<DiscordSession> getSession() + public Scheduler mainThread() { - return Optional.ofNullable(session); + return mainThread; } - public Optional<TextChannel> currentPublicChannel() + public Optional<Snowflake> currentPublicChannel() { return getSession().flatMap(DiscordSession::publicChannel); } - public Optional<TextChannel> currentAdminchatChannel() + public Optional<Snowflake> currentAdminchatChannel() { return getSession().flatMap(DiscordSession::adminchatChannel); } - public Optional<TextChannel> currentConsoleChannel() + public Optional<Snowflake> currentConsoleChannel() { return getSession().flatMap(DiscordSession::consoleChannel); } - /** - * Report a send failure that means the client underneath us is gone, so the supervisor can - * spend a reconnect attempt on it rather than waiting for a gateway event that will not come. - */ - public void reportTransportFailure(final String context, final Throwable thrown) - { - final DiscordConnectionSupervisor currentSupervisor = supervisor; - if (currentSupervisor == null) - return; - - currentSupervisor.reportFailure(String.format("%s: %s", context, - DiscordConnectionSupervisor.describeFailure(thrown))); - } - public boolean isReady() { - return session != null; + return session.isPresent(); } - // ============================================ - // Account linking - // ============================================ - - /** - * Register a pending link code. Returns the generated code. - */ public String createPendingLink(UUID adminUuid) { final long expiryMs = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(linkCodeTtlSeconds); String code; - do - { - code = generateCode(); - } - while (pendingLinks.putIfAbsent(code, new PendingLink(adminUuid, expiryMs)) != null); + + do code = generateCode(); + while (Optional.ofNullable(pendingLinks.putIfAbsent(code, new PendingLink(adminUuid, expiryMs))).isPresent()); + return code; } @@ -457,14 +439,11 @@ public String createPendingLink(UUID adminUuid) */ public Optional<UUID> consumePendingLink(String code) { - if (code == null) - return Optional.empty(); - - final PendingLink link = pendingLinks.remove(code.toUpperCase()); - if (link == null || link.expiresAtMs() < System.currentTimeMillis()) - return Optional.empty(); - - return Optional.of(link.adminUuid()); + return Optional.ofNullable(code) + .map(String::toUpperCase) + .flatMap(upper -> Optional.ofNullable(pendingLinks.remove(upper))) + .filter(link -> link.expiresAtMs() >= System.currentTimeMillis()) + .map(PendingLink::adminUuid); } public int getLinkCodeTtlSeconds() @@ -472,54 +451,48 @@ public int getLinkCodeTtlSeconds() return linkCodeTtlSeconds; } - // ============================================ - // Helpers - // ============================================ - private void sendPlayerStatusMessage(String playerName, ConfigEntry configEntry) { - final String template = getConfiguredMessage(configEntry); - if (template == null) - return; - - sendToPublicRelay(template.replace("{player}", playerName)); + getConfiguredMessage(configEntry).ifPresent(template -> sendToPublicRelay(template.replace("{player}", playerName))); } private void sendToPublicRelay(final String message) { - final DiscordChatRelay relay = chatRelay; - if (relay == null || message == null || currentPublicChannel().isEmpty()) + if (currentPublicChannel().isEmpty()) return; - relay.sendSystemMessageToDiscord(message); + configured(message).ifPresent(text -> + chatRelay.ifPresent(relay -> relay.sendSystemMessageToDiscord(text))); } - private String getConfiguredMessage(ConfigEntry configEntry) + private Optional<String> getConfiguredMessage(ConfigEntry configEntry) { - final String message = configEntry.getString(); - return isBlank(message) ? null : message; + return configured(configEntry.getString()); } - private Optional<TextChannel> resolveChannel(final Guild guild, final String id, final String configKey) + /** + * A configured string that is actually set. Absent for both a missing key and a blank value, + * which the config layer does not distinguish. + */ + private static Optional<String> configured(final String value) { - if (isBlank(id)) - return Optional.empty(); - - final TextChannel channel = guild.getTextChannelById(id); - if (channel == null) - { - FLog.warning(String.format("[Discord] %s '%s' is not a text channel in %s.", - configKey, id, guild.getName())); - } - return Optional.ofNullable(channel); + return Optional.ofNullable(value).filter(text -> !text.isBlank()); } - private void runAsync(final String label, final Runnable body) + private static Optional<Snowflake> parseSnowflake(final String raw, final String configKey) { - if (!plugin.isEnabled()) - return; - - Bukkit.getScheduler().runTaskAsynchronously(plugin, FTask.guard(label, body)); + return configured(raw).flatMap(text -> + { + try + { + return Optional.of(Snowflake.of(text.trim())); + } + catch (NumberFormatException ex) + { + FLog.warning(String.format("[Discord] %s '%s' is not a valid Discord id.", configKey, raw)); + return Optional.empty(); + } + }); } private void cleanupPendingLinks() @@ -528,24 +501,13 @@ private void cleanupPendingLinks() pendingLinks.entrySet().removeIf(entry -> entry.getValue().expiresAtMs() < now); } - private static void cancel(final BukkitTask task) - { - if (task != null) - task.cancel(); - } - - private static boolean isBlank(final String value) - { - return value == null || value.isBlank(); - } - private static String generateCode() { final StringBuilder builder = new StringBuilder(CODE_LENGTH); + for (int i = 0; i < CODE_LENGTH; i++) - { builder.append(CODE_ALPHABET.charAt(CODE_RANDOM.nextInt(CODE_ALPHABET.length()))); - } + return builder.toString(); } @@ -553,13 +515,11 @@ private record PendingLink(UUID adminUuid, long expiresAtMs) { } - private static final class ReadyListener extends ListenerAdapter + private record ResolvedChannel(Optional<Snowflake> id, String name) { - @Override - public void onReady(@NotNull ReadyEvent event) + private static ResolvedChannel none() { - FLog.info(String.format("[Discord] JDA gateway ready. Bot: %s", - event.getJDA().getSelfUser().getName())); + return new ResolvedChannel(Optional.empty(), "(none)"); } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java index 55b8a3f27..e3e07f4af 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordCommands.java @@ -3,21 +3,30 @@ import java.sql.SQLException; import java.util.Optional; import java.util.UUID; + +import discord4j.core.GatewayDiscordClient; +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent; +import discord4j.core.object.command.ApplicationCommandInteractionOption; +import discord4j.core.object.command.ApplicationCommandInteractionOptionValue; +import discord4j.core.spec.InteractionApplicationCommandCallbackSpec; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.PlayerListUtil; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import org.bukkit.Bukkit; -import org.jetbrains.annotations.NotNull; /** - * JDA slash command handlers for {@code /list}, {@code /link}, {@code /unlink}. + * Slash command handlers for {@code /list}, {@code /link}, {@code /unlink}. + * <p> + * Two threading rules run through all of this. Repository work is JDBC and blocks, so it goes onto + * {@code boundedElastic} rather than running where the interaction arrived; and anything reading + * server state hops to the main thread through {@link DiscordBridge#mainThread()}. Neither was a + * concern under JDA, whose listener threads tolerated both. */ -public class DiscordCommands extends ListenerAdapter +public class DiscordCommands { private final TotalFreedomMod plugin; @@ -29,81 +38,112 @@ public DiscordCommands(TotalFreedomMod plugin, DiscordBridge bridge) this.bridge = bridge; } - @Override - public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) + /** + * The command half of the connection. Each interaction gets its own error boundary: an escaping + * error would terminate the stream, and Discord would then show every later command timing out + * with nothing in the log to say why. + */ + public Mono<Void> bind(final GatewayDiscordClient gateway) + { + return gateway.on(ChatInputInteractionEvent.class) + .flatMap(event -> handle(event).onErrorResume(thrown -> + { + FLog.warning(String.format( + "[Discord] /%s failed: %s", + event.getCommandName(), + DiscordConnection.describeFailure(thrown) + )); + return Mono.empty(); + })) + .then(); + } + + private Mono<Void> handle(final ChatInputInteractionEvent event) { - switch (event.getName()) + return switch (event.getCommandName()) { case "list" -> handleList(event); case "link" -> handleLink(event); case "unlink" -> handleUnlink(event); - default -> - { - } - } + default -> Mono.empty(); + }; } - private void handleList(SlashCommandInteractionEvent event) + private Mono<Void> handleList(final ChatInputInteractionEvent event) { - event.deferReply().queue(); - Bukkit.getScheduler().runTask(plugin, () -> - { - String body = "```\n" + PlayerListUtil.buildRankList() + "\n```"; - event.getHook().sendMessage(body).queue(); - }); + return event.deferReply() + .then(Mono.fromCallable(() -> "```\n" + PlayerListUtil.buildRankList() + "\n```") + .subscribeOn(bridge.mainThread())) + .flatMap(body -> event.createFollowup(body).then()); } - private void handleLink(SlashCommandInteractionEvent event) + private Mono<Void> handleLink(final ChatInputInteractionEvent event) { - OptionMapping codeOption = event.getOption("code"); - if (codeOption == null) - { - event.reply("Missing `code` argument.").setEphemeral(true).queue(); - return; - } - String code = codeOption.getAsString().trim().toUpperCase(); - Optional<UUID> pendingUuid = bridge.consumePendingLink(code); + final Optional<String> code = event.getOption("code") + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString); + + if (code.isEmpty()) + return replyPrivately(event, "Missing `code` argument."); + + final Optional<UUID> pendingUuid = bridge.consumePendingLink(code.get().trim().toUpperCase()); if (pendingUuid.isEmpty()) - { - event.reply("That code is unknown or expired. Run `/link` in-game to get a fresh one.") - .setEphemeral(true).queue(); - return; - } + return replyPrivately(event, "That code is unknown or expired. Run `/link` in-game to get a fresh one."); - UUID adminUuid = pendingUuid.get(); - Admin admin = plugin.al.getAdminByUuid(adminUuid); - if (admin == null) - { - event.reply("Internal error: admin record for the code is gone. Try again.") - .setEphemeral(true).queue(); - return; - } + final UUID adminUuid = pendingUuid.get(); + final Optional<Admin> admin = Optional.ofNullable(plugin.al.getAdminByUuid(adminUuid)); + if (admin.isEmpty()) + return replyPrivately(event, "Internal error: admin record for the code is gone. Try again."); + + final Admin linkedAdmin = admin.get(); + final String discordUserId = event.getInteraction().getUser().getId().asString(); + + return Mono.fromCallable(() -> persistLink(linkedAdmin, adminUuid, discordUserId)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(saved -> saved + ? replyPrivately(event, + "Linked as **" + linkedAdmin.getName() + "** (" + linkedAdmin.getRankId() + ").") + : replyPrivately(event, "Couldn't save the link — see server log. Try again.")); + } - DiscordLinkRepository repo = plugin.dm.getDiscordLinkRepository(); + private boolean persistLink(final Admin admin, final UUID adminUuid, final String discordUserId) + { + final DiscordLinkRepository repo = plugin.dm.getDiscordLinkRepository(); try { repo.deleteByAdminUuid(adminUuid); - repo.deleteByDiscordUserId(event.getUser().getId()); - repo.insert(adminUuid, event.getUser().getId()); + repo.deleteByDiscordUserId(discordUserId); + repo.insert(adminUuid, discordUserId); } catch (SQLException ex) { FLog.warning("[Discord] /link failed for " + admin.getName() + ": " + ex.getMessage()); - event.reply("Couldn't save the link — see server log. Try again.") - .setEphemeral(true).queue(); - return; + return false; } - event.reply("Linked as **" + admin.getName() + "** (" + admin.getRank().getName() + ").") - .setEphemeral(true).queue(); - FLog.info("[Discord] Linked admin " + admin.getName() + " ↔ Discord user " + event.getUser().getId() + "."); + DiscordLinkJsonSync.writeSnapshot(plugin, repo); + FLog.info("[Discord] Linked admin " + admin.getName() + " ↔ Discord user " + discordUserId + "."); + return true; + } + + private Mono<Void> handleUnlink(final ChatInputInteractionEvent event) + { + final String discordUserId = event.getInteraction().getUser().getId().asString(); + + return Mono.fromCallable(() -> removeLink(discordUserId)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(outcome -> replyPrivately(event, switch (outcome) + { + case REMOVED -> "Link removed."; + case NOT_LINKED -> "You aren't linked."; + case FAILED -> "Couldn't remove the link — see server log."; + })); } - private void handleUnlink(SlashCommandInteractionEvent event) + private UnlinkOutcome removeLink(final String discordUserId) { - String discordUserId = event.getUser().getId(); - DiscordLinkRepository repo = plugin.dm.getDiscordLinkRepository(); - boolean removed; + final DiscordLinkRepository repo = plugin.dm.getDiscordLinkRepository(); + final boolean removed; try { removed = repo.deleteByDiscordUserId(discordUserId); @@ -111,18 +151,29 @@ private void handleUnlink(SlashCommandInteractionEvent event) catch (SQLException ex) { FLog.warning("[Discord] /unlink failed for " + discordUserId + ": " + ex.getMessage()); - event.reply("Couldn't remove the link — see server log.") - .setEphemeral(true).queue(); - return; - } - if (removed) - { - event.reply("Link removed.").setEphemeral(true).queue(); - FLog.info("[Discord] Unlinked Discord user " + discordUserId + "."); - } - else - { - event.reply("You aren't linked.").setEphemeral(true).queue(); + return UnlinkOutcome.FAILED; } + + if (!removed) + return UnlinkOutcome.NOT_LINKED; + + DiscordLinkJsonSync.writeSnapshot(plugin, repo); + FLog.info("[Discord] Unlinked Discord user " + discordUserId + "."); + return UnlinkOutcome.REMOVED; + } + + private static Mono<Void> replyPrivately(final ChatInputInteractionEvent event, final String message) + { + return event.reply(InteractionApplicationCommandCallbackSpec.builder() + .content(message) + .ephemeral(true) + .build()); + } + + private enum UnlinkOutcome + { + REMOVED, + NOT_LINKED, + FAILED } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnection.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnection.java new file mode 100644 index 000000000..ee2bc3495 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnection.java @@ -0,0 +1,171 @@ +package me.totalfreedom.totalfreedommod.discord; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import discord4j.core.DiscordClient; +import discord4j.core.GatewayDiscordClient; +import discord4j.gateway.intent.Intent; +import discord4j.gateway.intent.IntentSet; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import reactor.util.retry.Retry; + +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + +/** + * Owns the bridge's gateway connection and its reconnect budget. + * <p> + * Attempts are spaced by {@code discord.reconnect.interval_seconds} and capped at + * {@code discord.reconnect.max_attempts} <em>consecutive</em> failures. Any successful connection + * resets the count, so a server that drops once an hour retries forever, while one that cannot + * reach Discord at all stops after the cap and stays down until restarted rather than reconnecting + * in a loop against a token or an outage that is not going to recover on its own. + */ +public final class DiscordConnection +{ + private static final int MIN_INTERVAL_SECONDS = 5; + + private final int maxAttempts; + private final int retrySeconds; + private final Duration retryInterval; + private final AtomicInteger consecutiveFailures = new AtomicInteger(); + private final AtomicBoolean stopping = new AtomicBoolean(); + private final AtomicBoolean givenUp = new AtomicBoolean(); + + private volatile Optional<Disposable> subscription = Optional.empty(); + + public DiscordConnection() + { + this.maxAttempts = Math.max(1, ConfigEntry.DISCORD_RECONNECT_MAX_ATTEMPTS.getInteger(5)); + this.retrySeconds = Math.max(MIN_INTERVAL_SECONDS, + ConfigEntry.DISCORD_RECONNECT_INTERVAL_SECONDS.getInteger(30)); + this.retryInterval = Duration.ofSeconds(retrySeconds); + } + + /** + * Open the gateway and keep it open, reconnecting within budget. + * + * @param token bot token + * @param handler builds the per-connection work + */ + public void start(final String token, final Function<GatewayDiscordClient, Mono<Void>> handler) + { + subscription = Optional.of(DiscordClient.builder(token) + .build() + .gateway() + .setEnabledIntents(IntentSet.of(Intent.GUILD_MESSAGES, + Intent.MESSAGE_CONTENT, + Intent.DIRECT_MESSAGES)) + .withGateway(handler::apply) + .then(Mono.error(DiscordConnection::disconnected)) + .retryWhen(budget()) + .doOnError(this::giveUp) + .onErrorComplete() + .subscribeOn(Schedulers.boundedElastic()) + .subscribe() + ); + } + + /** + * Disposing the subscription cancels the scope, and {@code withGateway} logs out as part of + * unwinding it. Cancellation is not an error signal, so this does not reach the retry spec + * even before {@link #stopping} is consulted. + */ + public void stop() + { + stopping.set(true); + + final Optional<Disposable> current = subscription; + subscription = Optional.empty(); + + current.filter(active -> !active.isDisposed()) + .ifPresent(Disposable::dispose); + } + + /** + * A connection reached ready. Clears the budget so an unrelated drop later gets a full set of + * attempts of its own. + */ + public void reportConnected() + { + final int spent = consecutiveFailures.getAndSet(0); + + if (spent > 0) + FLog.info(String.format("[Discord] Reconnected after %d failed attempt(s).", spent)); + } + + /** + * Whether the budget is spent. The bridge stays down for the rest of this run once true. + */ + public boolean hasGivenUp() + { + return givenUp.get(); + } + + public boolean isStopping() + { + return stopping.get(); + } + + /** + * {@code fixedDelay} with an unbounded cap plus a filter, rather than a bounded cap, because + * the cap has to count <em>consecutive</em> failures. Reactor's counter never resets for a + * source that emits nothing, so the budget check lives in the filter against a counter the + * bridge resets on every successful connection. + */ + private Retry budget() + { + return Retry.fixedDelay(Long.MAX_VALUE, retryInterval) + .filter(thrown -> + { + if (stopping.get() || givenUp.get()) + return false; + + final int attempt = consecutiveFailures.incrementAndGet(); + if (attempt >= maxAttempts) + return false; + + FLog.warning(String.format("[Discord] Connection failure %d/%d (%s); retrying in %ds.", + attempt, maxAttempts, describeFailure(thrown), retrySeconds)); + return true; + }); + } + + private void giveUp(final Throwable thrown) + { + if (stopping.get() || !givenUp.compareAndSet(false, true)) + return; + + FLog.severe(String.format( + """ + [Discord] Giving up after %d consecutive failed connection attempts %ds apart. + Last failure: %s. + """, + + consecutiveFailures.get(), + retrySeconds, + describeFailure(thrown) + )); + } + + private static Throwable disconnected() + { + return new IllegalStateException("gateway closed"); + } + + public static String describeFailure(final Throwable thrown) + { + return Optional.ofNullable(thrown) + .map(failure -> Optional.ofNullable(failure.getMessage()) + .filter(message -> !message.isBlank()) + .map(message -> String.format("%s: %s", failure.getClass().getSimpleName(), message)) + .orElseGet(() -> failure.getClass().getSimpleName())) + .orElse("unknown failure"); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnectionSupervisor.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnectionSupervisor.java deleted file mode 100644 index fdce353c4..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConnectionSupervisor.java +++ /dev/null @@ -1,239 +0,0 @@ -package me.totalfreedom.totalfreedommod.discord; - -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import net.dv8tion.jda.api.events.session.ShutdownEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.requests.CloseCode; -import org.bukkit.Bukkit; -import org.jetbrains.annotations.NotNull; - -/** - * Keeps one reconnect budget for the bridge and decides when to stop spending it. - * <p> - * JDA reconnects a dropped gateway on its own for close codes it considers recoverable. This - * supervisor covers what that leaves: the codes JDA gives up on, a client that shut itself down, - * a connect attempt that never completed, and REST work rejected because the client underneath it - * is gone. All of those funnel into one counter. - * <p> - * Attempts are spaced by {@code discord.reconnect.interval_seconds} and capped at - * {@code discord.reconnect.max_attempts} <em>consecutive</em> failures. Any successful connection - * resets the count, so a server that drops once an hour retries forever, while one that cannot - * reach Discord at all stops after the cap and stays down until restarted rather than reconnecting - * in a loop against a token or an outage that is not going to recover on its own. - */ -public final class DiscordConnectionSupervisor extends ListenerAdapter -{ - private static final int MIN_INTERVAL_SECONDS = 5; - - private final TotalFreedomMod plugin; - private final Runnable attemptConnect; - private final Runnable onConnectionLost; - private final int maxAttempts; - private final long retryTicks; - private final int retrySeconds; - - private final AtomicInteger consecutiveFailures = new AtomicInteger(); - private final AtomicBoolean retryPending = new AtomicBoolean(); - - /** - * Whether the connection the bridge currently holds has already had a failure counted against - * it. One disconnect can be noticed by more than one party at once, and without this it would - * cost an attempt per witness rather than per disconnect. Cleared as the next attempt begins, - * which is what makes the budget count attempts rather than reports. - */ - private final AtomicBoolean failureCounted = new AtomicBoolean(); - - private final AtomicBoolean givenUp = new AtomicBoolean(); - private final AtomicBoolean stopping = new AtomicBoolean(); - - /** - * @param attemptConnect performs exactly one connection attempt; runs off the main thread - * and is expected to call back into {@link #reportConnected()} or - * {@link #reportFailure(String)} - * @param onConnectionLost releases whatever the previous attempt published or opened. Called - * on every reported failure, not just the terminal one, so a retry - * never runs against a stale session. Always invoked off the - * reporting thread, so it is safe to block here. - */ - public DiscordConnectionSupervisor(final TotalFreedomMod plugin, final Runnable attemptConnect, - final Runnable onConnectionLost) - { - this.plugin = plugin; - this.attemptConnect = attemptConnect; - this.onConnectionLost = onConnectionLost; - this.maxAttempts = Math.max(1, ConfigEntry.DISCORD_RECONNECT_MAX_ATTEMPTS.getInteger(5)); - this.retrySeconds = Math.max(MIN_INTERVAL_SECONDS, - ConfigEntry.DISCORD_RECONNECT_INTERVAL_SECONDS.getInteger(30)); - this.retryTicks = retrySeconds * 20L; - } - - /** - * Turn a connection failure into the short reason string used in logs, naming the two cases - * worth telling apart: work rejected because the client is already gone, and an attempt that - * simply never completed. - */ - public static String describeFailure(final Throwable thrown) - { - if (thrown == null) - return "unknown failure"; - - if (thrown instanceof RejectedExecutionException) - return "rejected (client is shut down)"; - - if (thrown instanceof TimeoutException) - return "timed out"; - - if (thrown instanceof InterruptedException) - return "interrupted"; - - final String message = thrown.getMessage(); - return message == null || message.isBlank() - ? thrown.getClass().getSimpleName() - : String.format("%s: %s", thrown.getClass().getSimpleName(), message); - } - - /** - * A connection came up. Clears the budget so an unrelated drop later gets a full set of - * attempts of its own. - */ - public void reportConnected() - { - final int spent = consecutiveFailures.getAndSet(0); - retryPending.set(false); - failureCounted.set(false); - - if (spent > 0) - FLog.info(String.format("[Discord] Reconnected after %d failed attempt(s).", spent)); - } - - /** - * An attempt failed, or a live connection dropped. Always hops onto an async task first: this - * can be called from one of JDA's own threads (via {@link #onShutdown}) or the main thread - * (via a relay's rejected-send report), and {@code onConnectionLost} can block for several - * seconds releasing the old connection. Once on that task, releases the old connection, then - * either queues the next attempt or gives up if this failure spent the last of the budget. - * <p> - * Only the first report against a given connection is acted on, so a single disconnect costs a - * single attempt however many parties noticed it. The cases that overlap in practice are a - * gateway close arriving while a relay send is already in flight against the same dead client, - * and a connection that dies during the readiness wait, where the shutdown event and the throw - * out of the connect attempt describe the same event. - * - * @param reason already-formatted description, see {@link #describeFailure(Throwable)} - */ - public void reportFailure(final String reason) - { - if (stopping.get() || givenUp.get()) - return; - - if (!plugin.isEnabled()) - return; - - if (!failureCounted.compareAndSet(false, true)) - return; - - Bukkit.getScheduler().runTaskAsynchronously(plugin, FTask.guard("DiscordConnectionSupervisor/reportFailure", () -> - { - if (stopping.get() || givenUp.get()) - return; - - // Whatever connect() published, or was mid-publishing, is no longer usable. Release it - // before deciding retry vs. give-up, so neither path ever runs, or leaves the bridge - // parked, against a stale session or a still-HELD acquisition slot. - onConnectionLost.run(); - - final int attempt = consecutiveFailures.incrementAndGet(); - if (attempt >= maxAttempts) - { - giveUp(reason, attempt); - return; - } - - FLog.warning(String.format("[Discord] Connection failure %d/%d (%s); retrying in %ds.", - attempt, maxAttempts, reason, retrySeconds)); - scheduleRetry(); - })); - } - - /** - * Whether the budget is spent. The bridge stays down for the rest of this run once true. - */ - public boolean hasGivenUp() - { - return givenUp.get(); - } - - /** - * Mark the coming disconnect as deliberate, so the shutdown it produces is not mistaken for a - * dropped connection and does not spend an attempt. - */ - public void beginIntentionalShutdown() - { - stopping.set(true); - } - - /** - * Fires when JDA has fully stopped. This listener is attached from the moment the client is - * built and detached by the acquisition layer before any close the bridge asks for, so anything - * reaching here is a connection we lost rather than one we closed, including the close codes - * JDA declines to reconnect from. - */ - @Override - public void onShutdown(@NotNull final ShutdownEvent event) - { - if (stopping.get() || givenUp.get()) - return; - - final CloseCode closeCode = event.getCloseCode(); - reportFailure(closeCode == null - ? "gateway closed without a close code" - : String.format("gateway closed with %s (%d)", closeCode.name(), closeCode.getCode())); - } - - private void scheduleRetry() - { - // One attempt in flight at a time. reportFailure() already collapses simultaneous reports - // of the same drop, so this is a backstop for any other route into a retry. - if (!retryPending.compareAndSet(false, true)) - return; - - if (!plugin.isEnabled()) - { - retryPending.set(false); - return; - } - - Bukkit.getScheduler().runTaskLaterAsynchronously(plugin, FTask.guard("DiscordConnectionSupervisor/retry", () -> - { - retryPending.set(false); - - if (stopping.get() || givenUp.get()) - return; - - // The connection this failure was counted against is gone, and the attempt below gets - // to spend one of its own. Cleared before the attempt runs, not after, because that - // attempt can fail synchronously. - failureCounted.set(false); - attemptConnect.run(); - }), retryTicks); - } - - private void giveUp(final String reason, final int attempt) - { - if (!givenUp.compareAndSet(false, true)) - return; - - FLog.severe(String.format("[Discord] Giving up after %d consecutive failed connection attempts %ds apart. " - + "Last failure: %s. The bridge stays down until the server is restarted; the rest of the plugin is " - + "unaffected.", attempt, retrySeconds, reason)); - // onConnectionLost already ran at the top of reportFailure(); nothing left to release. - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java index 00ece2ff2..896b88953 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordConsoleRelay.java @@ -5,7 +5,19 @@ import java.util.Deque; import java.util.Optional; import java.util.UUID; -import java.util.concurrent.RejectedExecutionException; + +import org.bukkit.Bukkit; +import org.bukkit.scheduler.BukkitTask; + +import discord4j.common.util.Snowflake; +import discord4j.core.GatewayDiscordClient; +import discord4j.core.event.domain.message.MessageCreateEvent; +import discord4j.core.object.emoji.Emoji; +import discord4j.core.object.entity.Message; +import discord4j.core.object.entity.User; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -14,21 +26,15 @@ import me.totalfreedom.totalfreedommod.util.CallbackLogAppender; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FTask; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.entities.emoji.Emoji; -import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.core.Logger; -import org.bukkit.Bukkit; -import org.bukkit.scheduler.BukkitTask; -import org.jetbrains.annotations.NotNull; /** * Streams server log output into the Discord console channel and dispatches * plain Minecraft commands typed into that channel as the linked admin. */ -public class DiscordConsoleRelay extends ListenerAdapter +public class DiscordConsoleRelay { /** Discord hard message-length limit. We pack lines into chunks below this. */ @@ -45,16 +51,12 @@ public class DiscordConsoleRelay extends ListenerAdapter private static final int MIN_FLUSH_MS = 250; /** - * Loggers whose output is never relayed. JDA reports its own rate limiting and connection - * trouble through the root logger, and relaying that back into the channel being rate limited - * makes every problem generate more traffic describing itself. + * Loggers whose output should never be relayed. */ - private static final String[] EXCLUDED_LOGGERS = {"net.dv8tion.jda", "okhttp3", "club.minnced"}; + private static final String[] EXCLUDED_LOGGERS = {"discord4j", "reactor", "io.netty"}; /** - * Set while this relay logs its own failures. The appender is on the root logger, so without - * it a failed send would enqueue the warning about the failed send, and that warning would be - * in the next attempt's payload. + * Set while this relay logs its own failures. */ private static final ThreadLocal<Boolean> SUPPRESS_CAPTURE = ThreadLocal.withInitial(() -> Boolean.FALSE); @@ -69,8 +71,8 @@ public class DiscordConsoleRelay extends ListenerAdapter /** Lines discarded because the queue was full, reported in the next successful flush. */ private int droppedLines; - private CallbackLogAppender logAppender; - private BukkitTask flushTask; + private volatile Optional<CallbackLogAppender> logAppender = Optional.empty(); + private volatile Optional<BukkitTask> flushTask = Optional.empty(); public DiscordConsoleRelay(TotalFreedomMod plugin, DiscordBridge bridge) { @@ -79,39 +81,52 @@ public DiscordConsoleRelay(TotalFreedomMod plugin, DiscordBridge bridge) this.queueLimit = Math.max(64, ConfigEntry.DISCORD_CONSOLE_QUEUE_LIMIT.getInteger(DEFAULT_QUEUE_LIMIT)); } + public Mono<Void> bind(final GatewayDiscordClient gateway) + { + return gateway.on(MessageCreateEvent.class) + .filter(this::isConsoleCommand) + .flatMap(event -> handleCommand(event).onErrorResume(thrown -> + { + warnWithoutCapture(String.format("[Discord] Console command failed: %s", + DiscordConnection.describeFailure(thrown))); + return Mono.empty(); + })) + .then(); + } + void attachAppender() { if (bridge.currentConsoleChannel().isEmpty()) - { return; - } - Integer flushConfig = ConfigEntry.DISCORD_CONSOLE_FLUSH.getInteger(); - int flushMs = flushConfig == null || flushConfig < MIN_FLUSH_MS ? DEFAULT_FLUSH_MS : flushConfig; - long ticks = Math.max(1L, flushMs / 50L); + final int flushMs = Optional.ofNullable(ConfigEntry.DISCORD_CONSOLE_FLUSH.getInteger()) + .filter(configured -> configured >= MIN_FLUSH_MS) + .orElse(DEFAULT_FLUSH_MS); + final long ticks = Math.max(1L, flushMs / 50L); + + final CallbackLogAppender appender = new CallbackLogAppender("DiscordConsoleAppender", (line, level) -> enqueue(line)).excludeLoggers(EXCLUDED_LOGGERS); - logAppender = new CallbackLogAppender("DiscordConsoleAppender", (line, level) -> enqueue(line)) - .excludeLoggers(EXCLUDED_LOGGERS); - logAppender.start(); - ((Logger) LogManager.getRootLogger()).addAppender(logAppender); + appender.start(); - flushTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, - FTask.guard("DiscordConsoleRelay/flush", this::flush), ticks, ticks); + ((Logger) LogManager.getRootLogger()).addAppender(appender); + logAppender = Optional.of(appender); + + flushTask = Optional.of(Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, + FTask.guard("DiscordConsoleRelay/flush", this::flush), ticks, ticks)); } void detachAppender() { - if (logAppender != null) - { - ((Logger) LogManager.getRootLogger()).removeAppender(logAppender); - logAppender.stop(); - logAppender = null; - } - if (flushTask != null) + logAppender.ifPresent(appender -> { - flushTask.cancel(); - flushTask = null; - } + ((Logger) LogManager.getRootLogger()).removeAppender(appender); + appender.stop(); + }); + logAppender = Optional.empty(); + + flushTask.ifPresent(BukkitTask::cancel); + flushTask = Optional.empty(); + synchronized (pendingLock) { pendingLines.clear(); @@ -119,82 +134,83 @@ void detachAppender() } } - @Override - public void onMessageReceived(@NotNull MessageReceivedEvent event) + private boolean isConsoleCommand(final MessageCreateEvent event) { - if (event.getAuthor().isBot() || event.getAuthor().isSystem()) - { - return; - } - if (!event.isFromGuild()) - { - return; - } + if (event.getGuildId().isEmpty()) + return false; - Optional<TextChannel> channel = bridge.currentConsoleChannel(); - if (channel.isEmpty() || !event.getChannel().getId().equals(channel.get().getId())) - { - return; - } + final Optional<User> author = event.getMessage().getAuthor(); + if (author.isEmpty() || author.get().isBot()) + return false; - String content = event.getMessage().getContentRaw().trim(); + final Optional<Snowflake> channel = bridge.currentConsoleChannel(); + return channel.isPresent() && channel.get().equals(event.getMessage().getChannelId()); + } + + /** + * Resolve the author to a linked admin and run their command. + * <p> + * The repository lookup is JDBC, so it is pushed onto {@code boundedElastic} rather than run + * where the event arrived. + */ + private Mono<Void> handleCommand(final MessageCreateEvent event) + { + final Message message = event.getMessage(); + final String content = message.getContent().trim(); if (content.isEmpty()) - { - return; - } + return Mono.empty(); - String discordUserId = event.getAuthor().getId(); - UUID adminUuid; + final String commandLine = content.startsWith("/") ? content.substring(1) : content; + if (commandLine.isEmpty()) + return Mono.empty(); + + return Mono.justOrEmpty(message.getAuthor() + .map(User::getId) + .map(Snowflake::asString)) + .flatMap(discordUserId -> Mono.fromCallable(() -> resolveAdmin(discordUserId)) + .subscribeOn(Schedulers.boundedElastic())) + .flatMap(resolution -> resolution.admin() + .map(admin -> dispatch(admin, commandLine)) + .orElseGet(() -> react(message, resolution.reaction()))); + } + + private AdminResolution resolveAdmin(final String discordUserId) + { + final Optional<UUID> adminUuid; try { - adminUuid = plugin.dm.getDiscordLinkRepository().findAdminUuidByDiscordId(discordUserId); + adminUuid = Optional.ofNullable(plugin.dm.getDiscordLinkRepository().findAdminUuidByDiscordId(discordUserId)); } catch (SQLException ex) { - FLog.warning("[Discord] discord_links lookup failed: " + ex.getMessage()); - react(event, "⚠️"); - return; - } - - if (adminUuid == null) - { - react(event, "❌"); - return; - } - - Admin admin = plugin.al.getAdminByUuid(adminUuid); - if (admin == null || !admin.isActive()) - { - react(event, "❌"); - return; - } - - String commandLine = content.startsWith("/") ? content.substring(1) : content; - if (commandLine.isEmpty()) - { - return; + warnWithoutCapture("[Discord] discord_links lookup failed: " + ex.getMessage()); + return AdminResolution.lookupFailed(); } - String displayName = "Discord@" + admin.getName(); - RemoteDispatchSession session = new RemoteDispatchSession( - RemoteDispatchSession.Channel.DISCORD, - admin.getName(), - displayName, - true); + return adminUuid.flatMap(uuid -> Optional.ofNullable(plugin.al.getAdminByUuid(uuid))) + .filter(Admin::isActive) + .map(AdminResolution::linked) + .orElseGet(AdminResolution::notLinked); + } - Bukkit.getScheduler().runTask(plugin, () -> - { - FLog.info("[Discord: " + admin.getName() + "] /" + commandLine); - RemoteDispatchContext.dispatch(session, commandLine); - }); + private Mono<Void> dispatch(final Admin admin, final String commandLine) + { + final String displayName = "Discord@" + admin.getName(); + final RemoteDispatchSession session = new RemoteDispatchSession(RemoteDispatchSession.Channel.DISCORD, + admin.getName(), + displayName, + true); + + return Mono.<Void>fromRunnable(() -> + { + FLog.info("[Discord: " + admin.getName() + "] /" + commandLine); + RemoteDispatchContext.dispatch(session, commandLine); + }) + .subscribeOn(bridge.mainThread()); } /** * Queue one captured log line, dropping the oldest when the queue is full. - * <p> - * The bound matters because everything downstream can stall: a channel that has gone away, a - * bot without send permission, or a rate limit all leave lines arriving with nowhere to go. - * Unbounded, that is a heap leak that only ends when the server does. */ private void enqueue(String line) { @@ -216,43 +232,28 @@ private void enqueue(String line) private void flush() { - Optional<TextChannel> channel = bridge.currentConsoleChannel(); - if (channel.isEmpty()) - { + final Optional<DiscordSession> current = bridge.getSession(); + final Optional<Snowflake> channel = bridge.currentConsoleChannel(); + if (current.isEmpty() || channel.isEmpty()) return; - } String chunk = drainChunk(); if (chunk.isEmpty()) - { return; - } String body = "```ansi\n" + chunk + "\n```"; - try - { - channel.get().sendMessage(body).queue( - null, - err -> warnWithoutCapture("[Discord] Console flush failed: " + err.getMessage()) - ); - } - catch (RejectedExecutionException ex) - { - // The client is gone, so the failure consumer above will never run. Tell the - // supervisor so this counts toward the reconnect budget. - warnWithoutCapture("[Discord] Console flush rejected: " + ex.getMessage()); - bridge.reportTransportFailure("console flush", ex); - } + current.get() + .channel(channel) + .flatMap(target -> target.createMessage(body)) + .subscribe( + sent -> + { + }, + err -> warnWithoutCapture("[Discord] Console flush failed: " + err.getMessage())); } /** * Take as many queued lines as fit in one message. - * <p> - * Each line is shortened to fit <em>before</em> it is measured against the remaining budget. - * Testing an over-long line first and breaking out of the loop left it sitting at the head of - * the deque, where it failed the same test on every subsequent flush: a single log line longer - * than one Discord message used to stop console output permanently, with the queue behind it - * growing forever, while inbound commands kept working and made the relay look healthy. */ private String drainChunk() { @@ -265,26 +266,20 @@ private String drainChunk() droppedLines = 0; if (dropped > 0) - { chunk.append(String.format("... %d line(s) dropped, console output is falling behind ...", dropped)); - } while (!pendingLines.isEmpty()) { String line = truncateToFit(pendingLines.peekFirst()); - // The first line always goes in, having already been cut to fit on its own. if (!chunk.isEmpty() && chunk.length() + 1 + line.length() > MAX_CHUNK_LENGTH) - { break; - } pendingLines.pollFirst(); if (!chunk.isEmpty()) - { chunk.append('\n'); - } + chunk.append(line); } } @@ -295,8 +290,8 @@ private String drainChunk() private static String truncateToFit(String line) { return line.length() <= MAX_CHUNK_LENGTH - ? line - : line.substring(0, MAX_CHUNK_LENGTH - 1) + "…"; + ? line + : line.substring(0, MAX_CHUNK_LENGTH - 1) + "…"; } /** @@ -316,8 +311,30 @@ private static void warnWithoutCapture(String message) } } - private static void react(MessageReceivedEvent event, String emoji) + private static Mono<Void> react(final Message message, final String emoji) + { + return message.addReaction(Emoji.unicode(emoji)) + .onErrorResume(ignored -> Mono.empty()); + } + + /** + * Either the admin a console message should run as, or the reaction to leave on it saying why it will not run. + */ + private record AdminResolution(Optional<Admin> admin, String reaction) { - event.getMessage().addReaction(Emoji.fromUnicode(emoji)).queue(null, ignored -> {}); + private static AdminResolution linked(final Admin admin) + { + return new AdminResolution(Optional.of(admin), ""); + } + + private static AdminResolution notLinked() + { + return new AdminResolution(Optional.empty(), "❌"); + } + + private static AdminResolution lookupFailed() + { + return new AdminResolution(Optional.empty(), "⚠️"); + } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java new file mode 100644 index 000000000..dc9cd413c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordLinkJsonSync.java @@ -0,0 +1,104 @@ +package me.totalfreedom.totalfreedommod.discord; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.lang.reflect.Type; +import java.util.Map; +import java.util.UUID; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import com.google.gson.reflect.TypeToken; + +/** + * JSON write-through + startup reconciliation for admin-uuid to Discord-user-id links. + */ +final class DiscordLinkJsonSync +{ + static final String DATA_FILENAME = "discord_links.json"; + + private static final Type LINKS_MAP_TYPE = new TypeToken<Map<String, String>>() {}.getType(); + private static final PersistenceQueue WRITES = new PersistenceQueue("discord link"); + + private DiscordLinkJsonSync() + { + } + + /** + * Rewrites discord_links.json from the database's current state. Call after any + * successful link/unlink write. + */ + static void writeSnapshot(TotalFreedomMod plugin, DiscordLinkRepository repo) + { + try + { + Map<String, String> links = repo.loadAll(); + File file = new File(plugin.getDataFolder(), DATA_FILENAME); + try (FileWriter writer = new FileWriter(file)) + { + JsonUtil.GSON.toJson(links, LINKS_MAP_TYPE, writer); + } + } + catch (Exception ex) + { + FLog.severe("Failed to save " + DATA_FILENAME + ": " + ex.getMessage()); + } + } + + /** + * If discord_links.json was written more recently than the database's last update, re-import + * it into SQL. Runs entirely off the main thread, so this is safe to call from {@code onStart}. + */ + static void reconcileFromJsonIfNewer(TotalFreedomMod plugin, DiscordLinkRepository repo) + { + final File file = new File(plugin.getDataFolder(), DATA_FILENAME); + if (!file.exists()) + return; + + final Map<String, String> jsonLinks; + try (FileReader reader = new FileReader(file)) + { + final Map<String, String> loaded = JsonUtil.GSON.fromJson(reader, LINKS_MAP_TYPE); + jsonLinks = loaded != null ? loaded : Map.of(); + } + catch (Exception ex) + { + FLog.warning(String.format("Failed to read %s: %s", DATA_FILENAME, ex.getMessage())); + return; + } + + if (jsonLinks.isEmpty()) + return; + + final long fileModified = file.lastModified(); + + WRITES.enqueue(repo.getMaxUpdatedAtAsync() + .map(sqlUpdatedAt -> FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt)) + .defaultIfEmpty(Boolean.TRUE) + .filter(Boolean::booleanValue) + .flatMap(ignored -> + { + FLog.info(String.format("%s is newer than the database; re-importing %d discord link(s) from it.", + DATA_FILENAME, jsonLinks.size())); + return Flux.fromIterable(jsonLinks.entrySet()) + .concatMap(entry -> repo.relinkAsync(UUID.fromString(entry.getKey()), entry.getValue())) + .then(); + }) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + DATA_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordMarkdown.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordMarkdown.java index 9891d02c1..4d02bea04 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordMarkdown.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordMarkdown.java @@ -2,6 +2,7 @@ import java.util.EnumSet; import java.util.Set; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.format.TextDecoration; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordSession.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordSession.java index 48e142454..4cf6b86bb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordSession.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/discord/DiscordSession.java @@ -2,46 +2,53 @@ import java.util.Optional; -import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import discord4j.common.util.Snowflake; +import discord4j.core.GatewayDiscordClient; +import discord4j.core.object.entity.channel.MessageChannel; +import reactor.core.publisher.Mono; /** - * Everything one live gateway connection consists of, as a single immutable value. + * The bridge publishes this through one volatile reference and never mutates it. + * One reference means a reader either sees a whole connection or sees none, and reconnecting is a single assignment rather than five. * <p> - * The bridge publishes this through one volatile reference and never mutates it. That matters - * because the connection is written on the main thread but read from JDA's gateway threads and - * from the async console flush task: as separate plain fields there was no happens-before edge - * between the write and those reads, and a reader could see a channel from one connection - * alongside a guild from the next. One reference means a reader either sees a whole connection - * or sees none, and reconnecting is a single assignment rather than five. + * Channels are held as {@link Snowflake} ids rather than entity objects. Discord4J entities carry a + * reference to the client that produced them, so holding one across a reconnect would leave a relay + * writing through a gateway that no longer exists. * - * @param jda the client this connection belongs to - * @param guild configured guild, already resolved + * @param gateway the client this connection belongs to + * @param guildId configured guild, already verified to exist + * @param guildName resolved once for log lines, so logging never needs a fetch * @param publicChannel public chat relay channel, absent when unconfigured or unresolvable * @param adminchatChannel admin chat relay channel, absent when unconfigured or unresolvable * @param consoleChannel console relay channel, absent when unconfigured or unresolvable + * @param channelSummary resolved channel names for the startup log line */ public record DiscordSession( - JDA jda, - Guild guild, - Optional<TextChannel> publicChannel, - Optional<TextChannel> adminchatChannel, - Optional<TextChannel> consoleChannel) + GatewayDiscordClient gateway, + Snowflake guildId, + String guildName, + Optional<Snowflake> publicChannel, + Optional<Snowflake> adminchatChannel, + Optional<Snowflake> consoleChannel, + String channelSummary) { /** - * Describes the resolved channels for the startup log line. + * Resolve one of this connection's channels for sending. + * <p> + * Empty rather than an error when the channel is unconfigured or has since been deleted. */ - public String describeChannels() + public Mono<MessageChannel> channel(final Optional<Snowflake> id) { - return String.format("public: %s | adminchat: %s | console: %s", - describe(publicChannel), describe(adminchatChannel), describe(consoleChannel)); + return id.map(gateway::getChannelById) + .orElseGet(Mono::empty) + .ofType(MessageChannel.class); } - private static String describe(final Optional<TextChannel> channel) + /** + * Describes the resolved channels for the startup log line. + */ + public String describeChannels() { - return channel - .map(TextChannel::getName) - .orElse("(none)"); + return channelSummary; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/discord/acquisition/DiscordAcquisition.java b/src/main/java/me/totalfreedom/totalfreedommod/discord/acquisition/DiscordAcquisition.java deleted file mode 100644 index 3952d95c5..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/discord/acquisition/DiscordAcquisition.java +++ /dev/null @@ -1,200 +0,0 @@ -package me.totalfreedom.totalfreedommod.discord.acquisition; - -import java.time.Duration; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; - -import me.totalfreedom.totalfreedommod.util.FLog; -import net.dv8tion.jda.api.JDA; - -/** - * Sole owner of the bridge's gateway connection. - * <p> - * A bot token supports one gateway session. Discord does not reject a second one, it serves it: - * both sessions receive every message, so every console command typed in Discord runs twice and - * both sessions share a REST rate-limit bucket that each client models privately. Nothing in the - * Discord API will tell you this is happening, which is why acquiring the connection has to be - * guarded on this side. - * <p> - * This class exists because the bridge previously did not guard it. {@code JDABuilder.build()} - * opens the gateway before {@code awaitReady()} returns, so a start that failed after build lost - * its only reference to a client that was already connected and stayed connected. A reload then - * opened a second one, and that server was talking to Discord twice over a single token, with the - * first session still holding listeners nothing could reach. - * <p> - * The guard is a small state machine: - * <ul> - * <li>{@link #acquire} refuses outright if a connection is already held or being opened, so a - * retry, a reload and a standby poll racing each other still produce exactly one client;</li> - * <li>the client reference is captured the instant {@code build()} returns, before anything that - * can throw, so a failed acquisition always has something to shut down;</li> - * <li>a failed acquisition shuts that client down and returns to idle, leaking nothing;</li> - * <li>{@link #release} shuts the held client down exactly once, whoever calls it;</li> - * <li>every close performed here detaches the client's listeners first, so a close this layer - * performed is never mistaken for a connection that dropped on its own.</li> - * </ul> - */ -public final class DiscordAcquisition -{ - private static final Duration SHUTDOWN_GRACE = Duration.ofSeconds(10); - - /** - * Opens a client. Implementations return as soon as the builder does; waiting for the gateway - * to become ready is this class's job, so the reference is captured first. - */ - @FunctionalInterface - public interface ConnectionFactory - { - JDA build() throws Exception; - } - - private enum State - { - /** No client, and none being opened. The only state {@link #acquire} will act from. */ - IDLE, - - /** A client is being opened. Blocks any concurrent acquisition. */ - ACQUIRING, - - /** A client is open and in use. */ - HELD, - - /** The held client is being shut down. Blocks acquisition until it finishes. */ - RELEASING - } - - private final AtomicReference<State> state = new AtomicReference<>(State.IDLE); - - private volatile JDA client; - - /** - * Open a connection and take ownership of it. - * <p> - * On any failure the partially opened client is shut down before this returns, so the caller - * never has to clean up after a failed attempt and no orphan session survives. - * - * @return the ready client, or empty if a connection is already held or being opened, which is - * a refusal rather than an error: something else already owns the token - * @throws Exception whatever the factory or the readiness wait threw, after cleanup - */ - public Optional<JDA> acquire(final ConnectionFactory factory) throws Exception - { - if (!state.compareAndSet(State.IDLE, State.ACQUIRING)) - { - FLog.warning(String.format("[Discord] Refusing to open a second gateway connection: one is already %s.", - state.get() == State.HELD ? "established" : "being opened or closed")); - return Optional.empty(); - } - - try - { - // Captured before awaitReady() so a throw below still has a client to shut down. - // This assignment is the whole reason connections stopped being orphaned. - final JDA opened = factory.build(); - client = opened; - - opened.awaitReady(); - state.set(State.HELD); - return Optional.of(opened); - } - catch (Exception | Error thrown) - { - shutdownHeldClient(); - state.set(State.IDLE); - throw thrown; - } - } - - /** - * Shut the held connection down and return to idle. Idempotent, and safe to call when nothing - * is held or while an acquisition is still in flight. - */ - public void release() - { - final State previous = state.getAndSet(State.RELEASING); - if (previous == State.IDLE && client == null) - { - state.set(State.IDLE); - return; - } - - shutdownHeldClient(); - state.set(State.IDLE); - } - - /** - * The live client, absent whenever one is not currently held. - */ - public Optional<JDA> current() - { - return state.get() == State.HELD ? Optional.ofNullable(client) : Optional.empty(); - } - - public boolean isHeld() - { - return state.get() == State.HELD; - } - - /** - * Shut down whatever client this layer is holding, clearing the reference first so a second - * caller cannot shut the same client down again. - */ - private void shutdownHeldClient() - { - final JDA held = client; - client = null; - - if (held == null) - return; - - detachListeners(held); - - try - { - held.shutdown(); - if (!held.awaitShutdown(SHUTDOWN_GRACE)) - { - FLog.warning("[Discord] Gateway did not close within the grace period; forcing it."); - held.shutdownNow(); - } - } - catch (InterruptedException ex) - { - Thread.currentThread().interrupt(); - held.shutdownNow(); - } - catch (Exception ex) - { - FLog.warning(String.format("[Discord] Error while closing the gateway: %s", ex.getMessage())); - } - } - - /** - * Drop everything listening on {@code client} before it is closed, so the shutdown event that - * follows reaches nobody. - * <p> - * Every close this class performs is deliberate: a caller releasing the connection, or cleanup - * after an attempt that failed. The supervisor treats a shutdown it observes as a connection - * that dropped on its own and spends a reconnect attempt on it, and none of these are that. - * Detaching here rather than at each call site is what lets the supervisor stay attached from - * the moment the client is built, with no window where a genuine drop goes unseen. - * <p> - * Failing to detach only costs a duplicate failure report, so it must not stop the shutdown - * below from running. - */ - private static void detachListeners(final JDA client) - { - try - { - final List<Object> listeners = client.getRegisteredListeners(); - if (!listeners.isEmpty()) - client.removeEventListener(listeners.toArray()); - } - catch (Exception ex) - { - FLog.warning(String.format("[Discord] Could not detach listeners before closing the gateway: %s", - ex.getMessage())); - } - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/disguise/DisallowedDisguises.java b/src/main/java/me/totalfreedom/totalfreedommod/disguise/DisallowedDisguises.java index fde901fe2..a54b2516c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/disguise/DisallowedDisguises.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/disguise/DisallowedDisguises.java @@ -3,11 +3,13 @@ import java.util.HashSet; import java.util.List; import java.util.Set; + +import org.bukkit.entity.EntityType; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.entity.EntityType; /** * Manages forbidden disguise types and global disguise state. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/Displayable.java b/src/main/java/me/totalfreedom/totalfreedommod/display/Displayable.java similarity index 94% rename from src/main/java/me/totalfreedom/totalfreedommod/rank/Displayable.java rename to src/main/java/me/totalfreedom/totalfreedommod/display/Displayable.java index 7000e67df..ff7894c07 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/Displayable.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/display/Displayable.java @@ -1,4 +1,4 @@ -package me.totalfreedom.totalfreedommod.rank; +package me.totalfreedom.totalfreedommod.display; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/framework/AbstractService.java b/src/main/java/me/totalfreedom/totalfreedommod/framework/AbstractService.java index 1b2727062..52f942d18 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/framework/AbstractService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/framework/AbstractService.java @@ -1,8 +1,9 @@ package me.totalfreedom.totalfreedommod.framework; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import org.bukkit.Server; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + /** * Base class for all services. * Services are components that have a lifecycle (onStart/onStop). diff --git a/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginComponent.java b/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginComponent.java index e6c9f1afa..821455d3b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginComponent.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginComponent.java @@ -1,8 +1,9 @@ package me.totalfreedom.totalfreedommod.framework; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import org.bukkit.Server; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + /** * Base class for plugin components. * Provides access to the plugin and server instances. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginListener.java b/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginListener.java index 35ff88a71..662c507ef 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginListener.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/framework/PluginListener.java @@ -1,8 +1,9 @@ package me.totalfreedom.totalfreedommod.framework; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import org.bukkit.event.Listener; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + /** * Base class for plugin listeners. * Extends PluginComponent and implements Listener. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/framework/ServiceManager.java b/src/main/java/me/totalfreedom/totalfreedommod/framework/ServiceManager.java index 9ca7ac5f0..00605e427 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/framework/ServiceManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/framework/ServiceManager.java @@ -2,9 +2,11 @@ import java.util.ArrayList; import java.util.List; + +import org.bukkit.event.Listener; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.event.Listener; /** * Manages the lifecycle of services. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/freeze/FreezeData.java b/src/main/java/me/totalfreedom/totalfreedommod/freeze/FreezeData.java index 8ae65a626..5587d8453 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/freeze/FreezeData.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/freeze/FreezeData.java @@ -1,16 +1,19 @@ package me.totalfreedom.totalfreedommod.freeze; -import lombok.Getter; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.player.FPlayer; import me.totalfreedom.totalfreedommod.player.PlayerData; -import static me.totalfreedom.totalfreedommod.player.FPlayer.AUTO_PURGE_TICKS; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.GameMode; -import org.bukkit.Location; -import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitTask; + +import lombok.Getter; + +import static me.totalfreedom.totalfreedommod.player.FPlayer.AUTO_PURGE_TICKS; public class FreezeData { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/freeze/Freezer.java b/src/main/java/me/totalfreedom/totalfreedommod/freeze/Freezer.java index 3b503c7c8..26eb30a4f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/freeze/Freezer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/freeze/Freezer.java @@ -1,15 +1,17 @@ package me.totalfreedom.totalfreedommod.freeze; -import lombok.Getter; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FUtil; import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerMoveEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import lombok.Getter; + public class Freezer extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/fun/ItemFun.java b/src/main/java/me/totalfreedom/totalfreedommod/fun/ItemFun.java index d8f0f2163..be6fff54f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/fun/ItemFun.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/fun/ItemFun.java @@ -6,13 +6,6 @@ import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Registry; @@ -27,6 +20,15 @@ import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.util.Vector; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.util.FUtil; + public class ItemFun extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/fun/Jumppads.java b/src/main/java/me/totalfreedom/totalfreedommod/fun/Jumppads.java index 6a3db5c13..42fb9601b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/fun/Jumppads.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/fun/Jumppads.java @@ -1,15 +1,7 @@ package me.totalfreedom.totalfreedommod.fun; -import com.google.common.collect.Maps; - import java.util.*; -import lombok.Getter; -import lombok.Setter; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; import org.bukkit.GameMode; import org.bukkit.Tag; import org.bukkit.block.Block; @@ -18,6 +10,15 @@ import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.util.Vector; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + +import com.google.common.collect.Maps; +import lombok.Getter; +import lombok.Setter; + public class Jumppads extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/fun/Landminer.java b/src/main/java/me/totalfreedom/totalfreedommod/fun/Landminer.java index b19402999..29767ea55 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/fun/Landminer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/fun/Landminer.java @@ -3,10 +3,7 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; -import lombok.Getter; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; + import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -17,6 +14,12 @@ import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.util.Vector; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; + +import lombok.Getter; + public class Landminer extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/fun/MP44.java b/src/main/java/me/totalfreedom/totalfreedommod/fun/MP44.java index 763aa2783..d2a42e383 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/fun/MP44.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/fun/MP44.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.fun; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerQuitEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public class MP44 extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/fun/Trailer.java b/src/main/java/me/totalfreedom/totalfreedommod/fun/Trailer.java index 3a278a3a3..35f302595 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/fun/Trailer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/fun/Trailer.java @@ -3,8 +3,6 @@ import java.util.*; import java.util.concurrent.atomic.AtomicInteger; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.entity.Player; @@ -12,6 +10,9 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerMoveEvent; +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public class Trailer extends FreedomService { private final Material[] woolColors = new Material[] { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTMLGenerationTools.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTMLGenerationTools.java index a980a94d7..b2463c043 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTMLGenerationTools.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTMLGenerationTools.java @@ -1,10 +1,11 @@ package me.totalfreedom.totalfreedommod.httpd; -import com.google.common.html.HtmlEscapers; import java.util.Collection; import java.util.Iterator; import java.util.Map; +import com.google.common.html.HtmlEscapers; + public class HTMLGenerationTools { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTTPDaemon.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTTPDaemon.java index 0638c7b13..ff147a54f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTTPDaemon.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/HTTPDaemon.java @@ -7,17 +7,12 @@ import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.Response; -import me.totalfreedom.totalfreedommod.httpd.module.HTTPDModule; -import me.totalfreedom.totalfreedommod.httpd.module.Module_file; -import me.totalfreedom.totalfreedommod.httpd.module.Module_help; -import me.totalfreedom.totalfreedommod.httpd.module.Module_list; -import me.totalfreedom.totalfreedommod.httpd.module.Module_permbans; -import me.totalfreedom.totalfreedommod.httpd.module.Module_players; -import me.totalfreedom.totalfreedommod.httpd.module.Module_schematic; +import me.totalfreedom.totalfreedommod.httpd.module.*; import me.totalfreedom.totalfreedommod.util.FLog; public class HTTPDaemon extends FreedomService diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/ModuleExecutable.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/ModuleExecutable.java index 0934351aa..10be6a5fb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/ModuleExecutable.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/ModuleExecutable.java @@ -5,13 +5,16 @@ import java.util.concurrent.Callable; import java.util.logging.Level; import java.util.logging.Logger; -import lombok.Getter; + +import org.bukkit.Bukkit; + import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.framework.PluginComponent; import me.totalfreedom.totalfreedommod.httpd.module.HTTPDModule; import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.framework.PluginComponent; -import org.bukkit.Bukkit; + +import lombok.Getter; public abstract class ModuleExecutable { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/NanoHTTPD.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/NanoHTTPD.java index efe0600c7..657aed8e4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/NanoHTTPD.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/NanoHTTPD.java @@ -1,37 +1,12 @@ package me.totalfreedom.totalfreedommod.httpd; -import java.io.BufferedReader; -import java.io.ByteArrayInputStream; -import java.io.Closeable; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.PrintWriter; -import java.io.RandomAccessFile; -import java.io.SequenceInputStream; -import java.io.UnsupportedEncodingException; -import java.net.InetSocketAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.SocketException; -import java.net.URLDecoder; +import java.io.*; +import java.net.*; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Date; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.StringTokenizer; -import java.util.TimeZone; +import java.util.*; + import me.totalfreedom.totalfreedommod.util.FLog; /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/HTTPDModule.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/HTTPDModule.java index fc6c575ff..56b63b8da 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/HTTPDModule.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/HTTPDModule.java @@ -5,15 +5,16 @@ import java.util.List; import java.util.Map; -import com.google.common.html.HtmlEscapers; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.framework.PluginComponent; import me.totalfreedom.totalfreedommod.httpd.HTTPDPageBuilder; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.HTTPSession; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.Method; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.Response; import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.framework.PluginComponent; + +import com.google.common.html.HtmlEscapers; public abstract class HTTPDModule extends PluginComponent<TotalFreedomMod> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_file.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_file.java index 076b526bc..5dbbd917f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_file.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_file.java @@ -1,17 +1,9 @@ package me.totalfreedom.totalfreedommod.httpd.module; -import java.io.File; -import java.io.FileInputStream; -import java.io.FilenameFilter; -import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.io.*; import java.net.URLEncoder; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.StringTokenizer; +import java.util.*; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.httpd.HTTPDaemon; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java index 96b27b726..d30243caf 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_help.java @@ -1,6 +1,5 @@ package me.totalfreedom.totalfreedommod.httpd.module; -import com.google.common.collect.Lists; import java.util.Collection; import java.util.Collections; import java.util.Comparator; @@ -9,18 +8,24 @@ import java.util.List; import java.util.Map; +import org.bukkit.command.Command; +import org.bukkit.command.CommandMap; +import org.bukkit.command.PluginIdentifiableCommand; +import org.bukkit.command.SimpleCommandMap; + +import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.cmd.CommandRegistry; import me.totalfreedom.totalfreedommod.cmd.FCommand; import me.totalfreedom.totalfreedommod.cmd.internal.annotation.Permission; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD; +import me.totalfreedom.totalfreedommod.rank.CustomRank; + +import com.google.common.collect.Lists; + import static me.totalfreedom.totalfreedommod.httpd.HTMLGenerationTools.heading; import static me.totalfreedom.totalfreedommod.httpd.HTMLGenerationTools.paragraph; -import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD; -import me.totalfreedom.totalfreedommod.rank.Displayable; -import org.bukkit.command.Command; -import org.bukkit.command.CommandMap; -import org.bukkit.command.PluginIdentifiableCommand; -import org.bukkit.command.SimpleCommandMap; public class Module_help extends HTTPDModule { @@ -87,7 +92,15 @@ public String getBody() continue; } - Displayable tfmCommandLevel = perm.level(); + // The tier is derived from ranks.json rather than declared, so a command whose node + // no rank grants has no heading to sit under and is listed without one. + Displayable tfmCommandLevel = requiredRank(perm); + if (tfmCommandLevel == null) + { + responseBody.append(buildDescription(command)); + continue; + } + if (lastTfmCommandLevel == null || lastTfmCommandLevel != tfmCommandLevel) { responseBody.append("</ul>\r\n").append(heading(tfmCommandLevel.getName(), 3)).append("<ul>\r\n"); @@ -102,6 +115,19 @@ public String getBody() return responseBody.toString(); } + /** + * The least privileged rank that grants {@code perm}'s node, which is the tier the command + * actually requires, or {@code null} when no rank grants it. + */ + private static CustomRank requiredRank(Permission perm) + { + final TotalFreedomMod plugin = PluginProvider.get(); + + return plugin == null || plugin.rm == null + ? null + : plugin.rm.getRegistry().requiredFor(perm.permission()).orElse(null); + } + private static String buildDescription(Command command) { // Fall back to annotation data from the FCommand registry. @@ -183,7 +209,15 @@ public int compare(Command a, Command b) return a.getName().compareTo(b.getName()); } - return pa.level().getName().compareTo(pb.level().getName()); + final CustomRank ra = requiredRank(pa); + final CustomRank rb = requiredRank(pb); + + if (ra == null || rb == null) + { + return a.getName().compareTo(b.getName()); + } + + return Integer.compare(ra.getLevel(), rb.getLevel()); } } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_list.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_list.java index 80087c951..417a23080 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_list.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_list.java @@ -1,11 +1,13 @@ package me.totalfreedom.totalfreedommod.httpd.module; import java.util.Collection; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD; + import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD; + public class Module_list extends HTTPDModule { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_permbans.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_permbans.java index 0e44761f9..96db8d914 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_permbans.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_permbans.java @@ -1,6 +1,7 @@ package me.totalfreedom.totalfreedommod.httpd.module; import java.io.File; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.banning.PermbanList; import me.totalfreedom.totalfreedommod.httpd.HTTPDaemon; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java index a5dfb27a1..bacbb99f0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_players.java @@ -1,13 +1,15 @@ package me.totalfreedom.totalfreedommod.httpd.module; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; public class Module_players extends HTTPDModule { @@ -42,15 +44,14 @@ public NanoHTTPD.Response getResponse() for (Admin admin : plugin.al.getActiveAdmins()) { final String username = admin.getName(); - - switch (admin.getRank()) + + if (plugin.al.grantsSeniorStatus(admin)) + { + senioradmins.add(username); + } + else { - case SUPER_ADMIN: - superadmins.add(username); - break; - case SENIOR_ADMIN: - senioradmins.add(username); - break; + superadmins.add(username); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_schematic.java b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_schematic.java index 81e75ee5d..baec7b9a2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_schematic.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/httpd/module/Module_schematic.java @@ -2,13 +2,9 @@ import java.io.File; import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.regex.Pattern; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.config.ConfigEntry; @@ -19,6 +15,7 @@ import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.Method; import me.totalfreedom.totalfreedommod.httpd.NanoHTTPD.Response; import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.commons.io.FileUtils; public class Module_schematic extends HTTPDModule diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/CommandSpyMode.java b/src/main/java/me/totalfreedom/totalfreedommod/player/CommandSpyMode.java deleted file mode 100644 index 2bbb35bf1..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/CommandSpyMode.java +++ /dev/null @@ -1,34 +0,0 @@ -package me.totalfreedom.totalfreedommod.player; - -import java.util.Locale; - -public enum CommandSpyMode -{ - - OFF, - ADMINS, - OPS, - ALL; - - public static CommandSpyMode fromString(String value) - { - if (value == null) - { - return OFF; - } - - try - { - return valueOf(value.toUpperCase(Locale.ROOT)); - } - catch (IllegalArgumentException ex) - { - return OFF; - } - } - - public String getName() - { - return name().toLowerCase(Locale.ROOT); - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java b/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java index 0cd0afbaa..1473ff97b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/FPlayer.java @@ -2,17 +2,7 @@ import java.util.ArrayList; import java.util.List; -import lombok.Getter; -import lombok.Setter; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.caging.CageData; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.freeze.FreezeData; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.Bukkit; import org.bukkit.entity.Arrow; import org.bukkit.entity.EntityType; @@ -21,6 +11,20 @@ import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitTask; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.caging.CageData; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.freeze.FreezeData; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; +import me.totalfreedom.totalfreedommod.util.FTask; +import me.totalfreedom.totalfreedommod.util.FUtil; + +import lombok.Getter; +import lombok.Setter; + public class FPlayer { @@ -84,7 +88,7 @@ public static void refreshConfig() @Getter @Setter private boolean superadminIdVerified = false; - private CommandSpyMode commandSpyMode = CommandSpyMode.OFF; + private SpyMode commandSpyMode = SpyMode.OFF; private boolean joinLeaveMessagesEnabled = true; private Component tag = null; private String tagInternal = null; @@ -436,22 +440,22 @@ public void setCommandsBlocked(boolean commandsBlocked) public void setCommandSpy(boolean enabled) { - this.commandSpyMode = enabled ? CommandSpyMode.ALL : CommandSpyMode.OFF; + this.commandSpyMode = enabled ? SpyMode.ALL : SpyMode.OFF; } public boolean cmdspyEnabled() { - return commandSpyMode != CommandSpyMode.OFF; + return commandSpyMode != SpyMode.OFF; } - public CommandSpyMode getCommandSpyMode() + public SpyMode getCommandSpyMode() { return commandSpyMode; } - public void setCommandSpyMode(CommandSpyMode commandSpyMode) + public void setCommandSpyMode(SpyMode commandSpyMode) { - this.commandSpyMode = commandSpyMode == null ? CommandSpyMode.OFF : commandSpyMode; + this.commandSpyMode = commandSpyMode == null ? SpyMode.OFF : commandSpyMode; } public boolean joinLeaveMessagesEnabled() diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java index d9c3dfa8d..360b74b98 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerData.java @@ -1,63 +1,57 @@ package me.totalfreedom.totalfreedommod.player; -import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; +import java.util.Collection; import java.util.Collections; import java.util.List; -import lombok.Getter; -import lombok.Setter; +import java.util.Set; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigLoadable; -import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.ConfigSavable; import me.totalfreedom.totalfreedommod.util.ConfigInterfaces.Validatable; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.entity.Player; +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; -public class PlayerData implements ConfigLoadable, ConfigSavable, Validatable +public class PlayerData implements ConfigLoadable, Validatable { public static final int MAX_STRIKES = 3; // max number of IP addresses retained per player public static final int MAX_IPS = 2; - @Getter - @Setter private String username; - @Getter - @Setter private long firstJoinUnix; - @Getter - @Setter private long lastJoinUnix; - @Getter - @Setter - private boolean potionSpy; - private boolean signSpy; - private CommandSpyMode commandSpyMode = CommandSpyMode.OFF; - @Getter - @Setter + private SpyMode potionSpyMode = SpyMode.OFF; + private SpyMode signSpyMode = SpyMode.OFF; + private SpyMode bookSpyMode = SpyMode.OFF; + private SpyMode commandSpyMode = SpyMode.OFF; private boolean muted; - @Getter - @Setter private boolean frozen; - @Getter - @Setter private boolean commandsBlocked; private boolean joinLeaveMessagesEnabled = true; - @Getter - @Setter private String savedTag; - @Getter private Component nickname; - @Getter private int strikes; private final List<String> ips = Lists.newArrayList(); + /** + * Ids of the titles this player holds. + * <p> + * A player may hold several at once; their capabilities are the union of what those titles + * grant, and no title implies any other. Stored as ids rather than as resolved titles so that + * deleting a title simply stops it applying instead of stranding every holder. + */ + private final Set<String> titles = Sets.newLinkedHashSet(); + public PlayerData(Player player) { this(player.getName()); @@ -68,6 +62,101 @@ public PlayerData(String username) this.username = username; } + public String getUsername() + { + return username; + } + + public void setUsername(String username) + { + this.username = username; + } + + public long getFirstJoinUnix() + { + return firstJoinUnix; + } + + public void setFirstJoinUnix(long firstJoinUnix) + { + this.firstJoinUnix = firstJoinUnix; + } + + public long getLastJoinUnix() + { + return lastJoinUnix; + } + + public void setLastJoinUnix(long lastJoinUnix) + { + this.lastJoinUnix = lastJoinUnix; + } + + public boolean isPotionSpy() + { + return getPotionSpyMode() != SpyMode.OFF; + } + + public SpyMode getPotionSpyMode() + { + return potionSpyMode == null ? SpyMode.OFF : potionSpyMode; + } + + public void setPotionSpyMode(SpyMode potionSpyMode) + { + this.potionSpyMode = potionSpyMode == null ? SpyMode.OFF : potionSpyMode; + } + + public boolean isMuted() + { + return muted; + } + + public void setMuted(boolean muted) + { + this.muted = muted; + } + + public boolean isFrozen() + { + return frozen; + } + + public void setFrozen(boolean frozen) + { + this.frozen = frozen; + } + + public boolean isCommandsBlocked() + { + return commandsBlocked; + } + + public void setCommandsBlocked(boolean commandsBlocked) + { + this.commandsBlocked = commandsBlocked; + } + + public String getSavedTag() + { + return savedTag; + } + + public void setSavedTag(String savedTag) + { + this.savedTag = savedTag; + } + + public Component getNickname() + { + return nickname; + } + + public int getStrikes() + { + return strikes; + } + @Override public void loadFrom(ConfigurationSection cs) { @@ -77,15 +166,17 @@ public void loadFrom(ConfigurationSection cs) trimIps(); this.firstJoinUnix = cs.getLong("first_join", 0); this.lastJoinUnix = cs.getLong("last_join", 0); - this.potionSpy = cs.getBoolean("potion_spy", false); - this.signSpy = cs.getBoolean("sign_spy", false); + this.potionSpyMode = SpyMode.fromStorage(cs.getString("potion_spy_mode", cs.getBoolean("potion_spy", false) ? "all" : "off")); + this.signSpyMode = SpyMode.fromStorage(cs.getString("sign_spy_mode", cs.getBoolean("sign_spy", false) ? "all" : "off")); + this.bookSpyMode = SpyMode.fromStorage(cs.getString("book_spy_mode", "off")); final boolean legacyCommandSpy = cs.getBoolean("command_spy", false); - this.commandSpyMode = CommandSpyMode.fromString(cs.getString("command_spy_mode", legacyCommandSpy ? "ops" : "off")); + this.commandSpyMode = SpyMode.fromStorage(cs.getString("command_spy_mode", legacyCommandSpy ? "ops" : "off")); this.muted = cs.getBoolean("muted", false); this.frozen = cs.getBoolean("frozen", false); this.commandsBlocked = cs.getBoolean("commands_blocked", false); this.joinLeaveMessagesEnabled = cs.getBoolean("join_leave_messages", true); this.strikes = cs.getInt("strikes", 0); + setTitles(cs.getStringList("titles")); this.savedTag = cs.getString("saved_tag"); if (this.savedTag != null && this.savedTag.isEmpty()) { @@ -99,55 +190,54 @@ public void loadFrom(ConfigurationSection cs) } } - @Override - public void saveTo(ConfigurationSection cs) - { - Preconditions.checkArgument(isValid(), "Could not save player entry: " + username + ". Entry not valid!"); - cs.set("username", username); - cs.set("ips", ips); - cs.set("first_join", firstJoinUnix); - cs.set("last_join", lastJoinUnix); - cs.set("potion_spy", potionSpy); - cs.set("sign_spy", signSpy); - cs.set("command_spy", isCommandSpy()); - cs.set("command_spy_mode", commandSpyMode.getName()); - cs.set("muted", muted); - cs.set("frozen", frozen); - cs.set("commands_blocked", commandsBlocked); - cs.set("join_leave_messages", joinLeaveMessagesEnabled); - cs.set("strikes", strikes); - cs.set("saved_tag", savedTag); - cs.set("nickname", nickname != null ? AdventureUtil.componentToLegacy(nickname) : null); - } - public boolean isCommandSpy() { - return commandSpyMode != CommandSpyMode.OFF; + return getCommandSpyMode() != SpyMode.OFF; } public void setCommandSpy(boolean commandSpy) { - this.commandSpyMode = commandSpy ? CommandSpyMode.ALL : CommandSpyMode.OFF; + this.commandSpyMode = commandSpy ? SpyMode.ALL : SpyMode.OFF; } - public CommandSpyMode getCommandSpyMode() + public SpyMode getCommandSpyMode() { - return commandSpyMode; + return commandSpyMode == null ? SpyMode.OFF : commandSpyMode; } - public void setCommandSpyMode(CommandSpyMode commandSpyMode) + public void setCommandSpyMode(SpyMode commandSpyMode) { - this.commandSpyMode = commandSpyMode == null ? CommandSpyMode.OFF : commandSpyMode; + this.commandSpyMode = commandSpyMode == null ? SpyMode.OFF : commandSpyMode; } public boolean isSignSpy() { - return signSpy; + return getSignSpyMode() != SpyMode.OFF; + } + + public SpyMode getSignSpyMode() + { + return signSpyMode == null ? SpyMode.OFF : signSpyMode; + } + + public void setSignSpyMode(SpyMode signSpyMode) + { + this.signSpyMode = signSpyMode == null ? SpyMode.OFF : signSpyMode; + } + + public boolean isBookSpy() + { + return getBookSpyMode() != SpyMode.OFF; } - public void setSignSpy(final boolean signSpy) + public SpyMode getBookSpyMode() { - this.signSpy = signSpy; + return bookSpyMode == null ? SpyMode.OFF : bookSpyMode; + } + + public void setBookSpyMode(SpyMode bookSpyMode) + { + this.bookSpyMode = bookSpyMode == null ? SpyMode.OFF : bookSpyMode; } public boolean isJoinLeaveMessagesEnabled() @@ -160,6 +250,51 @@ public void setJoinLeaveMessagesEnabled(boolean joinLeaveMessagesEnabled) this.joinLeaveMessagesEnabled = joinLeaveMessagesEnabled; } + /** + * The ids of every title this player holds. Unmodifiable: use {@link #addTitle(String)} and + * {@link #removeTitle(String)} so ids stay normalised. + */ + public Set<String> getTitles() + { + return Collections.unmodifiableSet(titles); + } + + /** + * Grants a title. Returns {@code false} when it was already held. + */ + public boolean addTitle(String titleId) + { + return titleId != null && titles.add(titleId.toLowerCase()); + } + + /** + * Revokes a title. Returns {@code false} when it was not held. + */ + public boolean removeTitle(String titleId) + { + return titleId != null && titles.remove(titleId.toLowerCase()); + } + + public boolean hasTitle(String titleId) + { + return titleId != null && titles.contains(titleId.toLowerCase()); + } + + /** + * Replaces the whole set, normalising ids. Used by the persistence layer on load. + */ + public void setTitles(Collection<String> titleIds) + { + titles.clear(); + if (titleIds != null) + { + titleIds.stream() + .filter(id -> id != null && !id.isBlank()) + .map(String::toLowerCase) + .forEach(titles::add); + } + } + public List<String> getIps() { return Collections.unmodifiableList(ips); @@ -217,6 +352,18 @@ public void setNickname(Component nickname) plugin.pl.saveData(this); } + /** + * Set the nickname without the display/save side effects {@link #setNickname} has. + * Used when hydrating a PlayerData from storage (repository load), where the player + * is not necessarily online and a save-after-load would be redundant. + */ + public void setNicknameRaw(Component nickname) + { + this.nickname = nickname; + if (!hasCustomNickname()) + this.nickname = null; + } + public Component getDisplayedNickname() { if (!hasCustomNickname()) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java index 4db90c30f..403294941 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/PlayerList.java @@ -1,39 +1,62 @@ package me.totalfreedom.totalfreedommod.player; -import com.google.common.collect.Maps; import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; import java.util.Arrays; import java.util.Collection; +import java.util.List; import java.util.Map; -import lombok.Getter; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; -import java.io.IOException; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.configuration.file.YamlConfiguration; + import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import org.bukkit.event.player.AsyncPlayerPreLoginEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.FUtil; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +import com.google.common.collect.Maps; + public class PlayerList extends FreedomService { public static final long AUTO_PURGE_TICKS = 20L * 60L * 5L; + + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; // - @Getter public final Map<String, FPlayer> playerMap = Maps.newHashMap(); // key: lowercase username - @Getter public final Map<String, PlayerData> dataMap = Maps.newHashMap(); // key: lowercase username private final File configFolder; - - // Manual getter - Lombok @Getter not processing reliably + private final PersistenceQueue writes = new PersistenceQueue("player data"); + + public Map<String, FPlayer> getPlayerMap() + { + return playerMap; + } + + public Map<String, PlayerData> getDataMap() + { + return dataMap; + } + public File getConfigFolder() { return configFolder; @@ -46,6 +69,11 @@ public PlayerList(TotalFreedomMod plugin) this.configFolder = new File(plugin.getDataFolder(), "players"); } + private boolean usingSql() + { + return plugin.dm != null && plugin.dm.isInitialized(); + } + @Override protected void onStart() { @@ -63,40 +91,56 @@ protected void onStart() protected void onStop() { save(); + writes.await(SHUTDOWN_FLUSH_TIMEOUT_MS); } + /** + * Queue a write of every loaded player record. + */ public void save() { - for (PlayerData data : dataMap.values()) - { - saveOne(data); - } + List.copyOf(dataMap.values()).forEach(this::saveOne); } public void saveAsync() { - if (!plugin.isEnabled()) + save(); + } + + /** + * Queue one player's SQL write plus its JSON snapshot refresh. Safe from an event handler: + * the round trip runs off the main thread. + */ + private void saveOne(final PlayerData data) + { + if (!usingSql()) { - save(); + writes.enqueue(writeJsonAsync(data)); return; } - final java.util.List<PlayerData> snapshot = new java.util.ArrayList<>(dataMap.values()); - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> - { - for (PlayerData data : snapshot) - { - saveOne(data); - } - }); + + writes.enqueue(plugin.dm.getPlayerRepository().save(data) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save player data for %s to SQL: %s", + data.getUsername(), ex.getMessage())); + return Mono.empty(); + }) + .then(writeJsonAsync(data))); } - private void saveOne(PlayerData data) + private Mono<Void> writeJsonAsync(final PlayerData data) { - final YamlConfiguration config = getConfig(data); - data.saveTo(config); - try + return Mono.<Void>fromRunnable(() -> saveToJson(data)) + .subscribeOn(Schedulers.boundedElastic()); + } + + private void saveToJson(PlayerData data) + { + final File configFile = getConfigFile(data.getUsername().toLowerCase()); + try (FileWriter writer = new FileWriter(configFile)) { - config.save(getConfigFile(data.getUsername().toLowerCase())); + JsonUtil.GSON.toJson(data, PlayerData.class, writer); } catch (IOException ex) { @@ -201,16 +245,7 @@ public PlayerData getData(Player player) dataMap.put(player.getName().toLowerCase(), data); // Save player - YamlConfiguration config = getConfig(data); - data.saveTo(config); - try - { - config.save(getConfigFile(data.getUsername().toLowerCase())); - } - catch (IOException ex) - { - FLog.severe("Could not save player data for " + data.getUsername()); - } + saveOne(data); } return data; @@ -221,18 +256,87 @@ public PlayerData getData(String username) { username = username.toLowerCase(); - // Check if the player is a known player + if (usingSql()) + { + PlayerData data = loadFromSql(username); + if (data != null) + { + return data; + } + } + + return loadFromJson(username); + } + + private PlayerData loadFromSql(String username) + { + try + { + PlayerRepository repo = plugin.dm.getPlayerRepository(); + reconcileFromJsonIfNewer(username, repo); + + return repo.findByUsername(username) + .map(data -> + { + if (Bukkit.getPlayerExact(data.getUsername()) != null) + { + dataMap.put(data.getUsername().toLowerCase(), data); + } + return data; + }) + .orElse(null); + } + catch (Exception ex) + { + FLog.warning("Failed to load player data for " + username + " from SQL, falling back to JSON: " + ex.getMessage()); + return null; + } + } + + /** + * If this player's JSON snapshot was written more recently than the database's last update + * for them, re-import it into SQL. + */ + private void reconcileFromJsonIfNewer(String username, PlayerRepository repo) + { + final File jsonFile = getConfigFile(username); + if (!jsonFile.exists()) + { + return; + } + + try + { + Long sqlUpdatedAt = repo.getUpdatedAt(username); + if (!FUtil.isSnapshotNewer(jsonFile.lastModified(), sqlUpdatedAt)) + return; + + PlayerData jsonData = readJsonPlayer(username); + if (jsonData == null || !jsonData.isValid()) + { + return; + } + + FLog.info(jsonFile.getName() + " is newer than the database; re-importing player data for " + username + " from it."); + repo.saveOrUpdate(jsonData); + } + catch (Exception ex) + { + FLog.warning("Failed to reconcile player data for " + username + " into the database: " + ex.getMessage()); + } + } + + private PlayerData loadFromJson(String username) + { final File configFile = getConfigFile(username); if (!configFile.exists()) { return null; } - // Create and load entry - final PlayerData data = new PlayerData(username); - data.loadFrom(getConfig(data)); + final PlayerData data = readJsonPlayer(username); - if (!data.isValid()) + if (data == null || !data.isValid()) { FLog.warning("Could not load player data entry: " + username + ". Entry is not valid!"); configFile.delete(); @@ -248,13 +352,57 @@ public PlayerData getData(String username) return data; } + private PlayerData readJsonPlayer(String username) + { + final File configFile = getConfigFile(username); + try (FileReader reader = new FileReader(configFile)) + { + return JsonUtil.GSON.fromJson(reader, PlayerData.class); + } + catch (Exception ex) + { + FLog.severe("Could not read player data for " + username + ": " + ex.getMessage()); + return null; + } + } + + /** + * Reads the pre-JSON per-player {@code players/<name>.yml} format. Retained only for the + * one-time legacy-install migration path (not called during normal startup). + */ + private PlayerData loadLegacyYaml(String username) + { + final File legacyFile = new File(getConfigFolder(), username + ".yml"); + if (!legacyFile.exists()) + { + return null; + } + + final YamlConfiguration config = YamlConfiguration.loadConfiguration(legacyFile); + final PlayerData data = new PlayerData(username); + data.loadFrom(config); + return data; + } + public Collection<PlayerData> getAllData() { + if (usingSql()) + { + try + { + return plugin.dm.getPlayerRepository().loadAll().values(); + } + catch (Exception ex) + { + FLog.warning("Failed to load all player data from SQL, falling back to JSON: " + ex.getMessage()); + } + } + return Arrays.stream(configFolder.listFiles()) .filter(file -> file != null) .map(File::getName) - .filter(name -> name.endsWith(".yml")) - .map(name -> getData(name.substring(0, name.length() - ".yml".length()))).toList(); + .filter(name -> name.endsWith(".json")) + .map(name -> getData(name.substring(0, name.length() - ".json".length()))).toList(); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @@ -295,10 +443,26 @@ public void onPlayerJoin(PlayerJoinEvent event) } if (plugin.isEnabled()) { - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> saveOne(data)); + saveOne(data); } } + /** + * Warm {@link #dataMap} before the player reaches the main thread. This event already runs + * off it, so the lookup that {@code onPlayerJoin} would otherwise make against the database + * happens here instead and finds a cached entry on join. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onPreLogin(AsyncPlayerPreLoginEvent event) + { + if (event.getLoginResult() != AsyncPlayerPreLoginEvent.Result.ALLOWED) + return; + + final PlayerData data = getData(event.getName()); + if (data != null) + dataMap.put(data.getUsername().toLowerCase(), data); + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onPlayerQuit(PlayerQuitEvent event) { @@ -308,7 +472,7 @@ public void onPlayerQuit(PlayerQuitEvent event) if (data != null && plugin.isEnabled()) { - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> saveOne(data)); + saveOne(data); } } @@ -330,19 +494,22 @@ public int purgeAllData() deleted += file.delete() ? 1 : 0; } + if (usingSql()) + { + writes.enqueue(plugin.dm.getPlayerRepository().deleteAll() + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not purge player data from SQL: %s", ex.getMessage())); + return Mono.empty(); + })); + } + dataMap.clear(); return deleted; } protected File getConfigFile(String name) { - return new File(getConfigFolder(), name + ".yml"); - } - - protected YamlConfiguration getConfig(PlayerData data) - { - final File configFile = getConfigFile(data.getUsername().toLowerCase()); - final YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); - return config; + return new File(getConfigFolder(), name + ".json"); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/player/SpyMode.java b/src/main/java/me/totalfreedom/totalfreedommod/player/SpyMode.java new file mode 100644 index 000000000..3ae08addc --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/player/SpyMode.java @@ -0,0 +1,44 @@ +package me.totalfreedom.totalfreedommod.player; + +import java.util.Locale; + +/** + * Filter shared by the spy services deciding whose activity a spy is shown. + */ +public enum SpyMode +{ + OFF, + OPS, + ADMINS, + ALL; + + public static SpyMode fromStorage(String value) + { + if (value == null) return OFF; + + try + { + return valueOf(value.toUpperCase(Locale.ROOT)); + } + catch (IllegalArgumentException ex) + { + return OFF; + } + } + + public boolean shows(boolean senderIsAdmin) + { + return switch (this) + { + case OFF -> false; + case OPS -> !senderIsAdmin; + case ADMINS -> senderIsAdmin; + case ALL -> true; + }; + } + + public String getName() + { + return name().toLowerCase(Locale.ROOT); + } +} \ No newline at end of file diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java index de85d48b1..d490efbb3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/ConsoleSenderRegistry.java @@ -4,37 +4,36 @@ import java.util.List; import java.util.Map; import java.util.Set; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; /** - * Loads and exposes the {@code host_senders:} whitelist that binds shared-secret / - * no-identity senders to a specific rank. - * - * Reload by calling {@link #load()} + * Loads and exposes the {@code host_senders:} whitelist that binds shared-secret / no-identity + * senders to a specific rank. + * <p> + * Reload by calling {@link #load()}. */ public class ConsoleSenderRegistry { /** * Console-class channels that reach the server through the host itself rather than through a - * user identity. Console access is the privilege, so these sit at {@link Rank#SENIOR_ADMIN} - * and {@code host_senders:} may raise them but never lower them. A binding below the floor is - * a misconfiguration that silently locks the panel out of senior-gated commands, which is - * precisely how these channels were broken before. + * user identity. Console access is itself the privilege, so these hold the floor named by + * {@link RankRole#CONSOLE_FLOOR}, which {@code host_senders:} may raise but never lower. * <p> - * SSH and Discord are deliberately absent: they carry a real identity and are resolved per - * user by {@link RankManager#getEffectiveRank}, falling back to their {@code host_senders:} - * binding only when the session proved no identity. + * SSH and Discord are deliberately absent: they carry a real identity and are resolved per user + * by {@link RankRegistry}, falling back to their {@code host_senders:} binding only when the + * session proved no identity. */ private static final Set<String> HOST_CHANNELS = Set.of("rcon", "remotebukkit", "console"); - private static final Rank HOST_CHANNEL_FLOOR = Rank.SENIOR_ADMIN; + private static final String ENTRY_SEPARATOR = ":"; private final TotalFreedomMod plugin; + private final Map<String, String> senderToRankId = new HashMap<>(); - private final Map<String, Rank> senderToLegacyRank = new HashMap<>(); public ConsoleSenderRegistry(TotalFreedomMod plugin) { @@ -44,158 +43,128 @@ public ConsoleSenderRegistry(TotalFreedomMod plugin) public void load() { senderToRankId.clear(); - senderToLegacyRank.clear(); - List<?> raw = ConfigEntry.HOST_SENDERS.getList(); + final List<?> raw = ConfigEntry.HOST_SENDERS.getList(); if (raw == null) { - FLog.warning("Host sender whitelist (config 'host_senders:') is missing. Identity-less senders will be denied, " - + "except the host channels, which hold their floor."); + FLog.warning("Host sender whitelist (config 'host_senders:') is missing. Identity-less senders " + + "will be denied, except the host channels, which hold their floor."); applyHostChannelFloor(); return; } - for (Object obj : raw) - { - if (!(obj instanceof String)) - { - FLog.warning("Console whitelist entry is not a string, skipping: " + obj); - continue; - } - String entry = ((String) obj).trim(); - int colon = entry.indexOf(':'); - if (colon <= 0 || colon == entry.length() - 1) - { - FLog.warning("Console whitelist entry is malformed (expected '<rank_id>:<sender_name>'), skipping: " + entry); - continue; - } + raw.forEach(this::loadEntry); + applyHostChannelFloor(); - String rankId = entry.substring(0, colon).trim(); - String senderName = entry.substring(colon + 1).trim().toLowerCase(); + FLog.info(String.format("Loaded %d console whitelist binding(s).", senderToRankId.size())); + } - Rank legacyRank = parseLegacyRank(rankId); - if (legacyRank == null && !isKnownCustomRank(rankId)) - { - FLog.warning("Console whitelist entry references unknown rank '" + rankId + "', skipping: " + entry); - continue; - } + public String getRankIdForSender(String senderName) + { + return senderName == null ? null : senderToRankId.get(senderName.toLowerCase()); + } - String existingId = senderToRankId.put(senderName, rankId.toLowerCase()); - if (existingId != null && !existingId.equalsIgnoreCase(rankId)) - { - FLog.warning("Console whitelist binds sender '" + senderName + "' to multiple ranks; using " + rankId); - } - if (legacyRank != null) - { - senderToLegacyRank.put(senderName, legacyRank); - } + public boolean isWhitelisted(String senderName) + { + return getRankIdForSender(senderName) != null; + } + + /** + * Parses one {@code <rank_id>:<sender_name>} entry, rejecting anything that is malformed or + * names a rank the registry does not know. + */ + private void loadEntry(final Object obj) + { + if (!(obj instanceof String)) + { + FLog.warning(String.format("Console whitelist entry is not a string, skipping: %s", obj)); + return; } - applyHostChannelFloor(); + final String entry = ((String) obj).trim(); + final int colon = entry.indexOf(ENTRY_SEPARATOR); + if (colon <= 0 || colon == entry.length() - 1) + { + FLog.warning(String.format( + "Console whitelist entry is malformed (expected '<rank_id>:<sender_name>'), skipping: %s", entry)); + return; + } - FLog.info("Loaded " + senderToRankId.size() + " console whitelist binding(s)."); + final String rankId = entry.substring(0, colon).trim().toLowerCase(); + final String senderName = entry.substring(colon + 1).trim().toLowerCase(); + + if (!isKnownRank(rankId)) + { + FLog.warning(String.format("Console whitelist entry references unknown rank '%s', skipping: %s", + rankId, entry)); + return; + } + + final String existing = senderToRankId.put(senderName, rankId); + if (existing != null && !existing.equals(rankId)) + { + FLog.warning(String.format("Console whitelist binds sender '%s' to multiple ranks; using %s", + senderName, rankId)); + } } /** * Raises every {@link #HOST_CHANNELS host channel} to {@link #HOST_CHANNEL_FLOOR}, whether it - * was bound too low or left out of {@code host_senders:} entirely. A binding above the floor is - * left alone, so operators can still hand a host channel a higher custom rank. + * was bound too low or left out of {@code host_senders:} entirely. A binding at or above the + * floor is left alone, so any console user can still hand a host channel a higher custom rank. */ private void applyHostChannelFloor() { + final CustomRank floor = hostChannelFloor(); + if (floor == null) + { + FLog.warning("No rank fills the console floor role, so host channels keep whatever " + + "'host_senders:' bound them to."); + return; + } + HOST_CHANNELS.forEach(channel -> { - Rank bound = senderToLegacyRank.get(channel); - if (bound != null && bound.isAtLeast(HOST_CHANNEL_FLOOR)) - { - return; - } + final String boundId = senderToRankId.get(channel); - // A custom rank has no legacy entry; only override it when it genuinely sits lower, - // so 'executive:rcon' and friends survive. - String boundId = senderToRankId.get(channel); - if (bound == null && boundId != null && outranksFloor(boundId)) - { + if (boundId != null && outranksFloor(boundId, floor)) return; - } if (boundId != null) { - FLog.warning("Console whitelist binds host channel '" + channel + "' to '" + boundId - + "', below the " + HOST_CHANNEL_FLOOR.name().toLowerCase() + " floor for host channels; raising it."); + FLog.warning(String.format( + "Console whitelist binds host channel '%s' to '%s', below the '%s' floor for host " + + "channels; raising it.", channel, boundId, floor.getId())); } - senderToRankId.put(channel, HOST_CHANNEL_FLOOR.name().toLowerCase()); - senderToLegacyRank.put(channel, HOST_CHANNEL_FLOOR); + senderToRankId.put(channel, floor.getId()); }); } /** - * Whether the custom rank {@code rankId} sits at or above the host-channel floor, compared on - * the registry's own level scale so operator-defined numbering is honoured. + * The rank host channels are floored at, taken from whichever rank fills that role. */ - private boolean outranksFloor(String rankId) + private CustomRank hostChannelFloor() { - if (plugin.rm == null) - { - return false; - } - - CustomRank bound = plugin.rm.getCustomRank(rankId); - CustomRank floor = plugin.rm.getCustomRankForLegacy(HOST_CHANNEL_FLOOR); - - return bound != null && floor != null && bound.isAtLeast(floor); - } - - public String getRankIdForSender(String senderName) - { - if (senderName == null) - { - return null; - } - return senderToRankId.get(senderName.toLowerCase()); + return plugin.rm == null + ? null + : plugin.rm.getRegistry().byRole(RankRole.CONSOLE_FLOOR).orElse(null); } - public Rank getRankForSender(String senderName) + /** + * Whether {@code rankId} sits at or above the host-channel floor, compared on the registry's own + * level scale so custom defined numbering is honoured. + */ + private boolean outranksFloor(final String rankId, final CustomRank floor) { - if (senderName == null) - { - return null; - } - return senderToLegacyRank.get(senderName.toLowerCase()); - } + final CustomRank bound = plugin.rm.getCustomRank(rankId); - public boolean isWhitelisted(String senderName) - { - return getRankIdForSender(senderName) != null; + return bound != null && bound.isAtLeast(floor); } - private boolean isKnownCustomRank(String rankId) + private boolean isKnownRank(final String rankId) { - return plugin.rm != null && plugin.rm.getCustomRank(rankId.toLowerCase()) != null; + return plugin.rm != null && plugin.rm.getCustomRank(rankId) != null; } - private static Rank parseLegacyRank(String id) - { - if (id == null || id.isEmpty()) - { - return null; - } - String key = id.toUpperCase(); - try - { - Rank rank = Rank.valueOf(key); - switch (rank) - { - case SENIOR_CONSOLE: - return Rank.SENIOR_ADMIN; - default: - return rank; - } - } - catch (IllegalArgumentException ex) - { - return null; - } - } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java index 1b0999baf..b811f9788 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/CustomRank.java @@ -1,25 +1,26 @@ package me.totalfreedom.totalfreedommod.rank; +import java.util.EnumSet; import java.util.HashSet; import java.util.Set; -import lombok.Getter; -import lombok.Setter; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; + +import org.bukkit.configuration.ConfigurationSection; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; -import org.bukkit.configuration.ConfigurationSection; + +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; /** * Represents a configurable rank in the TFM permission system. * Unlike the built-in Rank enum, CustomRank instances can be created, * modified, and persisted at runtime. - * + * * The permission system is internal and does NOT use Bukkit permission nodes, * because all players on TotalFreedom servers have OP status. */ -@Getter -@Setter public class CustomRank implements Displayable, Comparable<CustomRank> { /** @@ -58,11 +59,6 @@ public class CustomRank implements Displayable, Comparable<CustomRank> */ private boolean admin = false; - /** - * Whether this rank is a console-only variant. - */ - private boolean consoleOnly = false; - /** * Internal permissions granted to this rank. * These are TFM-specific permission strings, NOT Bukkit permission nodes. @@ -79,11 +75,20 @@ public class CustomRank implements Displayable, Comparable<CustomRank> * ID of parent rank to inherit permissions from. */ private String inheritFrom = null; + + /** + * Jobs this rank fills for the plugin, declared in configuration so that no rank has to be + * named in code. Roles are not inherited: a role names exactly one rank, and letting a child + * pick one up from its parent would make two ranks claim it. + */ + private Set<RankRole> roles = EnumSet.noneOf(RankRole.class); /** - * Flattened permissions including inherited permissions. + * Flattened permissions including inherited permissions. Computed at runtime by + * RankManager.resolveInheritance(), not raw stored data, so it's excluded from + * JSON serialization. */ - private Set<String> resolvedPermissions = new HashSet<>(); + private transient Set<String> resolvedPermissions = new HashSet<>(); // Cached components for performance private transient Component cachedColoredTag; @@ -95,28 +100,43 @@ public class CustomRank implements Displayable, Comparable<CustomRank> */ public CustomRank(String id) { - this.id = id.toLowerCase().replace(" ", "_"); + this.id = normalizeId(id); this.name = id; this.abbreviation = id.length() > 3 ? id.substring(0, 3).toUpperCase() : id.toUpperCase(); this.level = 0; invalidateCache(); } - + /** - * Creates a CustomRank from an existing Rank enum (for migration). + * Gson deserialises through this constructor rather than allocating the object unsafely, which + * is what keeps the field initialisers above running. Without it an entry that omits + * {@code determiner}, {@code color}, {@code permissions} or {@code roles} comes back null + * instead of carrying its documented default. + * <p> + * The id is deliberately left unset here: a rank carries its id as the key it is filed under + * rather than as a property of the entry, so {@link #assignId(String)} stamps it after the read. */ - public static CustomRank fromLegacyRank(Rank rank) - { - CustomRank custom = new CustomRank(rank.name().toLowerCase()); - custom.setName(rank.getName()); - custom.setDeterminer(rank.getDeterminer()); - custom.setAbbreviation(rank.getTag().replace("[", "").replace("]", "")); - custom.setLevel(rank.getLevel()); - custom.setColor(rank.getColor()); - custom.setAdmin(rank.isAdmin()); - custom.setConsoleOnly(rank.isConsole()); + private CustomRank() {} - return custom; + /** + * Stamps the id this rank was filed under, normalising it exactly as the public constructor + * does so that a rank's id always matches the key it is stored against. + */ + public void assignId(final String key) + { + this.id = normalizeId(key); + invalidateCache(); + } + + /** + * Ids reach SQL as a primary key and are used to build scoreboard team names, so anything + * outside the safe set is stripped rather than stored. + */ + public static String normalizeId(final String raw) + { + return raw.toLowerCase() + .replace(' ', '_') + .replaceAll("[^a-z0-9_\\-]", ""); } /** @@ -133,7 +153,6 @@ public void loadFrom(ConfigurationSection cs) this.color = parseColor(colorName); this.admin = cs.getBoolean("admin", false); - this.consoleOnly = cs.getBoolean("console_only", false); this.prefix = cs.getString("prefix", null); this.inheritFrom = cs.getString("inherit", null); @@ -146,23 +165,6 @@ public void loadFrom(ConfigurationSection cs) invalidateCache(); } - /** - * Save rank data to a configuration section. - */ - public void saveTo(ConfigurationSection cs) - { - cs.set("name", name); - cs.set("determiner", determiner); - cs.set("abbreviation", abbreviation); - cs.set("level", level); - cs.set("color", color.toString()); - cs.set("admin", admin); - cs.set("console_only", consoleOnly); - cs.set("prefix", prefix); - cs.set("inherit", inheritFrom); - cs.set("permissions", permissions.isEmpty() ? null : permissions.stream().toList()); - } - /** * Parse a color name to NamedTextColor. */ @@ -235,15 +237,6 @@ public boolean isAtLeast(CustomRank other) return this.level >= other.level; } - /** - * Check if this rank is at least as high as a legacy Rank. - */ - public boolean isAtLeast(Rank legacyRank) - { - if (legacyRank == null) return true; - return this.level >= legacyRank.getLevel(); - } - // ======================================================================== // Displayable Implementation // ======================================================================== @@ -357,32 +350,57 @@ public String getId() { return id; } - + + public void setName(String name) + { + this.name = name; + } + public String getDeterminer() { return determiner; } - + + public void setDeterminer(String determiner) + { + this.determiner = determiner; + } + public String getAbbreviation() { return abbreviation; } - + + public void setAbbreviation(String abbreviation) + { + this.abbreviation = abbreviation; + } + public int getLevel() { return level; } - + + public void setLevel(int level) + { + this.level = level; + } + + public void setColor(NamedTextColor color) + { + this.color = color; + } + public boolean isAdmin() { return admin; } - - public boolean isConsoleOnly() + + public void setAdmin(boolean admin) { - return consoleOnly; + this.admin = admin; } - + public Set<String> getPermissions() { return permissions; @@ -411,6 +429,21 @@ public String getInheritFrom() { return inheritFrom; } + + public Set<RankRole> getRoles() + { + return roles == null ? EnumSet.noneOf(RankRole.class) : roles; + } + + public void setRoles(Set<RankRole> roles) + { + this.roles = roles == null ? EnumSet.noneOf(RankRole.class) : EnumSet.copyOf(roles); + } + + public boolean hasRole(RankRole role) + { + return roles != null && roles.contains(role); + } public void setInheritFrom(String inheritFrom) { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/PermissionTrie.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/PermissionTrie.java new file mode 100644 index 000000000..8248f512e --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/PermissionTrie.java @@ -0,0 +1,168 @@ +package me.totalfreedom.totalfreedommod.rank; + +import java.util.*; + +/** + * An index of which ranks grant which internal permission nodes. + * <p> + * Permission nodes are dotted paths ({@code tfm.admin.ban}) and a rank may grant them either + * exactly or through a trailing wildcard ({@code tfm.admin.*}, or a bare {@code *} for everything). + * Answering "which ranks grant this node" by testing every rank against every wildcard prefix is + * what the old {@code hasCustomRankPermission} did on each check; indexing the nodes by segment + * instead turns that into a single walk whose length is the depth of the node being asked about, + * regardless of how many ranks or patterns exist. + * <p> + * The index is what lets a command declare only its permission node and have the required rank + * <em>derived</em> from {@code ranks.json}: see {@link #lowestGranting(String, Map)}. Because it is + * built from each rank's <em>resolved</em> permissions, inheritance is already folded in by the + * time a node reaches the index. + * <p> + * Not thread-safe. Rebuild it on the main thread whenever ranks change. + */ +public final class PermissionTrie +{ + + /** + * The wildcard suffix that makes a pattern cover everything beneath its prefix. + */ + private static final String WILDCARD = "*"; + + private static final String SEPARATOR = "\\."; + + private final Node root = new Node(); + + /** + * Discards the current index and rebuilds it from {@code ranks}, reading each rank's resolved + * permissions so that inherited grants are indexed alongside directly declared ones. + */ + public void rebuild(final Map<String, CustomRank> ranks) + { + root.children.clear(); + root.exact.clear(); + root.wildcard.clear(); + + ranks.values() + .forEach(rank -> rank.getResolvedPermissions() + .forEach(permission -> insert(permission, rank.getId()))); + } + + /** + * The ids of every rank that grants {@code permission}, whether exactly or through a wildcard. + * Returns an empty set when no rank grants it, which is the signal that a node is unreachable + * and its command therefore ungrantable. + */ + public Set<String> grantingRanks(final String permission) + { + if (permission == null || permission.isEmpty()) + return Set.of(); + + final String[] segments = permission.toLowerCase().split(SEPARATOR); + final Set<String> granting = new HashSet<>(root.wildcard); + + Node current = root; + for (int i = 0; i < segments.length; i++) + { + current = current.children.get(segments[i]); + if (current == null) + return Collections.unmodifiableSet(granting); + + // A node's wildcard set holds the ranks granting "<path-to-here>.*", which covers + // anything strictly below it. The terminal segment is skipped so that "a.b.c.*" is not + // read as granting "a.b.c" itself, matching how the nodes are written in ranks.json. + if (i < segments.length - 1) + granting.addAll(current.wildcard); + } + + granting.addAll(current.exact); + + return Collections.unmodifiableSet(granting); + } + + /** + * Whether {@code rankId} grants {@code permission}. + */ + public boolean grants(final String rankId, final String permission) + { + return rankId != null && grantingRanks(permission).contains(rankId.toLowerCase()); + } + + /** + * The cheapest rank that grants {@code permission}, or {@code null} when no rank does. + * <p> + * This is the inversion the command gate depends on. A handler declares only the node it needs; + * the tier required to reach that handler is whatever the least privileged rank holding it + * happens to be, so moving a permission between ranks in {@code ranks.json} moves the gate with + * it and no annotation has to name a rank. Where two ranks genuinely differ in what they may do + * with the same feature, they are expected to hold distinct nodes rather than share one, since + * a shared node necessarily resolves to the lower of the two. + * + * @param ranks the registry to resolve ids against, so levels are read off one scale + */ + public CustomRank lowestGranting(final String permission, final Map<String, CustomRank> ranks) + { + return grantingRanks(permission).stream() + .map(ranks::get) + .filter(rank -> rank != null) + .min(Comparator.comparingInt(CustomRank::getLevel)) + .orElse(null); + } + + /** + * Indexes a single pattern, storing it against the node for its prefix when it ends in a + * wildcard and against the node for the whole path otherwise. + */ + private void insert(final String permission, final String rankId) + { + if (permission == null || permission.isEmpty()) + return; + + final String pattern = permission.toLowerCase(); + + if (WILDCARD.equals(pattern)) + { + root.wildcard.add(rankId); + return; + } + + final String[] segments = pattern.split(SEPARATOR); + final boolean trailingWildcard = WILDCARD.equals(segments[segments.length - 1]); + final int depth = trailingWildcard ? segments.length - 1 : segments.length; + + Node current = root; + for (int i = 0; i < depth; i++) + { + current = current.children.computeIfAbsent(segments[i], ignored -> new Node()); + } + + if (trailingWildcard) + { + current.wildcard.add(rankId); + } + else + { + current.exact.add(rankId); + } + } + + /** + * One dotted segment of the index. Mutable and deliberately package-private in behaviour: it is + * an implementation detail of the surrounding trie and never escapes it. + */ + private static final class Node + { + + private final Map<String, Node> children = new HashMap<>(); + + /** + * Ranks granting the exact path ending at this node. + */ + private final Set<String> exact = new HashSet<>(); + + /** + * Ranks granting this node's path followed by {@code .*}. + */ + private final Set<String> wildcard = new HashSet<>(); + + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/Rank.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/Rank.java deleted file mode 100644 index 15cb567fc..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/Rank.java +++ /dev/null @@ -1,176 +0,0 @@ -package me.totalfreedom.totalfreedommod.rank; - -import lombok.Getter; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.format.TextDecoration; - -@Getter -public enum Rank implements Displayable -{ - - IMPOSTOR("an", "Impostor", Type.PLAYER, "Imp", NamedTextColor.YELLOW), - NON_OP("a", "Non-Op", Type.PLAYER, "", NamedTextColor.GREEN), - OP("an", "Op", Type.PLAYER, "OP", NamedTextColor.RED), - SUPER_ADMIN("a", "Super Admin", Type.ADMIN, "SA", NamedTextColor.AQUA), - SENIOR_ADMIN("a", "Senior Admin", Type.ADMIN, "SrA", NamedTextColor.GOLD), - /** - * @deprecated use {@link #SENIOR_ADMIN} directly; console-class senders now resolve to admin ranks via {@code ConsoleSenderRegistry}. - */ - @Deprecated - SENIOR_CONSOLE("the", "Console", Type.ADMIN_CONSOLE, "Console", NamedTextColor.DARK_PURPLE); - private final Type type; - private final String name; - private final String determiner; - private final String tag; - private final Component coloredTag; - private final NamedTextColor color; - - private Rank(String determiner, String name, Type type, String abbr, NamedTextColor color) - { - this.type = type; - this.name = name; - this.determiner = determiner; - this.tag = abbr.isEmpty() ? "" : "[" + abbr + "]"; - this.color = color; - - // Build colored tag as Component - if (abbr.isEmpty()) - { - this.coloredTag = Component.empty(); - } - else - { - this.coloredTag = Component.text("[") - .color(NamedTextColor.DARK_GRAY) - .append(Component.text(abbr).color(color)) - .append(Component.text("]").color(NamedTextColor.DARK_GRAY)) - .append(Component.text("").color(color)); - } - } - - @Override - public Component getColoredName() - { - return Component.text(name).color(color); - } - - @Override - public Component getColoredLoginMessage() - { - return Component.text(determiner + " ") - .append(Component.text(name).color(color).decorate(TextDecoration.ITALIC)); - } - - public boolean isConsole() - { - return getType() == Type.ADMIN_CONSOLE; - } - - public int getLevel() - { - return ordinal(); - } - - public boolean isAtLeast(Rank rank) - { - if (getLevel() < rank.getLevel()) - { - return false; - } - - if (!hasConsoleVariant() || !rank.hasConsoleVariant()) - { - return true; - } - - return getConsoleVariant().getLevel() >= rank.getConsoleVariant().getLevel(); - } - - public boolean isAdmin() - { - return getType() == Type.ADMIN || getType() == Type.ADMIN_CONSOLE; - } - - // Manual getters - Lombok @Getter not processing on enum fields - public Type getType() - { - return type; - } - - public String getName() - { - return name; - } - - @Override - public Component getColoredTag() - { - return coloredTag; - } - - @Override - public NamedTextColor getColor() - { - return color; - } - - @Deprecated - public boolean hasConsoleVariant() - { - return getConsoleVariant() != null; - } - - @Deprecated - public Rank getConsoleVariant() - { - switch (this) - { - case SENIOR_ADMIN: - case SENIOR_CONSOLE: - return SENIOR_CONSOLE; - default: - return null; - } - } - - @Deprecated - public Rank getPlayerVariant() - { - switch (this) - { - case SENIOR_ADMIN: - case SENIOR_CONSOLE: - return SENIOR_ADMIN; - default: - return null; - } - } - - public static Rank findRank(String string) - { - try - { - return Rank.valueOf(string.toUpperCase()); - } - catch (Exception ignored) - { - } - - return Rank.NON_OP; - } - - public static enum Type - { - - PLAYER, - ADMIN, - ADMIN_CONSOLE; - - public boolean isAdmin() - { - return this != PLAYER; - } - } - -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java index ce9d09c7c..c07563051 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankManager.java @@ -1,11 +1,14 @@ package me.totalfreedom.totalfreedommod.rank; -import com.google.common.collect.Maps; import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; import java.io.IOException; +import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -13,24 +16,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.stream.Stream; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; -import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import me.totalfreedom.totalfreedommod.util.AdventureUtil; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FTask; -import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.format.TextDecoration; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + import io.papermc.paper.event.player.AsyncChatEvent; import org.bukkit.GameMode; import org.bukkit.command.BlockCommandSender; @@ -50,9 +36,39 @@ import org.bukkit.scoreboard.ScoreboardManager; import org.bukkit.scoreboard.Team; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; +import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.FPlayer; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; +import me.totalfreedom.totalfreedommod.util.*; + +import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; + public class RankManager extends FreedomService { - public static final String RANKS_FILENAME = "ranks.yml"; + public static final String RANKS_FILENAME = "ranks.json"; + + private static final Type RANK_MAP_TYPE = new TypeToken<Map<String, CustomRank>>() {}.getType(); + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; /** * All custom ranks, keyed by ID. @@ -60,14 +76,19 @@ public class RankManager extends FreedomService private final Map<String, CustomRank> customRanks = Maps.newLinkedHashMap(); /** - * File for storing custom ranks. + * Resolves senders to ranks and permission nodes to the tier they require. Reads + * {@link #customRanks} live, so a reload is visible without rebuilding it. */ - private File ranksFile; + private final RankRegistry registry; /** - * YAML configuration for ranks. + * File for storing custom ranks. */ - private YamlConfiguration ranksConfig; + private File ranksFile; + + private final PersistenceQueue writes = new PersistenceQueue("rank"); + + private boolean usingSql = false; /** * Chat input handler for interactive menus. @@ -77,6 +98,16 @@ public class RankManager extends FreedomService public RankManager(TotalFreedomMod plugin) { super(plugin); + this.registry = new RankRegistry(plugin, customRanks); + } + + /** + * The rank registry, which is the only supported way to ask what rank something holds or what + * tier a permission node requires. + */ + public RankRegistry getRegistry() + { + return registry; } private BukkitRunnable persistentMonitorTask = null; @@ -84,13 +115,9 @@ public RankManager(TotalFreedomMod plugin) @Override protected void onStart() { - // Load custom ranks loadRanks(); + plugin.dm.whenReady(this::loadRanks); - // The console registry is built during onEnable, before any service starts, so its first - // read happened with no ranks in memory and every binding naming a custom rank was thrown - // away as unknown. Re-read it now that the ranks exist, which is also what lets the - // host-channel floor compare against real rank levels. if (plugin.csr != null) { plugin.csr.load(); @@ -108,8 +135,10 @@ protected void onStart() @Override protected void onStop() { - // Save ranks before shutdown + // Save ranks before shutdown, then let the queue drain: a queued write landing after + // the flush would restore a stale snapshot. saveRanks(); + awaitPendingWrites(SHUTDOWN_FLUSH_TIMEOUT_MS); // Stop persistent monitor if (persistentMonitorTask != null) @@ -123,253 +152,312 @@ protected void onStop() } /** - * Load custom ranks from ranks.yml. + * Load custom ranks from SQL, falling back to ranks.json. Never blocks: the SQL read runs + * off-thread and its result is applied back on the main thread, so this is safe from a + * command handler as well as from startup. */ public void loadRanks() { ranksFile = new File(plugin.getDataFolder(), RANKS_FILENAME); - if (!ranksFile.exists()) + if (plugin.dm != null && plugin.dm.isInitialized()) { - createDefaultRanks(); - migrateConfigRanks(); + loadFromSqlAsync(); return; } - ranksConfig = YamlConfiguration.loadConfiguration(ranksFile); - customRanks.clear(); + loadFromJsonOrDefaults(); + } + + private void loadFromSqlAsync() + { + final RankRepository repo = plugin.dm.getRankRepository(); + plugin.dm.readAsync("RankManager/loadFromSql", repo.loadAllAsync(), + loaded -> applyLoadedRanks(repo, loaded), + () -> + { + usingSql = false; + loadFromJsonOrDefaults(); + }); + } + + private void applyLoadedRanks(final RankRepository repo, final Map<String, CustomRank> loaded) + { + usingSql = true; - for (String key : ranksConfig.getKeys(false)) + if (loaded.isEmpty() && !ranksFile.exists()) { - ConfigurationSection section = ranksConfig.getConfigurationSection(key); - if (section == null) continue; + installBundledRanks(); - CustomRank rank = new CustomRank(key); - rank.loadFrom(section); - customRanks.put(key.toLowerCase(), rank); + // The bundled set only reached memory. Push it so a database that started empty ends + // up holding the shipped ranks rather than staying empty until someone edits one. + saveRanks(); + return; } - validateEssentialRanks(); + customRanks.clear(); + customRanks.putAll(loaded); resolveInheritance(); updateAllPlayerTeams(); - FLog.info("Loaded " + customRanks.size() + " custom ranks."); + refreshConsoleBindings(); + FLog.info(String.format("Loaded %d custom ranks from SQL database.", customRanks.size())); + reconcileFromJsonIfNewer(repo); } - private static final String[] ESSENTIAL_RANKS = { - "non_op", "op", "super_admin", "senior_admin" - }; + /** + * Re-resolve the console whitelist against the rank set that is now in memory. + * <p> + * The first read happens before this service starts, when no custom ranks are loaded, + * so bindings that name one are skipped with a warning. {@code onStart} rereads it + * once the JSON ranks are in, but the swap to SQL and the snapshot reconcile both + * land later and asynchronously, and until now neither told the registry that the + * ranks had changed. + */ + private void refreshConsoleBindings() + { + if (plugin.csr != null) + plugin.csr.load(); + } - private void validateEssentialRanks() + private void loadFromJsonOrDefaults() { - boolean modified = false; - for (String rankId : ESSENTIAL_RANKS) - { - if (!customRanks.containsKey(rankId)) - { - FLog.warning("Essential rank '" + rankId + "' missing from ranks.yml, recreating..."); - Rank legacyRank = Rank.findRank(rankId); - CustomRank custom = CustomRank.fromLegacyRank(legacyRank); - customRanks.put(rankId, custom); - modified = true; - } - } - if (modified) + if (!ranksFile.exists()) { - saveRanks(); - FLog.info("Repaired ranks.yml with missing essential ranks."); + installBundledRanks(); + return; } + + loadFromJson(); } /** - * Create default ranks from the legacy Rank enum. + * Writes the {@code ranks.json} bundled with the plugin into the data folder and loads it. + * <p> + * Ranks are defined entirely by that file; nothing in code knows a default tier any more, so a + * first run copies the shipped definitions rather than synthesising them. If the resource is + * somehow missing the registry is left empty, which denies every guarded command instead of + * inventing ranks that the team never approved. */ - private void createDefaultRanks() + private void installBundledRanks() { - customRanks.clear(); + try + { + plugin.saveResource(RANKS_FILENAME, false); + } + catch (IllegalArgumentException ex) + { + FLog.severe(String.format("No bundled %s to install: %s", RANKS_FILENAME, ex.getMessage())); + return; + } - for (Rank legacyRank : Rank.values()) + if (!ranksFile.exists()) { - CustomRank custom = CustomRank.fromLegacyRank(legacyRank); + FLog.severe(String.format("Could not install a default %s; all guarded commands will be denied.", + RANKS_FILENAME)); + return; + } - // Add default permissions based on rank type - switch (legacyRank) - { - case SENIOR_ADMIN: - case SENIOR_CONSOLE: - custom.addPermission("tfm.manage.ranks"); - custom.addPermission("tfm.admin.senior"); - // Fall through - custom.addPermission("tfm.admin.telnet"); - custom.addPermission("tfm.admin.ban.perm"); - // Fall through - case SUPER_ADMIN: - custom.addPermission("tfm.admin.ban"); - custom.addPermission("tfm.admin.kick"); - custom.addPermission("tfm.admin.mute"); - custom.addPermission("tfm.admin.freeze"); - custom.addPermission("tfm.admin.cage"); - custom.addPermission("tfm.fun.smite"); - custom.addPermission("tfm.fun.doom"); - custom.addPermission("tfm.world.gamerule"); - break; - case OP: - custom.addPermission("tfm.player.op"); - break; - default: - break; - } + FLog.info(String.format("Installed the default %s.", RANKS_FILENAME)); + loadFromJson(); + } - customRanks.put(custom.getId(), custom); + private void loadFromJson() + { + customRanks.clear(); + try + { + customRanks.putAll(readJsonRanks()); + } + catch (IOException ex) + { + FLog.severe("Could not read " + RANKS_FILENAME + ": " + ex.getMessage()); } resolveInheritance(); - saveRanks(); - FLog.info("Created default ranks configuration."); + updateAllPlayerTeams(); + FLog.info("Loaded " + customRanks.size() + " custom ranks."); } - private void migrateConfigRanks() + private Map<String, CustomRank> readJsonRanks() throws IOException { - applyConfigPrefix("impostor", ConfigEntry.VAULT_PREFIX_IMPOSTOR); - applyConfigPrefix("non_op", ConfigEntry.VAULT_PREFIX_NON_OP); - applyConfigPrefix("op", ConfigEntry.VAULT_PREFIX_OP); - applyConfigPrefix("super_admin", ConfigEntry.VAULT_PREFIX_SUPER_ADMIN); - applyConfigPrefix("senior_admin", ConfigEntry.VAULT_PREFIX_SENIOR_ADMIN); - applyConfigPrefix("senior_console", ConfigEntry.VAULT_PREFIX_SENIOR_CONSOLE); - applyConfigPrefix("developer", ConfigEntry.VAULT_PREFIX_DEVELOPER); - applyConfigPrefix("owner", ConfigEntry.VAULT_PREFIX_OWNER); - - List<String> owners = ConfigEntry.SERVER_OWNERS.getStringList(); - if (owners != null && !owners.isEmpty()) + try (FileReader reader = new FileReader(ranksFile)) { - int found = 0; - for (String ownerName : owners) - { - if (ownerName != null && !ownerName.trim().isEmpty()) - { - if (plugin.al.getEntryByName(ownerName.trim()) != null) - { - found++; - } - } - } - if (found > 0) - { - FLog.info("Found " + found + " owner(s) from config.yml. They will display with the owner rank."); - } + return stampIds(JsonUtil.GSON.fromJson(reader, RANK_MAP_TYPE)); } - - saveRanks(); - removeConfigRanks(); - FLog.info("Migrated rank configuration from config.yml to ranks.yml."); } - private void applyConfigPrefix(String rankId, ConfigEntry entry) + /** + * Re-files deserialised ranks under their own normalised id. + * <p> + * A JSON entry carries its id as the key it sits under rather than as a field, so a freshly + * deserialised rank has none. Stamping it here and re-keying the map on the result keeps the + * key and the rank's own id in agreement even where normalisation rewrites the key. + */ + private static Map<String, CustomRank> stampIds(final Map<String, CustomRank> loaded) { - String prefix = entry.getString(); - if (prefix != null && !prefix.isEmpty()) + if (loaded == null) + return Maps.newLinkedHashMap(); + + final Map<String, CustomRank> keyed = Maps.newLinkedHashMap(); + + loaded.forEach((key, rank) -> { - CustomRank rank = getCustomRank(rankId); - if (rank != null) - { - rank.setPrefix(prefix); - } - } + rank.assignId(key); + keyed.put(rank.getId(), rank); + }); + + return keyed; } - private void removeConfigRanks() + /** + * If ranks.json was written more recently than the database's last update, re-import it into + * SQL. The comparison and the re-import both ride the write queue off the main thread. + * <p> + * The import replaces the table rather than merging into it, so a rank deleted from the file is + * deleted from SQL as well. An empty or unreadable file is ignored. + */ + private void reconcileFromJsonIfNewer(final RankRepository repo) { - File configFile = new File(plugin.getDataFolder(), "config.yml"); - if (!configFile.exists()) + if (!ranksFile.exists()) { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + writes.enqueue(writeJsonAsync()); return; } + final Map<String, CustomRank> jsonRanks; try { - YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); - boolean modified = false; - - if (config.contains("server.owners")) - { - config.set("server.owners", null); - modified = true; - } - - String[] prefixKeys = { - "chat.prefix.impostor", "chat.prefix.non_op", "chat.prefix.op", - "chat.prefix.super_admin", "chat.prefix.senior_admin", - "chat.prefix.senior_console", - "chat.prefix.developer", "chat.prefix.owner" - }; - - for (String key : prefixKeys) - { - if (config.contains(key)) - { - config.set(key, null); - modified = true; - } - } - - ConfigurationSection prefixSection = config.getConfigurationSection("chat.prefix"); - if (prefixSection != null && prefixSection.getKeys(false).isEmpty()) - { - config.set("chat.prefix", null); - } - - if (modified) - { - config.save(configFile); - } + jsonRanks = readJsonRanks(); } catch (IOException ex) { - FLog.warning("Could not update config.yml: " + ex.getMessage()); + FLog.warning(String.format("Failed to read %s: %s", RANKS_FILENAME, ex.getMessage())); + return; } + + if (jsonRanks.isEmpty()) + { + writes.enqueue(writeJsonAsync()); + return; + } + + final long fileModified = ranksFile.lastModified(); + + writes.enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMap(ignored -> + { + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d rank(s).", + RANKS_FILENAME, jsonRanks.size())); + return Flux.fromIterable(jsonRanks.values()) + .concatMap(repo::save) + .then(repo.loadAllAsync()) + .flatMapMany(existing -> Flux.fromIterable(existing.keySet())) + .filter(id -> !jsonRanks.containsKey(id)) + .concatMap(repo::deleteAsync) + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("RankManager/applyReconciled", + () -> applyReconciledRanks(jsonRanks)))); + }) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + RANKS_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); + } + + private void applyReconciledRanks(final Map<String, CustomRank> jsonRanks) + { + customRanks.clear(); + customRanks.putAll(jsonRanks); + resolveInheritance(); + updateAllPlayerTeams(); + refreshConsoleBindings(); } + /** - * Save custom ranks to ranks.yml. + * Queue a write of every custom rank to SQL, followed by a refresh of the ranks.json + * snapshot. Falls back to a JSON-only write when SQL is unavailable. Safe from a command + * handler: the SQL round trips run off the main thread. */ public void saveRanks() { - if (ranksFile == null) + if (!usingSql || plugin.dm == null || !plugin.dm.isInitialized()) { - ranksFile = new File(plugin.getDataFolder(), RANKS_FILENAME); + writes.enqueue(writeJsonAsync()); + return; } - ranksConfig = new YamlConfiguration(); + final RankRepository repo = plugin.dm.getRankRepository(); + final List<CustomRank> snapshot = new ArrayList<>(customRanks.values()); - for (CustomRank rank : customRanks.values()) - { - ConfigurationSection section = ranksConfig.createSection(rank.getId()); - rank.saveTo(section); - } + writes.enqueue(Flux.fromIterable(snapshot) + .concatMap(rank -> repo.save(rank) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save rank %s to SQL: %s", + rank.getId(), + ex.getMessage())); + return Mono.empty(); + })) + .then(writeJsonAsync())); + } - try + /** + * Wait for queued rank writes to land, up to {@code timeoutMs}. + */ + public void awaitPendingWrites(long timeoutMs) + { + writes.await(timeoutMs); + } + + private Mono<Void> writeJsonAsync() + { + final Map<String, CustomRank> snapshot = new LinkedHashMap<>(customRanks); + return Mono.<Void>fromRunnable(() -> writeJson(snapshot)) + .subscribeOn(Schedulers.boundedElastic()); + } + + private void writeJson(final Map<String, CustomRank> snapshot) + { + if (ranksFile == null) + ranksFile = new File(plugin.getDataFolder(), RANKS_FILENAME); + + try (FileWriter writer = new FileWriter(ranksFile)) { - ranksConfig.save(ranksFile); + JsonUtil.GSON.toJson(snapshot, RANK_MAP_TYPE, writer); } catch (IOException ex) { - FLog.severe("Could not save " + RANKS_FILENAME + ": " + ex.getMessage()); + FLog.severe(String.format("Could not save %s: %s", RANKS_FILENAME, ex.getMessage())); } - } + /** + * Flattens each rank's inherited permissions, then rebuilds the registry's permission index so + * that the tier a node requires is recomputed from what the ranks now grant. + */ private void resolveInheritance() { - for (CustomRank rank : customRanks.values()) - { - Set<String> resolved = collectPermissions(rank, new HashSet<>()); - rank.setResolvedPermissions(resolved); - } + customRanks.values() + .forEach(rank -> rank.setResolvedPermissions(collectPermissions(rank, new HashSet<>()))); + + registry.reindex(); } private Set<String> collectPermissions(CustomRank rank, Set<String> visited) { - if (rank == null) return Set.of(); + if (rank == null) + return Set.of(); if (visited.contains(rank.getId())) { @@ -384,13 +472,9 @@ private Set<String> collectPermissions(CustomRank rank, Set<String> visited) { CustomRank parent = customRanks.get(rank.getInheritFrom().toLowerCase()); if (parent == null) - { FLog.warning("Rank '" + rank.getId() + "' inherits from non-existent rank: " + rank.getInheritFrom()); - } else - { perms.addAll(collectPermissions(parent, visited)); - } } return perms; @@ -399,9 +483,8 @@ private Set<String> collectPermissions(CustomRank rank, Set<String> visited) public CustomRank getCustomRank(String id) { if (id == null) - { return null; - } + return customRanks.get(id.toLowerCase()); } @@ -409,28 +492,14 @@ public CustomRank getCustomRank(String id) private CustomRank getAssignedAdminRank(Player player) { if (plugin.al.isAdminImpostor(player)) - { return null; - } - Admin admin = plugin.al.getAdmin(player); + final Admin admin = plugin.al.getAdmin(player); if (admin == null || !admin.isActive()) - { return null; - } - if (admin.getCustomRankId() != null) - { - CustomRank customRank = getCustomRank(admin.getCustomRankId()); - - if (customRank != null) - { - return customRank; - } - } - - return getCustomRankForLegacy(admin.getRank()); + return getCustomRank(admin.getRankId()); } public void updatePlayerTeam(Player player) @@ -438,34 +507,24 @@ public void updatePlayerTeam(Player player) ScoreboardManager manager = server.getScoreboardManager(); if (manager == null) - { return; - } - - Scoreboard scoreboard = manager.getMainScoreboard(); - Team currentTeam = scoreboard.getEntryTeam(player.getName()); - CustomRank rank = getAssignedAdminRank(player); - final boolean admin = rank != null && rank.isAdmin(); - if (rank == null) - { - rank = CustomRank.fromLegacyRank(Rank.OP); // potential NPE, averting by setting to OP, should be an optional but that's outside of the scope. Rank system will get it's own dedicated branch scope. - } + final Scoreboard scoreboard = manager.getMainScoreboard(); + final Team currentTeam = scoreboard.getEntryTeam(player.getName()); + final CustomRank rank = getAssignedAdminRank(player); + final boolean admin = rank != null && rank.isAdmin(); final String teamName = admin ? createTeamName(rank) : DEFAULT_TEAM_NAME; if (currentTeam != null && !currentTeam.getName().equals(teamName)) - { currentTeam.removeEntry(player.getName()); - } Team team = scoreboard.getTeam(teamName); if (team == null) - { team = scoreboard.registerNewTeam(teamName); - } + // this npe warning can be ignored since boolean admin already validates that rank won't be null if true team.color(admin ? rank.getColor() : NamedTextColor.WHITE); team.prefix(Component.empty()); team.addEntry(player.getName()); @@ -480,9 +539,7 @@ private String createTeamName(CustomRank rank) rank.getId().replaceAll("[^A-Za-z0-9_\\-]", "_")); if (name.length() > 16) - { name = name.substring(0, 16); - } return name; } @@ -490,9 +547,7 @@ private String createTeamName(CustomRank rank) public void updateAllPlayerTeams() { for (Player player : server.getOnlinePlayers()) - { updatePlayerTeam(player); - } } /** @@ -526,14 +581,32 @@ public void setCustomRank(CustomRank rank) */ public boolean removeCustomRank(String id) { - CustomRank removed = customRanks.remove(id.toLowerCase()); - if (removed != null) + if (id == null) + return false; + + final CustomRank removed = customRanks.remove(CustomRank.normalizeId(id)); + if (removed == null) + return false; + + if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) { - saveRanks(); - updateAllPlayerTeams(); - return true; + writes.enqueue(plugin.dm.getRankRepository() + .deleteAsync(removed.getId()) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not delete rank %s from SQL: %s", + removed.getId(), + ex.getMessage())); + return Mono.empty(); + }) + .then()); } - return false; + + resolveInheritance(); + saveRanks(); + updateAllPlayerTeams(); + + return true; } /** @@ -544,146 +617,18 @@ public boolean hasCustomRank(String id) return customRanks.containsKey(id.toLowerCase()); } - // ======================================================================== - // Permission System (Internal, NOT Bukkit-based) - // ======================================================================== - /** - * Check if a sender has a specific TFM permission. - * This does NOT use Bukkit permission nodes - it's purely internal. + * Whether {@code sender} may exercise an internal TFM permission node. + * <p> + * These are TFM's own nodes and are never registered with Bukkit + * because if we registered with Bukkit then OPs would have these nodes too. * - * @param sender The command sender - * @param permission The TFM permission string (e.g., "tfm.admin.ban") - * @return true if the sender has the permission + * @param sender the command sender + * @param permission the internal node, for example {@code tfm.admin.ban} */ public boolean hasPermission(CommandSender sender, String permission) { - if (!(sender instanceof Player)) - { - if (sender instanceof BlockCommandSender || sender instanceof CommandMinecart) - { - return false; - } - - // getEffectiveRank already knows how this sender earned its rank. An identified SSH or - // Discord session resolves to that admin's own profile; a host channel resolves to its - // binding. That covers what the dispatch-excluded lookup here used to miss, namely - // that a remote user's custom rank was invisible to the permission check. - CustomRank effective = getEffectiveRank(sender); - if (effective != null && hasCustomRankPermission(effective, permission)) - { - return true; - } - - Rank rank = getRank(sender); - CustomRank customRank = getCustomRankForLegacy(rank); - if (customRank != null && hasCustomRankPermission(customRank, permission)) - { - return true; - } - return checkLegacyPermission(rank, permission); - } - - Player player = (Player) sender; - - // Check if admin - Admin admin = plugin.al.getAdmin(player); - if (admin != null && admin.isActive()) - { - // Try custom rank ID assigned to the admin first - if (admin.getCustomRankId() != null) - { - CustomRank custom = getCustomRank(admin.getCustomRankId()); - if (custom != null) - { - if (hasCustomRankPermission(custom, permission)) - { - return true; - } - } - } - - // Fallback to custom rank derived from legacy rank - CustomRank customRank = getCustomRankForLegacy(admin.getRank()); - if (customRank != null) - { - if (hasCustomRankPermission(customRank, permission)) - { - return true; - } - } - - // Legacy fallback: check rank level - return checkLegacyPermission(admin.getRank(), permission); - } - - // Non-admins: check if they have a custom rank assigned (for future expansion) - // For now, non-admins only have basic player permissions - CustomRank opRank = getCustomRank("op"); - if (player.isOp() && opRank != null) - { - return hasCustomRankPermission(opRank, permission); - } - - return false; - } - - private boolean hasCustomRankPermission(CustomRank rank, String permission) - { - if (rank.hasPermission(permission)) - { - return true; - } - - String[] parts = permission.split("\\."); - StringBuilder wildcard = new StringBuilder(); - for (int i = 0; i < parts.length - 1; i++) - { - wildcard.append(parts[i]).append("."); - if (rank.hasPermission(wildcard + "*")) - { - return true; - } - } - - return false; - } - - /** - * Check permission based on legacy rank level. - */ - private boolean checkLegacyPermission(Rank rank, String permission) - { - // Map common permissions to rank levels - if (permission.startsWith("tfm.manage.")) - { - return rank.isAtLeast(Rank.SENIOR_ADMIN); - } - if (permission.startsWith("tfm.admin.senior") || permission.equals("tfm.admin.ban.perm")) - { - return rank.isAtLeast(Rank.SENIOR_ADMIN); - } - if (permission.startsWith("tfm.admin.telnet")) - { - return rank.isAtLeast(Rank.SENIOR_ADMIN); - } - if (permission.startsWith("tfm.admin.")) - { - return rank.isAtLeast(Rank.SUPER_ADMIN); - } - if (permission.startsWith("tfm.fun.")) - { - return rank.isAtLeast(Rank.SUPER_ADMIN); - } - return false; - } - - /** - * Get the custom rank that corresponds to a legacy Rank enum. - */ - public CustomRank getCustomRankForLegacy(Rank legacyRank) - { - return getCustomRank(legacyRank.name().toLowerCase()); + return registry.satisfies(sender, permission); } /** @@ -694,10 +639,6 @@ public boolean canManageRanks(CommandSender sender) return hasPermission(sender, "tfm.manage.ranks"); } - // ======================================================================== - // Chat Input Handler (Inner Class) - // ======================================================================== - /** * Get the chat input handler for interactive menus. */ @@ -729,22 +670,17 @@ public void awaitInput(Player player, Component prompt, Consumer<String> callbac { UUID uuid = player.getUniqueId(); - // Cancel any existing pending input cancelInput(player); - // Send prompt player.sendMessage(Component.empty()); player.sendMessage(prompt); player.sendMessage(Component.text("Type your response in chat, or type 'cancel' to abort.") .color(NamedTextColor.GRAY).decorate(TextDecoration.ITALIC)); - // Register pending input PendingInput pending = new PendingInput(callback, System.currentTimeMillis()); pendingInputs.put(uuid, pending); - // Schedule timeout if specified if (timeoutSeconds > 0) - { new BukkitRunnable() { @Override @@ -755,14 +691,12 @@ public void run() { pendingInputs.remove(uuid); Player p = server.getPlayer(uuid); + if (p != null && p.isOnline()) - { p.sendMessage(Component.text("Input timed out.").color(NamedTextColor.RED)); - } } } }.runTaskLater(plugin, timeoutSeconds * 20L); - } } /** @@ -794,9 +728,7 @@ public boolean processChat(Player player, String message) PendingInput pending = pendingInputs.remove(uuid); if (pending == null) - { return false; - } // Check for cancel if (message.equalsIgnoreCase("cancel")) @@ -805,7 +737,6 @@ public boolean processChat(Player player, String message) return true; } - // Invoke callback try { pending.callback().accept(message); @@ -835,32 +766,17 @@ private record PendingInput(Consumer<String> callback, long timestamp) } } - // ======================================================================== - // Chat Event Handler (for input capture) - // ======================================================================== - @EventHandler(priority = EventPriority.LOWEST) public void onPlayerChat(AsyncChatEvent event) { Player player = event.getPlayer(); - // Check if this player has pending input if (chatInputHandler.hasPendingInput(player)) { - // Extract plain text from the Component message final String message = PlainTextComponentSerializer.plainText().serialize(event.message()); - // Process on main thread to avoid async issues - new BukkitRunnable() - { - @Override - public void run() - { - chatInputHandler.processChat(player, message); - } - }.runTask(plugin); + FTask.run("chatInputHandler#processChat", () -> chatInputHandler.processChat(player, message)); - // Cancel the chat event so the message isn't broadcast event.setCancelled(true); } } @@ -878,16 +794,10 @@ public void onPlayerQuit(PlayerQuitEvent event) Team team = manager.getMainScoreboard().getEntryTeam(event.getPlayer().getName()); if (team != null) - { team.removeEntry(event.getPlayer().getName()); - } } } - // ======================================================================== - // Interactive Menu Builder (for /rankconfig) - // ======================================================================== - /** * Build the main rank configuration menu. */ @@ -979,7 +889,6 @@ public Component buildEditMenu(CustomRank rank) builder.append(buildEditableProperty("Color", rank.getColor().toString(), "/rankconfig set " + rank.getId() + " color")); builder.append(buildEditableProperty("Determiner", rank.getDeterminer(), "/rankconfig set " + rank.getId() + " determiner")); builder.append(buildEditableProperty("Is Admin", String.valueOf(rank.isAdmin()), "/rankconfig set " + rank.getId() + " admin")); - builder.append(buildEditableProperty("Console Only", String.valueOf(rank.isConsoleOnly()), "/rankconfig set " + rank.getId() + " console")); builder.append(buildEditableProperty("Inherit From", rank.getInheritFrom() != null ? rank.getInheritFrom() : "(none)", "/rankconfig set " + rank.getId() + " inherit")); builder.append(Component.text("\n")); @@ -1045,10 +954,6 @@ private Component buildEditableProperty(String label, String value, String comma .append(Component.text("\n")); } - // ======================================================================== - // Original RankManager Methods (preserved) - // ======================================================================== - private void startPersistentMonitor() { final int interval = ConfigEntry.AUTO_OP_MONITOR_INTERVAL.getInteger(); @@ -1074,15 +979,11 @@ public void run() { // Skip admins and players who should not be OP if (plugin.al.isAdmin(player) || plugin.al.isAdminImpostor(player)) - { continue; - } // Re-OP players who lost OP status if (!player.isOp()) - { ensureOp(player); - } } }); } @@ -1097,27 +998,20 @@ public void run() private void ensureOp(Player player) { if (player == null || !player.isOnline()) - { return; - } + // Skip admins and impostors if (plugin.al.isAdmin(player) || plugin.al.isAdminImpostor(player)) - { return; - } // Only ensure OP if auto-OP is enabled if (!ConfigEntry.AUTO_OP_ENABLED.getBoolean()) - { return; - } // Set OP if not already set if (!player.isOp()) - { player.setOp(true); - } // Aggressively refresh permissions immediately try @@ -1132,7 +1026,6 @@ private void ensureOp(Player player) // Schedule multiple delayed recalculations to catch plugins that cache late // This ensures WorldEdit, Essentials, etc. pick up the OP status for (long delay : new long[]{2L, 5L, 10L, 20L}) // 100ms, 250ms, 500ms, 1s - { new BukkitRunnable() { @Override @@ -1151,260 +1044,59 @@ public void run() } } }.runTaskLater(plugin, delay); - } - } - - public Displayable getDisplay(CommandSender sender) - { - if (!(sender instanceof Player)) - { - Rank rank = getRank(sender); - CustomRank custom = getCustomRankForLegacy(rank); - return custom != null ? custom : rank; - } - - final Player player = (Player) sender; - - if (plugin.al.isAdminImpostor(player)) - { - CustomRank impostorRank = getCustomRank("impostor"); - return impostorRank != null ? impostorRank : Rank.IMPOSTOR; - } - - if (FUtil.DEVELOPERS.contains(player.getName())) - { - CustomRank devRank = getCustomRank("developer"); - if (devRank != null) return devRank; - } - - final Rank rank = getRank(player); - - if (ConfigEntry.SERVER_OWNERS.getList().contains(player.getName())) - { - CustomRank ownerRank = getCustomRank("owner"); - if (ownerRank != null) return ownerRank; - } - - Admin admin = plugin.al.getAdmin(player); - if (admin != null && admin.isActive() && admin.getCustomRankId() != null) - { - CustomRank custom = getCustomRank(admin.getCustomRankId()); - if (custom != null) - { - return custom; - } - } - - CustomRank customRank = getCustomRankForLegacy(rank); - return customRank != null ? customRank : rank; } /** - * Resolves the rank a sender actually acts at, as a {@link CustomRank}. + * The rank whose name, colour and tag should be shown for {@code sender}. * <p> - * This is the identity-aware view the permission gate tests against, and it is the only place - * that knows how a sender earns its rank: - * <ul> - * <li>SSH and Discord carry a proven identity (an SSH public key, or a {@code discord_links} - * row), so an identified session resolves to that admin's own profile rank, custom rank - * included. A session that proved nothing, meaning password-only SSH, falls back to the - * channel's {@code host_senders:} binding, which grants no identity and so no profile.</li> - * <li>Host channels (RCON, RemoteBukkit, console) carry no identity at all and resolve to - * their binding, which {@link ConsoleSenderRegistry} floors at senior admin.</li> - * </ul> - * Returning a {@link CustomRank} rather than a {@link Rank} matters: custom ranks carry - * operator-defined levels that need not line up with {@link Rank#ordinal()}, and an admin - * holding {@code executive} or {@code owner} would otherwise be demoted to their legacy tier - * the moment they acted through a console channel. - * - * @return the sender's effective rank, or {@code null} when no rank could be resolved (the - * caller should then fall back to {@link #getRank(CommandSender)} on the legacy scale) + * Display is not the same question as permission. A few identities are recognised here purely + * so they read correctly in chat, and none of them grants anything: the impostor marker, the + * hardcoded developer list, and the owners named in config. Each is honoured only when a rank + * of that name actually exists in the registry, so a staff member who removes one simply gets the + * sender's real rank instead. */ - public CustomRank getEffectiveRank(CommandSender sender) - { - if (sender instanceof Player player) - { - CustomRank assigned = getAssignedAdminRank(player); - if (assigned != null) - { - return assigned; - } - return getCustomRankForLegacy(getRank(player)); - } - - if (sender instanceof BlockCommandSender || sender instanceof CommandMinecart) - { - return getCustomRankForLegacy(Rank.NON_OP); - } - - RemoteDispatchSession dispatch = RemoteDispatchContext.getActiveSession(); - if (dispatch != null) - { - CustomRank identity = resolveDispatchIdentity(dispatch); - if (identity != null) - { - return identity; - } - - String channel = dispatch.getChannel() == RemoteDispatchSession.Channel.DISCORD ? "discord" : "ssh"; - return getBoundRank(channel); - } - - Admin admin = plugin.al.getEntryByName(sender.getName()); - if (admin != null && admin.isActive()) - { - return rankOf(admin); - } - - CustomRank bound = getBoundRank(sender.getName()); - return bound != null ? bound : getCustomRankForLegacy(Rank.NON_OP); - } - - /** - * The admin behind an identified dispatch session, as a {@link CustomRank}, or {@code null} - * when the session proved no identity or the name no longer maps to an active admin. - * <p> - * SSH additionally honours {@code ssh.inherit_rank}: with it off, even a public-key session is - * held to the flat {@code host_senders:} tier. - */ - private CustomRank resolveDispatchIdentity(RemoteDispatchSession dispatch) + public Displayable getDisplay(CommandSender sender) { - if (!dispatch.isIdentified()) + if (!(sender instanceof Player player)) { - return null; + return registry.forSender(sender).orElse(null); } - if (dispatch.getChannel() == RemoteDispatchSession.Channel.SSH - && !ConfigEntry.SSH_INHERIT_RANK.getBoolean()) + if (plugin.al.isAdminImpostor(player)) { - return null; + return registry.byRole(RankRole.IMPOSTOR).orElse(null); } - Admin admin = plugin.al.getEntryByName(dispatch.getUsername()); - return admin != null && admin.isActive() ? rankOf(admin) : null; - } + // A held title outranks the player's rank for display purposes: a title is the identity + // people recognise ("Master Builder"), while the rank underneath is only what they may do. + final Displayable title = plugin.tm == null ? null : plugin.tm.getDisplayTitle(player); - /** - * An admin's rank, preferring the custom rank pinned to their profile over their legacy tier. - */ - private CustomRank rankOf(Admin admin) - { - if (admin.getCustomRankId() != null) - { - CustomRank custom = getCustomRank(admin.getCustomRankId()); - if (custom != null) - { - return custom; - } - } - return getCustomRankForLegacy(admin.getRank()); + return title != null ? title : registry.forSender(player).orElse(null); } /** - * The custom rank bound to a sender name by {@code host_senders:}, resolving a legacy rank id - * through the registry so both naming styles work. + * The login line announcing an impostor, falling back to plain wording when the registry has no + * impostor rank to style it with. */ - private CustomRank getBoundRank(String senderName) + private Component impostorLoginMessage() { - String boundRankId = plugin.csr.getRankIdForSender(senderName); - if (boundRankId == null) - { - return null; - } - - CustomRank bound = getCustomRank(boundRankId); - if (bound != null) - { - return bound; - } - - Rank legacy = plugin.csr.getRankForSender(senderName); - return legacy != null ? getCustomRankForLegacy(legacy) : null; + return registry.byRole(RankRole.IMPOSTOR) + .map(CustomRank::getColoredLoginMessage) + .orElseGet(() -> Component.text("an Impostor").color(NamedTextColor.YELLOW)); } /** - * Places a custom rank on the legacy ladder by level, so callers that still speak {@link Rank} - * get a sane answer for operator-defined ranks. Compared on the registry's own scale, so it - * holds whatever numbering the operator chose. + * Resolves the rank a sender actually acts at. + * <p> + * Delegates to {@link RankRegistry}, which is the single place that knows how a sender earns a + * rank: an identified SSH or Discord session resolves to that admin's own profile, while a host + * channel carries no identity and resolves to its {@code host_senders:} binding. + * + * @return the sender's effective rank, or {@code null} when none could be resolved */ - public Rank toLegacyRank(CustomRank custom) - { - if (custom == null) - { - return Rank.NON_OP; - } - - return Stream.of(Rank.values()) - .filter(candidate -> !candidate.isConsole()) - .filter(candidate -> - { - CustomRank equivalent = getCustomRankForLegacy(candidate); - return equivalent != null && custom.getLevel() >= equivalent.getLevel(); - }) - .max(Comparator.comparingInt(Rank::getLevel)) - .orElse(Rank.NON_OP); - } - - public Rank getRank(CommandSender sender) + public CustomRank getEffectiveRank(CommandSender sender) { - if (sender instanceof Player player) - { - if (plugin.al.isAdminImpostor(player)) - { - return Rank.IMPOSTOR; - } - - final Admin entry = plugin.al.getAdmin(player); - if (entry != null) - { - return entry.getRank(); - } - - return player.isOp() ? Rank.OP : Rank.NON_OP; - } - - if (sender instanceof BlockCommandSender || sender instanceof CommandMinecart) - { - return Rank.NON_OP; - } - - RemoteDispatchSession dispatch = RemoteDispatchContext.getActiveSession(); - if (dispatch != null) - { - CustomRank identity = resolveDispatchIdentity(dispatch); - if (identity != null) - { - return toLegacyRank(identity); - } - - String channel = dispatch.getChannel() == RemoteDispatchSession.Channel.DISCORD ? "discord" : "ssh"; - Rank fallback = plugin.csr.getRankForSender(channel); - if (fallback != null) - { - return fallback; - } - - CustomRank bound = getBoundRank(channel); - return bound != null ? toLegacyRank(bound) : Rank.NON_OP; - } - - Admin admin = plugin.al.getEntryByName(sender.getName()); - if (admin != null) - { - return admin.getRank(); - } - - Rank rank = plugin.csr.getRankForSender(sender.getName()); - if (rank != null) - { - return rank; - } - - // A host channel may be bound to a custom rank with no legacy equivalent; place it on the - // ladder by level rather than assuming a tier, which used to hand every such sender - // SUPER_ADMIN regardless of what it was actually bound to. - CustomRank bound = getBoundRank(sender.getName()); - return bound != null ? toLegacyRank(bound) : Rank.NON_OP; + return registry.forSender(sender).orElse(null); } @EventHandler(priority = EventPriority.LOWEST) @@ -1460,7 +1152,7 @@ public void onPlayerJoin(PlayerJoinEvent event) { Component impostorMsg = Component.text(player.getName() + " is ") .color(NamedTextColor.AQUA) - .append(Rank.IMPOSTOR.getColoredLoginMessage()); + .append(impostorLoginMessage()); FUtil.bcastMsg(impostorMsg); if (plugin.db != null) { @@ -1482,8 +1174,9 @@ public void onPlayerJoin(PlayerJoinEvent event) return; } - // Set display - if (isAdmin || FUtil.DEVELOPERS.contains(player.getName())) + // Announce admins, and anyone holding a title worth announcing. The hardcoded developer + // list used to stand in for the latter; a title says the same thing as data instead. + if (isAdmin || (plugin.tm != null && plugin.tm.getDisplayTitle(player) != null)) { final Displayable display = getDisplay(player); Component loginMsg = formatLoginMessage(player); @@ -1527,7 +1220,7 @@ public Component formatLoginMessage(Player player) .replace("%coloredrank%", "<colored_rank>"); admin.setLoginMessage(loginMessage); - plugin.al.save(); + plugin.al.saveAsync(); plugin.al.updateTables(); } @@ -1535,7 +1228,7 @@ public Component formatLoginMessage(Player player) AdventureUtil.formatWithPlaceholders( loginMessage, Placeholder.unparsed("name", player.getName()), - Placeholder.unparsed("rank", admin.getRank().getName()), + Placeholder.unparsed("rank", display.getName()), Placeholder.component("colored_rank", display.getColoredName()) )); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java new file mode 100644 index 000000000..d42e0ef7a --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRegistry.java @@ -0,0 +1,306 @@ +package me.totalfreedom.totalfreedommod.rank; + +import java.util.Comparator; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import org.bukkit.command.BlockCommandSender; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.entity.minecart.CommandMinecart; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; +import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; + +/** + * The single place that answers "what rank is this?" and "what rank does this permission need?". + * <p> + * Ranks are defined entirely by {@code ranks.json}; nothing in code declares a tier. That inversion + * is the point of this class. A command names only the internal permission node it needs, and the + * tier required to reach it is derived by asking which is the least privileged rank that grants + * that node, so re-tiering a feature is a config edit rather than a code change. + * <p> + * Two consequences of deriving the tier are worth knowing: + * <ul> + * <li>A node granted to a low rank is reachable by everyone at or above it. Two ranks that should + * differ in what they may do with one feature must therefore hold <em>distinct</em> nodes; a + * node they share necessarily resolves to the lower of the two.</li> + * <li>A node no rank grants is unreachable, and its command is ungrantable rather than + * universally allowed. Denying is the safe direction for a typo.</li> + * </ul> + * The registry reads the live rank map owned by {@link RankManager} rather than copying it, so a + * reload is visible here immediately; {@link #reindex()} rebuilds the permission index to match. + */ +public final class RankRegistry +{ + + private final TotalFreedomMod plugin; + + /** + * The live rank map owned by {@link RankManager}, read but never mutated here. + */ + private final Map<String, CustomRank> ranks; + + private final PermissionTrie trie = new PermissionTrie(); + + /** + * Resolves any console-class sender: an identified SSH or Discord session becomes the admin + * behind it, and anything else falls back to its {@code host_senders:} binding. + */ + private final RankResolver<CommandSender> consoleResolver; + + private final RankResolver<Player> playerResolver; + + public RankRegistry(final TotalFreedomMod plugin, final Map<String, CustomRank> ranks) + { + this.plugin = plugin; + this.ranks = ranks; + this.playerResolver = this::resolvePlayer; + this.consoleResolver = ((RankResolver<CommandSender>) this::resolveDispatch) + .orElse(this::resolveAdminByName) + .orElse(this::resolveBoundSender); + } + + /** + * Rebuilds the permission index from the current rank map. Call after ranks are loaded or after + * inheritance is re-resolved, on the main thread. + */ + public void reindex() + { + trie.rebuild(ranks); + } + + public PermissionTrie getTrie() + { + return trie; + } + + /** + * The rank registered under {@code id}, if any. + */ + public Optional<CustomRank> byId(final String id) + { + return id == null ? Optional.empty() : Optional.ofNullable(ranks.get(id.toLowerCase())); + } + + /** + * The rank filling {@code role}, preferring one that declares it in {@code ranks.json} and + * otherwise deriving it from the shape of the registry. + * <p> + * Nothing in code names a rank, so a designated staff member may rename, re-tier or delete any of them and + * the roles follow. When two ranks declare the same role the lower-level one wins, which keeps + * the answer stable rather than depending on map order. + */ + public Optional<CustomRank> byRole(final RankRole role) + { + final Optional<CustomRank> declared = ranks.values() + .stream() + .filter(rank -> rank.hasRole(role)) + .min(Comparator.comparingInt(CustomRank::getLevel)); + + return declared.isPresent() ? declared : derivedRole(role); + } + + /** + * The fallback used when no rank declares {@code role}. + */ + private Optional<CustomRank> derivedRole(final RankRole role) + { + return switch (role) + { + case DEFAULT -> ordinaryRanks().min(Comparator.comparingInt(CustomRank::getLevel)); + case DEFAULT_OP -> ordinaryRanks().max(Comparator.comparingInt(CustomRank::getLevel)); + case IMPOSTOR -> ranks.values() + .stream() + .min(Comparator.comparingInt(CustomRank::getLevel)); + case ADMIN_DEFAULT -> adminRanks().min(Comparator.comparingInt(CustomRank::getLevel)); + case CONSOLE_FLOOR -> byRole(RankRole.ADMIN_DEFAULT); + }; + } + + /** + * Ranks a normal player can hold: neither staff nor the impostor marker. + */ + private Stream<CustomRank> ordinaryRanks() + { + final Optional<CustomRank> impostor = ranks.values() + .stream() + .filter(rank -> rank.hasRole(RankRole.IMPOSTOR)) + .findFirst(); + + return ranks.values() + .stream() + .filter(rank -> !rank.isAdmin()) + .filter(rank -> impostor.isEmpty() || !rank.equals(impostor.get())); + } + + private Stream<CustomRank> adminRanks() + { + return ranks.values() + .stream() + .filter(CustomRank::isAdmin); + } + + /** + * The rank every unplaceable sender falls back to. + */ + public Optional<CustomRank> floor() + { + return byRole(RankRole.DEFAULT); + } + + /** + * Whether the rank {@code rankId} sits at or above the rank filling {@code role}. An unknown + * rank answers {@code false}, so a stale id never clears a floor it was not granted. + */ + public boolean isAtLeast(final String rankId, final RankRole role) + { + final Optional<CustomRank> held = byId(rankId); + final Optional<CustomRank> floor = byRole(role); + + return held.isPresent() && floor.isPresent() + && held.get().getLevel() >= floor.get().getLevel(); + } + + /** + * The least privileged rank that grants {@code permission}, which is the tier a command guarded + * by that node actually requires. Empty when no rank grants it, meaning the node is unreachable. + */ + public Optional<CustomRank> requiredFor(final String permission) + { + return Optional.ofNullable(trie.lowestGranting(permission, ranks)); + } + + /** + * The rank {@code sender} acts at, falling back to {@link #floor()} when it cannot be placed. + * <p> + * Command blocks and command minecarts are deliberately pinned to the floor: they are world + * state that any player can create, so treating them as an identity would let anyone mint one. + */ + public Optional<CustomRank> forSender(final CommandSender sender) + { + if (sender == null) + return Optional.empty(); + + if (sender instanceof Player player) + return playerResolver.resolve(player).or(this::floor); + + if (sender instanceof BlockCommandSender || sender instanceof CommandMinecart) + return floor(); + + return consoleResolver.resolve(sender).or(this::floor); + } + + /** + * Whether {@code sender} may exercise {@code permission}, by rank or by title. + * <p> + * The two halves answer deliberately different questions: + * <ul> + * <li><b>Rank</b> is a tier comparison. The requirement resolves to the cheapest rank holding + * the node and any rank at or above that level passes, both sides read off the registry's + * own level scale.</li> + * <li><b>Title</b> is an exact match against that title's own grant list, with no tier + * involved. That asymmetry is the point: a title hands its holder one specific capability + * without implying anything else, so an ordinary operator can be trusted with a single + * permission rather than promoted past everything beneath it.</li> + * </ul> + */ + public boolean satisfies(final CommandSender sender, final String permission) + { + if (forSender(sender).map(rank -> satisfies(rank, permission)).orElse(false)) + return true; + + return plugin.tm != null && plugin.tm.grants(sender, permission); + } + + /** + * Whether a rank sits at or above the tier {@code permission} requires. + */ + public boolean satisfies(final CustomRank holder, final String permission) + { + if (holder == null || permission == null || permission.isEmpty()) + return false; + + return requiredFor(permission).map(required -> holder.getLevel() >= required.getLevel()) + .orElse(false); + } + + /** + * The admin behind an identified dispatch session. + * <p> + * SSH and Discord prove a real identity, so a session that carries one resolves to that admin's + * own rank rather than to the flat tier the channel is bound to. A password-only SSH session + * proves nothing and declines here, as does any session when {@code ssh.inherit_rank} is off. + */ + private Optional<CustomRank> resolveDispatch(final CommandSender sender) + { + final RemoteDispatchSession dispatch = RemoteDispatchContext.getActiveSession(); + if (dispatch == null) + return Optional.empty(); + + if (dispatch.isIdentified() + && !(dispatch.getChannel() == RemoteDispatchSession.Channel.SSH + && !ConfigEntry.SSH_INHERIT_RANK.getBoolean())) + { + final Optional<CustomRank> identity = adminRank(plugin.al.getEntryByName(dispatch.getUsername())); + if (identity.isPresent()) + return identity; + } + + final String channel = dispatch.getChannel() == RemoteDispatchSession.Channel.DISCORD + ? "discord" + : "ssh"; + + return boundRank(channel); + } + + private Optional<CustomRank> resolveAdminByName(final CommandSender sender) + { + return adminRank(plugin.al.getEntryByName(sender.getName())); + } + + private Optional<CustomRank> resolveBoundSender(final CommandSender sender) + { + return boundRank(sender.getName()); + } + + private Optional<CustomRank> resolvePlayer(final Player player) + { + if (plugin.al.isAdminImpostor(player)) + return byRole(RankRole.IMPOSTOR); + + final Optional<CustomRank> assigned = adminRank(plugin.al.getAdmin(player)); + if (assigned.isPresent()) + return assigned; + + return byRole(player.isOp() ? RankRole.DEFAULT_OP : RankRole.DEFAULT); + } + + /** + * An active admin's rank, or empty when the profile is missing or deactivated. + */ + private Optional<CustomRank> adminRank(final Admin admin) + { + if (admin == null || !admin.isActive()) + return Optional.empty(); + + final Optional<CustomRank> held = byId(admin.getRankId()); + + return held.isPresent() ? held : byRole(RankRole.ADMIN_DEFAULT); + } + + /** + * The rank bound to a no-identity sender by {@code host_senders:}. + */ + private Optional<CustomRank> boundRank(final String senderName) + { + return plugin.csr == null + ? Optional.empty() + : byId(plugin.csr.getRankIdForSender(senderName)); + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankResolver.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankResolver.java new file mode 100644 index 000000000..1c5698d99 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankResolver.java @@ -0,0 +1,35 @@ +package me.totalfreedom.totalfreedommod.rank; + +import java.util.Optional; + +/** + * Resolves the rank held by some kind of actor. + * <p> + * Rank is earned differently depending on what is asking. A player carries an admin profile; an SSH + * session carries a proven public key; an RCON channel carries no identity at all and falls back to + * what {@code host_senders:} bound it to. Expressing each of those as a resolver over its own + * subject type keeps the rules for one kind of actor in one place, instead of as another branch in + * a single method that has to know about all of them at once. + * + * @param <T> the kind of actor this resolver understands + */ +@FunctionalInterface +public interface RankResolver<T> +{ + + /** + * The rank {@code subject} holds, or empty when this resolver cannot place it. Empty is a + * routine answer rather than an error: it means "not mine to answer", and the caller is + * expected to fall through to another resolver or to a floor. + */ + Optional<CustomRank> resolve(T subject); + + /** + * A resolver that consults this one first and falls back to {@code next} when it declines. + */ + default RankResolver<T> orElse(final RankResolver<T> next) + { + return subject -> resolve(subject).or(() -> next.resolve(subject)); + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRole.java b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRole.java new file mode 100644 index 000000000..14274d511 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/rank/RankRole.java @@ -0,0 +1,81 @@ +package me.totalfreedom.totalfreedommod.rank; + +import java.util.Arrays; +import java.util.Optional; + +/** + * A job the plugin needs some rank to do, bound to an actual rank by {@code ranks.json}. + * <p> + * A few code paths cannot be expressed as a permission check because they have to answer "which + * rank?" rather than "may they?": what an unidentified sender falls back to, what a newly added + * admin starts as, which rank marks an impostor. Naming those ranks in code would break the moment + * a custom renamed or deleted one, so a rank instead declares the roles it fills: + * <pre> + * "senior_admin": { ..., "roles": ["console_floor"] } + * </pre> + * Roles are a closed set because each one exists to satisfy a specific call site. Every role has a + * structural fallback in {@link RankRegistry#byRole}, so a config that declares none still resolves + * sensibly; declaring them only removes the guesswork. + */ +public enum RankRole +{ + + /** + * The rank an ordinary, non-opped player holds, and the floor any sender falls back to when it + * cannot be placed. Falls back to the lowest-level rank that is neither an admin nor the + * impostor marker. + */ + DEFAULT("default"), + + /** + * The rank an opped but non-admin player holds. Falls back to the highest-level rank that is + * neither an admin nor the impostor marker. + */ + DEFAULT_OP("default_op"), + + /** + * The display-only rank shown for a player flagged as impersonating an admin. Falls back to the + * lowest-level rank overall, which is where an impostor marker naturally sits. + */ + IMPOSTOR("impostor"), + + /** + * The rank a newly added admin receives, and the one an admin is reset to. Falls back to the + * lowest-level rank marked {@code admin}. + */ + ADMIN_DEFAULT("admin_default"), + + /** + * The floor held by console channels that prove no user identity (RCON, RemoteBukkit, the + * server console). Console access is itself the privilege, so {@code host_senders:} may raise a + * channel above this but never below it. Falls back to {@link #ADMIN_DEFAULT}. + */ + CONSOLE_FLOOR("console_floor"); + + private final String id; + + RankRole(final String id) + { + this.id = id; + } + + public String getId() + { + return id; + } + + /** + * The role written as {@code id} in configuration, or empty when the name is not a known role. + * Unknown names are ignored rather than rejected so that a config written for a newer build + * still loads on an older one. + */ + public static Optional<RankRole> fromId(final String id) + { + return id == null + ? Optional.empty() + : Arrays.stream(values()) + .filter(role -> role.id.equalsIgnoreCase(id.trim())) + .findFirst(); + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java new file mode 100644 index 000000000..e6a4af5f5 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/AccessController.java @@ -0,0 +1,143 @@ +package me.totalfreedom.totalfreedommod.sql; + +import java.sql.SQLException; +import java.util.concurrent.Callable; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; + +import me.totalfreedom.totalfreedommod.util.FLog; + +/** + * This is a fair, non-blocking access controller that ensures our queries on the connection pool + * don't overload our available connections. + * + * The {@link Semaphore} utilizes a FIFO waiting queue. + * The query that has been waiting longest always receives the next available permit. + * Permit release is guaranteed on completion, error, and cancellation. + * + * A permit is owned by a <i>thread</i>, not by a call, and acquisition is re-entrant: nested + * acquires bump a depth counter and only the outermost release hands the permit back. A + * repository operation takes one permit via {@link #guard(Callable)} and the statements it + * issues re-enter it, so an operation never waits on a permit it is already holding. + * <p> + * {@link #acquireSync()} and its matching {@link #releaseSync()} must run on the same thread. + */ +public final class AccessController +{ + // Matches HikariCP's own default connection timeout. + private static final long ACQUIRE_TIMEOUT_SECONDS = 30L; + + private final Semaphore semaphore; + private final Scheduler scheduler; + + /** How many times the current thread has acquired without releasing. */ + private final ThreadLocal<int[]> holdDepth = ThreadLocal.withInitial(() -> new int[1]); + + /** + * @param permits maximum number of concurrently executing queries. + * Should always match the HikariCP maximum pool size. + * @param scheduler dedicated scheduler to run guarded work on, sized off the same pool. + */ + public AccessController(final int permits, final Scheduler scheduler) + { + this.semaphore = new Semaphore(permits, true); + this.scheduler = scheduler; + } + + /** + * Run a unit of database work on the scheduler holding exactly one permit, however many + * statements it issues, released on completion, error, and cancellation alike. The permit + * is taken on the thread that runs {@code work} so the nested acquires inside it re-enter. + * + * @return a {@link Mono} that is empty when {@code work} returns {@code null}. + */ + public <T> Mono<T> guard(final Callable<T> work) + { + return Mono.fromCallable(() -> callGuarded(work)).subscribeOn(scheduler); + } + + private <T> T callGuarded(final Callable<T> work) throws Exception + { + acquireSync(); + try + { + return work.call(); + } + finally + { + releaseSync(); + } + } + + /** + * Acquire a permit for a synchronous unit of work, for the call sites that reach JDBC + * directly instead of going through {@link #guard(Callable)}. + * <p> + * Returns immediately if this thread already holds the permit. Otherwise waits at most + * {@link #ACQUIRE_TIMEOUT_SECONDS} rather than blocking forever. + * Always paired with {@link #releaseSync()}, typically in a {@code finally} block. + */ + public void acquireSync() throws SQLException + { + final int[] depth = holdDepth.get(); + if (depth[0] > 0) + { + depth[0]++; + return; + } + + final boolean acquired; + try + { + acquired = semaphore.tryAcquire(ACQUIRE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + catch (InterruptedException ex) + { + Thread.currentThread().interrupt(); + throw new SQLException("Interrupted while waiting for a permit", ex); + } + + if (!acquired) + throw new SQLException(String.format( + "Timed out after %ds waiting for a permit", ACQUIRE_TIMEOUT_SECONDS)); + + depth[0] = 1; + } + + /** + * Release one level of this thread's hold. The permit goes back to the semaphore only when + * the outermost hold is released. + */ + public void releaseSync() + { + final int[] depth = holdDepth.get(); + if (depth[0] == 0) + { + // Releasing here would inflate the semaphore past the pool size. + FLog.severe("AccessController.releaseSync() on a thread holding no permit; " + + "acquire and release must be paired on the same thread"); + holdDepth.remove(); + return; + } + + if (--depth[0] == 0) + { + holdDepth.remove(); + semaphore.release(); + } + } + + public int availablePermits() + { + return semaphore.availablePermits(); + } + + /** Number of queries waiting for permit. */ + public int queueLength() + { + return semaphore.getQueueLength(); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java index 8ffc24312..22da7829d 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/ConnectionHandler.java @@ -1,44 +1,47 @@ package me.totalfreedom.totalfreedommod.sql; import java.sql.Connection; -import java.sql.DriverManager; import java.sql.SQLException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.SQLProperties.DatabaseType; - -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - import me.totalfreedom.totalfreedommod.util.FLog; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.HikariPoolMXBean; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.jetbrains.annotations.NotNull; + /** - * Handles database connections for all supported database types. - * Supports: SQLite, MySQL, MariaDB, PostgreSQL, H2, MongoDB, Redis + * Contains the HikariCP connection pool for the configured database. + * Supports: SQLite, MySQL, PostgreSQL */ public class ConnectionHandler { - private final TotalFreedomMod plugin; + private static final int DEFAULT_POOL_SIZE = 10; + private static final int SQLITE_POOL_SIZE = 1; + + /** + * A thread waiting to acquire an {@link AccessController} permit blocks for the duration of awaiting, + * so for SQLite's pool of 1, a scheduler capped at 1 thread would let only one caller + * ever be "waiting" at a time and starve every other concurrent request. + */ + private static final int SCHEDULER_POOL_MULTIPLIER = 4; + private static final int SCHEDULER_MIN_THREADS = 16; + private static final int SCHEDULER_QUEUED_TASK_CAP = 100_000; + private final SQLProperties sqlProperties; - private Connection connection = null; - private final ExecutorService dbExecutor; - - // For NoSQL databases, we'll store the client reference - private Object noSqlClient = null; + private volatile HikariDataSource dataSource; + private volatile AccessController accessController; + private volatile Scheduler scheduler; public ConnectionHandler(@NotNull final TotalFreedomMod plugin) { - this.plugin = plugin; this.sqlProperties = new SQLProperties(plugin); - // Use a single-thread executor for DB operations to avoid blocking main thread - this.dbExecutor = Executors.newSingleThreadExecutor(r -> { - Thread t = new Thread(r, "TFM-Database"); - t.setDaemon(true); - return t; - }); } /** @@ -51,140 +54,131 @@ public SQLProperties getSqlProperties() } /** - * Get or create a JDBC connection for SQL databases. - * For NoSQL databases (MongoDB, Redis), this will throw an exception. + * Build the HikariCP connection pool for the configured database. + * Blocks until Hikari's own initial-connection test succeeds or fails. + * + * @apiNote SQLite is a single-writer embedded engine, so it is pinned to a single pooled connection regardless of configuration. */ - @NotNull - public CompletableFuture<Connection> getConnection() + public void connect() throws SQLException { - return CompletableFuture.supplyAsync(() -> { - try + DatabaseType dbType = sqlProperties.getDatabaseType(); + + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(sqlProperties.getJdbcUrl()); + config.setPoolName("TFM-" + dbType.getName()); + + String driverClass = sqlProperties.getDriverClass(); + if (driverClass != null && !driverClass.isEmpty()) + { + config.setDriverClassName(driverClass); + } + + if (sqlProperties.hasCredentials()) + { + config.setUsername(sqlProperties.getUsername()); + config.setPassword(sqlProperties.getPassword()); + } + + if (dbType == DatabaseType.SQLITE) + { + config.setMaximumPoolSize(SQLITE_POOL_SIZE); + config.setConnectionTestQuery("SELECT 1"); + // SQLite pragmas are set per-connection so we need to make sure it doesn't accidentally get recycled + config.setMaxLifetime(0); + } + else + { + config.setMaximumPoolSize(DEFAULT_POOL_SIZE); + config.setMinimumIdle(1); + } + + try + { + FLog.info(String.format( + "Connecting to database: %s at %s", + dbType.getName(), + maskPassword(config.getJdbcUrl()) + )); + this.dataSource = new HikariDataSource(config); + final int schedulerThreads = Math.max(dataSource.getMaximumPoolSize() * SCHEDULER_POOL_MULTIPLIER, SCHEDULER_MIN_THREADS); + this.scheduler = Schedulers.newBoundedElastic(schedulerThreads, SCHEDULER_QUEUED_TASK_CAP, "tfm-sql-" + dbType.getName()); + this.accessController = new AccessController(dataSource.getMaximumPoolSize(), scheduler); + + if (dbType == DatabaseType.SQLITE) { - DatabaseType dbType = sqlProperties.getDatabaseType(); - - // Check if this is a NoSQL database - if (dbType.isNoSQL()) + try (Connection connection = dataSource.getConnection()) { - throw new SQLException("Database type " + dbType.getName() + - " is a NoSQL database and does not use JDBC connections. " + - "Use getNoSqlClient() instead."); + sqlProperties.applySqlitePragmas(connection); } - - if (connection == null || connection.isClosed()) + catch (SQLException e) { - // Load JDBC driver if specified - String driverClass = sqlProperties.getDriverClass(); - if (driverClass != null && !driverClass.isEmpty()) - { - try - { - Class.forName(driverClass); - } - catch (ClassNotFoundException e) - { - FLog.warning("JDBC driver not found: " + driverClass + - ". Attempting to connect anyway..."); - } - } - - String url = sqlProperties.getJdbcUrl(); - FLog.info("Connecting to database: " + dbType.getName() + " at " + maskPassword(url)); - - connection = DriverManager.getConnection(url, sqlProperties.getConnectionProperties()); - - // Apply SQLite-specific pragmas if applicable - if (dbType == DatabaseType.SQLITE) - { - sqlProperties.applySqlitePragmas(connection); - } - - FLog.info("Database connection established (" + dbType.getName() + ")"); + throw e; // this should be handled downstream, not here. + } + catch (Exception e) + { + throw new SQLException("Failed to apply SQLite pragmas", e); // this should also be handled downstream. } - return connection; - } - catch (SQLException e) - { - throw new RuntimeException("Failed to get database connection", e); - } - catch (Exception e) - { - throw new RuntimeException("Failed to initialize database", e); } - }, dbExecutor); + + FLog.info(String.format( + "Database connection pool established (%s, poolSize %d)", + dbType.getName(), + dataSource.getMaximumPoolSize() + )); + } + catch (SQLException e) + { + throw e; + } + catch (Exception e) + { + throw new SQLException("Failed to initialize database connection pool", e); + } } /** - * Get NoSQL client for MongoDB or Redis. - * Returns null for SQL databases. - * - * Note: Implementation depends on the NoSQL driver being used. - * This method provides a placeholder for NoSQL client initialization. + * Borrow a pooled connection. Callers are responsible for closing it, which + * returns it to the pool rather than actually closing the physical connection. */ - @Nullable - public CompletableFuture<Object> getNoSqlClient() + @NotNull + public Connection borrowConnection() throws SQLException { - return CompletableFuture.supplyAsync(() -> { - DatabaseType dbType = sqlProperties.getDatabaseType(); - - if (!dbType.isNoSQL()) - { - FLog.warning("getNoSqlClient() called for SQL database type: " + dbType.getName()); - return null; - } - - if (noSqlClient != null) - { - return noSqlClient; - } - - String url = sqlProperties.getJdbcUrl(); - FLog.info("Connecting to NoSQL database: " + dbType.getName()); - - switch (dbType) - { - case MONGODB: - // MongoDB client initialization - // Requires: org.mongodb:mongodb-driver-sync dependency - // noSqlClient = MongoClients.create(url); - FLog.warning("MongoDB support requires mongodb-driver-sync dependency. " + - "Add implementation 'org.mongodb:mongodb-driver-sync:4.11.1' to build.gradle"); - throw new UnsupportedOperationException( - "MongoDB client not implemented. Add MongoDB driver dependency."); - - case REDIS: - // Redis client initialization - // Requires: redis.clients:jedis dependency - // noSqlClient = new JedisPool(host, port); - FLog.warning("Redis support requires jedis dependency. " + - "Add implementation 'redis.clients:jedis:5.1.0' to build.gradle"); - throw new UnsupportedOperationException( - "Redis client not implemented. Add Jedis driver dependency."); - - default: - throw new IllegalStateException("Unknown NoSQL type: " + dbType); - } - }, dbExecutor); + if (dataSource == null) + { + throw new IllegalStateException("Connection pool not initialized. Call connect() first."); + } + return dataSource.getConnection(); } /** - * Check if the current database configuration uses JDBC. + * Semaphore holder which limits concurrent async queries to the pool's maximum connection count. */ - public boolean isJdbcDatabase() + @NotNull + public AccessController getAccessController() { - return sqlProperties.isJdbcDatabase(); + if (accessController == null) + { + throw new IllegalStateException("Connection pool not initialized. Call connect() first."); + } + return accessController; } /** - * Check if the current database configuration is NoSQL. + * Dedicated Reactor scheduler for SQL work, sized off the pool's actual max size plus additional headroom to avoid starvation. + * <p> + * Use {@link SCHEDULER_POOL_MULTIPLIER} rather than sharing the JVM-wide + * default {@link Schedulers#boundedElastic()} with unrelated plugin async work. */ - public boolean isNoSQL() + @NotNull + public Scheduler getScheduler() { - return sqlProperties.isNoSQL(); + if (scheduler == null) + { + throw new IllegalStateException("Connection pool not initialized. Call connect() first."); + } + return scheduler; } - /** - * Get the configured database type. - */ @NotNull public DatabaseType getDatabaseType() { @@ -192,97 +186,82 @@ public DatabaseType getDatabaseType() } /** - * Shutdown the connection handler, closing all connections. + * Snapshot of live pool and fairness-queue stats, for admin-facing diagnostics. */ - public void shutdown() + public record PoolStats( + String databaseType, + int maxPoolSize, + int activeConnections, + int idleConnections, + int totalConnections, + int threadsAwaitingConnection, + int availablePermits, + int queueLength) { - FLog.info("Shutting down database connection handler..."); - dbExecutor.shutdown(); - - // Close JDBC connection - if (connection != null) + } + + /** + * Read current pool health directly off Hikari's own {@link HikariPoolMXBean}, plus the + * {@link AccessController} fairness-queue counters. + */ + @NotNull + public PoolStats getPoolStats() + { + if (dataSource == null || accessController == null) { - try - { - connection.close(); - FLog.info("Database connection closed."); - } - catch (SQLException e) - { - FLog.warning("Failed to close database connection: " + e.getMessage()); - } + throw new IllegalStateException("Connection pool not initialized. Call connect() first."); } - // Close NoSQL client - if (noSqlClient != null) + final HikariPoolMXBean poolMXBean = dataSource.getHikariPoolMXBean(); + return new PoolStats( + getDatabaseType().getName(), + dataSource.getMaximumPoolSize(), + poolMXBean.getActiveConnections(), + poolMXBean.getIdleConnections(), + poolMXBean.getTotalConnections(), + poolMXBean.getThreadsAwaitingConnection(), + accessController.availablePermits(), + accessController.queueLength()); + } + + public boolean isConnected() + { + return dataSource != null && !dataSource.isClosed(); + } + + public boolean testConnection() + { + try (Connection connection = borrowConnection()) { - try - { - // MongoDB: ((MongoClient) noSqlClient).close(); - // Redis: ((JedisPool) noSqlClient).close(); - FLog.info("NoSQL client closed."); - } - catch (Exception e) - { - FLog.warning("Failed to close NoSQL client: " + e.getMessage()); - } - noSqlClient = null; + return connection.isValid(5); + } + catch (Exception e) + { + FLog.severe(String.format( + "Database connection test failed: %s", + ExceptionUtils.getRootCauseMessage(e) + )); + return false; } } - /** - * Test the database connection. - */ - public CompletableFuture<Boolean> testConnection() + public void shutdown() { - return CompletableFuture.supplyAsync(() -> { - try - { - if (isNoSQL()) - { - // For NoSQL, just check if we can get a client - getNoSqlClient().join(); - return true; - } - else - { - Connection conn = getConnection().join(); - return conn != null && !conn.isClosed() && conn.isValid(5); - } - } - catch (Exception e) - { - FLog.severe("Database connection test failed: " + e.getMessage()); - return false; - } - }, dbExecutor); + FLog.info("Shutting down database connection handler..."); + if (scheduler != null) + { + scheduler.dispose(); + } + if (dataSource != null && !dataSource.isClosed()) + { + dataSource.close(); + FLog.info("Database connection pool closed."); + } } - /** - * Mask password in connection URL for logging. - */ private String maskPassword(String url) { - // Mask any password patterns like :password@ or password=xxx return url.replaceAll(":[^:@/]+@", ":****@") .replaceAll("password=[^&;]+", "password=****"); } - - public CompletableFuture<Void> closeConnection() { - return CompletableFuture.runAsync(() -> { - if (connection != null) - { - try - { - connection.close(); - FLog.info("Database connection closed."); - } - catch (SQLException e) - { - FLog.warning("Failed to close database connection: " + e.getMessage()); - } - connection = null; - } - }, dbExecutor); - } -} \ No newline at end of file +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/DataManager.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/DataManager.java deleted file mode 100644 index afd70676b..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/DataManager.java +++ /dev/null @@ -1,171 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; - -public class DataManager { - - private final TotalFreedomMod plugin; - private final ConnectionHandler connectionHandler; - private final StatementHandler statementHandler; - - public DataManager(TotalFreedomMod plugin) { - this.plugin = plugin; - this.connectionHandler = new ConnectionHandler(plugin); - this.statementHandler = new StatementHandler(connectionHandler); - } - - public void initialize() { - try { - connectionHandler.getConnection().join(); - runMigrations(); - } - catch (SQLException ex) - { - plugin.getLogger().severe("Failed to initialize database: " + ex.getMessage()); - } - catch (Exception ex) - { - Throwable cause = ex.getCause() != null ? ex.getCause() : ex; - plugin.getLogger().severe("Failed to initialize database: " + cause.getMessage()); - } - } - - private void runMigrations() throws SQLException { - createMigrationTable(); - - if (!hasMigrationRun("001_create_admins_table")) { - migrateAdmins(); - recordMigration("001_create_admins_table"); - } - - if (!hasMigrationRun("002_create_bans_table")) { - migrateBans(); - recordMigration("002_create_bans_table"); - } - - if (!hasMigrationRun("003_create_permbans_table")) { - migratePermbans(); - recordMigration("003_create_permbans_table"); - } - } - - private void createMigrationTable() throws SQLException { - String sql = """ - CREATE TABLE IF NOT EXISTS migrations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - migration_name TEXT NOT NULL UNIQUE, - applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """; - statementHandler.executeUpdate(sql); - } - - private boolean hasMigrationRun(String migrationName) throws SQLException { - String sql = "SELECT COUNT(*) FROM migrations WHERE migration_name = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, migrationName); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - private void recordMigration(String migrationName) throws SQLException { - String sql = "INSERT INTO migrations (migration_name) VALUES (?)"; - statementHandler.executeUpdate(sql, migrationName); - } - - private void migrateAdmins() throws SQLException { - // Admins table with UUID as primary identifier - // uuid, username, active, rank, ips[], last_login, login_message - String adminsSql = """ - CREATE TABLE IF NOT EXISTS admins ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT NOT NULL UNIQUE, - username TEXT NOT NULL, - rank TEXT NOT NULL DEFAULT 'SUPER_ADMIN', - active BOOLEAN DEFAULT TRUE, - last_login TEXT, - login_message TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """; - statementHandler.executeUpdate(adminsSql); - - // Separate table for admin IPs (one-to-many relationship) - String adminIpsSql = """ - CREATE TABLE IF NOT EXISTS admin_ips ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - admin_id INTEGER NOT NULL, - ip TEXT NOT NULL, - FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE, - UNIQUE(admin_id, ip) - ) - """; - statementHandler.executeUpdate(adminIpsSql); - plugin.getLogger().info("Created admins and admin_ips tables"); - } - - private void migrateBans() throws SQLException { - // Bans table with UUID as primary identifier - // uuid, username (cached), ips[], by, reason, expiry_unix - String bansSql = """ - CREATE TABLE IF NOT EXISTS bans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT, - username TEXT, - by TEXT, - reason TEXT, - expiry_unix INTEGER DEFAULT -1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """; - statementHandler.executeUpdate(bansSql); - - // Separate table for ban IPs (one-to-many relationship) - String banIpsSql = """ - CREATE TABLE IF NOT EXISTS ban_ips ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ban_id INTEGER NOT NULL, - ip TEXT NOT NULL, - FOREIGN KEY (ban_id) REFERENCES bans(id) ON DELETE CASCADE, - UNIQUE(ban_id, ip) - ) - """; - statementHandler.executeUpdate(banIpsSql); - plugin.getLogger().info("Created bans and ban_ips tables"); - } - - private void migratePermbans() throws SQLException { - // Permbans table with UUID as primary identifier - // uuid, username (cached), ips[] - String sql = """ - CREATE TABLE IF NOT EXISTS permbans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT NOT NULL UNIQUE, - username TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """; - statementHandler.executeUpdate(sql); - - // Separate table for permban IPs (one-to-many relationship) - String permbanIpsSql = """ - CREATE TABLE IF NOT EXISTS permban_ips ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - permban_id INTEGER NOT NULL, - ip TEXT NOT NULL, - FOREIGN KEY (permban_id) REFERENCES permbans(id) ON DELETE CASCADE, - UNIQUE(permban_id, ip) - ) - """; - statementHandler.executeUpdate(permbanIpsSql); - plugin.getLogger().info("Created permbans and permban_ips tables"); - } - - public void close() { - statementHandler.close(); - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java index 8d73d6dc7..3e8320da0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/FreedomDatabase.java @@ -1,18 +1,18 @@ package me.totalfreedom.totalfreedommod.sql; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.SQLProperties.DatabaseType; -import me.totalfreedom.totalfreedommod.sql.adapter.AdapterFactory; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.*; import me.totalfreedom.totalfreedommod.util.FLog; - -import java.sql.SQLException; +import me.totalfreedom.totalfreedommod.util.FTask; /** * Central database management service. @@ -27,7 +27,15 @@ public class FreedomDatabase extends FreedomService private ConnectionHandler connectionHandler; private StatementHandler statementHandler; private DatabaseAdapter adapter; - private boolean initialized = false; + private volatile boolean initialized = false; + + /** + * Callbacks waiting on the database, and whether they have already been drained. Both are + * touched only on the main thread, so registering during {@code onStart} cannot race the + * drain that happens once the background bootstrap finishes. + */ + private final List<Runnable> readyCallbacks = new ArrayList<>(); + private boolean readyFired = false; public FreedomDatabase(TotalFreedomMod plugin) { @@ -37,15 +45,7 @@ public FreedomDatabase(TotalFreedomMod plugin) @Override protected void onStart() { - try - { - initialize(); - } - catch (Exception ex) - { - FLog.severe("Failed to initialize database: " + ex.getMessage()); - ex.printStackTrace(); - } + initializeAsync(); } @Override @@ -55,9 +55,12 @@ protected void onStop() } /** - * Initialize the database connection and adapter. + * Build the connection pool, run schema migrations, then fold any legacy YAML data in, + * entirely on a background thread. {@code onEnable} does not wait for any of it: every + * domain starts on its JSON snapshot and swaps to SQL through {@link #whenReady(Runnable)} + * once this finishes, so an unreachable database host delays nothing but the swap. */ - public void initialize() throws SQLException + public void initializeAsync() { if (initialized) { @@ -65,43 +68,84 @@ public void initialize() throws SQLException return; } - FLog.info("Initializing database..."); + FLog.info("Initializing database in the background..."); - // Create connection handler connectionHandler = new ConnectionHandler(plugin); + final SQLProperties properties = connectionHandler.getSqlProperties(); + final DatabaseType dbType = properties.getDatabaseType(); - // Check database type - SQLProperties properties = connectionHandler.getSqlProperties(); - DatabaseType dbType = properties.getDatabaseType(); + Mono.fromCallable(() -> + { + connectionHandler.connect(); + statementHandler = new StatementHandler(connectionHandler); + DatabaseAdapter created = AdapterFactory.createAdapter(plugin, properties, connectionHandler, statementHandler); + created.initialize(); + return created; + }) + .subscribeOn(Schedulers.boundedElastic()) + .doOnNext(built -> + { + adapter = built; + initialized = true; + FLog.info(String.format("Database initialized successfully (%s)", dbType.getName())); + }) + // Migrations have to finish before any domain reads SQL, so they ride this + // chain rather than registering as another ready callback. + .then(Mono.defer(() -> new YamlMigrationService(plugin, this).runMigrations())) + .subscribe( + ignored -> {}, + ex -> FLog.severe(String.format( + "Failed to initialize database, every domain stays on its JSON fallback: %s", + ex.getMessage())), + () -> sync("FreedomDatabase/ready", this::fireReady)); + } - if (dbType.isNoSQL()) + /** + * Register {@code callback} to run on the main thread once the database is up and its YAML + * migrations have finished, or immediately if that has already happened. Callbacks run in + * registration order and are skipped entirely if the database never comes up. + * <p> + * Main thread only. + */ + public void whenReady(final Runnable callback) + { + if (readyFired) { - FLog.warning("NoSQL database type '" + dbType.getName() + "' is not yet fully supported."); - FLog.warning("Please use a SQL database (sqlite, mysql, mariadb, postgresql, h2) instead."); + callback.run(); return; } + readyCallbacks.add(callback); + } - // Wait for connection - try - { - connectionHandler.getConnection().join(); - } - catch (Exception ex) + /** + * Run {@code query} off the main thread and hand its result to {@code apply} back on it. + * A failed or empty query logs against {@code label} and runs {@code onFailure} instead, + * also on the main thread. + */ + public <T> void readAsync(final String label, final Mono<T> query, final Consumer<T> apply, + final Runnable onFailure) + { + query.switchIfEmpty(Mono.error(new IllegalStateException("query returned no result"))) + .subscribe( + result -> sync(label, () -> apply.accept(result)), + ex -> + { + FLog.warning(String.format("%s failed: %s", label, ex.getMessage())); + sync(label, onFailure); + }); + } + + /** + * Run {@code body} on the main thread, or inline if the plugin is already disabled. + */ + public void sync(final String label, final Runnable body) + { + if (!plugin.isEnabled()) { - throw new SQLException("Failed to establish database connection", ex); + FTask.run(label, body); + return; } - - // Create statement handler - statementHandler = new StatementHandler(connectionHandler); - - // Create the adapter using the factory - adapter = AdapterFactory.createAdapter(plugin, properties, connectionHandler, statementHandler); - - // Initialize the adapter (runs migrations) - adapter.initialize(); - - initialized = true; - FLog.info("Database initialized successfully (" + dbType.getName() + ")"); + plugin.getServer().getScheduler().runTask(plugin, FTask.guard(label, body)); } /** @@ -127,6 +171,11 @@ public void shutdown() } initialized = false; + + // Cleared so a tfm reload queues the domains' swap callbacks again instead of running them immediately against a pool that is still being rebuilt. + readyFired = false; + readyCallbacks.clear(); + FLog.info("Database shutdown complete"); } @@ -216,6 +265,73 @@ public DiscordLinkRepository getDiscordLinkRepository() return adapter.getDiscordLinkRepository(); } + public RankRepository getRankRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getRankRepository(); + } + + public TitleRepository getTitleRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getTitleRepository(); + } + + public ProtectedAreaRepository getProtectedAreaRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getProtectedAreaRepository(); + } + + public SavedFlagRepository getSavedFlagRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getSavedFlagRepository(); + } + + public PlayerRepository getPlayerRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getPlayerRepository(); + } + + public MigrationRepository getMigrationRepository() + { + if (adapter == null) + { + throw new IllegalStateException("Database not initialized"); + } + return adapter.getMigrationRepository(); + } + + /** + * Snapshot of live connection pool and fairness-queue health, or {@code null} if the + * database isn't initialized. + */ + public ConnectionHandler.PoolStats getPoolStats() + { + if (!initialized || connectionHandler == null) + { + return null; + } + return connectionHandler.getPoolStats(); + } + /** * Get the database type. */ @@ -227,4 +343,11 @@ public DatabaseType getDatabaseType() } return connectionHandler.getSqlProperties().getDatabaseType(); } + + private void fireReady() + { + readyFired = true; + readyCallbacks.forEach(callback -> FTask.run("FreedomDatabase/readyCallback", callback)); + readyCallbacks.clear(); + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java new file mode 100644 index 000000000..43c3583a2 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/PersistenceQueue.java @@ -0,0 +1,90 @@ +package me.totalfreedom.totalfreedommod.sql; + +import java.time.Duration; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.util.FLog; + +/** + * Serialises one domain's persistence work onto a single ordered chain. Each queued unit runs + * after the one before it, so a whole-list batch can never land behind a single-row update that + * was requested later. + * <p> + * Shared by every SQL-primary/JSON-fallback domain: {@code AdminList}, {@code BanManager}, + * {@code PermbanList}, {@code StrikeList}, {@code RankManager}, {@code ProtectArea} and + * {@code SavedFlags}. + */ +public final class PersistenceQueue +{ + private final String domain; + private final Object lock = new Object(); + private Mono<Void> chain = Mono.empty(); + + /** + * @param domain name this queue uses when it logs, e.g. {@code "admin"}. + */ + public PersistenceQueue(final String domain) + { + this.domain = domain; + } + + /** + * Append {@code work} to the chain and subscribe. + */ + public void enqueue(final Mono<Void> work) + { + synchronized (lock) + { + final Mono<Void> queued = chain + .onErrorResume(ignored -> Mono.empty()) + .then(work) + .cache(); + + chain = queued; + queued.doFinally(signal -> collapse(queued)).subscribe(); + } + } + + /** + * Wait for queued writes to land, up to {@code timeoutMs}. Called before a shutdown flush, + * which would otherwise race the queue and let an older queued snapshot overwrite the state + * just written. + */ + public void await(final long timeoutMs) + { + final Mono<Void> pending; + synchronized (lock) + { + pending = chain; + } + + try + { + pending.block(Duration.ofMillis(timeoutMs)); + } + catch (IllegalStateException ex) + { + // Reactor answers a blocking-read timeout with IllegalStateException. + FLog.warning(String.format("Gave up after %dms waiting for pending %s writes (%s); flushing anyway", + timeoutMs, domain, ex.getMessage())); + } + catch (RuntimeException ex) + { + FLog.warning(String.format("A queued %s write failed before shutdown: %s", domain, ex.getMessage())); + } + } + + /** + * Drop the retained chain once its tail completes. The chain is strictly + * sequential, so a completed tail means every write before it is done. + */ + private void collapse(final Mono<Void> completed) + { + synchronized (lock) + { + if (chain == completed) + chain = Mono.empty(); + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java index 7e6ff02e2..873686124 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/SQLProperties.java @@ -1,19 +1,19 @@ package me.totalfreedom.totalfreedommod.sql; +import java.io.File; import java.sql.Connection; import java.sql.PreparedStatement; -import java.util.Map; import java.util.HashMap; +import java.util.Map; import java.util.Properties; -import java.io.File; - -import org.jetbrains.annotations.NotNull; import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import org.jetbrains.annotations.NotNull; + /** * Handles SQL database configuration properties. - * Supports: sqlite, mysql, mariadb, postgresql, h2, mongodb, redis + * Supports: sqlite, mysql, postgresql */ public final class SQLProperties { @@ -24,11 +24,7 @@ public enum DatabaseType { SQLITE("sqlite", 0, "org.sqlite.JDBC", false), MYSQL("mysql", 3306, "com.mysql.cj.jdbc.Driver", true), - MARIADB("mariadb", 3306, "org.mariadb.jdbc.Driver", true), - POSTGRESQL("postgresql", 5432, "org.postgresql.Driver", true), - H2("h2", 9092, "org.h2.Driver", false), - MONGODB("mongodb", 27017, "mongodb.jdbc.MongoDriver", true), - REDIS("redis", 6379, null, true); // Redis doesn't use JDBC + POSTGRESQL("postgresql", 5432, "org.postgresql.Driver", true); private final String name; private final int defaultPort; @@ -60,14 +56,9 @@ public static DatabaseType fromString(String type) return SQLITE; // Default fallback } - public boolean isNoSQL() - { - return this == MONGODB || this == REDIS; - } - public boolean isEmbedded() { - return this == SQLITE || this == H2; + return this == SQLITE; } } @@ -138,8 +129,6 @@ private String getDefaultDatabaseName(DatabaseType type) return switch (type) { case SQLITE -> "totalfreedom.db"; - case H2 -> "./totalfreedom"; - case REDIS -> "0"; default -> "totalfreedom"; }; } @@ -260,58 +249,12 @@ public String getJdbcUrl() appendOptions(url); break; - case MARIADB: - // jdbc:mariadb://host:port/database - url.append("mariadb://").append(host).append(":").append(port).append("/").append(databaseName); - appendOptions(url); - break; - case POSTGRESQL: // jdbc:postgresql://host:port/database url.append("postgresql://").append(host).append(":").append(port).append("/").append(databaseName); appendOptions(url); break; - case H2: - // jdbc:h2:./database (file) or jdbc:h2:mem:database (memory) or jdbc:h2:tcp://host:port/database (server) - if (databaseName.startsWith("mem:") || databaseName.startsWith("./") || databaseName.startsWith("/")) - { - url.append("h2:").append(databaseName); - } - else if (host != null && !host.isEmpty() && !host.equals("localhost") && port > 0) - { - url.append("h2:tcp://").append(host).append(":").append(port).append("/").append(databaseName); - } - else - { - url.append("h2:").append(databaseName); - } - appendOptions(url, ";"); - break; - - case MONGODB: - // mongodb://host:port/database - url.setLength(0); // Clear "jdbc:" - url.append("mongodb://"); - if (hasCredentials()) - { - url.append(username).append(":").append(password).append("@"); - } - url.append(host).append(":").append(port).append("/").append(databaseName); - appendOptions(url); - break; - - case REDIS: - // redis://host:port/database - url.setLength(0); // Clear "jdbc:" - url.append("redis://"); - if (hasCredentials()) - { - url.append(":").append(password).append("@"); - } - url.append(host).append(":").append(port).append("/").append(databaseName); - break; - default: throw new IllegalArgumentException("Unsupported database type: " + databaseType); } @@ -327,14 +270,6 @@ private void appendOptions(StringBuilder url) appendOptions(url, "?", "&"); } - /** - * Append options using custom separators (e.g., H2 uses ; instead of & and ?) - */ - private void appendOptions(StringBuilder url, String separator) - { - appendOptions(url, separator, separator); - } - private void appendOptions(StringBuilder url, String firstSeparator, String separator) { if (additionalOptions.isEmpty()) @@ -351,22 +286,6 @@ private void appendOptions(StringBuilder url, String firstSeparator, String sepa } } - /** - * Check if the configured database type uses standard JDBC. - */ - public boolean isJdbcDatabase() - { - return databaseType != DatabaseType.REDIS; - } - - /** - * Check if the configured database type is a NoSQL database. - */ - public boolean isNoSQL() - { - return databaseType.isNoSQL(); - } - /** * Check if the configured database type is embedded (no server needed). */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java index 98ab25ebc..2c75a3b85 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/StatementHandler.java @@ -1,12 +1,24 @@ package me.totalfreedom.totalfreedommod.sql; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Collection; -import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Callable; +import reactor.core.publisher.Mono; + +/** + * Executes SQL against connections borrowed from {@link ConnectionHandler}'s pool. + * + * The {@link PreparedStatement}/{@link ResultSet} returned by {@link #prepareStatement} + * and {@link #executeQuery} are wrapped so that closing them via try-with-resources + * also returns the borrowed pooled {@link Connection}. Callers do not need to change how they consume + * these methods, but the underlying connection is no longer held open indefinitely. + */ public class StatementHandler { private final ConnectionHandler connectionHandler; @@ -16,29 +28,54 @@ public StatementHandler(ConnectionHandler connectionHandler) this.connectionHandler = connectionHandler; } - private Connection getConnection() throws SQLException + /** + * Prepares a statement, guarded by the same {@link AccessController} permit used by the + * reactive path so synchronous and async callers draw from one fairly-queued pool of + * concurrent queries. The permit is released when the returned statement is closed OR when a ResultSet is obtained. + */ + public PreparedStatement prepareStatement(String sql, Object... params) throws SQLException { + final AccessController accessController = connectionHandler.getAccessController(); + accessController.acquireSync(); + + Connection connection; try { - return connectionHandler.getConnection().join(); + connection = connectionHandler.borrowConnection(); } - catch (Exception e) + catch (SQLException e) { - throw new SQLException("Failed to obtain connection", e); + accessController.releaseSync(); + throw e; } - } - public PreparedStatement prepareStatement(String sql, Object... params) throws SQLException - { - PreparedStatement statement = getConnection().prepareStatement(sql); - setParameters(statement, params); - return statement; + PreparedStatement statement; + try + { + statement = connection.prepareStatement(sql); + setParameters(statement, params); + } + catch (SQLException e) + { + closeQuietly(connection); + accessController.releaseSync(); + throw e; + } + return closingStatementProxy(statement, connection, accessController); } public ResultSet executeQuery(String sql, Object... params) throws SQLException { PreparedStatement statement = prepareStatement(sql, params); - return statement.executeQuery(); + try + { + return closingResultSetProxy(statement.executeQuery(), statement); + } + catch (SQLException e) + { + closeQuietly(statement); + throw e; + } } public int executeUpdate(String sql, Object... params) throws SQLException @@ -49,34 +86,62 @@ public int executeUpdate(String sql, Object... params) throws SQLException } } - public CompletableFuture<ResultSet> executeQueryAsync(String sql, Object... params) + public long executeUpdateReturnKey(String sql, Object... params) throws SQLException { - return CompletableFuture.supplyAsync(() -> + final AccessController accessController = connectionHandler.getAccessController(); + accessController.acquireSync(); + try { - try + Connection connection = connectionHandler.borrowConnection(); + try (PreparedStatement statement = connection.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS)) { - return executeQuery(sql, params); + setParameters(statement, params); + statement.executeUpdate(); + try (ResultSet keys = statement.getGeneratedKeys()) + { + return keys.next() ? keys.getLong(1) : -1L; + } } - catch (SQLException e) + finally { - throw new RuntimeException(e); + closeQuietly(connection); } - }); + } + finally + { + accessController.releaseSync(); + } + } + + /** + * Run {@code work} on the SQL scheduler under a single {@link AccessController} permit. + * The statements {@code work} issues re-enter that same permit rather than queueing for + * one of their own, so a repository operation costs exactly one permit no matter how many + * round trips it makes. + */ + public <T> Mono<T> supplyMono(Callable<T> work) + { + return connectionHandler.getAccessController().guard(work); } - public CompletableFuture<Integer> executeUpdateAsync(String sql, Object... params) + public Mono<Void> runMono(SqlRunnable work) { - return CompletableFuture.supplyAsync(() -> + return connectionHandler.getAccessController().<Void>guard(() -> { - try - { - return executeUpdate(sql, params); - } - catch (SQLException e) - { - throw new RuntimeException(e); - } - }); + work.run(); + return null; + }).then(); + } + + @FunctionalInterface + public interface SqlRunnable + { + void run() throws SQLException; + } + + public void close() + { + connectionHandler.shutdown(); } private void setParameters(PreparedStatement statement, Object... params) throws SQLException @@ -96,18 +161,18 @@ private int setParameter(PreparedStatement statement, int index, Object param) t return index + 1; } - if (param instanceof String[]) + if (param instanceof String[] ss) { - for (String s : (String[]) param) + for (String s : (String[]) ss) { statement.setString(index++, s); } return index; } - if (param instanceof Collection<?>) + if (param instanceof Collection<?> coll) { - for (Object item : (Collection<?>) param) + for (Object item : coll) { if (item instanceof String) { @@ -121,11 +186,11 @@ private int setParameter(PreparedStatement statement, int index, Object param) t return index; } - if (param instanceof String) + if (param instanceof String s) { - statement.setString(index, (String) param); + statement.setString(index, s); } - else if (param instanceof Integer || param.getClass() == int.class) + else if (param instanceof Integer || param.getClass() == int.class) // no instanceof capture available here { statement.setInt(index, (Integer) param); } @@ -161,8 +226,89 @@ else if (param instanceof Short || param.getClass() == short.class) return index + 1; } - public void close() + private static void closeQuietly(AutoCloseable closeable) { - connectionHandler.shutdown(); + try + { + closeable.close(); + } + catch (Exception ignored) {} + } + + /** + * Wraps a PreparedStatement so that closing it also returns the pooled Connection + * that produced it and releases the {@link AccessController} permit acquired in + * {@link #prepareStatement}, without changing anything about how callers use the statement. + */ + private static PreparedStatement closingStatementProxy(PreparedStatement target, Connection connection, + AccessController accessController) + { + return (PreparedStatement) Proxy.newProxyInstance( + StatementHandler.class.getClassLoader(), + new Class<?>[] { PreparedStatement.class }, + (proxy, method, args) -> + { + if ("close".equals(method.getName())) + { + try + { + target.close(); + } + finally + { + try + { + connection.close(); + } + finally + { + accessController.releaseSync(); + } + } + return null; + } + try + { + return method.invoke(target, args); + } + catch (InvocationTargetException e) + { + throw e.getCause(); + } + }); + } + + /** + * Wraps a ResultSet so that closing it also closes the proxied PreparedStatement + * that produced it, cascading through to the pooled Connection. + */ + private static ResultSet closingResultSetProxy(ResultSet target, PreparedStatement statement) + { + return (ResultSet) Proxy.newProxyInstance( + StatementHandler.class.getClassLoader(), + new Class<?>[] { ResultSet.class }, + (proxy, method, args) -> + { + if ("close".equals(method.getName())) + { + try + { + target.close(); + } + finally + { + statement.close(); + } + return null; + } + try + { + return method.invoke(target, args); + } + catch (InvocationTargetException e) + { + throw e.getCause(); + } + }); } -} \ No newline at end of file +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java index c379d6a7f..a8d59ce77 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/YamlMigrationService.java @@ -1,23 +1,30 @@ package me.totalfreedom.totalfreedommod.sql; +import java.io.File; +import java.sql.SQLException; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.admin.Admin; import me.totalfreedom.totalfreedommod.banning.Ban; import me.totalfreedom.totalfreedommod.banning.PermBan; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.sql.adapter.*; import me.totalfreedom.totalfreedommod.util.FLog; import me.totalfreedom.totalfreedommod.util.FUtil; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; - -import java.io.File; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicInteger; /** * Service for migrating data from YAML files to SQL database. @@ -38,6 +45,25 @@ public class YamlMigrationService private static final String ADMINS_FILE = "admins.yml"; private static final String BANS_FILE = "bans.yml"; private static final String PERMBANS_FILE = "permbans.yml"; + private static final String RANKS_FILE = "ranks.yml"; + private static final String PROTECTED_AREAS_FILE = "protectedareas.yml"; + private static final String SAVED_FLAGS_FILE = "savedflags.yml"; + private static final String PLAYERS_DIR = "players"; + + /** + * Ledger keys, one per domain. Recorded in the {@code migrations} table once that domain's + * YAML import has run, whether or not it found anything to import. + */ + private static final String V_ADMINS = "yaml-import:admins"; + private static final String V_BANS = "yaml-import:bans"; + private static final String V_PERMBANS = "yaml-import:permbans"; + private static final String V_RANKS = "yaml-import:ranks"; + private static final String V_PROTECTED_AREAS = "yaml-import:protected_areas"; + private static final String V_SAVED_FLAGS = "yaml-import:saved_flags"; + private static final String V_PLAYERS = "yaml-import:players"; + + private static final Set<String> ALL_VERSIONS = Set.of( + V_ADMINS, V_BANS, V_PERMBANS, V_RANKS, V_PROTECTED_AREAS, V_SAVED_FLAGS, V_PLAYERS); public YamlMigrationService(TotalFreedomMod plugin, FreedomDatabase databaseManager) { @@ -47,11 +73,11 @@ public YamlMigrationService(TotalFreedomMod plugin, FreedomDatabase databaseMana /** * Run all migrations if they haven't been completed yet. - * @return CompletableFuture that completes when all migrations are done + * @return Mono that completes when all migrations are done */ - public CompletableFuture<Void> runMigrations() + public Mono<Void> runMigrations() { - return CompletableFuture.runAsync(() -> { + return Mono.<Void>fromRunnable(() -> { try { FLog.info("Checking for YAML data migrations..."); @@ -62,9 +88,7 @@ public CompletableFuture<Void> runMigrations() return; } - migrateAdmins(); - migrateBans(); - migratePermbans(); + runAllMigrations(); FLog.info("YAML data migration check complete"); } @@ -73,7 +97,59 @@ public CompletableFuture<Void> runMigrations() FLog.severe("Error during YAML migrations: " + ex.getMessage()); ex.printStackTrace(); } - }); + }).subscribeOn(Schedulers.boundedElastic()); + } + + /** + * Run every domain's one-time YAML import, in the order their tables reference each other, + * skipping any already recorded in the ledger. + */ + private void runAllMigrations() throws SQLException + { + final MigrationRepository ledger = databaseManager.getMigrationRepository(); + final Set<String> applied = ledger.findApplied(); + + if (applied.containsAll(ALL_VERSIONS)) + { + FLog.info("YAML imports already applied to this database; nothing to migrate."); + return; + } + + runOnce(ledger, applied, V_ADMINS, this::migrateAdmins); + runOnce(ledger, applied, V_BANS, this::migrateBans); + runOnce(ledger, applied, V_PERMBANS, this::migratePermbans); + runOnce(ledger, applied, V_RANKS, this::migrateRanks); + runOnce(ledger, applied, V_PROTECTED_AREAS, this::migrateProtectedAreas); + runOnce(ledger, applied, V_SAVED_FLAGS, this::migrateSavedFlags); + runOnce(ledger, applied, V_PLAYERS, this::migratePlayers); + } + + /** + * Run {@code body} unless {@code version} is already in the ledger, then record it. + * <p> + * The version is recorded even when the domain had no YAML to import, so an install that + * never had the file stops re-checking for it. A {@code body} that throws is left + * unrecorded and retried on the next start; the remaining domains still run, since one + * unimportable file should not strand every other domain's data. + */ + private void runOnce(final MigrationRepository ledger, final Set<String> applied, + final String version, final Runnable body) throws SQLException + { + if (applied.contains(version)) + return; + + try + { + body.run(); + } + catch (RuntimeException ex) + { + FLog.warning(String.format("YAML import '%s' did not complete, will retry on next start: %s", + version, ex.getMessage())); + return; + } + + ledger.markApplied(version); } /** @@ -93,7 +169,7 @@ private void migrateAdmins() // Check if we already have admins in the database try { - List<Admin> existingAdmins = repo.findAll().join(); + List<Admin> existingAdmins = repo.findAll().block(); if (!existingAdmins.isEmpty()) { FLog.info("Database already contains " + existingAdmins.size() + " admins, skipping YAML migration"); @@ -136,7 +212,7 @@ private void migrateAdmins() // Generate UUID if not present (legacy data) UUID uuid = generateUuidForAdmin(admin); - repo.save(uuid, admin).join(); + repo.save(uuid, admin).block(); migrated.incrementAndGet(); } catch (Exception ex) @@ -148,8 +224,7 @@ private void migrateAdmins() FLog.info("Admin migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); - // Backup the old file - backupFile(adminsFile); + finishMigration(failed.get(), adminsFile); } /** @@ -169,7 +244,7 @@ private void migrateBans() // Check if we already have bans in the database try { - List<Ban> existingBans = repo.findAll().join(); + List<Ban> existingBans = repo.findAll().block(); if (!existingBans.isEmpty()) { FLog.info("Database already contains " + existingBans.size() + " bans, skipping YAML migration"); @@ -209,7 +284,7 @@ private void migrateBans() continue; } - repo.save(ban).join(); + repo.save(ban).block(); migrated.incrementAndGet(); } catch (Exception ex) @@ -221,8 +296,7 @@ private void migrateBans() FLog.info("Ban migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); - // Backup the old file - backupFile(bansFile); + finishMigration(failed.get(), bansFile); } /** @@ -242,7 +316,7 @@ private void migratePermbans() // Check if we already have permbans in the database try { - List<PermBan> existingPermbans = repo.findAll().join(); + List<PermBan> existingPermbans = repo.findAll().block(); if (!existingPermbans.isEmpty()) { FLog.info("Database already contains " + existingPermbans.size() + " permbans, skipping YAML migration"); @@ -274,7 +348,7 @@ private void migratePermbans() // Generate UUID for name permban.setUuid(FUtil.usernameToUuid(name)); - repo.save(permban).join(); + repo.save(permban).block(); migrated.incrementAndGet(); } catch (Exception ex) @@ -286,8 +360,268 @@ private void migratePermbans() FLog.info("Permban migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); - // Backup the old file - backupFile(permbansFile); + finishMigration(failed.get(), permbansFile); + } + + /** + * Migrate custom ranks from ranks.yml to database. + */ + private void migrateRanks() + { + File ranksFile = new File(plugin.getDataFolder(), RANKS_FILE); + if (!ranksFile.exists()) + { + FLog.info("No ranks.yml found, skipping rank migration"); + return; + } + + RankRepository repo = databaseManager.getRankRepository(); + + try + { + if (!repo.loadAll().isEmpty()) + { + FLog.info("Database already contains ranks, skipping YAML migration"); + return; + } + } + catch (Exception ex) + { + FLog.warning("Could not check existing ranks: " + ex.getMessage()); + } + + FLog.info("Migrating ranks from " + RANKS_FILE + "..."); + + YamlConfiguration config = YamlConfiguration.loadConfiguration(ranksFile); + AtomicInteger migrated = new AtomicInteger(0); + AtomicInteger failed = new AtomicInteger(0); + + for (String key : config.getKeys(false)) + { + ConfigurationSection section = config.getConfigurationSection(key); + if (section == null) + { + FLog.warning("Invalid rank entry: " + key); + failed.incrementAndGet(); + continue; + } + + try + { + CustomRank rank = new CustomRank(key); + rank.loadFrom(section); + repo.saveOrUpdate(rank); + migrated.incrementAndGet(); + } + catch (Exception ex) + { + FLog.warning("Failed to migrate rank " + key + ": " + ex.getMessage()); + failed.incrementAndGet(); + } + } + + FLog.info("Rank migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); + + finishMigration(failed.get(), ranksFile); + } + + /** + * Migrate protected areas from protectedareas.yml to database. + */ + private void migrateProtectedAreas() + { + File areasFile = new File(plugin.getDataFolder(), PROTECTED_AREAS_FILE); + if (!areasFile.exists()) + { + FLog.info("No protectedareas.yml found, skipping protected area migration"); + return; + } + + ProtectedAreaRepository repo = databaseManager.getProtectedAreaRepository(); + + try + { + if (!repo.loadAll().isEmpty()) + { + FLog.info("Database already contains protected areas, skipping YAML migration"); + return; + } + } + catch (Exception ex) + { + FLog.warning("Could not check existing protected areas: " + ex.getMessage()); + } + + FLog.info("Migrating protected areas from " + PROTECTED_AREAS_FILE + "..."); + + YamlConfiguration config = YamlConfiguration.loadConfiguration(areasFile); + ConfigurationSection areasSection = config.getConfigurationSection("areas"); + AtomicInteger migrated = new AtomicInteger(0); + AtomicInteger failed = new AtomicInteger(0); + + if (areasSection != null) + { + for (String id : areasSection.getKeys(false)) + { + ConfigurationSection areaSection = areasSection.getConfigurationSection(id); + if (areaSection == null) + { + FLog.warning("Invalid protected area entry: " + id); + failed.incrementAndGet(); + continue; + } + + try + { + UUID uuid = UUID.fromString(id); + String name = areaSection.getString("name"); + int minX = areaSection.getInt("min_x"); + int minY = areaSection.getInt("min_y"); + int minZ = areaSection.getInt("min_z"); + int maxX = areaSection.getInt("max_x"); + int maxY = areaSection.getInt("max_y"); + int maxZ = areaSection.getInt("max_z"); + String worldUUID = areaSection.getString("world"); + + ProtectedRegion region = new ProtectedRegion(uuid, name, minX, minY, minZ, maxX, maxY, maxZ, worldUUID); + repo.saveOrUpdate(region); + migrated.incrementAndGet(); + } + catch (CantFindWorldException | IllegalArgumentException ex) + { + FLog.warning("Failed to migrate protected area " + id + ": " + ex.getMessage()); + failed.incrementAndGet(); + } + catch (Exception ex) + { + FLog.warning("Failed to migrate protected area " + id + ": " + ex.getMessage()); + failed.incrementAndGet(); + } + } + } + + FLog.info("Protected area migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); + + finishMigration(failed.get(), areasFile); + } + + /** + * Migrate saved flags from savedflags.yml to database. + */ + private void migrateSavedFlags() + { + File flagsFile = new File(plugin.getDataFolder(), SAVED_FLAGS_FILE); + if (!flagsFile.exists()) + { + FLog.info("No savedflags.yml found, skipping saved flag migration"); + return; + } + + SavedFlagRepository repo = databaseManager.getSavedFlagRepository(); + + try + { + if (!repo.loadAll().isEmpty()) + { + FLog.info("Database already contains saved flags, skipping YAML migration"); + return; + } + } + catch (Exception ex) + { + FLog.warning("Could not check existing saved flags: " + ex.getMessage()); + } + + FLog.info("Migrating saved flags from " + SAVED_FLAGS_FILE + "..."); + + YamlConfiguration config = YamlConfiguration.loadConfiguration(flagsFile); + ConfigurationSection flagsSection = config.getConfigurationSection("flags"); + AtomicInteger migrated = new AtomicInteger(0); + + if (flagsSection != null) + { + for (String key : flagsSection.getKeys(false)) + { + try + { + repo.upsert(key, flagsSection.getBoolean(key)); + migrated.incrementAndGet(); + } + catch (Exception ex) + { + FLog.warning("Failed to migrate saved flag " + key + ": " + ex.getMessage()); + } + } + } + + FLog.info("Saved flag migration complete: " + migrated.get() + " migrated"); + + backupFile(flagsFile); + } + + /** + * Migrate per-player data from players/*.yml to database. + */ + private void migratePlayers() + { + File playersDir = new File(plugin.getDataFolder(), PLAYERS_DIR); + File[] files = playersDir.listFiles((dir, name) -> name.endsWith(".yml")); + if (files == null || files.length == 0) + { + FLog.info("No players/*.yml found, skipping player data migration"); + return; + } + + PlayerRepository repo = databaseManager.getPlayerRepository(); + + try + { + if (!repo.loadAll().isEmpty()) + { + FLog.info("Database already contains player data, skipping YAML migration"); + return; + } + } + catch (Exception ex) + { + FLog.warning("Could not check existing player data: " + ex.getMessage()); + } + + FLog.info("Migrating player data from players/*.yml..."); + + AtomicInteger migrated = new AtomicInteger(0); + AtomicInteger failed = new AtomicInteger(0); + + for (File file : files) + { + String username = file.getName().substring(0, file.getName().length() - ".yml".length()); + + try + { + YamlConfiguration config = YamlConfiguration.loadConfiguration(file); + PlayerData data = new PlayerData(username); + data.loadFrom(config); + + if (!data.isValid()) + { + FLog.warning("Invalid player data for: " + username); + failed.incrementAndGet(); + continue; + } + + repo.saveOrUpdate(data); + migrated.incrementAndGet(); + } + catch (Exception ex) + { + FLog.warning("Failed to migrate player data for " + username + ": " + ex.getMessage()); + failed.incrementAndGet(); + } + } + + FLog.info("Player data migration complete: " + migrated.get() + " migrated, " + failed.get() + " failed"); + + finishMigration(failed.get(), files); } /** @@ -306,6 +640,23 @@ private UUID generateUuidForAdmin(Admin admin) return UUID.nameUUIDFromBytes(("OfflinePlayer:" + admin.getName().toLowerCase()).getBytes()); } + /** + * Retire the imported YAML now that every entry landed in the database. + * <p> + * A domain that lost even one entry keeps its file under the original name and aborts, so + * {@link #runOnce} leaves the version unrecorded and the whole domain is retried on the next + * start rather than silently stranding the data in a {@code .migrated} file. + */ + private void finishMigration(final int failed, final File... sources) + { + if (failed > 0) + throw new IllegalStateException(String.format("%d entr%s could not be imported", + failed, failed == 1 ? "y" : "ies")); + + Stream.of(sources) + .forEach(this::backupFile); + } + /** * Backup a file by renaming it with .migrated extension. */ @@ -339,17 +690,25 @@ private void backupFile(File file) * Force re-migration of all YAML data. * WARNING: This will clear existing database data and re-import from YAML. */ - public CompletableFuture<Void> forceMigration() + public Mono<Void> forceMigration() { - return CompletableFuture.runAsync(() -> { + return Mono.<Void>fromRunnable(() -> { FLog.warning("Force migration requested - this will overwrite database data!"); - // Clear existing data try { - databaseManager.getAdminRepository().deleteAll().join(); - databaseManager.getBanRepository().deleteAll().join(); - databaseManager.getPermbanRepository().deleteAll().join(); + // Clear existing data + databaseManager.getAdminRepository().deleteAll().block(); + databaseManager.getBanRepository().deleteAll().block(); + databaseManager.getPermbanRepository().deleteAll().block(); + databaseManager.getStrikeRepository().deleteAll().block(); + databaseManager.getRankRepository().deleteAll().block(); + databaseManager.getProtectedAreaRepository().deleteAll().block(); + databaseManager.getSavedFlagRepository().deleteAll().block(); + databaseManager.getPlayerRepository().deleteAll().block(); + + // Forget the ledger, otherwise every import below is skipped as already applied + databaseManager.getMigrationRepository().clear(); } catch (Exception ex) { @@ -360,11 +719,15 @@ public CompletableFuture<Void> forceMigration() // Restore backup files if they exist restoreBackupFiles(); - // Run migrations - migrateAdmins(); - migrateBans(); - migratePermbans(); - }); + try + { + runAllMigrations(); + } + catch (SQLException ex) + { + FLog.severe(String.format("Force migration failed: %s", ex.getMessage())); + } + }).subscribeOn(Schedulers.boundedElastic()); } /** @@ -372,11 +735,10 @@ public CompletableFuture<Void> forceMigration() */ private void restoreBackupFiles() { - File dataFolder = plugin.getDataFolder(); + final File dataFolder = plugin.getDataFolder(); - restoreBackupFile(new File(dataFolder, ADMINS_FILE + ".migrated"), new File(dataFolder, ADMINS_FILE)); - restoreBackupFile(new File(dataFolder, BANS_FILE + ".migrated"), new File(dataFolder, BANS_FILE)); - restoreBackupFile(new File(dataFolder, PERMBANS_FILE + ".migrated"), new File(dataFolder, PERMBANS_FILE)); + Stream.of(ADMINS_FILE, BANS_FILE, PERMBANS_FILE, RANKS_FILE, PROTECTED_AREAS_FILE, SAVED_FLAGS_FILE) + .forEach(name -> restoreBackupFile(new File(dataFolder, name + ".migrated"), new File(dataFolder, name))); } private void restoreBackupFile(File backup, File original) diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdapterFactory.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdapterFactory.java index 37d8f24da..f94f1b282 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdapterFactory.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdapterFactory.java @@ -4,7 +4,6 @@ import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.SQLProperties; import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.h2.H2Adapter; import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLAdapter; import me.totalfreedom.totalfreedommod.sql.adapter.postgresql.PostgreSQLAdapter; import me.totalfreedom.totalfreedommod.sql.adapter.sqlite.SQLiteAdapter; @@ -14,16 +13,11 @@ * Factory class for creating database adapters based on the configured database type. * This factory handles the instantiation of the correct adapter implementation * based on the DatabaseType enum from SQLProperties. - * - * Supported SQL databases: + * + * Supported databases: * - SQLite (default, embedded) * - MySQL (standalone server) - * - MariaDB (MySQL-compatible, uses MySQL adapter) * - PostgreSQL (standalone server) - * - H2 (embedded, MySQL-compatible) - * - * NoSQL databases (MongoDB, Redis) are not supported by this factory - * and require separate handling through ConnectionHandler.getNoSqlClient(). */ public final class AdapterFactory { @@ -56,15 +50,8 @@ public static DatabaseAdapter createAdapter( { case SQLITE -> new SQLiteAdapter(plugin, connectionHandler, statementHandler); case MYSQL -> new MySQLAdapter(plugin, connectionHandler, statementHandler); - case MARIADB -> { - FLog.info("MariaDB selected, using MySQL adapter (compatible)"); - yield new MySQLAdapter(plugin, connectionHandler, statementHandler); - } case POSTGRESQL -> new PostgreSQLAdapter(plugin, connectionHandler, statementHandler); - case H2 -> new H2Adapter(plugin, connectionHandler, statementHandler); - case MONGODB, REDIS -> throw new UnsupportedOperationException( - "NoSQL database '" + type.name() + "' is not supported by the SQL adapter system. " + - "Use ConnectionHandler.getNoSqlClient() for NoSQL operations."); + default -> null; }; } @@ -78,8 +65,8 @@ public static boolean isSqlDatabase(SQLProperties.DatabaseType type) { return switch (type) { - case SQLITE, MYSQL, MARIADB, POSTGRESQL, H2 -> true; - case MONGODB, REDIS -> false; + case SQLITE, MYSQL, POSTGRESQL -> true; + default -> false; }; } @@ -95,10 +82,9 @@ public static Class<? extends DatabaseAdapter> getAdapterClass(SQLProperties.Dat return switch (type) { case SQLITE -> SQLiteAdapter.class; - case MYSQL, MARIADB -> MySQLAdapter.class; + case MYSQL -> MySQLAdapter.class; case POSTGRESQL -> PostgreSQLAdapter.class; - case H2 -> H2Adapter.class; - case MONGODB, REDIS -> null; + default -> null; }; } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java index 3ffadacad..5c690efaf 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/AdminRepository.java @@ -1,13 +1,14 @@ package me.totalfreedom.totalfreedommod.sql.adapter; -import me.totalfreedom.totalfreedommod.admin.Admin; - import java.sql.SQLException; import java.util.Date; import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.admin.Admin; /** * Abstract repository interface for Admin data. @@ -160,35 +161,41 @@ public interface AdminRepository */ void deleteAllSync() throws SQLException; + /** + * Epoch millis of the most recently updated admin row, or null if the table is empty. + * Used to compare SQL freshness against the admins.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + // ============================================ // Async Operations // ============================================ - CompletableFuture<Map<String, Admin>> loadAllAsync(); + Mono<Map<String, Admin>> loadAllAsync(); - CompletableFuture<Integer> insertAsync(UUID uuid, Admin admin); + Mono<Integer> insertAsync(UUID uuid, Admin admin); - CompletableFuture<Boolean> updateAsync(UUID uuid, Admin admin); + Mono<Boolean> updateAsync(UUID uuid, Admin admin); + + Mono<Boolean> deleteAsync(UUID uuid); - CompletableFuture<Boolean> deleteAsync(UUID uuid); - /** * Save admin asynchronously (upsert). */ - CompletableFuture<Integer> save(UUID uuid, Admin admin); - + Mono<Integer> save(UUID uuid, Admin admin); + /** * Find all admins asynchronously. */ - CompletableFuture<List<Admin>> findAll(); - + Mono<List<Admin>> findAll(); + /** * Delete admin by UUID asynchronously. */ - CompletableFuture<Boolean> deleteByUuid(UUID uuid); - + Mono<Boolean> deleteByUuid(UUID uuid); + /** * Delete all admins asynchronously. */ - CompletableFuture<Void> deleteAll(); + Mono<Void> deleteAll(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java index cd4b37d00..08400e841 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/BanRepository.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.sql.adapter; -import me.totalfreedom.totalfreedommod.banning.Ban; - import java.sql.SQLException; import java.util.Date; import java.util.List; import java.util.UUID; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.banning.Ban; /** * Repository interface for Ban data. @@ -128,6 +129,12 @@ public interface BanRepository */ void deleteAllSync() throws SQLException; + /** + * Epoch millis of the most recently updated ban row, or null if the table is empty. + * Used to compare SQL freshness against the bans.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + // ============================================ // DELETE Operations // ============================================ @@ -162,31 +169,36 @@ public interface BanRepository // Async Operations // ============================================ - CompletableFuture<List<Ban>> loadAllAsync(); + Mono<List<Ban>> loadAllAsync(); - CompletableFuture<Integer> insertAsync(Ban ban); + Mono<Integer> insertAsync(Ban ban); - CompletableFuture<Boolean> updateAsync(Ban ban); + Mono<Boolean> updateAsync(Ban ban); + + Mono<Boolean> deleteAsync(UUID uuid); - CompletableFuture<Boolean> deleteAsync(UUID uuid); - /** * Save ban asynchronously (insert or update). */ - CompletableFuture<Integer> save(Ban ban); - + Mono<Integer> save(Ban ban); + /** * Find all bans asynchronously. */ - CompletableFuture<List<Ban>> findAll(); - + Mono<List<Ban>> findAll(); + /** * Delete ban by UUID asynchronously. */ - CompletableFuture<Boolean> deleteByUuid(UUID uuid); - + Mono<Boolean> deleteByUuid(UUID uuid); + + /** + * Delete every ban carrying {@code ip}, off the main thread. + */ + Mono<Boolean> deleteByIpAsync(String ip); + /** * Delete all bans asynchronously. */ - CompletableFuture<Void> deleteAll(); + Mono<Void> deleteAll(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java index b2e3d22e0..edbd0d1a6 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DatabaseAdapter.java @@ -1,11 +1,13 @@ package me.totalfreedom.totalfreedommod.sql.adapter; +import java.sql.SQLException; +import java.sql.ResultSet; +import java.sql.Timestamp; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import java.sql.SQLException; - /** * Abstract base class for database adapters. * Each database type (SQLite, MySQL, PostgreSQL, etc.) has its own implementation @@ -52,7 +54,7 @@ public void initialize() throws SQLException */ public void shutdown() { - connectionHandler.closeConnection(); + connectionHandler.shutdown(); } // ============================================ @@ -84,6 +86,36 @@ public void shutdown() */ public abstract DiscordLinkRepository getDiscordLinkRepository(); + /** + * Get the rank repository for this database type. + */ + public abstract RankRepository getRankRepository(); + + /** + * Repository for titles, the flat capability grants that sit alongside ranks. + */ + public abstract TitleRepository getTitleRepository(); + + /** + * Get the protected area repository for this database type. + */ + public abstract ProtectedAreaRepository getProtectedAreaRepository(); + + /** + * Get the saved flag repository for this database type. + */ + public abstract SavedFlagRepository getSavedFlagRepository(); + + /** + * Get the per-player data repository for this database type. + */ + public abstract PlayerRepository getPlayerRepository(); + + /** + * Get the applied-migration ledger for this database type. + */ + public abstract MigrationRepository getMigrationRepository(); + // ============================================ // SQL Dialect Methods (override for differences) // ============================================ @@ -91,9 +123,8 @@ public void shutdown() /** * Get the auto-increment syntax for primary keys. * SQLite: INTEGER PRIMARY KEY AUTOINCREMENT - * MySQL/MariaDB: INT AUTO_INCREMENT + * MySQL: INT AUTO_INCREMENT * PostgreSQL: SERIAL - * H2: INT AUTO_INCREMENT */ public abstract String autoIncrementSyntax(); @@ -105,7 +136,7 @@ public void shutdown() /** * Get the text/string type for this database. * SQLite: TEXT - * MySQL/MariaDB: TEXT or VARCHAR + * MySQL: TEXT or VARCHAR * PostgreSQL: TEXT */ public abstract String textType(); @@ -113,7 +144,7 @@ public void shutdown() /** * Get the timestamp/datetime type for this database. * SQLite: TEXT (stored as ISO string) - * MySQL/MariaDB: DATETIME + * MySQL: DATETIME * PostgreSQL: TIMESTAMP */ public abstract String timestampType(); @@ -121,40 +152,104 @@ public void shutdown() /** * Get the boolean type for this database. * SQLite: INTEGER (0/1) - * MySQL/MariaDB: TINYINT(1) + * MySQL: TINYINT(1) * PostgreSQL: BOOLEAN */ public abstract String booleanType(); + /** + * Get the JSON column type for this database. + * SQLite: TEXT (no native JSON type; validate/query with the JSON1 extension's + * json_valid()/json_extract() if needed) + * MySQL: JSON + * PostgreSQL: JSONB + */ + public abstract String jsonType(); + + /** + * Get the bind-parameter placeholder for a value being written into a native + * JSON column. PostgreSQL requires an explicit cast since it won't implicitly + * convert a bound String to jsonb. + * SQLite/MySQL: "?" + * PostgreSQL: "?::jsonb" + */ + public abstract String jsonParamPlaceholder(); + /** * Get the INSERT IGNORE / INSERT OR IGNORE syntax prefix. * SQLite: INSERT OR IGNORE - * MySQL/MariaDB: INSERT IGNORE - * PostgreSQL: INSERT (use ON CONFLICT DO NOTHING suffix) + * MySQL: INSERT IGNORE + * PostgreSQL: INSERT (use insertIgnoreSuffix() for ON CONFLICT DO NOTHING) */ public abstract String insertIgnoreSyntax(); + /** + * Get the trailing clause (if any) needed to make an INSERT a no-op on conflict. + * SQLite/MySQL: "" (handled entirely by the insertIgnoreSyntax() prefix) + * PostgreSQL: " ON CONFLICT DO NOTHING" + */ + public abstract String insertIgnoreSuffix(); + /** * Quote an identifier (table name, column name) for this database. * SQLite: No quoting or double quotes - * MySQL/MariaDB: `identifier` (backticks) + * MySQL: `identifier` (backticks) * PostgreSQL: "identifier" (double quotes) */ public abstract String quoteIdentifier(String identifier); /** - * Get the current timestamp function. - * SQLite: CURRENT_TIMESTAMP or datetime('now') - * MySQL/MariaDB: NOW() + * Get the current timestamp function, for use as a literal insert value. + * SQLite: CURRENT_TIMESTAMP + * MySQL: NOW() * PostgreSQL: CURRENT_TIMESTAMP */ public abstract String currentTimestamp(); /** - * Get the case-insensitive LIKE operator. - * SQLite: LIKE (case-insensitive by default) - * MySQL: LIKE (depends on collation) - * PostgreSQL: ILIKE + * Reads a timestamp column as epoch millis. Overridden where the dialect's + * stored value is not in the JVM's own timezone. + */ + public Long readTimestamp(final ResultSet rs, final int index) throws SQLException + { + final Timestamp ts = rs.getTimestamp(index); + + return ts != null ? ts.getTime() : null; + } + + /** + * Get the bind-parameter placeholder for a value being written into a native + * timestamp column. PostgreSQL requires an explicit cast since it won't + * implicitly convert a bound String to timestamp. + * SQLite/MySQL: "?" + * PostgreSQL: "?::timestamp" + */ + public abstract String timestampParamPlaceholder(); + + /** + * Build a case-insensitive equality comparison between a (already-quoted) column + * reference and a bind-parameter placeholder. + * SQLite/MySQL: LOWER(columnRef) = LOWER(paramPlaceholder) + * PostgreSQL: columnRef ILIKE paramPlaceholder + */ + public abstract String caseInsensitiveEquals(String columnRef, String paramPlaceholder); + + /** + * Build a boolean expression comparing a stored (already-quoted) timestamp column + * against the current time. SQLite stores timestamps as formatted TEXT, so both + * sides need datetime() normalization; MySQL/PostgreSQL compare native + * DATETIME/TIMESTAMP columns directly. + * + * @param columnRef the already-quoted column reference + * @param operator e.g. ">", "<=" + */ + public abstract String compareToNow(String columnRef, String operator); + + /** + * Build the upsert clause appended after an INSERT ... VALUES (...) to update the + * given columns from the incoming row on a conflict against conflictColumn. + * MySQL: ON DUPLICATE KEY UPDATE col = VALUES(col), ... + * SQLite/PostgreSQL: ON CONFLICT(conflictColumn) DO UPDATE SET col = EXCLUDED.col, ... */ - public abstract String caseInsensitiveLike(); + public abstract String upsertClause(String conflictColumn, String... updateColumns); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java index 3c92e4578..ad4d85d41 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/DiscordLinkRepository.java @@ -1,10 +1,19 @@ package me.totalfreedom.totalfreedommod.sql.adapter; import java.sql.SQLException; +import java.util.Map; import java.util.UUID; +import reactor.core.publisher.Mono; + public interface DiscordLinkRepository { + /** + * @return all links, keyed by admin UUID string, valued by Discord user id. + * Used to write the discord_links.json snapshot. + */ + Map<String, String> loadAll() throws SQLException; + /** * Insert a new link. Fails if either side is already linked. Callers * should remove any existing link first via {@link #deleteByAdminUuid} or @@ -31,4 +40,20 @@ public interface DiscordLinkRepository * @return true if a row was deleted. */ boolean deleteByDiscordUserId(String discordUserId) throws SQLException; + + /** + * Epoch millis of the most recently updated link row, or null if the table is empty. + * Used to compare SQL freshness against the discord_links.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + + Mono<Map<String, String>> loadAllAsync(); + + /** + * Replace any existing link for either side, then link {@code adminUuid} to + * {@code discordUserId}. + */ + Mono<Void> relinkAsync(UUID adminUuid, String discordUserId); + + Mono<Long> getMaxUpdatedAtAsync(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/MigrationRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/MigrationRepository.java new file mode 100644 index 000000000..f0ff45788 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/MigrationRepository.java @@ -0,0 +1,35 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import java.sql.SQLException; +import java.util.Set; + +import reactor.core.publisher.Mono; + +/** + * Ledger of one-off data migrations that have already been applied to this database, backed by + * the {@code migrations} table every adapter creates. + * <p> + * Keyed by an opaque version string rather than by table contents, so a migration whose source + * data was empty still counts as done and is not retried on every boot. + */ +public interface MigrationRepository +{ + /** + * Version keys already recorded as applied. + */ + Set<String> findApplied() throws SQLException; + + /** + * Record {@code version} as applied. Does nothing if it already is. + */ + void markApplied(String version) throws SQLException; + + /** + * Forget every recorded version, so the migrations they guard run again. + */ + void clear() throws SQLException; + + Mono<Set<String>> findAppliedAsync(); + + Mono<Void> markAppliedAsync(String version); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java index 71219999d..e076cb331 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PermbanRepository.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.sql.adapter; -import me.totalfreedom.totalfreedommod.banning.PermBan; - import java.sql.SQLException; import java.util.List; import java.util.UUID; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.banning.PermBan; /** * Repository interface for Permban data. @@ -136,30 +137,41 @@ public interface PermbanRepository */ void deleteAllSync() throws SQLException; + /** + * Epoch millis of the most recently updated permban row, or null if the table is empty. + * Used to compare SQL freshness against the permbans.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + // ============================================ // Async Operations // ============================================ - CompletableFuture<List<PermBan>> loadAllAsync(); + Mono<List<PermBan>> loadAllAsync(); - CompletableFuture<Integer> insertAsync(PermBan permban); + Mono<Integer> insertAsync(PermBan permban); - CompletableFuture<Boolean> updateAsync(PermBan permban); + Mono<Boolean> updateAsync(PermBan permban); + + Mono<Boolean> deleteAsync(UUID uuid); - CompletableFuture<Boolean> deleteAsync(UUID uuid); - /** * Save permban asynchronously (insert or update). */ - CompletableFuture<Integer> save(PermBan permban); - + Mono<Integer> save(PermBan permban); + /** * Find all permbans asynchronously. */ - CompletableFuture<List<PermBan>> findAll(); - + Mono<List<PermBan>> findAll(); + + /** + * Delete every permban carrying {@code ip}, off the main thread. + */ + Mono<Boolean> deleteByIpAsync(String ip); + /** * Delete all permbans asynchronously. */ - CompletableFuture<Void> deleteAll(); + Mono<Void> deleteAll(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java new file mode 100644 index 000000000..d5ccde3f8 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/PlayerRepository.java @@ -0,0 +1,61 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import java.sql.SQLException; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.player.PlayerData; + +/** + * Repository interface for per-player data. + * + * A player's own {@code username} is its primary key, so no generated-key handling is needed. + */ +public interface PlayerRepository +{ + void insert(PlayerData data) throws SQLException; + + /** + * Insert IPs for a player, preserving list order (oldest first) so it round-trips + * through {@link #getIps} correctly. + */ + void insertIps(String username, List<String> ips) throws SQLException; + + void addIp(String username, String ip) throws SQLException; + + Map<String, PlayerData> loadAll() throws SQLException; + + Optional<PlayerData> findByUsername(String username) throws SQLException; + + boolean exists(String username) throws SQLException; + + List<String> getIps(String username) throws SQLException; + + boolean update(PlayerData data) throws SQLException; + + void syncIps(String username, List<String> ips) throws SQLException; + + void saveOrUpdate(PlayerData data) throws SQLException; + + boolean delete(String username) throws SQLException; + + void deleteAllSync() throws SQLException; + + /** + * Epoch millis of this player's row's last update, or null if no such row exists. + * Used to compare SQL freshness against that player's {@code players/<username>.json} + * snapshot file's last-modified time. + */ + Long getUpdatedAt(String username) throws SQLException; + + Mono<Map<String, PlayerData>> loadAllAsync(); + + Mono<Void> save(PlayerData data); + + Mono<Boolean> deleteAsync(String username); + + Mono<Void> deleteAll(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java new file mode 100644 index 000000000..65d9c91d0 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/ProtectedAreaRepository.java @@ -0,0 +1,53 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import java.sql.SQLException; +import java.util.List; +import java.util.UUID; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; + +/** + * Repository interface for ProtectedRegion data. + * + * A region's own {@code uuid} is its primary key, so no generated-key handling is needed. + */ +public interface ProtectedAreaRepository +{ + void insert(ProtectedRegion region) throws SQLException; + + /** + * Load all regions. Rows whose stored world UUID fails to parse are skipped + * with a warning, matching {@code ProtectArea}'s existing YAML-load behavior. + */ + List<ProtectedRegion> loadAll() throws SQLException; + + ProtectedRegion findByUuid(UUID uuid) throws SQLException; + + ProtectedRegion findByName(String name) throws SQLException; + + boolean exists(UUID uuid) throws SQLException; + + boolean update(ProtectedRegion region) throws SQLException; + + void saveOrUpdate(ProtectedRegion region) throws SQLException; + + boolean delete(UUID uuid) throws SQLException; + + void deleteAllSync() throws SQLException; + + /** + * Epoch millis of the most recently updated region row, or null if the table is empty. + * Used to compare SQL freshness against the protectedareas.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + + Mono<List<ProtectedRegion>> loadAllAsync(); + + Mono<Void> save(ProtectedRegion region); + + Mono<Boolean> deleteAsync(UUID uuid); + + Mono<Void> deleteAll(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java new file mode 100644 index 000000000..a917323c3 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/RankRepository.java @@ -0,0 +1,57 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import java.sql.SQLException; +import java.util.Map; +import java.util.Set; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.rank.CustomRank; + +/** + * Repository interface for CustomRank data. + * + * A rank's own {@code id} string is its primary key, so no generated-key handling is needed. + */ +public interface RankRepository +{ + void insert(CustomRank rank) throws SQLException; + + void insertPermissions(String rankId, Set<String> permissions) throws SQLException; + + void addPermission(String rankId, String permission) throws SQLException; + + Map<String, CustomRank> loadAll() throws SQLException; + + CustomRank findById(String id) throws SQLException; + + boolean exists(String id) throws SQLException; + + Set<String> getPermissions(String rankId) throws SQLException; + + boolean update(CustomRank rank) throws SQLException; + + void syncPermissions(String rankId, Set<String> permissions) throws SQLException; + + void saveOrUpdate(CustomRank rank) throws SQLException; + + boolean delete(String id) throws SQLException; + + boolean removePermission(String rankId, String permission) throws SQLException; + + void deleteAllSync() throws SQLException; + + /** + * Epoch millis of the most recently updated rank row, or null if the table is empty. + * Used to compare SQL freshness against the ranks.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + + Mono<Map<String, CustomRank>> loadAllAsync(); + + Mono<Void> save(CustomRank rank); + + Mono<Boolean> deleteAsync(String id); + + Mono<Void> deleteAll(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/SavedFlagRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/SavedFlagRepository.java new file mode 100644 index 000000000..1dc71b400 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/SavedFlagRepository.java @@ -0,0 +1,34 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import java.sql.SQLException; +import java.util.Map; + +import reactor.core.publisher.Mono; + +/** + * Repository interface for saved flag data. + */ +public interface SavedFlagRepository +{ + Map<String, Boolean> loadAll() throws SQLException; + + void upsert(String flagName, boolean enabled) throws SQLException; + + boolean delete(String flagName) throws SQLException; + + void deleteAllSync() throws SQLException; + + /** + * Epoch millis of the most recently updated flag row, or null if the table is empty. + * Used to compare SQL freshness against the savedflags.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + + Mono<Map<String, Boolean>> loadAllAsync(); + + Mono<Void> upsertAsync(String flagName, boolean enabled); + + Mono<Boolean> deleteAsync(String flagName); + + Mono<Void> deleteAll(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java index 7eb2ce11b..038ae9799 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/StrikeRepository.java @@ -2,7 +2,9 @@ import java.sql.SQLException; import java.util.Map; -import java.util.concurrent.CompletableFuture; + +import reactor.core.publisher.Mono; + import me.totalfreedom.totalfreedommod.banning.StrikeRecord; public interface StrikeRepository @@ -15,11 +17,17 @@ public interface StrikeRepository void deleteAllSync() throws SQLException; - CompletableFuture<Map<String, StrikeRecord>> loadAllAsync(); + /** + * Epoch millis of the most recently updated strike row, or null if the table is empty. + * Used to compare SQL freshness against the strikes.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + + Mono<Map<String, StrikeRecord>> loadAllAsync(); - CompletableFuture<Void> upsertAsync(StrikeRecord record); + Mono<Void> upsertAsync(StrikeRecord record); - CompletableFuture<Boolean> deleteByIpAsync(String ip); + Mono<Boolean> deleteByIpAsync(String ip); - CompletableFuture<Void> deleteAll(); + Mono<Void> deleteAll(); } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java new file mode 100644 index 000000000..c79d35bb6 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/TitleRepository.java @@ -0,0 +1,60 @@ +package me.totalfreedom.totalfreedommod.sql.adapter; + +import java.sql.SQLException; +import java.util.Map; +import java.util.Set; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.title.Title; + +/** + * Repository interface for {@link Title} data. + * <p> + * A title's own {@code id} string is its primary key, so no generated-key handling is needed. The + * shape mirrors {@link RankRepository} deliberately: titles carry a permission set that lives in a + * child table for the same reasons rank permissions do, so the two persist the same way even though + * a title has no level and no parent. + */ +public interface TitleRepository +{ + void insert(Title title) throws SQLException; + + void insertPermissions(String titleId, Set<String> permissions) throws SQLException; + + void addPermission(String titleId, String permission) throws SQLException; + + Map<String, Title> loadAll() throws SQLException; + + Title findById(String id) throws SQLException; + + boolean exists(String id) throws SQLException; + + Set<String> getPermissions(String titleId) throws SQLException; + + boolean update(Title title) throws SQLException; + + void syncPermissions(String titleId, Set<String> permissions) throws SQLException; + + void saveOrUpdate(Title title) throws SQLException; + + boolean delete(String id) throws SQLException; + + boolean removePermission(String titleId, String permission) throws SQLException; + + void deleteAllSync() throws SQLException; + + /** + * Epoch millis of the most recently updated title row, or null if the table is empty. + * Used to compare SQL freshness against the titles.json snapshot's last-modified time. + */ + Long getMaxUpdatedAt() throws SQLException; + + Mono<Map<String, Title>> loadAllAsync(); + + Mono<Void> save(Title title); + + Mono<Boolean> deleteAsync(String id); + + Mono<Void> deleteAll(); +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java new file mode 100644 index 000000000..44a1b4471 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericAdminRepository.java @@ -0,0 +1,489 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.*; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.admin.Admin; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.util.FUtil; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericAdminRepository implements AdminRepository +{ + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblAdmins; + private final String tblAdminIps; + private final String colId; + private final String colUuid; + private final String colUsername; + private final String colRank; + private final String colActive; + private final String colLastLogin; + private final String colLoginMessage; + private final String colCustomRank; + private final String colAdminId; + private final String colIp; + private final String colUpdatedAt; + private final String selectColumns; + + public GenericAdminRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblAdmins = adapter.quoteIdentifier("admins"); + this.tblAdminIps = adapter.quoteIdentifier("admin_ips"); + this.colId = adapter.quoteIdentifier("id"); + this.colUuid = adapter.quoteIdentifier("uuid"); + this.colUsername = adapter.quoteIdentifier("username"); + this.colRank = adapter.quoteIdentifier("rank"); + this.colActive = adapter.quoteIdentifier("active"); + this.colLastLogin = adapter.quoteIdentifier("last_login"); + this.colLoginMessage = adapter.quoteIdentifier("login_message"); + this.colCustomRank = adapter.quoteIdentifier("custom_rank"); + this.colAdminId = adapter.quoteIdentifier("admin_id"); + this.colIp = adapter.quoteIdentifier("ip"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s", + colId, colUuid, colUsername, colRank, colActive, colLastLogin, colLoginMessage, colCustomRank); + } + + @Override + public int insert(UUID uuid, Admin admin) throws SQLException + { + String sql = String.format("INSERT INTO %s (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, %s, ?, ?, %s)", + tblAdmins, colUuid, colUsername, colRank, colActive, colLastLogin, colLoginMessage, colCustomRank, + colUpdatedAt, adapter.timestampParamPlaceholder(), adapter.currentTimestamp()); + + long adminId = statementHandler.executeUpdateReturnKey(sql, + uuid.toString(), + admin.getName(), + admin.getRankId(), + admin.isActive(), + FUtil.dateToString(admin.getLastLogin()), + admin.getLoginMessage(), + null); + + if (adminId < 0) + { + return -1; + } + insertIps((int) adminId, admin.getIps()); + return (int) adminId; + } + + @Override + public void insertIps(int adminId, List<String> ips) throws SQLException + { + if (ips == null || ips.isEmpty()) return; + + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblAdminIps, colAdminId, colIp, adapter.insertIgnoreSuffix()); + for (String ip : ips) + { + statementHandler.executeUpdate(sql, adminId, ip); + } + } + + @Override + public void addIp(int adminId, String ip) throws SQLException + { + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblAdminIps, colAdminId, colIp, adapter.insertIgnoreSuffix()); + statementHandler.executeUpdate(sql, adminId, ip); + } + + @Override + public Map<String, Admin> loadAll() throws SQLException + { + Map<String, Admin> admins = new HashMap<>(); + Map<Integer, Admin> adminById = new HashMap<>(); + + String sql = String.format("SELECT %s FROM %s", selectColumns, tblAdmins); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + int id = rs.getInt("id"); + Admin admin = loadAdminFromRow(rs); + admins.put(admin.getName().toLowerCase(), admin); + adminById.put(id, admin); + } + } + + attachIps(adminById); + + return admins; + } + + /** + * Fold the {@code admin_ips} child rows into admins that have already been read. + * One flat query, run after the caller's cursor is closed: a nested read would hold two + * connections at once, which SQLite's single-connection pool cannot serve. + */ + private void attachIps(Map<Integer, Admin> adminById) throws SQLException + { + if (adminById.isEmpty()) + return; + + String ipSql = String.format("SELECT %s, %s FROM %s", colAdminId, colIp, tblAdminIps); + try (ResultSet rs = statementHandler.executeQuery(ipSql)) + { + while (rs.next()) + { + Admin admin = adminById.get(rs.getInt("admin_id")); + if (admin != null) + { + admin.addIp(rs.getString("ip")); + } + } + } + } + + /** + * Read a single admin and its IPs, closing the row's statement before looking the IPs up. + */ + private Admin findOne(String sql, Object... params) throws SQLException + { + final Admin admin; + final int adminId; + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, params); + ResultSet rs = stmt.executeQuery()) + { + if (!rs.next()) + return null; + + admin = loadAdminFromRow(rs); + adminId = rs.getInt("id"); + } + + admin.addIps(getIps(adminId)); + return admin; + } + + @Override + public Admin findByUuid(UUID uuid) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblAdmins, colUuid); + return findOne(sql, uuid.toString()); + } + + @Override + public Admin findByUsername(String username) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s", + selectColumns, tblAdmins, adapter.caseInsensitiveEquals(colUsername, "?")); + return findOne(sql, username); + } + + @Override + public Admin findByIp(String ip) throws SQLException + { + String sql = String.format( + "SELECT a.%s, a.%s, a.%s, a.%s, a.%s, a.%s, a.%s, a.%s FROM %s a INNER JOIN %s ai ON a.%s = ai.%s WHERE ai.%s = ?", + colId, colUuid, colUsername, colRank, colActive, colLastLogin, colLoginMessage, colCustomRank, + tblAdmins, tblAdminIps, colId, colAdminId, colIp); + return findOne(sql, ip); + } + + @Override + public int getAdminId(String username) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s", + colId, tblAdmins, adapter.caseInsensitiveEquals(colUsername, "?")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) return rs.getInt("id"); + } + return -1; + } + + @Override + public int getAdminIdByUuid(UUID uuid) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colId, tblAdmins, colUuid); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) return rs.getInt("id"); + } + return -1; + } + + @Override + public List<String> getIps(int adminId) throws SQLException + { + List<String> ips = new ArrayList<>(); + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colIp, tblAdminIps, colAdminId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, adminId); + ResultSet rs = stmt.executeQuery()) + { + while (rs.next()) + { + ips.add(rs.getString("ip")); + } + } + return ips; + } + + @Override + public boolean exists(String username) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s", + tblAdmins, adapter.caseInsensitiveEquals(colUsername, "?")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public boolean existsByUuid(UUID uuid) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblAdmins, colUuid); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public UUID getUuidByUsername(String username) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s", + colUuid, tblAdmins, adapter.caseInsensitiveEquals(colUsername, "?")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + return UUID.fromString(rs.getString("uuid")); + } + } + return null; + } + + @Override + public boolean update(UUID uuid, Admin admin) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = %s, %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblAdmins, colUsername, colRank, colActive, colLastLogin, adapter.timestampParamPlaceholder(), + colLoginMessage, colCustomRank, colUpdatedAt, adapter.currentTimestamp(), colUuid); + + int rows = statementHandler.executeUpdate(sql, + admin.getName(), + admin.getRankId(), + admin.isActive(), + FUtil.dateToString(admin.getLastLogin()), + admin.getLoginMessage(), + null, + uuid.toString()); + + return rows > 0; + } + + @Override + public boolean updateRank(String username, String rank) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ? WHERE %s", + tblAdmins, colRank, adapter.caseInsensitiveEquals(colUsername, "?")); + return statementHandler.executeUpdate(sql, rank, username) > 0; + } + + @Override + public boolean updateActive(String username, boolean active) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ? WHERE %s", + tblAdmins, colActive, adapter.caseInsensitiveEquals(colUsername, "?")); + return statementHandler.executeUpdate(sql, active, username) > 0; + } + + @Override + public boolean updateLastLogin(String username, Date lastLogin) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = %s WHERE %s", + tblAdmins, colLastLogin, adapter.timestampParamPlaceholder(), adapter.caseInsensitiveEquals(colUsername, "?")); + return statementHandler.executeUpdate(sql, FUtil.dateToString(lastLogin), username) > 0; + } + + @Override + public boolean updateUsername(UUID uuid, String newUsername) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ? WHERE %s = ?", tblAdmins, colUsername, colUuid); + return statementHandler.executeUpdate(sql, newUsername, uuid.toString()) > 0; + } + + @Override + public void syncIps(int adminId, List<String> ips) throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s = ?", tblAdminIps, colAdminId), adminId); + insertIps(adminId, ips); + } + + @Override + public int saveOrUpdate(UUID uuid, Admin admin) throws SQLException + { + if (existsByUuid(uuid)) + { + update(uuid, admin); + int adminId = getAdminIdByUuid(uuid); + syncIps(adminId, admin.getIps()); + return adminId; + } + else + { + return insert(uuid, admin); + } + } + + @Override + public boolean delete(UUID uuid) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblAdmins, colUuid); + return statementHandler.executeUpdate(sql, uuid.toString()) > 0; + } + + @Override + public boolean deleteByUsername(String username) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s", tblAdmins, adapter.caseInsensitiveEquals(colUsername, "?")); + return statementHandler.executeUpdate(sql, username) > 0; + } + + @Override + public boolean removeIp(int adminId, String ip) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ? AND %s = ?", tblAdminIps, colAdminId, colIp); + return statementHandler.executeUpdate(sql, adminId, ip) > 0; + } + + @Override + public boolean clearIps(int adminId) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblAdminIps, colAdminId); + return statementHandler.executeUpdate(sql, adminId) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblAdminIps)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblAdmins)); + } + + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblAdmins); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + return adapter.readTimestamp(rs, 1); + } + } + return null; + } + + @Override + public Mono<Map<String, Admin>> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono<Integer> insertAsync(UUID uuid, Admin admin) + { + return statementHandler.supplyMono(() -> insert(uuid, admin)); + } + + @Override + public Mono<Boolean> updateAsync(UUID uuid, Admin admin) + { + return statementHandler.supplyMono(() -> update(uuid, admin)); + } + + @Override + public Mono<Boolean> deleteAsync(UUID uuid) + { + return statementHandler.supplyMono(() -> delete(uuid)); + } + + @Override + public Mono<Integer> save(UUID uuid, Admin admin) + { + return statementHandler.supplyMono(() -> saveOrUpdate(uuid, admin)); + } + + @Override + public Mono<List<Admin>> findAll() + { + return statementHandler.supplyMono(() -> new ArrayList<>(loadAll().values())); + } + + @Override + public Mono<Boolean> deleteByUuid(UUID uuid) + { + return deleteAsync(uuid); + } + + @Override + public Mono<Void> deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } + + /** + * Picks the rank id out of the two columns admin rows may still carry. + * <p> + * {@code custom_rank} held the custom-assigned rank and took precedence over {@code rank}, + * which held a fixed tier name, so it is preferred here as well. Rows are rewritten with the id + * in {@code rank} and {@code custom_rank} cleared, so this only matters until a row is next + * saved. A tier name lowercases into an id, which is the convention {@code ranks.json} uses. + */ + private static String resolveRankId(final String customRank, final String legacyRank) + { + if (customRank != null && !customRank.isBlank()) + return customRank.toLowerCase(); + + return legacyRank == null || legacyRank.isBlank() ? null : legacyRank.toLowerCase(); + } + + private Admin loadAdminFromRow(ResultSet rs) throws SQLException + { + String username = rs.getString("username"); + String rankStr = rs.getString("rank"); + boolean active = rs.getBoolean("active"); + String lastLoginStr = rs.getString("last_login"); + String loginMessage = rs.getString("login_message"); + + Admin admin = new Admin(username.toLowerCase()); + admin.setName(username); + admin.setRankId(resolveRankId(rs.getString("custom_rank"), rankStr)); + admin.setActive(active); + admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); + admin.setLoginMessage(loginMessage); + + UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); + if (dbUuid != null) + { + admin.setUuid(dbUuid); + } + + return admin; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java new file mode 100644 index 000000000..484c5107e --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericBanRepository.java @@ -0,0 +1,555 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.*; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.banning.Ban; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.util.FUtil; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericBanRepository implements BanRepository +{ + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblBans; + private final String tblBanIps; + private final String colId; + private final String colUuid; + private final String colUsername; + private final String colBannedBy; + private final String colBannedByUuid; + private final String colReason; + private final String colExpireAt; + private final String colBanId; + private final String colIp; + private final String colUpdatedAt; + private final String selectColumns; + + public GenericBanRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblBans = adapter.quoteIdentifier("bans"); + this.tblBanIps = adapter.quoteIdentifier("ban_ips"); + this.colId = adapter.quoteIdentifier("id"); + this.colUuid = adapter.quoteIdentifier("uuid"); + this.colUsername = adapter.quoteIdentifier("username"); + this.colBannedBy = adapter.quoteIdentifier("banned_by"); + this.colBannedByUuid = adapter.quoteIdentifier("banned_by_uuid"); + this.colReason = adapter.quoteIdentifier("reason"); + this.colExpireAt = adapter.quoteIdentifier("expire_at"); + this.colBanId = adapter.quoteIdentifier("ban_id"); + this.colIp = adapter.quoteIdentifier("ip"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s", + colId, colUuid, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt); + } + + @Override + public int insert(Ban ban) throws SQLException + { + String sql = String.format("INSERT INTO %s (%s, %s, %s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, ?, %s, %s)", + tblBans, colUuid, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, colUpdatedAt, + adapter.timestampParamPlaceholder(), adapter.currentTimestamp()); + + long banId = statementHandler.executeUpdateReturnKey(sql, + ban.getUuid() != null ? ban.getUuid().toString() : null, + ban.getUsername(), + ban.getBannedBy(), + ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, + ban.getReason(), + ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null); + + if (banId < 0) + { + return -1; + } + insertIps((int) banId, ban.getIps()); + return (int) banId; + } + + @Override + public void insertIps(int banId, List<String> ips) throws SQLException + { + if (ips == null || ips.isEmpty()) return; + + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblBanIps, colBanId, colIp, adapter.insertIgnoreSuffix()); + for (String ip : ips) + { + statementHandler.executeUpdate(sql, banId, ip); + } + } + + @Override + public void addIp(int banId, String ip) throws SQLException + { + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblBanIps, colBanId, colIp, adapter.insertIgnoreSuffix()); + statementHandler.executeUpdate(sql, banId, ip); + } + + @Override + public List<Ban> loadAll() throws SQLException + { + List<Ban> bans = new ArrayList<>(); + Map<Integer, Ban> banById = new HashMap<>(); + + String sql = String.format("SELECT %s FROM %s", selectColumns, tblBans); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + int id = rs.getInt("id"); + Ban ban = loadBanFromRow(rs); + bans.add(ban); + banById.put(id, ban); + } + } + + attachIps(banById); + + return bans; + } + + /** + * Fold the {@code ban_ips} child rows into bans that have already been read. + * One flat query, run after the caller's cursor is closed: a nested read would hold two + * connections at once, which SQLite's single-connection pool cannot serve. + */ + private void attachIps(Map<Integer, Ban> banById) throws SQLException + { + if (banById.isEmpty()) + return; + + String ipSql = String.format("SELECT %s, %s FROM %s", colBanId, colIp, tblBanIps); + try (ResultSet rs = statementHandler.executeQuery(ipSql)) + { + while (rs.next()) + { + Ban ban = banById.get(rs.getInt("ban_id")); + if (ban != null) + { + ban.addIp(rs.getString("ip")); + } + } + } + } + + /** + * Read a single ban and its IPs, closing the row's statement before looking the IPs up. + */ + private Ban findOne(String sql, Object... params) throws SQLException + { + final Ban ban; + final int banId; + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, params); + ResultSet rs = stmt.executeQuery()) + { + if (!rs.next()) + return null; + + ban = loadBanFromRow(rs); + banId = rs.getInt("id"); + } + + ban.setIps(getIps(banId)); + return ban; + } + + /** + * Read every ban matching {@code sql}, then attach IPs once the cursor is closed. + */ + private List<Ban> findMany(final String sql) throws SQLException + { + List<Ban> bans = new ArrayList<>(); + Map<Integer, Ban> banById = new HashMap<>(); + + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + Ban ban = loadBanFromRow(rs); + bans.add(ban); + banById.put(rs.getInt("id"), ban); + } + } + + attachIps(banById); + return bans; + } + + @Override + public Ban findByUuid(UUID uuid) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblBans, colUuid); + return findOne(sql, uuid.toString()); + } + + @Override + public Ban findByUsername(String username) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s", + selectColumns, tblBans, adapter.caseInsensitiveEquals(colUsername, "?")); + return findOne(sql, username); + } + + @Override + public Ban findByIp(String ip) throws SQLException + { + String sql = String.format( + "SELECT b.%s, b.%s, b.%s, b.%s, b.%s, b.%s, b.%s FROM %s b INNER JOIN %s bi ON b.%s = bi.%s WHERE bi.%s = ?", + colId, colUuid, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, + tblBans, tblBanIps, colId, colBanId, colIp); + return findOne(sql, ip); + } + + @Override + public List<Ban> findActiveBans() throws SQLException + { + return findMany(String.format("SELECT %s FROM %s WHERE %s IS NULL OR %s", + selectColumns, tblBans, colExpireAt, adapter.compareToNow(colExpireAt, ">"))); + } + + @Override + public List<Ban> findExpiredBans() throws SQLException + { + return findMany(String.format("SELECT %s FROM %s WHERE %s IS NOT NULL AND %s", + selectColumns, tblBans, colExpireAt, adapter.compareToNow(colExpireAt, "<="))); + } + + @Override + public int getBanId(UUID uuid) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colId, tblBans, colUuid); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) return rs.getInt("id"); + } + return -1; + } + + @Override + public int getBanIdByUsername(String username) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s", + colId, tblBans, adapter.caseInsensitiveEquals(colUsername, "?")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) return rs.getInt("id"); + } + return -1; + } + + @Override + public List<String> getIps(int banId) throws SQLException + { + List<String> ips = new ArrayList<>(); + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colIp, tblBanIps, colBanId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, banId); + ResultSet rs = stmt.executeQuery()) + { + while (rs.next()) + { + ips.add(rs.getString("ip")); + } + } + return ips; + } + + @Override + public boolean isBanned(UUID uuid) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ? AND (%s IS NULL OR %s)", + tblBans, colUuid, colExpireAt, adapter.compareToNow(colExpireAt, ">")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public boolean isBannedByUsername(String username) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s AND (%s IS NULL OR %s)", + tblBans, adapter.caseInsensitiveEquals(colUsername, "?"), colExpireAt, adapter.compareToNow(colExpireAt, ">")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public boolean isBannedByIp(String ip) throws SQLException + { + String sql = String.format( + "SELECT COUNT(*) FROM %s b INNER JOIN %s bi ON b.%s = bi.%s WHERE bi.%s = ? AND (b.%s IS NULL OR %s)", + tblBans, tblBanIps, colId, colBanId, colIp, colExpireAt, adapter.compareToNow("b." + colExpireAt, ">")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public boolean update(Ban ban) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = %s, %s = %s WHERE %s = ?", + tblBans, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, + adapter.timestampParamPlaceholder(), colUpdatedAt, adapter.currentTimestamp(), colUuid); + + int rows = statementHandler.executeUpdate(sql, + ban.getUsername(), + ban.getBannedBy(), + ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, + ban.getReason(), + ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null, + ban.getUuid().toString()); + + return rows > 0; + } + + @Override + public boolean updateReason(UUID uuid, String reason) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ? WHERE %s = ?", tblBans, colReason, colUuid); + return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; + } + + @Override + public boolean updateExpiry(UUID uuid, Date expireAt) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = %s WHERE %s = ?", + tblBans, colExpireAt, adapter.timestampParamPlaceholder(), colUuid); + return statementHandler.executeUpdate(sql, expireAt != null ? FUtil.dateToString(expireAt) : null, uuid.toString()) > 0; + } + + @Override + public void syncIps(int banId, List<String> ips) throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s = ?", tblBanIps, colBanId), banId); + insertIps(banId, ips); + } + + @Override + public boolean delete(UUID uuid) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblBans, colUuid); + return statementHandler.executeUpdate(sql, uuid.toString()) > 0; + } + + @Override + public boolean deleteByUsername(String username) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s", tblBans, adapter.caseInsensitiveEquals(colUsername, "?")); + return statementHandler.executeUpdate(sql, username) > 0; + } + + @Override + public boolean deleteByIp(String ip) throws SQLException + { + String selectSql = String.format("SELECT %s FROM %s WHERE %s = ?", colBanId, tblBanIps, colIp); + List<Integer> banIds = new ArrayList<>(); + try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); + ResultSet rs = stmt.executeQuery()) + { + while (rs.next()) + { + banIds.add(rs.getInt("ban_id")); + } + } + + String deleteSql = String.format("DELETE FROM %s WHERE %s = ?", tblBans, colId); + for (int banId : banIds) + { + statementHandler.executeUpdate(deleteSql, banId); + } + + return !banIds.isEmpty(); + } + + @Override + public boolean removeIp(int banId, String ip) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ? AND %s = ?", tblBanIps, colBanId, colIp); + return statementHandler.executeUpdate(sql, banId, ip) > 0; + } + + @Override + public int deleteExpiredBans() throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s IS NOT NULL AND %s", + tblBans, colExpireAt, adapter.compareToNow(colExpireAt, "<=")); + return statementHandler.executeUpdate(sql); + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblBanIps)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblBans)); + } + + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblBans); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + return adapter.readTimestamp(rs, 1); + } + } + return null; + } + + @Override + public Mono<List<Ban>> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono<Integer> insertAsync(Ban ban) + { + return statementHandler.supplyMono(() -> insert(ban)); + } + + @Override + public Mono<Boolean> updateAsync(Ban ban) + { + return statementHandler.supplyMono(() -> update(ban)); + } + + @Override + public Mono<Boolean> deleteAsync(UUID uuid) + { + return statementHandler.supplyMono(() -> delete(uuid)); + } + + @Override + public Mono<Boolean> deleteByIpAsync(String ip) + { + return statementHandler.supplyMono(() -> deleteByIp(ip)); + } + + @Override + public Mono<Integer> save(Ban ban) + { + return statementHandler.supplyMono(() -> + { + final int existing = findBanId(ban); + if (existing <= 0) + return insert(ban); + + updateById(existing, ban); + syncIps(existing, ban.getIps()); + return existing; + }); + } + + private int findBanId(final Ban ban) throws SQLException + { + if (ban.getUuid() != null) + return getBanId(ban.getUuid()); + + if (ban.hasUsername()) + return getBanIdByUsername(ban.getUsername()); + + for (final String ip : ban.getIps()) + { + final int id = getBanIdByIp(ip); + if (id > 0) + return id; + } + + return -1; + } + + private int getBanIdByIp(final String ip) throws SQLException + { + String sql = String.format( + "SELECT b.%s FROM %s b INNER JOIN %s i ON b.%s = i.%s WHERE i.%s = ? AND b.%s IS NULL AND b.%s IS NULL", + colId, tblBans, tblBanIps, colId, colBanId, colIp, colUuid, colUsername); + + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + return rs.getInt(1); + } + + return -1; + } + + private boolean updateById(final int banId, final Ban ban) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = %s, %s = %s WHERE %s = ?", + tblBans, colUsername, colBannedBy, colBannedByUuid, colReason, colExpireAt, + adapter.timestampParamPlaceholder(), colUpdatedAt, adapter.currentTimestamp(), colId); + + return statementHandler.executeUpdate(sql, + ban.getUsername(), + ban.getBannedBy(), + ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, + ban.getReason(), + ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null, + banId) > 0; + } + + @Override + public Mono<List<Ban>> findAll() + { + return loadAllAsync(); + } + + @Override + public Mono<Boolean> deleteByUuid(UUID uuid) + { + return deleteAsync(uuid); + } + + @Override + public Mono<Void> deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } + + private Ban loadBanFromRow(ResultSet rs) throws SQLException + { + String uuidStr = rs.getString("uuid"); + String username = rs.getString("username"); + String bannedBy = rs.getString("banned_by"); + String bannedByUuidStr = rs.getString("banned_by_uuid"); + String reason = rs.getString("reason"); + String expireAtStr = rs.getString("expire_at"); + + Ban ban = new Ban(); + ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); + ban.setUsername(username); + ban.setBannedBy(bannedBy); + ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); + ban.setReason(reason); + ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); + + return ban; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java new file mode 100644 index 000000000..4c79d39bb --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericDiscordLinkRepository.java @@ -0,0 +1,144 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericDiscordLinkRepository implements DiscordLinkRepository +{ + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String insertSql; + private final String selectDiscordIdSql; + private final String selectAdminUuidSql; + private final String deleteByAdminUuidSql; + private final String deleteByDiscordUserIdSql; + private final String maxUpdatedAtSql; + private final String selectAllSql; + + public GenericDiscordLinkRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + String tblDiscordLinks = adapter.quoteIdentifier("discord_links"); + String colAdminUuid = adapter.quoteIdentifier("admin_uuid"); + String colDiscordUserId = adapter.quoteIdentifier("discord_user_id"); + String colLinkedAt = adapter.quoteIdentifier("linked_at"); + String colUpdatedAt = adapter.quoteIdentifier("updated_at"); + + this.insertSql = String.format("INSERT INTO %s (%s, %s, %s, %s) VALUES (?, ?, %s, %s)", + tblDiscordLinks, colAdminUuid, colDiscordUserId, colLinkedAt, colUpdatedAt, + adapter.currentTimestamp(), adapter.currentTimestamp()); + this.selectDiscordIdSql = String.format("SELECT %s FROM %s WHERE %s = ?", colDiscordUserId, tblDiscordLinks, colAdminUuid); + this.selectAdminUuidSql = String.format("SELECT %s FROM %s WHERE %s = ?", colAdminUuid, tblDiscordLinks, colDiscordUserId); + this.deleteByAdminUuidSql = String.format("DELETE FROM %s WHERE %s = ?", tblDiscordLinks, colAdminUuid); + this.deleteByDiscordUserIdSql = String.format("DELETE FROM %s WHERE %s = ?", tblDiscordLinks, colDiscordUserId); + this.maxUpdatedAtSql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblDiscordLinks); + this.selectAllSql = String.format("SELECT %s, %s FROM %s", colAdminUuid, colDiscordUserId, tblDiscordLinks); + } + + @Override + public Map<String, String> loadAll() throws SQLException + { + Map<String, String> links = new LinkedHashMap<>(); + try (ResultSet rs = statementHandler.executeQuery(selectAllSql)) + { + while (rs.next()) + { + links.put(rs.getString(1), rs.getString(2)); + } + } + return links; + } + + @Override + public void insert(UUID adminUuid, String discordUserId) throws SQLException + { + statementHandler.executeUpdate(insertSql, adminUuid.toString(), discordUserId); + } + + @Override + public String findDiscordIdByAdminUuid(UUID adminUuid) throws SQLException + { + try (ResultSet rs = statementHandler.executeQuery(selectDiscordIdSql, adminUuid.toString())) + { + return rs.next() ? rs.getString(1) : null; + } + } + + @Override + public UUID findAdminUuidByDiscordId(String discordUserId) throws SQLException + { + try (ResultSet rs = statementHandler.executeQuery(selectAdminUuidSql, discordUserId)) + { + if (!rs.next()) + { + return null; + } + String raw = rs.getString(1); + return raw == null ? null : UUID.fromString(raw); + } + } + + @Override + public boolean deleteByAdminUuid(UUID adminUuid) throws SQLException + { + return statementHandler.executeUpdate(deleteByAdminUuidSql, adminUuid.toString()) > 0; + } + + @Override + public boolean deleteByDiscordUserId(String discordUserId) throws SQLException + { + return statementHandler.executeUpdate(deleteByDiscordUserIdSql, discordUserId) > 0; + } + + @Override + public Long getMaxUpdatedAt() throws SQLException + { + try (ResultSet rs = statementHandler.executeQuery(maxUpdatedAtSql)) + { + if (rs.next()) + { + return adapter.readTimestamp(rs, 1); + } + } + return null; + } + + @Override + public Mono<Map<String, String>> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono<Void> relinkAsync(UUID adminUuid, String discordUserId) + { + return statementHandler.runMono(() -> + { + deleteByAdminUuid(adminUuid); + deleteByDiscordUserId(discordUserId); + insert(adminUuid, discordUserId); + }); + } + + @Override + public Mono<Long> getMaxUpdatedAtAsync() + { + return statementHandler.supplyMono(this::getMaxUpdatedAt); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericMigrationRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericMigrationRepository.java new file mode 100644 index 000000000..b18d03f4b --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericMigrationRepository.java @@ -0,0 +1,77 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.LinkedHashSet; +import java.util.Set; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.MigrationRepository; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericMigrationRepository implements MigrationRepository +{ + private final StatementHandler statementHandler; + + private final String selectAllSql; + private final String insertSql; + private final String deleteAllSql; + + public GenericMigrationRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + + final String tblMigrations = adapter.quoteIdentifier("migrations"); + final String colVersion = adapter.quoteIdentifier("version"); + final String colAppliedAt = adapter.quoteIdentifier("applied_at"); + + this.selectAllSql = String.format("SELECT %s FROM %s", colVersion, tblMigrations); + this.insertSql = String.format("%s INTO %s (%s, %s) VALUES (?, %s)%s", + adapter.insertIgnoreSyntax(), tblMigrations, colVersion, colAppliedAt, + adapter.currentTimestamp(), adapter.insertIgnoreSuffix()); + this.deleteAllSql = String.format("DELETE FROM %s", tblMigrations); + } + + @Override + public Set<String> findApplied() throws SQLException + { + final Set<String> versions = new LinkedHashSet<>(); + try (ResultSet rs = statementHandler.executeQuery(selectAllSql)) + { + while (rs.next()) + { + versions.add(rs.getString(1)); + } + } + return versions; + } + + @Override + public void markApplied(String version) throws SQLException + { + statementHandler.executeUpdate(insertSql, version); + } + + @Override + public void clear() throws SQLException + { + statementHandler.executeUpdate(deleteAllSql); + } + + @Override + public Mono<Set<String>> findAppliedAsync() + { + return statementHandler.supplyMono(this::findApplied); + } + + @Override + public Mono<Void> markAppliedAsync(String version) + { + return statementHandler.runMono(() -> markApplied(version)); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java new file mode 100644 index 000000000..3950cc7be --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPermbanRepository.java @@ -0,0 +1,421 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.*; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.banning.PermBan; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericPermbanRepository implements PermbanRepository +{ + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblPermbans; + private final String tblPermbanIps; + private final String colId; + private final String colUuid; + private final String colUsername; + private final String colReason; + private final String colPermbanId; + private final String colIp; + private final String colUpdatedAt; + private final String selectColumns; + + public GenericPermbanRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblPermbans = adapter.quoteIdentifier("permbans"); + this.tblPermbanIps = adapter.quoteIdentifier("permban_ips"); + this.colId = adapter.quoteIdentifier("id"); + this.colUuid = adapter.quoteIdentifier("uuid"); + this.colUsername = adapter.quoteIdentifier("username"); + this.colReason = adapter.quoteIdentifier("reason"); + this.colPermbanId = adapter.quoteIdentifier("permban_id"); + this.colIp = adapter.quoteIdentifier("ip"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); + this.selectColumns = String.format("%s, %s, %s, %s", colId, colUuid, colUsername, colReason); + } + + @Override + public int insert(PermBan permban) throws SQLException + { + String sql = String.format("INSERT INTO %s (%s, %s, %s, %s) VALUES (?, ?, ?, %s)", + tblPermbans, colUuid, colUsername, colReason, colUpdatedAt, adapter.currentTimestamp()); + + long permbanId = statementHandler.executeUpdateReturnKey(sql, + permban.getUuid() != null ? permban.getUuid().toString() : null, + permban.getUsername(), + permban.getReason()); + + if (permbanId < 0) + { + return -1; + } + insertIps((int) permbanId, permban.getIps()); + return (int) permbanId; + } + + @Override + public void insertIps(int permbanId, List<String> ips) throws SQLException + { + if (ips == null || ips.isEmpty()) return; + + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblPermbanIps, colPermbanId, colIp, adapter.insertIgnoreSuffix()); + for (String ip : ips) + { + statementHandler.executeUpdate(sql, permbanId, ip); + } + } + + @Override + public void addIp(int permbanId, String ip) throws SQLException + { + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblPermbanIps, colPermbanId, colIp, adapter.insertIgnoreSuffix()); + statementHandler.executeUpdate(sql, permbanId, ip); + } + + @Override + public List<PermBan> loadAll() throws SQLException + { + List<PermBan> permbans = new ArrayList<>(); + Map<Integer, PermBan> permbanById = new HashMap<>(); + + String sql = String.format("SELECT %s FROM %s", selectColumns, tblPermbans); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + int id = rs.getInt("id"); + PermBan permban = loadPermbanFromRow(rs); + permbans.add(permban); + permbanById.put(id, permban); + } + } + + attachIps(permbanById); + + return permbans; + } + + /** + * Fold the {@code permban_ips} child rows into permbans that have already been read. + * One flat query, run after the caller's cursor is closed: a nested read would hold two + * connections at once, which SQLite's single-connection pool cannot serve. + */ + private void attachIps(Map<Integer, PermBan> permbanById) throws SQLException + { + if (permbanById.isEmpty()) + return; + + String ipSql = String.format("SELECT %s, %s FROM %s", colPermbanId, colIp, tblPermbanIps); + try (ResultSet rs = statementHandler.executeQuery(ipSql)) + { + while (rs.next()) + { + PermBan permban = permbanById.get(rs.getInt("permban_id")); + if (permban != null) + { + permban.addIp(rs.getString("ip")); + } + } + } + } + + /** + * Read a single permban and its IPs, closing the row's statement before looking the IPs up. + */ + private PermBan findOne(String sql, Object... params) throws SQLException + { + final PermBan permban; + final int permbanId; + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, params); + ResultSet rs = stmt.executeQuery()) + { + if (!rs.next()) + return null; + + permban = loadPermbanFromRow(rs); + permbanId = rs.getInt("id"); + } + + permban.setIps(getIps(permbanId)); + return permban; + } + + @Override + public PermBan findByUuid(UUID uuid) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblPermbans, colUuid); + return findOne(sql, uuid.toString()); + } + + @Override + public PermBan findByUsername(String username) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s", + selectColumns, tblPermbans, adapter.caseInsensitiveEquals(colUsername, "?")); + return findOne(sql, username); + } + + @Override + public PermBan findByIp(String ip) throws SQLException + { + String sql = String.format( + "SELECT p.%s, p.%s, p.%s, p.%s FROM %s p INNER JOIN %s pi ON p.%s = pi.%s WHERE pi.%s = ?", + colId, colUuid, colUsername, colReason, tblPermbans, tblPermbanIps, colId, colPermbanId, colIp); + return findOne(sql, ip); + } + + @Override + public int getPermbanId(UUID uuid) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colId, tblPermbans, colUuid); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) return rs.getInt("id"); + } + return -1; + } + + @Override + public int getPermbanIdByUsername(String username) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s", + colId, tblPermbans, adapter.caseInsensitiveEquals(colUsername, "?")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) return rs.getInt("id"); + } + return -1; + } + + @Override + public List<String> getIps(int permbanId) throws SQLException + { + List<String> ips = new ArrayList<>(); + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colIp, tblPermbanIps, colPermbanId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, permbanId); + ResultSet rs = stmt.executeQuery()) + { + while (rs.next()) + { + ips.add(rs.getString("ip")); + } + } + return ips; + } + + @Override + public boolean isPermBanned(UUID uuid) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblPermbans, colUuid); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public boolean isPermBannedByUsername(String username) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s", + tblPermbans, adapter.caseInsensitiveEquals(colUsername, "?")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public boolean isPermBannedByIp(String ip) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s p INNER JOIN %s pi ON p.%s = pi.%s WHERE pi.%s = ?", + tblPermbans, tblPermbanIps, colId, colPermbanId, colIp); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public boolean update(PermBan permban) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblPermbans, colUsername, colReason, colUpdatedAt, adapter.currentTimestamp(), colUuid); + + int rows = statementHandler.executeUpdate(sql, + permban.getUsername(), + permban.getReason(), + permban.getUuid().toString()); + + return rows > 0; + } + + @Override + public boolean updateReason(UUID uuid, String reason) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ? WHERE %s = ?", tblPermbans, colReason, colUuid); + return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; + } + + @Override + public void syncIps(int permbanId, List<String> ips) throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s = ?", tblPermbanIps, colPermbanId), permbanId); + insertIps(permbanId, ips); + } + + @Override + public boolean delete(UUID uuid) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblPermbans, colUuid); + return statementHandler.executeUpdate(sql, uuid.toString()) > 0; + } + + @Override + public boolean deleteByUsername(String username) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s", tblPermbans, adapter.caseInsensitiveEquals(colUsername, "?")); + return statementHandler.executeUpdate(sql, username) > 0; + } + + @Override + public boolean deleteByIp(String ip) throws SQLException + { + String selectSql = String.format("SELECT %s FROM %s WHERE %s = ?", colPermbanId, tblPermbanIps, colIp); + List<Integer> permbanIds = new ArrayList<>(); + try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); + ResultSet rs = stmt.executeQuery()) + { + while (rs.next()) + { + permbanIds.add(rs.getInt("permban_id")); + } + } + + String deleteSql = String.format("DELETE FROM %s WHERE %s = ?", tblPermbans, colId); + for (int permbanId : permbanIds) + { + statementHandler.executeUpdate(deleteSql, permbanId); + } + + return !permbanIds.isEmpty(); + } + + @Override + public boolean removeIp(int permbanId, String ip) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ? AND %s = ?", tblPermbanIps, colPermbanId, colIp); + return statementHandler.executeUpdate(sql, permbanId, ip) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblPermbanIps)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblPermbans)); + } + + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblPermbans); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + return adapter.readTimestamp(rs, 1); + } + } + return null; + } + + @Override + public Mono<List<PermBan>> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono<Integer> insertAsync(PermBan permban) + { + return statementHandler.supplyMono(() -> insert(permban)); + } + + @Override + public Mono<Boolean> updateAsync(PermBan permban) + { + return statementHandler.supplyMono(() -> update(permban)); + } + + @Override + public Mono<Boolean> deleteAsync(UUID uuid) + { + return statementHandler.supplyMono(() -> delete(uuid)); + } + + @Override + public Mono<Boolean> deleteByIpAsync(String ip) + { + return statementHandler.supplyMono(() -> deleteByIp(ip)); + } + + @Override + public Mono<Integer> save(PermBan permban) + { + return statementHandler.supplyMono(() -> { + if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) + { + update(permban); + return getPermbanId(permban.getUuid()); + } + return insert(permban); + }); + } + + @Override + public Mono<List<PermBan>> findAll() + { + return loadAllAsync(); + } + + @Override + public Mono<Void> deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } + + private PermBan loadPermbanFromRow(ResultSet rs) throws SQLException + { + String uuidStr = rs.getString("uuid"); + String username = rs.getString("username"); + String reason = rs.getString("reason"); + + PermBan permban = new PermBan(); + permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); + permban.setUsername(username); + permban.setReason(reason); + + return permban; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java new file mode 100644 index 000000000..08a5c1a2a --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericPlayerRepository.java @@ -0,0 +1,373 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.*; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.player.SpyMode; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.PlayerRepository; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericPlayerRepository implements PlayerRepository +{ + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblPlayers; + private final String tblPlayerIps; + private final String colUsername; + private final String colFirstJoin; + private final String colLastJoin; + private final String colPotionSpyMode; + private final String colCommandSpyMode; + private final String colSignSpyMode; + private final String colBookSpyMode; + private final String colMuted; + private final String colFrozen; + private final String colCommandsBlocked; + private final String colJoinLeaveMessages; + private final String colStrikes; + private final String colSavedTag; + private final String colTitles; + private final String colNickname; + private final String colId; + private final String colPlayerUsername; + private final String colIp; + private final String colUpdatedAt; + private final String selectColumns; + + public GenericPlayerRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblPlayers = adapter.quoteIdentifier("players"); + this.tblPlayerIps = adapter.quoteIdentifier("player_ips"); + this.colUsername = adapter.quoteIdentifier("username"); + this.colFirstJoin = adapter.quoteIdentifier("first_join_unix"); + this.colLastJoin = adapter.quoteIdentifier("last_join_unix"); + this.colPotionSpyMode = adapter.quoteIdentifier("potion_spy_mode"); + this.colCommandSpyMode = adapter.quoteIdentifier("command_spy_mode"); + this.colSignSpyMode = adapter.quoteIdentifier("sign_spy_mode"); + this.colBookSpyMode = adapter.quoteIdentifier("book_spy_mode"); + this.colMuted = adapter.quoteIdentifier("muted"); + this.colFrozen = adapter.quoteIdentifier("frozen"); + this.colCommandsBlocked = adapter.quoteIdentifier("commands_blocked"); + this.colJoinLeaveMessages = adapter.quoteIdentifier("join_leave_messages"); + this.colStrikes = adapter.quoteIdentifier("strikes"); + this.colSavedTag = adapter.quoteIdentifier("saved_tag"); + this.colTitles = adapter.quoteIdentifier("titles"); + this.colNickname = adapter.quoteIdentifier("nickname"); + this.colId = adapter.quoteIdentifier("id"); + this.colPlayerUsername = adapter.quoteIdentifier("username"); + this.colIp = adapter.quoteIdentifier("ip"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s", + colUsername, colFirstJoin, colLastJoin, colPotionSpyMode, colCommandSpyMode, colSignSpyMode, + colBookSpyMode, colMuted, colFrozen, colCommandsBlocked, colJoinLeaveMessages, colStrikes, + colSavedTag, colTitles) + ", " + colNickname; + } + + @Override + public void insert(PlayerData data) throws SQLException + { + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", + tblPlayers, selectColumns, colUpdatedAt, adapter.currentTimestamp()); + + statementHandler.executeUpdate(sql, + data.getUsername(), + data.getFirstJoinUnix(), + data.getLastJoinUnix(), + data.getPotionSpyMode().getName(), + data.getCommandSpyMode().getName(), + data.getSignSpyMode().getName(), + data.getBookSpyMode().getName(), + data.isMuted(), + data.isFrozen(), + data.isCommandsBlocked(), + data.isJoinLeaveMessagesEnabled(), + data.getStrikes(), + data.getSavedTag(), + serializeTitles(data), + serializeNickname(data)); + + insertIps(data.getUsername(), data.getIps()); + } + + @Override + public void insertIps(String username, List<String> ips) throws SQLException + { + if (ips == null || ips.isEmpty()) return; + + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblPlayerIps, colPlayerUsername, colIp, adapter.insertIgnoreSuffix()); + for (String ip : ips) + { + statementHandler.executeUpdate(sql, username, ip); + } + } + + @Override + public void addIp(String username, String ip) throws SQLException + { + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblPlayerIps, colPlayerUsername, colIp, adapter.insertIgnoreSuffix()); + statementHandler.executeUpdate(sql, username, ip); + } + + @Override + public Map<String, PlayerData> loadAll() throws SQLException + { + Map<String, PlayerData> players = new HashMap<>(); + + String sql = String.format("SELECT %s FROM %s", selectColumns, tblPlayers); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + PlayerData data = loadPlayerFromRow(rs); + players.put(data.getUsername(), data); + } + } + + String ipSql = String.format("SELECT %s, %s FROM %s ORDER BY %s ASC", colPlayerUsername, colIp, tblPlayerIps, colId); + try (ResultSet rs = statementHandler.executeQuery(ipSql)) + { + while (rs.next()) + { + PlayerData data = players.get(rs.getString("username")); + if (data != null) + { + data.addIp(rs.getString("ip")); + } + } + } + + return players; + } + + @Override + public Optional<PlayerData> findByUsername(String username) throws SQLException + { + PlayerData data = null; + String sql = String.format("SELECT %s FROM %s WHERE %s", selectColumns, tblPlayers, + adapter.caseInsensitiveEquals(colUsername, "?")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + data = loadPlayerFromRow(rs); + } + } + + if (data == null) + { + return Optional.empty(); + } + + getIps(data.getUsername()).forEach(data::addIp); + return Optional.of(data); + } + + @Override + public boolean exists(String username) throws SQLException + { + String sql = String.format("SELECT COUNT (*) FROM %s WHERE %s", tblPlayers, + adapter.caseInsensitiveEquals(colUsername, "?")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public List<String> getIps(String username) throws SQLException + { + List<String> ips = new ArrayList<>(); + String sql = String.format("SELECT %s FROM %s WHERE %s ORDER BY %s ASC", colIp, tblPlayerIps, + adapter.caseInsensitiveEquals(colPlayerUsername, "?"), colId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + while (rs.next()) + { + ips.add(rs.getString("ip")); + } + } + return ips; + } + + @Override + public boolean update(PlayerData data) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblPlayers, colFirstJoin, colLastJoin, colPotionSpyMode, colCommandSpyMode, colSignSpyMode, + colBookSpyMode, colMuted, colFrozen, colCommandsBlocked, colJoinLeaveMessages, colStrikes, colSavedTag, colTitles, + colUpdatedAt, adapter.currentTimestamp(), colUsername); + + int rows = statementHandler.executeUpdate(sql, + data.getFirstJoinUnix(), + data.getLastJoinUnix(), + data.getPotionSpyMode().getName(), + data.getCommandSpyMode().getName(), + data.getSignSpyMode().getName(), + data.getBookSpyMode().getName(), + data.isMuted(), + data.isFrozen(), + data.isCommandsBlocked(), + data.isJoinLeaveMessagesEnabled(), + data.getStrikes(), + data.getSavedTag(), + serializeTitles(data), + data.getUsername()); + + statementHandler.executeUpdate( + String.format("UPDATE %s SET %s = ? WHERE %s", tblPlayers, colNickname, + adapter.caseInsensitiveEquals(colPlayerUsername, "?")), + serializeNickname(data), data.getUsername()); + + return rows > 0; + } + + @Override + public void syncIps(String username, List<String> ips) throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s", tblPlayerIps, + adapter.caseInsensitiveEquals(colPlayerUsername, "?")), username); + insertIps(username, ips); + } + + @Override + public void saveOrUpdate(PlayerData data) throws SQLException + { + if (exists(data.getUsername())) + { + update(data); + syncIps(data.getUsername(), data.getIps()); + } + else + { + insert(data); + } + } + + @Override + public boolean delete(String username) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s", tblPlayers, + adapter.caseInsensitiveEquals(colUsername, "?")); + return statementHandler.executeUpdate(sql, username) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblPlayerIps)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblPlayers)); + } + + @Override + public Long getUpdatedAt(String username) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s", colUpdatedAt, tblPlayers, + adapter.caseInsensitiveEquals(colUsername, "?")); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + return adapter.readTimestamp(rs, 1); + } + } + return null; + } + + @Override + public Mono<Map<String, PlayerData>> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono<Void> save(PlayerData data) + { + return statementHandler.runMono(() -> saveOrUpdate(data)); + } + + @Override + public Mono<Boolean> deleteAsync(String username) + { + return statementHandler.supplyMono(() -> delete(username)); + } + + @Override + public Mono<Void> deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } + + private PlayerData loadPlayerFromRow(ResultSet rs) throws SQLException + { + PlayerData data = new PlayerData(rs.getString("username")); + data.setFirstJoinUnix(rs.getLong("first_join_unix")); + data.setLastJoinUnix(rs.getLong("last_join_unix")); + data.setPotionSpyMode(SpyMode.fromStorage(rs.getString("potion_spy_mode"))); + data.setCommandSpyMode(SpyMode.fromStorage(rs.getString("command_spy_mode"))); + data.setSignSpyMode(SpyMode.fromStorage(rs.getString("sign_spy_mode"))); + data.setBookSpyMode(SpyMode.fromStorage(rs.getString("book_spy_mode"))); + data.setMuted(rs.getBoolean("muted")); + data.setFrozen(rs.getBoolean("frozen")); + data.setCommandsBlocked(rs.getBoolean("commands_blocked")); + data.setJoinLeaveMessagesEnabled(rs.getBoolean("join_leave_messages")); + data.setStrikes(rs.getInt("strikes")); + data.setSavedTag(rs.getString("saved_tag")); + data.setTitles(parseTitles(rs.getString("titles"))); + + String rawNickname = rs.getString("nickname"); + if (rawNickname != null && !rawNickname.isEmpty()) + { + data.setNicknameRaw(AdventureUtil.legacyToComponent(rawNickname)); + } + + return data; + } + + /** + * Held titles are stored as a comma-separated list in one column rather than as a child table: + * a player holds a handful at most and they are always read with the player row itself, so a + * join would cost more than it saves. + */ + private static String serializeTitles(PlayerData data) + { + final Set<String> titles = data.getTitles(); + + return titles.isEmpty() ? null : String.join(",", titles); + } + + private static List<String> parseTitles(String stored) + { + return stored == null || stored.isBlank() + ? List.of() + : Arrays.stream(stored.split(",")) + .map(String::trim) + .filter(id -> !id.isEmpty()) + .toList(); + } + + private static String serializeNickname(PlayerData data) + { + return data.getNickname() != null ? AdventureUtil.componentToLegacy(data.getNickname()) : null; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java new file mode 100644 index 000000000..289c3e7f6 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericProtectedAreaRepository.java @@ -0,0 +1,245 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion; +import me.totalfreedom.totalfreedommod.ProtectArea.ProtectedRegion.CantFindWorldException; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.ProtectedAreaRepository; +import me.totalfreedom.totalfreedommod.util.FLog; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericProtectedAreaRepository implements ProtectedAreaRepository +{ + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblProtectedAreas; + private final String colUuid; + private final String colName; + private final String colMinX; + private final String colMinY; + private final String colMinZ; + private final String colMaxX; + private final String colMaxY; + private final String colMaxZ; + private final String colWorldUuid; + private final String colUpdatedAt; + private final String selectColumns; + + public GenericProtectedAreaRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblProtectedAreas = adapter.quoteIdentifier("protected_areas"); + this.colUuid = adapter.quoteIdentifier("uuid"); + this.colName = adapter.quoteIdentifier("name"); + this.colMinX = adapter.quoteIdentifier("min_x"); + this.colMinY = adapter.quoteIdentifier("min_y"); + this.colMinZ = adapter.quoteIdentifier("min_z"); + this.colMaxX = adapter.quoteIdentifier("max_x"); + this.colMaxY = adapter.quoteIdentifier("max_y"); + this.colMaxZ = adapter.quoteIdentifier("max_z"); + this.colWorldUuid = adapter.quoteIdentifier("world_uuid"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s", + colUuid, colName, colMinX, colMinY, colMinZ, colMaxX, colMaxY, colMaxZ, colWorldUuid); + } + + @Override + public void insert(ProtectedRegion region) throws SQLException + { + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", + tblProtectedAreas, selectColumns, colUpdatedAt, adapter.currentTimestamp()); + statementHandler.executeUpdate(sql, + region.getUuid().toString(), + region.getName(), + region.getMinVector().getBlockX(), + region.getMinVector().getBlockY(), + region.getMinVector().getBlockZ(), + region.getMaxVector().getBlockX(), + region.getMaxVector().getBlockY(), + region.getMaxVector().getBlockZ(), + region.getWorldUUID().toString()); + } + + @Override + public List<ProtectedRegion> loadAll() throws SQLException + { + List<ProtectedRegion> regions = new ArrayList<>(); + String sql = String.format("SELECT %s FROM %s", selectColumns, tblProtectedAreas); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + ProtectedRegion region = tryLoadRegionFromRow(rs); + if (region != null) + { + regions.add(region); + } + } + } + return regions; + } + + @Override + public ProtectedRegion findByUuid(UUID uuid) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblProtectedAreas, colUuid); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + return tryLoadRegionFromRow(rs); + } + } + return null; + } + + @Override + public ProtectedRegion findByName(String name) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblProtectedAreas, colName); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, name); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + return tryLoadRegionFromRow(rs); + } + } + return null; + } + + @Override + public boolean exists(UUID uuid) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblProtectedAreas, colUuid); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public boolean update(ProtectedRegion region) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblProtectedAreas, colName, colMinX, colMinY, colMinZ, colMaxX, colMaxY, colMaxZ, colWorldUuid, + colUpdatedAt, adapter.currentTimestamp(), colUuid); + + int rows = statementHandler.executeUpdate(sql, + region.getName(), + region.getMinVector().getBlockX(), + region.getMinVector().getBlockY(), + region.getMinVector().getBlockZ(), + region.getMaxVector().getBlockX(), + region.getMaxVector().getBlockY(), + region.getMaxVector().getBlockZ(), + region.getWorldUUID().toString(), + region.getUuid().toString()); + + return rows > 0; + } + + @Override + public void saveOrUpdate(ProtectedRegion region) throws SQLException + { + if (exists(region.getUuid())) + { + update(region); + } + else + { + insert(region); + } + } + + @Override + public boolean delete(UUID uuid) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblProtectedAreas, colUuid); + return statementHandler.executeUpdate(sql, uuid.toString()) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblProtectedAreas)); + } + + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblProtectedAreas); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + return adapter.readTimestamp(rs, 1); + } + } + return null; + } + + @Override + public Mono<List<ProtectedRegion>> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono<Void> save(ProtectedRegion region) + { + return statementHandler.runMono(() -> saveOrUpdate(region)); + } + + @Override + public Mono<Boolean> deleteAsync(UUID uuid) + { + return statementHandler.supplyMono(() -> delete(uuid)); + } + + @Override + public Mono<Void> deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } + + private ProtectedRegion tryLoadRegionFromRow(ResultSet rs) throws SQLException + { + UUID uuid = UUID.fromString(rs.getString("uuid")); + String name = rs.getString("name"); + int minX = rs.getInt("min_x"); + int minY = rs.getInt("min_y"); + int minZ = rs.getInt("min_z"); + int maxX = rs.getInt("max_x"); + int maxY = rs.getInt("max_y"); + int maxZ = rs.getInt("max_z"); + String worldUuid = rs.getString("world_uuid"); + + try + { + return new ProtectedRegion(uuid, name, minX, minY, minZ, maxX, maxY, maxZ, worldUuid); + } + catch (CantFindWorldException ex) + { + FLog.warning(ex.getMessage()); + return null; + } + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java new file mode 100644 index 000000000..23d7cdcfe --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericRankRepository.java @@ -0,0 +1,352 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.*; +import java.util.stream.Collectors; + +import net.kyori.adventure.text.format.NamedTextColor; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.rank.CustomRank; +import me.totalfreedom.totalfreedommod.rank.RankRole; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.RankRepository; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericRankRepository implements RankRepository +{ + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblRanks; + private final String tblRankPermissions; + private final String colId; + private final String colName; + private final String colDeterminer; + private final String colAbbreviation; + private final String colLevel; + private final String colColor; + private final String colAdmin; + private final String colPrefix; + private final String colInheritFrom; + private final String colRoles; + private final String colRankId; + private final String colPermission; + private final String colUpdatedAt; + private final String selectColumns; + + public GenericRankRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblRanks = adapter.quoteIdentifier("ranks"); + this.tblRankPermissions = adapter.quoteIdentifier("rank_permissions"); + this.colId = adapter.quoteIdentifier("id"); + this.colName = adapter.quoteIdentifier("name"); + this.colDeterminer = adapter.quoteIdentifier("determiner"); + this.colAbbreviation = adapter.quoteIdentifier("abbreviation"); + this.colLevel = adapter.quoteIdentifier("level"); + this.colColor = adapter.quoteIdentifier("color"); + this.colAdmin = adapter.quoteIdentifier("admin"); + this.colPrefix = adapter.quoteIdentifier("prefix"); + this.colInheritFrom = adapter.quoteIdentifier("inherit_from"); + this.colRoles = adapter.quoteIdentifier("roles"); + this.colRankId = adapter.quoteIdentifier("rank_id"); + this.colPermission = adapter.quoteIdentifier("permission"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s, %s, %s", + colId, colName, colDeterminer, colAbbreviation, colLevel, colColor, colAdmin, + colPrefix, colInheritFrom, colRoles); + } + + @Override + public void insert(CustomRank rank) throws SQLException + { + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)", + tblRanks, selectColumns, colUpdatedAt, adapter.currentTimestamp()); + + statementHandler.executeUpdate(sql, + rank.getId(), + rank.getName(), + rank.getDeterminer(), + rank.getAbbreviation(), + rank.getLevel(), + serializeColor(rank.getColor()), + rank.isAdmin(), + rank.getPrefix(), + rank.getInheritFrom(), + serializeRoles(rank.getRoles())); + + insertPermissions(rank.getId(), rank.getPermissions()); + } + + @Override + public void insertPermissions(String rankId, Set<String> permissions) throws SQLException + { + if (permissions == null || permissions.isEmpty()) return; + + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblRankPermissions, colRankId, colPermission, adapter.insertIgnoreSuffix()); + for (String permission : permissions) + { + statementHandler.executeUpdate(sql, rankId, permission); + } + } + + @Override + public void addPermission(String rankId, String permission) throws SQLException + { + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblRankPermissions, colRankId, colPermission, adapter.insertIgnoreSuffix()); + statementHandler.executeUpdate(sql, rankId, permission); + } + + @Override + public Map<String, CustomRank> loadAll() throws SQLException + { + Map<String, CustomRank> ranks = new LinkedHashMap<>(); + + String sql = String.format("SELECT %s FROM %s", selectColumns, tblRanks); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + CustomRank rank = loadRankFromRow(rs); + ranks.put(rank.getId(), rank); + } + } + + String permSql = String.format("SELECT %s, %s FROM %s", colRankId, colPermission, tblRankPermissions); + try (ResultSet rs = statementHandler.executeQuery(permSql)) + { + while (rs.next()) + { + CustomRank rank = ranks.get(rs.getString("rank_id")); + if (rank != null) + { + rank.addPermission(rs.getString("permission")); + } + } + } + + return ranks; + } + + @Override + public CustomRank findById(String id) throws SQLException + { + CustomRank rank = null; + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblRanks, colId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, id); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + rank = loadRankFromRow(rs); + } + } + + if (rank == null) + { + return null; + } + + getPermissions(rank.getId()).forEach(rank::addPermission); + return rank; + } + + @Override + public boolean exists(String id) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblRanks, colId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, id); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public Set<String> getPermissions(String rankId) throws SQLException + { + Set<String> permissions = new HashSet<>(); + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colPermission, tblRankPermissions, colRankId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, rankId); + ResultSet rs = stmt.executeQuery()) + { + while (rs.next()) + { + permissions.add(rs.getString("permission")); + } + } + return permissions; + } + + @Override + public boolean update(CustomRank rank) throws SQLException + { + String sql = String.format("UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblRanks, colName, colDeterminer, colAbbreviation, colLevel, colColor, colAdmin, + colPrefix, colInheritFrom, colRoles, colUpdatedAt, adapter.currentTimestamp(), colId); + + int rows = statementHandler.executeUpdate(sql, + rank.getName(), + rank.getDeterminer(), + rank.getAbbreviation(), + rank.getLevel(), + serializeColor(rank.getColor()), + rank.isAdmin(), + rank.getPrefix(), + rank.getInheritFrom(), + serializeRoles(rank.getRoles()), + rank.getId()); + + return rows > 0; + } + + @Override + public void syncPermissions(String rankId, Set<String> permissions) throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s WHERE %s = ?", tblRankPermissions, colRankId), rankId); + insertPermissions(rankId, permissions); + } + + @Override + public void saveOrUpdate(CustomRank rank) throws SQLException + { + if (exists(rank.getId())) + { + update(rank); + syncPermissions(rank.getId(), rank.getPermissions()); + } + else + { + insert(rank); + } + } + + @Override + public boolean delete(String id) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblRanks, colId); + return statementHandler.executeUpdate(sql, id) > 0; + } + + @Override + public boolean removePermission(String rankId, String permission) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ? AND %s = ?", tblRankPermissions, colRankId, colPermission); + return statementHandler.executeUpdate(sql, rankId, permission) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblRankPermissions)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblRanks)); + } + + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblRanks); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + return adapter.readTimestamp(rs, 1); + } + } + return null; + } + + @Override + public Mono<Map<String, CustomRank>> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono<Void> save(CustomRank rank) + { + return statementHandler.runMono(() -> saveOrUpdate(rank)); + } + + @Override + public Mono<Boolean> deleteAsync(String id) + { + return statementHandler.supplyMono(() -> delete(id)); + } + + @Override + public Mono<Void> deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } + + private CustomRank loadRankFromRow(ResultSet rs) throws SQLException + { + String id = rs.getString("id"); + CustomRank rank = new CustomRank(id); + rank.setName(rs.getString("name")); + rank.setDeterminer(rs.getString("determiner")); + rank.setAbbreviation(rs.getString("abbreviation")); + rank.setLevel(rs.getInt("level")); + rank.setColor(parseColor(rs.getString("color"))); + rank.setAdmin(rs.getBoolean("admin")); + rank.setPrefix(rs.getString("prefix")); + rank.setInheritFrom(rs.getString("inherit_from")); + rank.setRoles(parseRoles(rs.getString("roles"))); + return rank; + } + + /** + * Roles are stored as a comma-separated list in one column rather than as a child table: the + * set is tiny, closed, and always read with the rank it belongs to, so a join would cost more + * than it saves. + */ + private static String serializeRoles(Set<RankRole> roles) + { + return roles == null || roles.isEmpty() + ? null + : roles.stream() + .filter(Objects::nonNull) + .map(RankRole::getId) + .collect(Collectors.joining(",")); + } + + /** + * Reads roles back, ignoring any name this build does not know so that a database written by a + * newer version still loads. + */ + private static Set<RankRole> parseRoles(String stored) + { + if (stored == null || stored.isBlank()) + return EnumSet.noneOf(RankRole.class); + + return Arrays.stream(stored.split(",")) + .map(RankRole::fromId) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(RankRole.class))); + } + + private static String serializeColor(NamedTextColor color) + { + return NamedTextColor.NAMES.keyOrThrow(color); + } + + private static NamedTextColor parseColor(String name) + { + NamedTextColor color = name == null ? null : NamedTextColor.NAMES.value(name.toLowerCase()); + return color != null ? color : NamedTextColor.WHITE; + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java new file mode 100644 index 000000000..457ad3afe --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericSavedFlagRepository.java @@ -0,0 +1,117 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.HashMap; +import java.util.Map; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.SavedFlagRepository; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericSavedFlagRepository implements SavedFlagRepository +{ + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblSavedFlags; + private final String colFlagName; + private final String colEnabled; + private final String colUpdatedAt; + private final String selectSql; + private final String upsertSql; + private final String maxUpdatedAtSql; + + public GenericSavedFlagRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblSavedFlags = adapter.quoteIdentifier("saved_flags"); + this.colFlagName = adapter.quoteIdentifier("flag_name"); + this.colEnabled = adapter.quoteIdentifier("enabled"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); + + this.selectSql = String.format("SELECT %s, %s FROM %s", colFlagName, colEnabled, tblSavedFlags); + this.upsertSql = String.format("INSERT INTO %s (%s, %s, %s) VALUES (?, ?, %s) %s", + tblSavedFlags, colFlagName, colEnabled, colUpdatedAt, adapter.currentTimestamp(), + adapter.upsertClause(colFlagName, colEnabled, colUpdatedAt)); + this.maxUpdatedAtSql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblSavedFlags); + } + + @Override + public Map<String, Boolean> loadAll() throws SQLException + { + Map<String, Boolean> flags = new HashMap<>(); + try (ResultSet rs = statementHandler.executeQuery(selectSql)) + { + while (rs.next()) + { + flags.put(rs.getString("flag_name"), rs.getBoolean("enabled")); + } + } + return flags; + } + + @Override + public void upsert(String flagName, boolean enabled) throws SQLException + { + statementHandler.executeUpdate(upsertSql, flagName, enabled); + } + + @Override + public boolean delete(String flagName) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblSavedFlags, colFlagName); + return statementHandler.executeUpdate(sql, flagName) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblSavedFlags)); + } + + @Override + public Long getMaxUpdatedAt() throws SQLException + { + try (ResultSet rs = statementHandler.executeQuery(maxUpdatedAtSql)) + { + if (rs.next()) + { + return adapter.readTimestamp(rs, 1); + } + } + return null; + } + + @Override + public Mono<Map<String, Boolean>> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono<Void> upsertAsync(String flagName, boolean enabled) + { + return statementHandler.runMono(() -> upsert(flagName, enabled)); + } + + @Override + public Mono<Boolean> deleteAsync(String flagName) + { + return statementHandler.supplyMono(() -> delete(flagName)); + } + + @Override + public Mono<Void> deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java new file mode 100644 index 000000000..d52e0749c --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericStrikeRepository.java @@ -0,0 +1,127 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.HashMap; +import java.util.Map; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.banning.StrikeRecord; +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericStrikeRepository implements StrikeRepository +{ + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblStrikes; + private final String colIp; + private final String colStrikeCount; + private final String colLastStrikeUnix; + private final String colLastUsername; + private final String colUpdatedAt; + private final String upsertSql; + private final String selectSql; + + public GenericStrikeRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblStrikes = adapter.quoteIdentifier("strikes"); + this.colIp = adapter.quoteIdentifier("ip"); + this.colStrikeCount = adapter.quoteIdentifier("strike_count"); + this.colLastStrikeUnix = adapter.quoteIdentifier("last_strike_unix"); + this.colLastUsername = adapter.quoteIdentifier("last_username"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); + + this.selectSql = String.format("SELECT %s, %s, %s, %s FROM %s", + colIp, colStrikeCount, colLastStrikeUnix, colLastUsername, tblStrikes); + this.upsertSql = String.format("INSERT INTO %s (%s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, %s) %s", + tblStrikes, colIp, colStrikeCount, colLastStrikeUnix, colLastUsername, colUpdatedAt, + adapter.currentTimestamp(), + adapter.upsertClause(colIp, colStrikeCount, colLastStrikeUnix, colLastUsername, colUpdatedAt)); + } + + @Override + public Map<String, StrikeRecord> loadAll() throws SQLException + { + Map<String, StrikeRecord> out = new HashMap<>(); + try (ResultSet rs = statementHandler.executeQuery(selectSql)) + { + while (rs.next()) + { + String ip = rs.getString("ip"); + int count = rs.getInt("strike_count"); + long last = rs.getLong("last_strike_unix"); + String username = rs.getString("last_username"); + out.put(ip, new StrikeRecord(ip, count, last, username)); + } + } + return out; + } + + @Override + public void upsert(StrikeRecord r) throws SQLException + { + statementHandler.executeUpdate(upsertSql, r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); + } + + @Override + public boolean deleteByIp(String ip) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblStrikes, colIp); + return statementHandler.executeUpdate(sql, ip) > 0; + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblStrikes)); + } + + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblStrikes); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + return adapter.readTimestamp(rs, 1); + } + } + return null; + } + + @Override + public Mono<Map<String, StrikeRecord>> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono<Void> upsertAsync(StrikeRecord r) + { + return statementHandler.runMono(() -> upsert(r)); + } + + @Override + public Mono<Boolean> deleteByIpAsync(String ip) + { + return statementHandler.supplyMono(() -> deleteByIp(ip)); + } + + @Override + public Mono<Void> deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java new file mode 100644 index 000000000..a923030a7 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/generic/GenericTitleRepository.java @@ -0,0 +1,319 @@ +package me.totalfreedom.totalfreedommod.sql.adapter.generic; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import net.kyori.adventure.text.format.NamedTextColor; + +import reactor.core.publisher.Mono; + +import me.totalfreedom.totalfreedommod.sql.StatementHandler; +import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; +import me.totalfreedom.totalfreedommod.sql.adapter.TitleRepository; +import me.totalfreedom.totalfreedommod.title.Title; + +/** + * All dialect differences are resolved through the {@link DatabaseAdapter} passed in. + */ +public class GenericTitleRepository implements TitleRepository +{ + + private final StatementHandler statementHandler; + private final DatabaseAdapter adapter; + + private final String tblTitles; + private final String tblTitlePermissions; + private final String colId; + private final String colName; + private final String colDeterminer; + private final String colAbbreviation; + private final String colColor; + private final String colPrefix; + private final String colWeight; + private final String colAnnounce; + private final String colTitleId; + private final String colPermission; + private final String colUpdatedAt; + private final String selectColumns; + + public GenericTitleRepository(StatementHandler statementHandler, DatabaseAdapter adapter) + { + this.statementHandler = statementHandler; + this.adapter = adapter; + + this.tblTitles = adapter.quoteIdentifier("titles"); + this.tblTitlePermissions = adapter.quoteIdentifier("title_permissions"); + this.colId = adapter.quoteIdentifier("id"); + this.colName = adapter.quoteIdentifier("name"); + this.colDeterminer = adapter.quoteIdentifier("determiner"); + this.colAbbreviation = adapter.quoteIdentifier("abbreviation"); + this.colColor = adapter.quoteIdentifier("color"); + this.colPrefix = adapter.quoteIdentifier("prefix"); + this.colWeight = adapter.quoteIdentifier("weight"); + this.colAnnounce = adapter.quoteIdentifier("announce"); + this.colTitleId = adapter.quoteIdentifier("title_id"); + this.colPermission = adapter.quoteIdentifier("permission"); + this.colUpdatedAt = adapter.quoteIdentifier("updated_at"); + this.selectColumns = String.format("%s, %s, %s, %s, %s, %s, %s, %s", + colId, colName, colDeterminer, colAbbreviation, colColor, colPrefix, colWeight, colAnnounce); + } + + @Override + public void insert(Title title) throws SQLException + { + String sql = String.format("INSERT INTO %s (%s, %s) VALUES (?, ?, ?, ?, ?, ?, ?, ?, %s)", + tblTitles, selectColumns, colUpdatedAt, adapter.currentTimestamp()); + + statementHandler.executeUpdate(sql, + title.getId(), + title.getName(), + title.getDeterminer(), + title.getAbbreviation(), + serializeColor(title.getColor()), + title.getPrefix(), + title.getWeight(), + title.isAnnounce()); + + insertPermissions(title.getId(), title.getPermissions()); + } + + @Override + public void insertPermissions(String titleId, Set<String> permissions) throws SQLException + { + if (permissions == null || permissions.isEmpty()) return; + + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblTitlePermissions, colTitleId, colPermission, + adapter.insertIgnoreSuffix()); + + for (String permission : permissions) + { + statementHandler.executeUpdate(sql, titleId, permission); + } + } + + @Override + public void addPermission(String titleId, String permission) throws SQLException + { + String sql = String.format("%s INTO %s (%s, %s) VALUES (?, ?)%s", + adapter.insertIgnoreSyntax(), tblTitlePermissions, colTitleId, colPermission, + adapter.insertIgnoreSuffix()); + statementHandler.executeUpdate(sql, titleId, permission); + } + + @Override + public Map<String, Title> loadAll() throws SQLException + { + Map<String, Title> titles = new LinkedHashMap<>(); + + String sql = String.format("SELECT %s FROM %s", selectColumns, tblTitles); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + while (rs.next()) + { + Title title = loadTitleFromRow(rs); + titles.put(title.getId(), title); + } + } + + // One flat read of the child table rather than a query per title: the cursor above is + // already closed, and a nested read would hold two connections at once, which SQLite's + // single-connection pool cannot serve. + String permSql = String.format("SELECT %s, %s FROM %s", colTitleId, colPermission, tblTitlePermissions); + try (ResultSet rs = statementHandler.executeQuery(permSql)) + { + while (rs.next()) + { + Title title = titles.get(rs.getString("title_id")); + if (title != null) + { + title.addPermission(rs.getString("permission")); + } + } + } + + return titles; + } + + @Override + public Title findById(String id) throws SQLException + { + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", selectColumns, tblTitles, colId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, id); + ResultSet rs = stmt.executeQuery()) + { + if (rs.next()) + { + Title title = loadTitleFromRow(rs); + getPermissions(title.getId()).forEach(title::addPermission); + return title; + } + } + return null; + } + + @Override + public boolean exists(String id) throws SQLException + { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", tblTitles, colId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, id); + ResultSet rs = stmt.executeQuery()) + { + return rs.next() && rs.getInt(1) > 0; + } + } + + @Override + public Set<String> getPermissions(String titleId) throws SQLException + { + Set<String> permissions = new HashSet<>(); + String sql = String.format("SELECT %s FROM %s WHERE %s = ?", colPermission, tblTitlePermissions, colTitleId); + try (PreparedStatement stmt = statementHandler.prepareStatement(sql, titleId); + ResultSet rs = stmt.executeQuery()) + { + while (rs.next()) + { + permissions.add(rs.getString("permission")); + } + } + return permissions; + } + + @Override + public boolean update(Title title) throws SQLException + { + String sql = String.format( + "UPDATE %s SET %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = ?, %s = %s WHERE %s = ?", + tblTitles, colName, colDeterminer, colAbbreviation, colColor, colPrefix, colWeight, + colAnnounce, colUpdatedAt, adapter.currentTimestamp(), colId); + + int rows = statementHandler.executeUpdate(sql, + title.getName(), + title.getDeterminer(), + title.getAbbreviation(), + serializeColor(title.getColor()), + title.getPrefix(), + title.getWeight(), + title.isAnnounce(), + title.getId()); + + return rows > 0; + } + + @Override + public void syncPermissions(String titleId, Set<String> permissions) throws SQLException + { + statementHandler.executeUpdate( + String.format("DELETE FROM %s WHERE %s = ?", tblTitlePermissions, colTitleId), titleId); + insertPermissions(titleId, permissions); + } + + @Override + public void saveOrUpdate(Title title) throws SQLException + { + if (exists(title.getId())) + { + update(title); + syncPermissions(title.getId(), title.getPermissions()); + } + else + { + insert(title); + } + } + + @Override + public boolean delete(String id) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ?", tblTitles, colId); + return statementHandler.executeUpdate(sql, id) > 0; + } + + @Override + public boolean removePermission(String titleId, String permission) throws SQLException + { + String sql = String.format("DELETE FROM %s WHERE %s = ? AND %s = ?", + tblTitlePermissions, colTitleId, colPermission); + return statementHandler.executeUpdate(sql, titleId, permission) > 0; + } + + @Override + public Long getMaxUpdatedAt() throws SQLException + { + String sql = String.format("SELECT MAX(%s) FROM %s", colUpdatedAt, tblTitles); + try (ResultSet rs = statementHandler.executeQuery(sql)) + { + if (rs.next()) + { + return adapter.readTimestamp(rs, 1); + } + } + return null; + } + + @Override + public Mono<Map<String, Title>> loadAllAsync() + { + return statementHandler.supplyMono(this::loadAll); + } + + @Override + public Mono<Void> save(Title title) + { + return statementHandler.runMono(() -> saveOrUpdate(title)); + } + + @Override + public Mono<Boolean> deleteAsync(String id) + { + return statementHandler.supplyMono(() -> delete(id)); + } + + @Override + public void deleteAllSync() throws SQLException + { + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblTitlePermissions)); + statementHandler.executeUpdate(String.format("DELETE FROM %s", tblTitles)); + } + + @Override + public Mono<Void> deleteAll() + { + return statementHandler.runMono(this::deleteAllSync); + } + + private Title loadTitleFromRow(ResultSet rs) throws SQLException + { + Title title = new Title(rs.getString("id")); + title.setName(rs.getString("name")); + title.setDeterminer(rs.getString("determiner")); + title.setAbbreviation(rs.getString("abbreviation")); + title.setColor(parseColor(rs.getString("color"))); + title.setPrefix(rs.getString("prefix")); + title.setWeight(rs.getInt("weight")); + title.setAnnounce(rs.getBoolean("announce")); + return title; + } + + private static String serializeColor(NamedTextColor color) + { + return NamedTextColor.NAMES.keyOrThrow(color); + } + + private static NamedTextColor parseColor(String name) + { + if (name == null) + return NamedTextColor.WHITE; + + final NamedTextColor color = NamedTextColor.NAMES.value(name.toLowerCase()); + + return color != null ? color : NamedTextColor.WHITE; + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/h2/H2Adapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/h2/H2Adapter.java deleted file mode 100644 index 05e2451cc..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/h2/H2Adapter.java +++ /dev/null @@ -1,318 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.h2; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLAdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLBanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLDiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLPermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.mysql.MySQLStrikeRepository; -import me.totalfreedom.totalfreedommod.util.FLog; - -import java.sql.SQLException; - -/** - * H2 database adapter. - * H2 is mostly MySQL-compatible, so we reuse MySQL repositories. - * Key differences: - * - Uses IDENTITY instead of AUTO_INCREMENT (but AUTO_INCREMENT also works) - * - Uses MERGE for upsert operations (but INSERT IGNORE can be simulated) - * - Double quotes (") for identifier quoting by default (can be configured) - */ -public class H2Adapter extends DatabaseAdapter -{ - private MySQLAdminRepository adminRepository; - private MySQLBanRepository banRepository; - private MySQLPermbanRepository permbanRepository; - private MySQLStrikeRepository strikeRepository; - private MySQLDiscordLinkRepository discordLinkRepository; - - public H2Adapter(TotalFreedomMod plugin, ConnectionHandler connectionHandler, StatementHandler statementHandler) - { - super(plugin, connectionHandler, statementHandler); - } - - // ============================================ - // SQL Dialect Methods - // ============================================ - - @Override - public String autoIncrementSyntax() - { - // H2 supports AUTO_INCREMENT for MySQL compatibility - return "INT AUTO_INCREMENT"; - } - - @Override - public String primaryKeySyntax() - { - return "PRIMARY KEY"; - } - - @Override - public String textType() - { - return "VARCHAR(65535)"; // H2 doesn't have TEXT by default, use large VARCHAR - } - - @Override - public String timestampType() - { - return "TIMESTAMP"; - } - - @Override - public String booleanType() - { - return "BOOLEAN"; - } - - @Override - public String insertIgnoreSyntax() - { - // H2: MERGE INTO for upsert, but we'll use a workaround - return "INSERT"; - } - - @Override - public String quoteIdentifier(String identifier) - { - return "\"" + identifier + "\""; - } - - @Override - public String currentTimestamp() - { - return "CURRENT_TIMESTAMP()"; - } - - @Override - public String caseInsensitiveLike() - { - return "ILIKE"; // H2 supports ILIKE - } - - // ============================================ - // Migration Methods - // ============================================ - - @Override - public void runMigrations() throws SQLException - { - FLog.info("[H2] Running database migrations..."); - - createMigrationTable(); - createAdminsTable(); - createAdminIpsTable(); - createBansTable(); - createBanIpsTable(); - createPermbansTable(); - createPermbanIpsTable(); - createStrikesTable(); - createDiscordLinksTable(); - - FLog.info("[H2] Database migrations complete."); - } - - private void createMigrationTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "migrations" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "version" VARCHAR(50) NOT NULL UNIQUE, - "applied_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP() - ) - """; - statementHandler.executeUpdate(sql); - } - - private void createAdminsTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "admins" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "uuid" VARCHAR(36) NOT NULL UNIQUE, - "username" VARCHAR(16) NOT NULL, - "rank" VARCHAR(32) NOT NULL, - "active" BOOLEAN DEFAULT TRUE, - "last_login" TIMESTAMP, - "login_message" VARCHAR(65535) - ) - """; - statementHandler.executeUpdate(sql); - - // Create indexes - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_username ON \"admins\"(\"username\")"); } catch (SQLException ignored) {} - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_active ON \"admins\"(\"active\")"); } catch (SQLException ignored) {} - } - - private void createAdminIpsTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "admin_ips" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "admin_id" INT NOT NULL, - "ip" VARCHAR(45) NOT NULL, - UNIQUE ("admin_id", "ip"), - FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE - ) - """; - statementHandler.executeUpdate(sql); - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admin_ips_ip ON \"admin_ips\"(\"ip\")"); } catch (SQLException ignored) {} - } - - private void createBansTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "bans" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "uuid" VARCHAR(36), - "username" VARCHAR(16), - "banned_by" VARCHAR(16), - "banned_by_uuid" VARCHAR(36), - "reason" VARCHAR(65535), - "expire_at" TIMESTAMP - ) - """; - statementHandler.executeUpdate(sql); - - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_uuid ON \"bans\"(\"uuid\")"); } catch (SQLException ignored) {} - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_username ON \"bans\"(\"username\")"); } catch (SQLException ignored) {} - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_expire ON \"bans\"(\"expire_at\")"); } catch (SQLException ignored) {} - } - - private void createBanIpsTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "ban_ips" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "ban_id" INT NOT NULL, - "ip" VARCHAR(45) NOT NULL, - UNIQUE ("ban_id", "ip"), - FOREIGN KEY ("ban_id") REFERENCES "bans"("id") ON DELETE CASCADE - ) - """; - statementHandler.executeUpdate(sql); - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ban_ips_ip ON \"ban_ips\"(\"ip\")"); } catch (SQLException ignored) {} - } - - private void createPermbansTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "permbans" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "uuid" VARCHAR(36), - "username" VARCHAR(16), - "reason" VARCHAR(65535) - ) - """; - statementHandler.executeUpdate(sql); - - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_uuid ON \"permbans\"(\"uuid\")"); } catch (SQLException ignored) {} - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_username ON \"permbans\"(\"username\")"); } catch (SQLException ignored) {} - } - - private void createPermbanIpsTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "permban_ips" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "permban_id" INT NOT NULL, - "ip" VARCHAR(45) NOT NULL, - UNIQUE ("permban_id", "ip"), - FOREIGN KEY ("permban_id") REFERENCES "permbans"("id") ON DELETE CASCADE - ) - """; - statementHandler.executeUpdate(sql); - try { statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permban_ips_ip ON \"permban_ips\"(\"ip\")"); } catch (SQLException ignored) {} - } - - private void createStrikesTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "strikes" ( - "ip" VARCHAR(45) PRIMARY KEY, - "strike_count" INT NOT NULL DEFAULT 0, - "last_strike_unix" BIGINT NOT NULL DEFAULT 0, - "last_username" VARCHAR(16), - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP() - ) - """; - statementHandler.executeUpdate(sql); - } - - private void createDiscordLinksTable() throws SQLException - { - String sql = """ - CREATE TABLE IF NOT EXISTS "discord_links" ( - "id" INT AUTO_INCREMENT PRIMARY KEY, - "admin_uuid" VARCHAR(36) NOT NULL UNIQUE, - "discord_user_id" VARCHAR(32) NOT NULL UNIQUE, - "linked_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() - ) - """; - statementHandler.executeUpdate(sql); - } - - // ============================================ - // Repository Getters - // H2 is MySQL-compatible, so we reuse MySQL repositories - // ============================================ - - @Override - public AdminRepository getAdminRepository() - { - if (adminRepository == null) - { - adminRepository = new MySQLAdminRepository(plugin, statementHandler); - } - return adminRepository; - } - - @Override - public BanRepository getBanRepository() - { - if (banRepository == null) - { - banRepository = new MySQLBanRepository(plugin, statementHandler); - } - return banRepository; - } - - @Override - public PermbanRepository getPermbanRepository() - { - if (permbanRepository == null) - { - permbanRepository = new MySQLPermbanRepository(plugin, statementHandler); - } - return permbanRepository; - } - - @Override - public StrikeRepository getStrikeRepository() - { - if (strikeRepository == null) - { - strikeRepository = new MySQLStrikeRepository(plugin, statementHandler); - } - return strikeRepository; - } - - @Override - public DiscordLinkRepository getDiscordLinkRepository() - { - if (discordLinkRepository == null) - { - discordLinkRepository = new MySQLDiscordLinkRepository(plugin, statementHandler); - } - return discordLinkRepository; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java index 194fb0098..da7832194 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdapter.java @@ -1,18 +1,16 @@ package me.totalfreedom.totalfreedommod.sql.adapter.mysql; +import java.sql.SQLException; +import java.util.stream.Collectors; +import java.util.stream.Stream; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.*; +import me.totalfreedom.totalfreedommod.sql.adapter.generic.*; import me.totalfreedom.totalfreedommod.util.FLog; -import java.sql.SQLException; - /** * MySQL-specific database adapter. * Works with both MySQL and MariaDB as they share SQL syntax. @@ -25,11 +23,17 @@ */ public class MySQLAdapter extends DatabaseAdapter { - private MySQLAdminRepository adminRepository; - private MySQLBanRepository banRepository; - private MySQLPermbanRepository permbanRepository; - private MySQLStrikeRepository strikeRepository; - private MySQLDiscordLinkRepository discordLinkRepository; + private AdminRepository adminRepository; + private BanRepository banRepository; + private PermbanRepository permbanRepository; + private StrikeRepository strikeRepository; + private DiscordLinkRepository discordLinkRepository; + private RankRepository rankRepository; + private TitleRepository titleRepository; + private ProtectedAreaRepository protectedAreaRepository; + private SavedFlagRepository savedFlagRepository; + private PlayerRepository playerRepository; + private MigrationRepository migrationRepository; public MySQLAdapter(TotalFreedomMod plugin, ConnectionHandler connectionHandler, StatementHandler statementHandler) { @@ -70,12 +74,30 @@ public String booleanType() return "TINYINT(1)"; } + @Override + public String jsonType() + { + return "JSON"; + } + + @Override + public String jsonParamPlaceholder() + { + return "?"; + } + @Override public String insertIgnoreSyntax() { return "INSERT IGNORE"; } + @Override + public String insertIgnoreSuffix() + { + return ""; + } + @Override public String quoteIdentifier(String identifier) { @@ -89,10 +111,31 @@ public String currentTimestamp() } @Override - public String caseInsensitiveLike() + public String timestampParamPlaceholder() + { + return "?"; + } + + @Override + public String caseInsensitiveEquals(String columnRef, String paramPlaceholder) { - // MySQL is case-insensitive by default with utf8_general_ci collation - return "LIKE"; + return String.format("LOWER(%s) = LOWER(%s)", columnRef, paramPlaceholder); + } + + @Override + public String compareToNow(String columnRef, String operator) + { + return String.format("%s %s NOW()", columnRef, operator); + } + + // MySQL's ON DUPLICATE KEY UPDATE infers the conflicting row from whatever key was violated. + @Override + public String upsertClause(String conflictColumn, String... updateColumns) + { + String assignments = Stream.of(updateColumns) + .map(col -> String.format("%s = VALUES(%s)", col, col)) + .collect(Collectors.joining(", ")); + return String.format("ON DUPLICATE KEY UPDATE %s", assignments); } // ============================================ @@ -113,6 +156,14 @@ public void runMigrations() throws SQLException createPermbanIpsTable(); createStrikesTable(); createDiscordLinksTable(); + createRanksTable(); + createRankPermissionsTable(); + createTitlesTable(); + createTitlePermissionsTable(); + createProtectedAreasTable(); + createSavedFlagsTable(); + createPlayersTable(); + createPlayerIpsTable(); FLog.info("[MySQL] Database migrations complete."); } @@ -140,11 +191,17 @@ private void createAdminsTable() throws SQLException `active` TINYINT(1) DEFAULT 1, `last_login` DATETIME, `login_message` TEXT, + `custom_rank` VARCHAR(64), + `updated_at` DATETIME NOT NULL DEFAULT NOW(), INDEX `idx_admins_username` (`username`), INDEX `idx_admins_active` (`active`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + + // Migration for tables created before custom_rank/updated_at existed. + addColumnIfMissing("admins", "custom_rank", "VARCHAR(64)"); + addColumnIfMissing("admins", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createAdminIpsTable() throws SQLException @@ -173,12 +230,14 @@ private void createBansTable() throws SQLException `banned_by_uuid` VARCHAR(36), `reason` TEXT, `expire_at` DATETIME, + `updated_at` DATETIME NOT NULL DEFAULT NOW(), INDEX `idx_bans_uuid` (`uuid`), INDEX `idx_bans_username` (`username`), INDEX `idx_bans_expire` (`expire_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("bans", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createBanIpsTable() throws SQLException @@ -202,13 +261,15 @@ private void createPermbansTable() throws SQLException CREATE TABLE IF NOT EXISTS `permbans` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `uuid` VARCHAR(36), - `username` VARCHAR(16), + `username` VARCHAR(16) NOT NULL, `reason` TEXT, + `updated_at` DATETIME NOT NULL DEFAULT NOW(), INDEX `idx_permbans_uuid` (`uuid`), INDEX `idx_permbans_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("permbans", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createPermbanIpsTable() throws SQLException @@ -234,10 +295,12 @@ private void createStrikesTable() throws SQLException `strike_count` INT NOT NULL DEFAULT 0, `last_strike_unix` BIGINT NOT NULL DEFAULT 0, `last_username` VARCHAR(16), - `created_at` DATETIME DEFAULT NOW() + `created_at` DATETIME DEFAULT NOW(), + `updated_at` DATETIME NOT NULL DEFAULT NOW() ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("strikes", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); } private void createDiscordLinksTable() throws SQLException @@ -247,10 +310,176 @@ private void createDiscordLinksTable() throws SQLException `id` INT AUTO_INCREMENT PRIMARY KEY, `admin_uuid` VARCHAR(36) NOT NULL UNIQUE, `discord_user_id` VARCHAR(32) NOT NULL UNIQUE, - `linked_at` DATETIME NOT NULL DEFAULT NOW() + `linked_at` DATETIME NOT NULL DEFAULT NOW(), + `updated_at` DATETIME NOT NULL DEFAULT NOW() + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + addColumnIfMissing("discord_links", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); + } + + private void createRanksTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `ranks` ( + `id` VARCHAR(64) PRIMARY KEY, + `name` VARCHAR(64) NOT NULL, + `determiner` VARCHAR(8) NOT NULL DEFAULT 'a', + `abbreviation` VARCHAR(16), + `level` INT NOT NULL DEFAULT 0, + `color` VARCHAR(32) NOT NULL DEFAULT 'white', + `admin` TINYINT(1) NOT NULL DEFAULT 0, + `prefix` VARCHAR(64), + `inherit_from` VARCHAR(64), + `roles` VARCHAR(255), + `updated_at` DATETIME NOT NULL DEFAULT NOW(), + INDEX `idx_ranks_level` (`level`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + addColumnIfMissing("ranks", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); + addColumnIfMissing("ranks", "roles", "VARCHAR(255)"); + } + + private void createRankPermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `rank_permissions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `rank_id` VARCHAR(64) NOT NULL, + `permission` VARCHAR(128) NOT NULL, + UNIQUE KEY `uk_rank_permission` (`rank_id`, `permission`), + FOREIGN KEY (`rank_id`) REFERENCES `ranks`(`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + + private void createTitlesTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `titles` ( + `id` VARCHAR(64) PRIMARY KEY, + `name` VARCHAR(64) NOT NULL, + `determiner` VARCHAR(8) NOT NULL DEFAULT 'a', + `abbreviation` VARCHAR(16), + `color` VARCHAR(32) NOT NULL DEFAULT 'white', + `prefix` VARCHAR(64), + `weight` INT NOT NULL DEFAULT 0, + `announce` TINYINT(1) NOT NULL DEFAULT 1, + `updated_at` DATETIME NOT NULL DEFAULT NOW() + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + + private void createTitlePermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `title_permissions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `title_id` VARCHAR(64) NOT NULL, + `permission` VARCHAR(128) NOT NULL, + UNIQUE KEY `uk_title_permission` (`title_id`, `permission`), + FOREIGN KEY (`title_id`) REFERENCES `titles`(`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + + private void createProtectedAreasTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `protected_areas` ( + `uuid` VARCHAR(36) PRIMARY KEY, + `name` VARCHAR(64) NOT NULL, + `min_x` INT NOT NULL, + `min_y` INT NOT NULL, + `min_z` INT NOT NULL, + `max_x` INT NOT NULL, + `max_y` INT NOT NULL, + `max_z` INT NOT NULL, + `world_uuid` VARCHAR(36) NOT NULL, + `updated_at` DATETIME NOT NULL DEFAULT NOW(), + INDEX `idx_protected_areas_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """; statementHandler.executeUpdate(sql); + addColumnIfMissing("protected_areas", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); + } + + private void createSavedFlagsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `saved_flags` ( + `flag_name` VARCHAR(64) PRIMARY KEY, + `enabled` TINYINT(1) NOT NULL DEFAULT 0, + `updated_at` DATETIME NOT NULL DEFAULT NOW() + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + addColumnIfMissing("saved_flags", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); + } + + private void createPlayersTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `players` ( + `username` VARCHAR(16) PRIMARY KEY, + `first_join_unix` BIGINT NOT NULL DEFAULT 0, + `last_join_unix` BIGINT NOT NULL DEFAULT 0, + `potion_spy_mode` VARCHAR(16) NOT NULL DEFAULT 'off', + `command_spy_mode` VARCHAR(16) NOT NULL DEFAULT 'off', + `sign_spy_mode` VARCHAR(16) NOT NULL DEFAULT 'off', + `book_spy_mode` VARCHAR(16) NOT NULL DEFAULT 'off', + `muted` TINYINT(1) NOT NULL DEFAULT 0, + `frozen` TINYINT(1) NOT NULL DEFAULT 0, + `commands_blocked` TINYINT(1) NOT NULL DEFAULT 0, + `join_leave_messages` TINYINT(1) NOT NULL DEFAULT 1, + `strikes` INT NOT NULL DEFAULT 0, + `saved_tag` TEXT, + `nickname` TEXT, + `updated_at` DATETIME NOT NULL DEFAULT NOW() + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + addColumnIfMissing("players", "updated_at", "DATETIME NOT NULL DEFAULT NOW()"); + addColumnIfMissing("players", "titles", "TEXT"); + addColumnIfMissing("players", "potion_spy_mode", "VARCHAR(16) NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "sign_spy_mode", "VARCHAR(16) NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "book_spy_mode", "VARCHAR(16) NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "join_leave_messages", "TINYINT(1) NOT NULL DEFAULT 1"); + } + + private void createPlayerIpsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS `player_ips` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `username` VARCHAR(16) NOT NULL, + `ip` VARCHAR(45) NOT NULL, + UNIQUE KEY `uk_player_ip` (`username`, `ip`), + FOREIGN KEY (`username`) REFERENCES `players`(`username`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """; + statementHandler.executeUpdate(sql); + } + + /** + * Add a column to a table created before that column existed. Ignores the error + * when the column is already present (MySQL has no ADD COLUMN IF NOT EXISTS + * before 8.0.29, so this stays a try/catch like the rest of this file's migrations). + */ + private void addColumnIfMissing(String table, String column, String definition) + { + try + { + statementHandler.executeUpdate(String.format("ALTER TABLE `%s` ADD COLUMN `%s` %s", table, column, definition)); + } + catch (SQLException ignored) + { + // Column already exists. + } } // ============================================ @@ -262,7 +491,7 @@ public AdminRepository getAdminRepository() { if (adminRepository == null) { - adminRepository = new MySQLAdminRepository(plugin, statementHandler); + adminRepository = new GenericAdminRepository(statementHandler, this); } return adminRepository; } @@ -272,7 +501,7 @@ public BanRepository getBanRepository() { if (banRepository == null) { - banRepository = new MySQLBanRepository(plugin, statementHandler); + banRepository = new GenericBanRepository(statementHandler, this); } return banRepository; } @@ -282,7 +511,7 @@ public PermbanRepository getPermbanRepository() { if (permbanRepository == null) { - permbanRepository = new MySQLPermbanRepository(plugin, statementHandler); + permbanRepository = new GenericPermbanRepository(statementHandler, this); } return permbanRepository; } @@ -292,7 +521,7 @@ public StrikeRepository getStrikeRepository() { if (strikeRepository == null) { - strikeRepository = new MySQLStrikeRepository(plugin, statementHandler); + strikeRepository = new GenericStrikeRepository(statementHandler, this); } return strikeRepository; } @@ -302,8 +531,68 @@ public DiscordLinkRepository getDiscordLinkRepository() { if (discordLinkRepository == null) { - discordLinkRepository = new MySQLDiscordLinkRepository(plugin, statementHandler); + discordLinkRepository = new GenericDiscordLinkRepository(statementHandler, this); } return discordLinkRepository; } + + @Override + public TitleRepository getTitleRepository() + { + if (titleRepository == null) + { + titleRepository = new GenericTitleRepository(statementHandler, this); + } + return titleRepository; + } + + @Override + public RankRepository getRankRepository() + { + if (rankRepository == null) + { + rankRepository = new GenericRankRepository(statementHandler, this); + } + return rankRepository; + } + + @Override + public ProtectedAreaRepository getProtectedAreaRepository() + { + if (protectedAreaRepository == null) + { + protectedAreaRepository = new GenericProtectedAreaRepository(statementHandler, this); + } + return protectedAreaRepository; + } + + @Override + public SavedFlagRepository getSavedFlagRepository() + { + if (savedFlagRepository == null) + { + savedFlagRepository = new GenericSavedFlagRepository(statementHandler, this); + } + return savedFlagRepository; + } + + @Override + public PlayerRepository getPlayerRepository() + { + if (playerRepository == null) + { + playerRepository = new GenericPlayerRepository(statementHandler, this); + } + return playerRepository; + } + + @Override + public MigrationRepository getMigrationRepository() + { + if (migrationRepository == null) + { + migrationRepository = new GenericMigrationRepository(statementHandler, this); + } + return migrationRepository; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdminRepository.java deleted file mode 100644 index a8a14cd10..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLAdminRepository.java +++ /dev/null @@ -1,494 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.mysql; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -/** - * MySQL/MariaDB implementation of AdminRepository. - * Uses MySQL-specific SQL syntax. - */ -public class MySQLAdminRepository implements AdminRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public MySQLAdminRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(UUID uuid, Admin admin) throws SQLException - { - String sql = """ - INSERT INTO `admins` (`uuid`, `username`, `rank`, `active`, `last_login`, `login_message`) - VALUES (?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - uuid.toString(), - admin.getName(), - admin.getRank().toString(), - admin.isActive() ? 1 : 0, - FUtil.dateToString(admin.getLastLogin()), - admin.getLoginMessage())) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int adminId = rs.getInt(1); - insertIps(adminId, admin.getIps()); - return adminId; - } - } - } - return -1; - } - - @Override - public void insertIps(int adminId, List<String> ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - // MySQL: INSERT IGNORE - String sql = "INSERT IGNORE INTO `admin_ips` (`admin_id`, `ip`) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, adminId, ip); - } - } - - @Override - public void addIp(int adminId, String ip) throws SQLException - { - String sql = "INSERT IGNORE INTO `admin_ips` (`admin_id`, `ip`) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, adminId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public Map<String, Admin> loadAll() throws SQLException - { - Map<String, Admin> admins = new HashMap<>(); - Map<Integer, Admin> adminById = new HashMap<>(); - - String sql = "SELECT `id`, `uuid`, `username`, `rank`, `active`, `last_login`, `login_message` FROM `admins`"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String username = rs.getString("username"); - String rankStr = rs.getString("rank"); - boolean active = rs.getInt("active") == 1; - String lastLoginStr = rs.getString("last_login"); - String loginMessage = rs.getString("login_message"); - - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); - admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); - admin.setActive(active); - admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); - admin.setLoginMessage(loginMessage); - - UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); - if (dbUuid != null) - { - admin.setUuid(dbUuid); - } - - admins.put(configKey, admin); - adminById.put(id, admin); - } - } - - // Load IPs - String ipSql = "SELECT `admin_id`, `ip` FROM `admin_ips`"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int adminId = rs.getInt("admin_id"); - String ip = rs.getString("ip"); - Admin admin = adminById.get(adminId); - if (admin != null) - { - admin.addIp(ip); - } - } - } - - return admins; - } - - @Override - public Admin findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT `id`, `uuid`, `username`, `rank`, `active`, `last_login`, `login_message` FROM `admins` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public Admin findByUsername(String username) throws SQLException - { - // MySQL: case-insensitive with collation, but using LOWER() for consistency - String sql = "SELECT `id`, `uuid`, `username`, `rank`, `active`, `last_login`, `login_message` FROM `admins` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public Admin findByIp(String ip) throws SQLException - { - String sql = """ - SELECT a.`id`, a.`uuid`, a.`username`, a.`rank`, a.`active`, a.`last_login`, a.`login_message` - FROM `admins` a - INNER JOIN `admin_ips` ai ON a.`id` = ai.`admin_id` - WHERE ai.`ip` = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public int getAdminId(String username) throws SQLException - { - String sql = "SELECT `id` FROM `admins` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getAdminIdByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT `id` FROM `admins` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List<String> getIps(int adminId) throws SQLException - { - List<String> ips = new ArrayList<>(); - String sql = "SELECT `ip` FROM `admin_ips` WHERE `admin_id` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, adminId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean exists(String username) throws SQLException - { - String sql = "SELECT COUNT(*) FROM `admins` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean existsByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT COUNT(*) FROM `admins` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public UUID getUuidByUsername(String username) throws SQLException - { - String sql = "SELECT `uuid` FROM `admins` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return UUID.fromString(rs.getString("uuid")); - } - } - return null; - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(UUID uuid, Admin admin) throws SQLException - { - String sql = """ - UPDATE `admins` - SET `username` = ?, `rank` = ?, `active` = ?, `last_login` = ?, `login_message` = ? - WHERE `uuid` = ? - """; - - int rows = statementHandler.executeUpdate(sql, - admin.getName(), - admin.getRank().toString(), - admin.isActive() ? 1 : 0, - FUtil.dateToString(admin.getLastLogin()), - admin.getLoginMessage(), - uuid.toString()); - - return rows > 0; - } - - @Override - public boolean updateRank(String username, String rank) throws SQLException - { - String sql = "UPDATE `admins` SET `rank` = ? WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, rank, username) > 0; - } - - @Override - public boolean updateActive(String username, boolean active) throws SQLException - { - String sql = "UPDATE `admins` SET `active` = ? WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, active ? 1 : 0, username) > 0; - } - - @Override - public boolean updateLastLogin(String username, Date lastLogin) throws SQLException - { - String sql = "UPDATE `admins` SET `last_login` = ? WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, FUtil.dateToString(lastLogin), username) > 0; - } - - @Override - public boolean updateUsername(UUID uuid, String newUsername) throws SQLException - { - String sql = "UPDATE `admins` SET `username` = ? WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, newUsername, uuid.toString()) > 0; - } - - @Override - public void syncIps(int adminId, List<String> ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `admin_ips` WHERE `admin_id` = ?", adminId); - insertIps(adminId, ips); - } - - @Override - public int saveOrUpdate(UUID uuid, Admin admin) throws SQLException - { - if (existsByUuid(uuid)) - { - update(uuid, admin); - int adminId = getAdminIdByUuid(uuid); - syncIps(adminId, admin.getIps()); - return adminId; - } - else - { - return insert(uuid, admin); - } - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM `admins` WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM `admins` WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean removeIp(int adminId, String ip) throws SQLException - { - String sql = "DELETE FROM `admin_ips` WHERE `admin_id` = ? AND `ip` = ?"; - return statementHandler.executeUpdate(sql, adminId, ip) > 0; - } - - @Override - public boolean clearIps(int adminId) throws SQLException - { - String sql = "DELETE FROM `admin_ips` WHERE `admin_id` = ?"; - return statementHandler.executeUpdate(sql, adminId) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `admin_ips`"); - statementHandler.executeUpdate("DELETE FROM `admins`"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public CompletableFuture<Map<String, Admin>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load admins: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> insertAsync(UUID uuid, Admin admin) - { - return CompletableFuture.supplyAsync(() -> { - try { return insert(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to insert admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> updateAsync(UUID uuid, Admin admin) - { - return CompletableFuture.supplyAsync(() -> { - try { return update(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to update admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteAsync(UUID uuid) - { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> save(UUID uuid, Admin admin) - { - return CompletableFuture.supplyAsync(() -> { - try { return saveOrUpdate(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to save admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<List<Admin>> findAll() - { - return CompletableFuture.supplyAsync(() -> { - try { return new ArrayList<>(loadAll().values()); } - catch (SQLException e) { FLog.severe("Failed to find all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteByUuid(UUID uuid) - { - return deleteAsync(uuid); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - // ============================================ - // Helper Methods - // ============================================ - - private Admin loadAdminFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String username = rs.getString("username"); - String rankStr = rs.getString("rank"); - boolean active = rs.getInt("active") == 1; - String lastLoginStr = rs.getString("last_login"); - String loginMessage = rs.getString("login_message"); - - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); - admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); - admin.setActive(active); - admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); - admin.setLoginMessage(loginMessage); - - UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); - if (dbUuid != null) - { - admin.setUuid(dbUuid); - } - - List<String> ips = getIps(id); - admin.addIps(ips); - - return admin; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLBanRepository.java deleted file mode 100644 index 6f0466600..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLBanRepository.java +++ /dev/null @@ -1,524 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.mysql; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -/** - * MySQL/MariaDB implementation of BanRepository. - * Uses MySQL-specific SQL syntax. - */ -public class MySQLBanRepository implements BanRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public MySQLBanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(Ban ban) throws SQLException - { - String sql = """ - INSERT INTO `bans` (`uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at`) - VALUES (?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - ban.getUuid() != null ? ban.getUuid().toString() : null, - ban.getUsername(), - ban.getBannedBy(), - ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, - ban.getReason(), - ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null)) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int banId = rs.getInt(1); - insertIps(banId, ban.getIps()); - return banId; - } - } - } - return -1; - } - - @Override - public void insertIps(int banId, List<String> ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - String sql = "INSERT IGNORE INTO `ban_ips` (`ban_id`, `ip`) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, banId, ip); - } - } - - @Override - public void addIp(int banId, String ip) throws SQLException - { - String sql = "INSERT IGNORE INTO `ban_ips` (`ban_id`, `ip`) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, banId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public List<Ban> loadAll() throws SQLException - { - List<Ban> bans = new ArrayList<>(); - Map<Integer, Ban> banById = new HashMap<>(); - - String sql = "SELECT `id`, `uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at` FROM `bans`"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String bannedBy = rs.getString("banned_by"); - String bannedByUuidStr = rs.getString("banned_by_uuid"); - String reason = rs.getString("reason"); - String expireAtStr = rs.getString("expire_at"); - - Ban ban = new Ban(); - ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - ban.setUsername(username); - ban.setBannedBy(bannedBy); - ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); - ban.setReason(reason); - ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - - bans.add(ban); - banById.put(id, ban); - } - } - - // Load IPs - String ipSql = "SELECT `ban_id`, `ip` FROM `ban_ips`"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int banId = rs.getInt("ban_id"); - String ip = rs.getString("ip"); - Ban ban = banById.get(banId); - if (ban != null) - { - ban.addIp(ip); - } - } - } - - return bans; - } - - @Override - public Ban findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT `id`, `uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at` FROM `bans` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public Ban findByUsername(String username) throws SQLException - { - String sql = "SELECT `id`, `uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at` FROM `bans` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public Ban findByIp(String ip) throws SQLException - { - String sql = """ - SELECT b.`id`, b.`uuid`, b.`username`, b.`banned_by`, b.`banned_by_uuid`, b.`reason`, b.`expire_at` - FROM `bans` b - INNER JOIN `ban_ips` bi ON b.`id` = bi.`ban_id` - WHERE bi.`ip` = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public List<Ban> findActiveBans() throws SQLException - { - // MySQL: NOW() for current time - String sql = """ - SELECT `id`, `uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at` - FROM `bans` - WHERE `expire_at` IS NULL OR `expire_at` > NOW() - """; - List<Ban> bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; - } - - @Override - public List<Ban> findExpiredBans() throws SQLException - { - String sql = """ - SELECT `id`, `uuid`, `username`, `banned_by`, `banned_by_uuid`, `reason`, `expire_at` - FROM `bans` - WHERE `expire_at` IS NOT NULL AND `expire_at` <= NOW() - """; - List<Ban> bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; - } - - @Override - public int getBanId(UUID uuid) throws SQLException - { - String sql = "SELECT `id` FROM `bans` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getBanIdByUsername(String username) throws SQLException - { - String sql = "SELECT `id` FROM `bans` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List<String> getIps(int banId) throws SQLException - { - List<String> ips = new ArrayList<>(); - String sql = "SELECT `ip` FROM `ban_ips` WHERE `ban_id` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, banId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean isBanned(UUID uuid) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM `bans` - WHERE `uuid` = ? AND (`expire_at` IS NULL OR `expire_at` > NOW()) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isBannedByUsername(String username) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM `bans` - WHERE LOWER(`username`) = LOWER(?) AND (`expire_at` IS NULL OR `expire_at` > NOW()) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isBannedByIp(String ip) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM `bans` b - INNER JOIN `ban_ips` bi ON b.`id` = bi.`ban_id` - WHERE bi.`ip` = ? AND (b.`expire_at` IS NULL OR b.`expire_at` > NOW()) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(Ban ban) throws SQLException - { - String sql = """ - UPDATE `bans` - SET `username` = ?, `banned_by` = ?, `banned_by_uuid` = ?, `reason` = ?, `expire_at` = ? - WHERE `uuid` = ? - """; - - int rows = statementHandler.executeUpdate(sql, - ban.getUsername(), - ban.getBannedBy(), - ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, - ban.getReason(), - ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null, - ban.getUuid().toString()); - - return rows > 0; - } - - @Override - public boolean updateReason(UUID uuid, String reason) throws SQLException - { - String sql = "UPDATE `bans` SET `reason` = ? WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; - } - - @Override - public boolean updateExpiry(UUID uuid, Date expireAt) throws SQLException - { - String sql = "UPDATE `bans` SET `expire_at` = ? WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, expireAt != null ? FUtil.dateToString(expireAt) : null, uuid.toString()) > 0; - } - - @Override - public void syncIps(int banId, List<String> ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `ban_ips` WHERE `ban_id` = ?", banId); - insertIps(banId, ips); - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM `bans` WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM `bans` WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - String selectSql = "SELECT `ban_id` FROM `ban_ips` WHERE `ip` = ?"; - List<Integer> banIds = new ArrayList<>(); - try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - banIds.add(rs.getInt("ban_id")); - } - } - - for (int banId : banIds) - { - statementHandler.executeUpdate("DELETE FROM `bans` WHERE `id` = ?", banId); - } - - return !banIds.isEmpty(); - } - - @Override - public boolean removeIp(int banId, String ip) throws SQLException - { - String sql = "DELETE FROM `ban_ips` WHERE `ban_id` = ? AND `ip` = ?"; - return statementHandler.executeUpdate(sql, banId, ip) > 0; - } - - @Override - public int deleteExpiredBans() throws SQLException - { - String sql = "DELETE FROM `bans` WHERE `expire_at` IS NOT NULL AND `expire_at` <= NOW()"; - return statementHandler.executeUpdate(sql); - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `ban_ips`"); - statementHandler.executeUpdate("DELETE FROM `bans`"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public CompletableFuture<List<Ban>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load bans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> insertAsync(Ban ban) - { - return CompletableFuture.supplyAsync(() -> { - try { return insert(ban); } - catch (SQLException e) { FLog.severe("Failed to insert ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> updateAsync(Ban ban) - { - return CompletableFuture.supplyAsync(() -> { - try { return update(ban); } - catch (SQLException e) { FLog.severe("Failed to update ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteAsync(UUID uuid) - { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> save(Ban ban) - { - return CompletableFuture.supplyAsync(() -> { - try - { - // Check if ban exists by UUID - if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) - { - update(ban); - return getBanId(ban.getUuid()); - } - return insert(ban); - } - catch (SQLException e) { FLog.severe("Failed to save ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<List<Ban>> findAll() - { - return loadAllAsync(); - } - - @Override - public CompletableFuture<Boolean> deleteByUuid(UUID uuid) - { - return deleteAsync(uuid); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all bans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - // ============================================ - // Helper Methods - // ============================================ - - private Ban loadBanFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String bannedBy = rs.getString("banned_by"); - String bannedByUuidStr = rs.getString("banned_by_uuid"); - String reason = rs.getString("reason"); - String expireAtStr = rs.getString("expire_at"); - - Ban ban = new Ban(); - ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - ban.setUsername(username); - ban.setBannedBy(bannedBy); - ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); - ban.setReason(reason); - ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - - List<String> ips = getIps(id); - ban.setIps(ips); - - return ban; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLDiscordLinkRepository.java deleted file mode 100644 index 7bcc62673..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLDiscordLinkRepository.java +++ /dev/null @@ -1,67 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.mysql; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.UUID; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; - -public class MySQLDiscordLinkRepository implements DiscordLinkRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public MySQLDiscordLinkRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public void insert(UUID adminUuid, String discordUserId) throws SQLException - { - statementHandler.executeUpdate( - "INSERT INTO `discord_links` (`admin_uuid`, `discord_user_id`, `linked_at`) VALUES (?, ?, NOW())", - adminUuid.toString(), discordUserId); - } - - @Override - public String findDiscordIdByAdminUuid(UUID adminUuid) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT `discord_user_id` FROM `discord_links` WHERE `admin_uuid` = ?", adminUuid.toString())) - { - return rs.next() ? rs.getString(1) : null; - } - } - - @Override - public UUID findAdminUuidByDiscordId(String discordUserId) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT `admin_uuid` FROM `discord_links` WHERE `discord_user_id` = ?", discordUserId)) - { - if (!rs.next()) - { - return null; - } - String raw = rs.getString(1); - return raw == null ? null : UUID.fromString(raw); - } - } - - @Override - public boolean deleteByAdminUuid(UUID adminUuid) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM `discord_links` WHERE `admin_uuid` = ?", adminUuid.toString()) > 0; - } - - @Override - public boolean deleteByDiscordUserId(String discordUserId) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM `discord_links` WHERE `discord_user_id` = ?", discordUserId) > 0; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLPermbanRepository.java deleted file mode 100644 index a5530f677..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLPermbanRepository.java +++ /dev/null @@ -1,439 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.mysql; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.PermBan; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -/** - * MySQL/MariaDB implementation of PermbanRepository. - * Uses MySQL-specific SQL syntax. - */ -public class MySQLPermbanRepository implements PermbanRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public MySQLPermbanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(PermBan permban) throws SQLException - { - String sql = """ - INSERT INTO `permbans` (`uuid`, `username`, `reason`) - VALUES (?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - permban.getUuid() != null ? permban.getUuid().toString() : null, - permban.getUsername(), - permban.getReason())) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int permbanId = rs.getInt(1); - insertIps(permbanId, permban.getIps()); - return permbanId; - } - } - } - return -1; - } - - @Override - public void insertIps(int permbanId, List<String> ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - String sql = "INSERT IGNORE INTO `permban_ips` (`permban_id`, `ip`) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, permbanId, ip); - } - } - - @Override - public void addIp(int permbanId, String ip) throws SQLException - { - String sql = "INSERT IGNORE INTO `permban_ips` (`permban_id`, `ip`) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, permbanId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public List<PermBan> loadAll() throws SQLException - { - List<PermBan> permbans = new ArrayList<>(); - Map<Integer, PermBan> permbanById = new HashMap<>(); - - String sql = "SELECT `id`, `uuid`, `username`, `reason` FROM `permbans`"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String reason = rs.getString("reason"); - - PermBan permban = new PermBan(); - permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - permban.setUsername(username); - permban.setReason(reason); - - permbans.add(permban); - permbanById.put(id, permban); - } - } - - // Load IPs - String ipSql = "SELECT `permban_id`, `ip` FROM `permban_ips`"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int permbanId = rs.getInt("permban_id"); - String ip = rs.getString("ip"); - PermBan permban = permbanById.get(permbanId); - if (permban != null) - { - permban.addIp(ip); - } - } - } - - return permbans; - } - - @Override - public PermBan findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT `id`, `uuid`, `username`, `reason` FROM `permbans` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public PermBan findByUsername(String username) throws SQLException - { - String sql = "SELECT `id`, `uuid`, `username`, `reason` FROM `permbans` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public PermBan findByIp(String ip) throws SQLException - { - String sql = """ - SELECT p.`id`, p.`uuid`, p.`username`, p.`reason` - FROM `permbans` p - INNER JOIN `permban_ips` pi ON p.`id` = pi.`permban_id` - WHERE pi.`ip` = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public int getPermbanId(UUID uuid) throws SQLException - { - String sql = "SELECT `id` FROM `permbans` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getPermbanIdByUsername(String username) throws SQLException - { - String sql = "SELECT `id` FROM `permbans` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List<String> getIps(int permbanId) throws SQLException - { - List<String> ips = new ArrayList<>(); - String sql = "SELECT `ip` FROM `permban_ips` WHERE `permban_id` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, permbanId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean isPermBanned(UUID uuid) throws SQLException - { - String sql = "SELECT COUNT(*) FROM `permbans` WHERE `uuid` = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isPermBannedByUsername(String username) throws SQLException - { - String sql = "SELECT COUNT(*) FROM `permbans` WHERE LOWER(`username`) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isPermBannedByIp(String ip) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM `permbans` p - INNER JOIN `permban_ips` pi ON p.`id` = pi.`permban_id` - WHERE pi.`ip` = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(PermBan permban) throws SQLException - { - String sql = """ - UPDATE `permbans` - SET `username` = ?, `reason` = ? - WHERE `uuid` = ? - """; - - int rows = statementHandler.executeUpdate(sql, - permban.getUsername(), - permban.getReason(), - permban.getUuid().toString()); - - return rows > 0; - } - - @Override - public boolean updateReason(UUID uuid, String reason) throws SQLException - { - String sql = "UPDATE `permbans` SET `reason` = ? WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; - } - - @Override - public void syncIps(int permbanId, List<String> ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `permban_ips` WHERE `permban_id` = ?", permbanId); - insertIps(permbanId, ips); - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM `permbans` WHERE `uuid` = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM `permbans` WHERE LOWER(`username`) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - String selectSql = "SELECT `permban_id` FROM `permban_ips` WHERE `ip` = ?"; - List<Integer> permbanIds = new ArrayList<>(); - try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - permbanIds.add(rs.getInt("permban_id")); - } - } - - for (int permbanId : permbanIds) - { - statementHandler.executeUpdate("DELETE FROM `permbans` WHERE `id` = ?", permbanId); - } - - return !permbanIds.isEmpty(); - } - - @Override - public boolean removeIp(int permbanId, String ip) throws SQLException - { - String sql = "DELETE FROM `permban_ips` WHERE `permban_id` = ? AND `ip` = ?"; - return statementHandler.executeUpdate(sql, permbanId, ip) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM `permban_ips`"); - statementHandler.executeUpdate("DELETE FROM `permbans`"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public CompletableFuture<List<PermBan>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> insertAsync(PermBan permban) - { - return CompletableFuture.supplyAsync(() -> { - try { return insert(permban); } - catch (SQLException e) { FLog.severe("Failed to insert permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> updateAsync(PermBan permban) - { - return CompletableFuture.supplyAsync(() -> { - try { return update(permban); } - catch (SQLException e) { FLog.severe("Failed to update permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteAsync(UUID uuid) - { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> save(PermBan permban) - { - return CompletableFuture.supplyAsync(() -> { - try - { - if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) - { - update(permban); - return getPermbanId(permban.getUuid()); - } - return insert(permban); - } - catch (SQLException e) { FLog.severe("Failed to save permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<List<PermBan>> findAll() - { - return loadAllAsync(); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - // ============================================ - // Helper Methods - // ============================================ - - private PermBan loadPermbanFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String reason = rs.getString("reason"); - - PermBan permban = new PermBan(); - permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - permban.setUsername(username); - permban.setReason(reason); - - List<String> ips = getIps(id); - permban.setIps(ips); - - return permban; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java deleted file mode 100644 index b04fd3963..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/mysql/MySQLStrikeRepository.java +++ /dev/null @@ -1,122 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.mysql; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.StrikeRecord; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import me.totalfreedom.totalfreedommod.util.FLog; - -public class MySQLStrikeRepository implements StrikeRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public MySQLStrikeRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public Map<String, StrikeRecord> loadAll() throws SQLException - { - Map<String, StrikeRecord> out = new HashMap<>(); - String sql = "SELECT ip, strike_count, last_strike_unix, last_username FROM strikes"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - String ip = rs.getString("ip"); - int count = rs.getInt("strike_count"); - long last = rs.getLong("last_strike_unix"); - String username = rs.getString("last_username"); - out.put(ip, new StrikeRecord(ip, count, last, username)); - } - } - return out; - } - - @Override - public void upsert(StrikeRecord r) throws SQLException - { - // MySQL/MariaDB dialect. H2 supports ON DUPLICATE KEY UPDATE in MySQL mode, - // which is the default when connection URL uses MODE=MySQL; the H2 adapter - // does not set that, so use MERGE-compatible form via standard upsert below. - String sql = """ - INSERT INTO strikes (ip, strike_count, last_strike_unix, last_username) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - strike_count = VALUES(strike_count), - last_strike_unix = VALUES(last_strike_unix), - last_username = VALUES(last_username) - """; - try - { - statementHandler.executeUpdate(sql, - r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); - } - catch (SQLException ex) - { - // H2 in default mode rejects ON DUPLICATE KEY UPDATE; fall back to MERGE. - String merge = """ - MERGE INTO strikes (ip, strike_count, last_strike_unix, last_username) - KEY(ip) VALUES (?, ?, ?, ?) - """; - statementHandler.executeUpdate(merge, - r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); - } - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - return statementHandler.executeUpdate("DELETE FROM strikes WHERE ip = ?", ip) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM strikes"); - } - - @Override - public CompletableFuture<Map<String, StrikeRecord>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Void> upsertAsync(StrikeRecord r) - { - return CompletableFuture.runAsync(() -> { - try { upsert(r); } - catch (SQLException e) { FLog.severe("Failed to upsert strike: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteByIpAsync(String ip) - { - return CompletableFuture.supplyAsync(() -> { - try { return deleteByIp(ip); } - catch (SQLException e) { FLog.severe("Failed to delete strike: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to clear strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java index 54cd9e68f..0cb413be2 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdapter.java @@ -1,18 +1,16 @@ package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; +import java.sql.SQLException; +import java.util.stream.Collectors; +import java.util.stream.Stream; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.*; +import me.totalfreedom.totalfreedommod.sql.adapter.generic.*; import me.totalfreedom.totalfreedommod.util.FLog; -import java.sql.SQLException; - /** * PostgreSQL-specific database adapter. * Uses PostgreSQL-specific SQL features like: @@ -24,11 +22,17 @@ */ public class PostgreSQLAdapter extends DatabaseAdapter { - private PostgreSQLAdminRepository adminRepository; - private PostgreSQLBanRepository banRepository; - private PostgreSQLPermbanRepository permbanRepository; - private PostgreSQLStrikeRepository strikeRepository; - private PostgreSQLDiscordLinkRepository discordLinkRepository; + private AdminRepository adminRepository; + private BanRepository banRepository; + private PermbanRepository permbanRepository; + private StrikeRepository strikeRepository; + private DiscordLinkRepository discordLinkRepository; + private RankRepository rankRepository; + private TitleRepository titleRepository; + private ProtectedAreaRepository protectedAreaRepository; + private SavedFlagRepository savedFlagRepository; + private PlayerRepository playerRepository; + private MigrationRepository migrationRepository; public PostgreSQLAdapter(TotalFreedomMod plugin, ConnectionHandler connectionHandler, StatementHandler statementHandler) { @@ -69,6 +73,18 @@ public String booleanType() return "BOOLEAN"; } + @Override + public String jsonType() + { + return "JSONB"; + } + + @Override + public String jsonParamPlaceholder() + { + return "?::jsonb"; + } + @Override public String insertIgnoreSyntax() { @@ -76,6 +92,12 @@ public String insertIgnoreSyntax() return "INSERT"; // Base INSERT, ON CONFLICT added per-statement } + @Override + public String insertIgnoreSuffix() + { + return " ON CONFLICT DO NOTHING"; + } + @Override public String quoteIdentifier(String identifier) { @@ -89,17 +111,31 @@ public String currentTimestamp() } @Override - public String caseInsensitiveLike() + public String timestampParamPlaceholder() { - return "ILIKE"; + // PostgreSQL won't implicitly convert a bound String to timestamp. + return "?::timestamp"; } - /** - * PostgreSQL-specific ON CONFLICT DO NOTHING clause - */ - public String onConflictDoNothing() + @Override + public String caseInsensitiveEquals(String columnRef, String paramPlaceholder) + { + return String.format("LOWER(%s) = LOWER(%s)", columnRef, paramPlaceholder); + } + + @Override + public String compareToNow(String columnRef, String operator) + { + return String.format("%s %s CURRENT_TIMESTAMP", columnRef, operator); + } + + @Override + public String upsertClause(String conflictColumn, String... updateColumns) { - return "ON CONFLICT DO NOTHING"; + String assignments = Stream.of(updateColumns) + .map(col -> String.format("%s = EXCLUDED.%s", col, col)) + .collect(Collectors.joining(", ")); + return String.format("ON CONFLICT(%s) DO UPDATE SET %s", conflictColumn, assignments); } // ============================================ @@ -120,6 +156,14 @@ public void runMigrations() throws SQLException createPermbanIpsTable(); createStrikesTable(); createDiscordLinksTable(); + createRanksTable(); + createRankPermissionsTable(); + createTitlesTable(); + createTitlePermissionsTable(); + createProtectedAreasTable(); + createSavedFlagsTable(); + createPlayersTable(); + createPlayerIpsTable(); FLog.info("[PostgreSQL] Database migrations complete."); } @@ -146,11 +190,17 @@ private void createAdminsTable() throws SQLException "rank" VARCHAR(32) NOT NULL, "active" BOOLEAN DEFAULT TRUE, "last_login" TIMESTAMP, - "login_message" TEXT + "login_message" TEXT, + "custom_rank" VARCHAR(64), + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + // Migration for tables created before custom_rank/updated_at existed. + statementHandler.executeUpdate("ALTER TABLE \"admins\" ADD COLUMN IF NOT EXISTS \"custom_rank\" VARCHAR(64)"); + statementHandler.executeUpdate("ALTER TABLE \"admins\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); + // Create indexes separately (PostgreSQL style) statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_username ON \"admins\"(\"username\")"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_active ON \"admins\"(\"active\")"); @@ -180,10 +230,12 @@ private void createBansTable() throws SQLException "banned_by" VARCHAR(16), "banned_by_uuid" VARCHAR(36), "reason" TEXT, - "expire_at" TIMESTAMP + "expire_at" TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"bans\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_uuid ON \"bans\"(\"uuid\")"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_username ON \"bans\"(\"username\")"); @@ -210,11 +262,13 @@ private void createPermbansTable() throws SQLException CREATE TABLE IF NOT EXISTS "permbans" ( "id" SERIAL PRIMARY KEY, "uuid" VARCHAR(36), - "username" VARCHAR(16), - "reason" TEXT + "username" VARCHAR(16) NOT NULL, + "reason" TEXT, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"permbans\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_uuid ON \"permbans\"(\"uuid\")"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_username ON \"permbans\"(\"username\")"); @@ -242,10 +296,12 @@ private void createStrikesTable() throws SQLException "strike_count" INTEGER NOT NULL DEFAULT 0, "last_strike_unix" BIGINT NOT NULL DEFAULT 0, "last_username" VARCHAR(16), - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"strikes\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); } private void createDiscordLinksTable() throws SQLException @@ -255,7 +311,153 @@ private void createDiscordLinksTable() throws SQLException "id" SERIAL PRIMARY KEY, "admin_uuid" VARCHAR(36) NOT NULL UNIQUE, "discord_user_id" VARCHAR(32) NOT NULL UNIQUE, - "linked_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + "linked_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """; + statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"discord_links\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); + } + + private void createRanksTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "ranks" ( + "id" VARCHAR(64) PRIMARY KEY, + "name" VARCHAR(64) NOT NULL, + "determiner" VARCHAR(8) NOT NULL DEFAULT 'a', + "abbreviation" VARCHAR(16), + "level" INTEGER NOT NULL DEFAULT 0, + "color" VARCHAR(32) NOT NULL DEFAULT 'white', + "admin" BOOLEAN NOT NULL DEFAULT FALSE, + "prefix" VARCHAR(64), + "inherit_from" VARCHAR(64), + "roles" VARCHAR(255), + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """; + statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"ranks\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); + statementHandler.executeUpdate("ALTER TABLE \"ranks\" ADD COLUMN IF NOT EXISTS \"roles\" VARCHAR(255)"); + statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ranks_level ON \"ranks\"(\"level\")"); + } + + private void createRankPermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "rank_permissions" ( + "id" SERIAL PRIMARY KEY, + "rank_id" VARCHAR(64) NOT NULL REFERENCES "ranks"("id") ON DELETE CASCADE, + "permission" VARCHAR(128) NOT NULL, + UNIQUE ("rank_id", "permission") + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createTitlesTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "titles" ( + "id" VARCHAR(64) PRIMARY KEY, + "name" VARCHAR(64) NOT NULL, + "determiner" VARCHAR(8) NOT NULL DEFAULT 'a', + "abbreviation" VARCHAR(16), + "color" VARCHAR(32) NOT NULL DEFAULT 'white', + "prefix" VARCHAR(64), + "weight" INTEGER NOT NULL DEFAULT 0, + "announce" BOOLEAN NOT NULL DEFAULT TRUE, + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW() + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createTitlePermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "title_permissions" ( + "id" SERIAL PRIMARY KEY, + "title_id" VARCHAR(64) NOT NULL REFERENCES "titles"("id") ON DELETE CASCADE, + "permission" VARCHAR(128) NOT NULL, + UNIQUE ("title_id", "permission") + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createProtectedAreasTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "protected_areas" ( + "uuid" VARCHAR(36) PRIMARY KEY, + "name" VARCHAR(64) NOT NULL, + "min_x" INTEGER NOT NULL, + "min_y" INTEGER NOT NULL, + "min_z" INTEGER NOT NULL, + "max_x" INTEGER NOT NULL, + "max_y" INTEGER NOT NULL, + "max_z" INTEGER NOT NULL, + "world_uuid" VARCHAR(36) NOT NULL, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """; + statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"protected_areas\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); + statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_protected_areas_name ON \"protected_areas\"(\"name\")"); + } + + private void createSavedFlagsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "saved_flags" ( + "flag_name" VARCHAR(64) PRIMARY KEY, + "enabled" BOOLEAN NOT NULL DEFAULT FALSE, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """; + statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"saved_flags\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); + } + + private void createPlayersTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "players" ( + "username" VARCHAR(16) PRIMARY KEY, + "first_join_unix" BIGINT NOT NULL DEFAULT 0, + "last_join_unix" BIGINT NOT NULL DEFAULT 0, + "potion_spy_mode" VARCHAR(16) NOT NULL DEFAULT 'off', + "command_spy_mode" VARCHAR(16) NOT NULL DEFAULT 'off', + "sign_spy_mode" VARCHAR(16) NOT NULL DEFAULT 'off', + "book_spy_mode" VARCHAR(16) NOT NULL DEFAULT 'off', + "muted" BOOLEAN NOT NULL DEFAULT FALSE, + "frozen" BOOLEAN NOT NULL DEFAULT FALSE, + "commands_blocked" BOOLEAN NOT NULL DEFAULT FALSE, + "join_leave_messages" BOOLEAN NOT NULL DEFAULT TRUE, + "strikes" INTEGER NOT NULL DEFAULT 0, + "saved_tag" TEXT, + "nickname" TEXT, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """; + statementHandler.executeUpdate(sql); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"updated_at\" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"titles\" TEXT"); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"potion_spy_mode\" VARCHAR(16) NOT NULL DEFAULT 'off'"); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"sign_spy_mode\" VARCHAR(16) NOT NULL DEFAULT 'off'"); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"book_spy_mode\" VARCHAR(16) NOT NULL DEFAULT 'off'"); + statementHandler.executeUpdate("ALTER TABLE \"players\" ADD COLUMN IF NOT EXISTS \"join_leave_messages\" BOOLEAN NOT NULL DEFAULT TRUE"); + } + + private void createPlayerIpsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS "player_ips" ( + "id" SERIAL PRIMARY KEY, + "username" VARCHAR(16) NOT NULL REFERENCES "players"("username") ON DELETE CASCADE, + "ip" VARCHAR(45) NOT NULL, + UNIQUE ("username", "ip") ) """; statementHandler.executeUpdate(sql); @@ -270,7 +472,7 @@ public AdminRepository getAdminRepository() { if (adminRepository == null) { - adminRepository = new PostgreSQLAdminRepository(plugin, statementHandler); + adminRepository = new GenericAdminRepository(statementHandler, this); } return adminRepository; } @@ -280,7 +482,7 @@ public BanRepository getBanRepository() { if (banRepository == null) { - banRepository = new PostgreSQLBanRepository(plugin, statementHandler); + banRepository = new GenericBanRepository(statementHandler, this); } return banRepository; } @@ -290,7 +492,7 @@ public PermbanRepository getPermbanRepository() { if (permbanRepository == null) { - permbanRepository = new PostgreSQLPermbanRepository(plugin, statementHandler); + permbanRepository = new GenericPermbanRepository(statementHandler, this); } return permbanRepository; } @@ -300,7 +502,7 @@ public StrikeRepository getStrikeRepository() { if (strikeRepository == null) { - strikeRepository = new PostgreSQLStrikeRepository(plugin, statementHandler); + strikeRepository = new GenericStrikeRepository(statementHandler, this); } return strikeRepository; } @@ -310,8 +512,68 @@ public DiscordLinkRepository getDiscordLinkRepository() { if (discordLinkRepository == null) { - discordLinkRepository = new PostgreSQLDiscordLinkRepository(plugin, statementHandler); + discordLinkRepository = new GenericDiscordLinkRepository(statementHandler, this); } return discordLinkRepository; } + + @Override + public TitleRepository getTitleRepository() + { + if (titleRepository == null) + { + titleRepository = new GenericTitleRepository(statementHandler, this); + } + return titleRepository; + } + + @Override + public RankRepository getRankRepository() + { + if (rankRepository == null) + { + rankRepository = new GenericRankRepository(statementHandler, this); + } + return rankRepository; + } + + @Override + public ProtectedAreaRepository getProtectedAreaRepository() + { + if (protectedAreaRepository == null) + { + protectedAreaRepository = new GenericProtectedAreaRepository(statementHandler, this); + } + return protectedAreaRepository; + } + + @Override + public SavedFlagRepository getSavedFlagRepository() + { + if (savedFlagRepository == null) + { + savedFlagRepository = new GenericSavedFlagRepository(statementHandler, this); + } + return savedFlagRepository; + } + + @Override + public PlayerRepository getPlayerRepository() + { + if (playerRepository == null) + { + playerRepository = new GenericPlayerRepository(statementHandler, this); + } + return playerRepository; + } + + @Override + public MigrationRepository getMigrationRepository() + { + if (migrationRepository == null) + { + migrationRepository = new GenericMigrationRepository(statementHandler, this); + } + return migrationRepository; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdminRepository.java deleted file mode 100644 index 5ee756545..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLAdminRepository.java +++ /dev/null @@ -1,495 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -/** - * PostgreSQL implementation of AdminRepository. - * Uses PostgreSQL-specific SQL syntax including: - * - ILIKE for case-insensitive comparisons - * - ON CONFLICT DO NOTHING for upsert operations - * - Double quotes for identifier escaping - * - Native BOOLEAN type - */ -public class PostgreSQLAdminRepository implements AdminRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public PostgreSQLAdminRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(UUID uuid, Admin admin) throws SQLException - { - String sql = """ - INSERT INTO "admins" ("uuid", "username", "rank", "active", "last_login", "login_message") - VALUES (?, ?, ?, ?, ?::timestamp, ?) - RETURNING "id" - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - uuid.toString(), - admin.getName(), - admin.getRank().toString(), - admin.isActive(), - FUtil.dateToString(admin.getLastLogin()), - admin.getLoginMessage()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - int adminId = rs.getInt("id"); - insertIps(adminId, admin.getIps()); - return adminId; - } - } - return -1; - } - - @Override - public void insertIps(int adminId, List<String> ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - // PostgreSQL: ON CONFLICT DO NOTHING - String sql = "INSERT INTO \"admin_ips\" (\"admin_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, adminId, ip); - } - } - - @Override - public void addIp(int adminId, String ip) throws SQLException - { - String sql = "INSERT INTO \"admin_ips\" (\"admin_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; - statementHandler.executeUpdate(sql, adminId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public Map<String, Admin> loadAll() throws SQLException - { - Map<String, Admin> admins = new HashMap<>(); - Map<Integer, Admin> adminById = new HashMap<>(); - - String sql = "SELECT \"id\", \"uuid\", \"username\", \"rank\", \"active\", \"last_login\", \"login_message\" FROM \"admins\""; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String username = rs.getString("username"); - String rankStr = rs.getString("rank"); - boolean active = rs.getBoolean("active"); - String lastLoginStr = rs.getString("last_login"); - String loginMessage = rs.getString("login_message"); - - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); - admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); - admin.setActive(active); - admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); - admin.setLoginMessage(loginMessage); - - UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); - if (dbUuid != null) - { - admin.setUuid(dbUuid); - } - - admins.put(configKey, admin); - adminById.put(id, admin); - } - } - - // Load IPs - String ipSql = "SELECT \"admin_id\", \"ip\" FROM \"admin_ips\""; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int adminId = rs.getInt("admin_id"); - String ip = rs.getString("ip"); - Admin admin = adminById.get(adminId); - if (admin != null) - { - admin.addIp(ip); - } - } - } - - return admins; - } - - @Override - public Admin findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT \"id\", \"uuid\", \"username\", \"rank\", \"active\", \"last_login\", \"login_message\" FROM \"admins\" WHERE \"uuid\" = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public Admin findByUsername(String username) throws SQLException - { - // PostgreSQL: ILIKE for case-insensitive comparison - String sql = "SELECT \"id\", \"uuid\", \"username\", \"rank\", \"active\", \"last_login\", \"login_message\" FROM \"admins\" WHERE \"username\" ILIKE ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public Admin findByIp(String ip) throws SQLException - { - String sql = """ - SELECT a."id", a."uuid", a."username", a."rank", a."active", a."last_login", a."login_message" - FROM "admins" a - INNER JOIN "admin_ips" ai ON a."id" = ai."admin_id" - WHERE ai."ip" = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public int getAdminId(String username) throws SQLException - { - String sql = "SELECT \"id\" FROM \"admins\" WHERE \"username\" ILIKE ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getAdminIdByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT \"id\" FROM \"admins\" WHERE \"uuid\" = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List<String> getIps(int adminId) throws SQLException - { - List<String> ips = new ArrayList<>(); - String sql = "SELECT \"ip\" FROM \"admin_ips\" WHERE \"admin_id\" = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, adminId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean exists(String username) throws SQLException - { - String sql = "SELECT COUNT(*) FROM \"admins\" WHERE \"username\" ILIKE ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean existsByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT COUNT(*) FROM \"admins\" WHERE \"uuid\" = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public UUID getUuidByUsername(String username) throws SQLException - { - String sql = "SELECT \"uuid\" FROM \"admins\" WHERE \"username\" ILIKE ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return UUID.fromString(rs.getString("uuid")); - } - } - return null; - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(UUID uuid, Admin admin) throws SQLException - { - String sql = """ - UPDATE "admins" - SET "username" = ?, "rank" = ?, "active" = ?, "last_login" = ?::timestamp, "login_message" = ? - WHERE "uuid" = ? - """; - - int rows = statementHandler.executeUpdate(sql, - admin.getName(), - admin.getRank().toString(), - admin.isActive(), - FUtil.dateToString(admin.getLastLogin()), - admin.getLoginMessage(), - uuid.toString()); - - return rows > 0; - } - - @Override - public boolean updateRank(String username, String rank) throws SQLException - { - String sql = "UPDATE \"admins\" SET \"rank\" = ? WHERE \"username\" ILIKE ?"; - return statementHandler.executeUpdate(sql, rank, username) > 0; - } - - @Override - public boolean updateActive(String username, boolean active) throws SQLException - { - String sql = "UPDATE \"admins\" SET \"active\" = ? WHERE \"username\" ILIKE ?"; - return statementHandler.executeUpdate(sql, active, username) > 0; - } - - @Override - public boolean updateLastLogin(String username, Date lastLogin) throws SQLException - { - String sql = "UPDATE \"admins\" SET \"last_login\" = ?::timestamp WHERE \"username\" ILIKE ?"; - return statementHandler.executeUpdate(sql, FUtil.dateToString(lastLogin), username) > 0; - } - - @Override - public boolean updateUsername(UUID uuid, String newUsername) throws SQLException - { - String sql = "UPDATE \"admins\" SET \"username\" = ? WHERE \"uuid\" = ?"; - return statementHandler.executeUpdate(sql, newUsername, uuid.toString()) > 0; - } - - @Override - public void syncIps(int adminId, List<String> ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM \"admin_ips\" WHERE \"admin_id\" = ?", adminId); - insertIps(adminId, ips); - } - - @Override - public int saveOrUpdate(UUID uuid, Admin admin) throws SQLException - { - if (existsByUuid(uuid)) - { - update(uuid, admin); - int adminId = getAdminIdByUuid(uuid); - syncIps(adminId, admin.getIps()); - return adminId; - } - else - { - return insert(uuid, admin); - } - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM \"admins\" WHERE \"uuid\" = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM \"admins\" WHERE \"username\" ILIKE ?"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean removeIp(int adminId, String ip) throws SQLException - { - String sql = "DELETE FROM \"admin_ips\" WHERE \"admin_id\" = ? AND \"ip\" = ?"; - return statementHandler.executeUpdate(sql, adminId, ip) > 0; - } - - @Override - public boolean clearIps(int adminId) throws SQLException - { - String sql = "DELETE FROM \"admin_ips\" WHERE \"admin_id\" = ?"; - return statementHandler.executeUpdate(sql, adminId) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM \"admin_ips\""); - statementHandler.executeUpdate("DELETE FROM \"admins\""); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public CompletableFuture<Map<String, Admin>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load admins: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> insertAsync(UUID uuid, Admin admin) - { - return CompletableFuture.supplyAsync(() -> { - try { return insert(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to insert admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> updateAsync(UUID uuid, Admin admin) - { - return CompletableFuture.supplyAsync(() -> { - try { return update(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to update admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteAsync(UUID uuid) - { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> save(UUID uuid, Admin admin) - { - return CompletableFuture.supplyAsync(() -> { - try { return saveOrUpdate(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to save admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<List<Admin>> findAll() - { - return CompletableFuture.supplyAsync(() -> { - try { return new ArrayList<>(loadAll().values()); } - catch (SQLException e) { FLog.severe("Failed to find all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteByUuid(UUID uuid) - { - return deleteAsync(uuid); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - // ============================================ - // Helper Methods - // ============================================ - - private Admin loadAdminFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String username = rs.getString("username"); - String rankStr = rs.getString("rank"); - boolean active = rs.getBoolean("active"); - String lastLoginStr = rs.getString("last_login"); - String loginMessage = rs.getString("login_message"); - - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); - admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); - admin.setActive(active); - admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); - admin.setLoginMessage(loginMessage); - - UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); - if (dbUuid != null) - { - admin.setUuid(dbUuid); - } - - List<String> ips = getIps(id); - admin.addIps(ips); - - return admin; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLBanRepository.java deleted file mode 100644 index 4deb55392..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLBanRepository.java +++ /dev/null @@ -1,520 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -/** - * PostgreSQL implementation of BanRepository. - * Uses PostgreSQL-specific SQL syntax. - */ -public class PostgreSQLBanRepository implements BanRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public PostgreSQLBanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(Ban ban) throws SQLException - { - String sql = """ - INSERT INTO "bans" ("uuid", "username", "banned_by", "banned_by_uuid", "reason", "expire_at") - VALUES (?, ?, ?, ?, ?, ?::timestamp) - RETURNING "id" - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - ban.getUuid() != null ? ban.getUuid().toString() : null, - ban.getUsername(), - ban.getBannedBy(), - ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, - ban.getReason(), - ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - int banId = rs.getInt("id"); - insertIps(banId, ban.getIps()); - return banId; - } - } - return -1; - } - - @Override - public void insertIps(int banId, List<String> ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - String sql = "INSERT INTO \"ban_ips\" (\"ban_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, banId, ip); - } - } - - @Override - public void addIp(int banId, String ip) throws SQLException - { - String sql = "INSERT INTO \"ban_ips\" (\"ban_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; - statementHandler.executeUpdate(sql, banId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public List<Ban> loadAll() throws SQLException - { - List<Ban> bans = new ArrayList<>(); - Map<Integer, Ban> banById = new HashMap<>(); - - String sql = "SELECT \"id\", \"uuid\", \"username\", \"banned_by\", \"banned_by_uuid\", \"reason\", \"expire_at\" FROM \"bans\""; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String bannedBy = rs.getString("banned_by"); - String bannedByUuidStr = rs.getString("banned_by_uuid"); - String reason = rs.getString("reason"); - String expireAtStr = rs.getString("expire_at"); - - Ban ban = new Ban(); - ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - ban.setUsername(username); - ban.setBannedBy(bannedBy); - ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); - ban.setReason(reason); - ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - - bans.add(ban); - banById.put(id, ban); - } - } - - // Load IPs - String ipSql = "SELECT \"ban_id\", \"ip\" FROM \"ban_ips\""; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int banId = rs.getInt("ban_id"); - String ip = rs.getString("ip"); - Ban ban = banById.get(banId); - if (ban != null) - { - ban.addIp(ip); - } - } - } - - return bans; - } - - @Override - public Ban findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT \"id\", \"uuid\", \"username\", \"banned_by\", \"banned_by_uuid\", \"reason\", \"expire_at\" FROM \"bans\" WHERE \"uuid\" = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public Ban findByUsername(String username) throws SQLException - { - String sql = "SELECT \"id\", \"uuid\", \"username\", \"banned_by\", \"banned_by_uuid\", \"reason\", \"expire_at\" FROM \"bans\" WHERE \"username\" ILIKE ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public Ban findByIp(String ip) throws SQLException - { - String sql = """ - SELECT b."id", b."uuid", b."username", b."banned_by", b."banned_by_uuid", b."reason", b."expire_at" - FROM "bans" b - INNER JOIN "ban_ips" bi ON b."id" = bi."ban_id" - WHERE bi."ip" = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public List<Ban> findActiveBans() throws SQLException - { - // PostgreSQL: CURRENT_TIMESTAMP for current time - String sql = """ - SELECT "id", "uuid", "username", "banned_by", "banned_by_uuid", "reason", "expire_at" - FROM "bans" - WHERE "expire_at" IS NULL OR "expire_at" > CURRENT_TIMESTAMP - """; - List<Ban> bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; - } - - @Override - public List<Ban> findExpiredBans() throws SQLException - { - String sql = """ - SELECT "id", "uuid", "username", "banned_by", "banned_by_uuid", "reason", "expire_at" - FROM "bans" - WHERE "expire_at" IS NOT NULL AND "expire_at" <= CURRENT_TIMESTAMP - """; - List<Ban> bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; - } - - @Override - public int getBanId(UUID uuid) throws SQLException - { - String sql = "SELECT \"id\" FROM \"bans\" WHERE \"uuid\" = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getBanIdByUsername(String username) throws SQLException - { - String sql = "SELECT \"id\" FROM \"bans\" WHERE \"username\" ILIKE ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List<String> getIps(int banId) throws SQLException - { - List<String> ips = new ArrayList<>(); - String sql = "SELECT \"ip\" FROM \"ban_ips\" WHERE \"ban_id\" = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, banId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean isBanned(UUID uuid) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM "bans" - WHERE "uuid" = ? AND ("expire_at" IS NULL OR "expire_at" > CURRENT_TIMESTAMP) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isBannedByUsername(String username) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM "bans" - WHERE "username" ILIKE ? AND ("expire_at" IS NULL OR "expire_at" > CURRENT_TIMESTAMP) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isBannedByIp(String ip) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM "bans" b - INNER JOIN "ban_ips" bi ON b."id" = bi."ban_id" - WHERE bi."ip" = ? AND (b."expire_at" IS NULL OR b."expire_at" > CURRENT_TIMESTAMP) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(Ban ban) throws SQLException - { - String sql = """ - UPDATE "bans" - SET "username" = ?, "banned_by" = ?, "banned_by_uuid" = ?, "reason" = ?, "expire_at" = ?::timestamp - WHERE "uuid" = ? - """; - - int rows = statementHandler.executeUpdate(sql, - ban.getUsername(), - ban.getBannedBy(), - ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, - ban.getReason(), - ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null, - ban.getUuid().toString()); - - return rows > 0; - } - - @Override - public boolean updateReason(UUID uuid, String reason) throws SQLException - { - String sql = "UPDATE \"bans\" SET \"reason\" = ? WHERE \"uuid\" = ?"; - return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; - } - - @Override - public boolean updateExpiry(UUID uuid, Date expireAt) throws SQLException - { - String sql = "UPDATE \"bans\" SET \"expire_at\" = ?::timestamp WHERE \"uuid\" = ?"; - return statementHandler.executeUpdate(sql, expireAt != null ? FUtil.dateToString(expireAt) : null, uuid.toString()) > 0; - } - - @Override - public void syncIps(int banId, List<String> ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM \"ban_ips\" WHERE \"ban_id\" = ?", banId); - insertIps(banId, ips); - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM \"bans\" WHERE \"uuid\" = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM \"bans\" WHERE \"username\" ILIKE ?"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - String selectSql = "SELECT \"ban_id\" FROM \"ban_ips\" WHERE \"ip\" = ?"; - List<Integer> banIds = new ArrayList<>(); - try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - banIds.add(rs.getInt("ban_id")); - } - } - - for (int banId : banIds) - { - statementHandler.executeUpdate("DELETE FROM \"bans\" WHERE \"id\" = ?", banId); - } - - return !banIds.isEmpty(); - } - - @Override - public boolean removeIp(int banId, String ip) throws SQLException - { - String sql = "DELETE FROM \"ban_ips\" WHERE \"ban_id\" = ? AND \"ip\" = ?"; - return statementHandler.executeUpdate(sql, banId, ip) > 0; - } - - @Override - public int deleteExpiredBans() throws SQLException - { - String sql = "DELETE FROM \"bans\" WHERE \"expire_at\" IS NOT NULL AND \"expire_at\" <= CURRENT_TIMESTAMP"; - return statementHandler.executeUpdate(sql); - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM \"ban_ips\""); - statementHandler.executeUpdate("DELETE FROM \"bans\""); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public CompletableFuture<List<Ban>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load bans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> insertAsync(Ban ban) - { - return CompletableFuture.supplyAsync(() -> { - try { return insert(ban); } - catch (SQLException e) { FLog.severe("Failed to insert ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> updateAsync(Ban ban) - { - return CompletableFuture.supplyAsync(() -> { - try { return update(ban); } - catch (SQLException e) { FLog.severe("Failed to update ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteAsync(UUID uuid) - { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> save(Ban ban) - { - return CompletableFuture.supplyAsync(() -> { - try - { - if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) - { - update(ban); - return getBanId(ban.getUuid()); - } - return insert(ban); - } - catch (SQLException e) { FLog.severe("Failed to save ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<List<Ban>> findAll() - { - return loadAllAsync(); - } - - @Override - public CompletableFuture<Boolean> deleteByUuid(UUID uuid) - { - return deleteAsync(uuid); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all bans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - // ============================================ - // Helper Methods - // ============================================ - - private Ban loadBanFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String bannedBy = rs.getString("banned_by"); - String bannedByUuidStr = rs.getString("banned_by_uuid"); - String reason = rs.getString("reason"); - String expireAtStr = rs.getString("expire_at"); - - Ban ban = new Ban(); - ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - ban.setUsername(username); - ban.setBannedBy(bannedBy); - ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); - ban.setReason(reason); - ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - - List<String> ips = getIps(id); - ban.setIps(ips); - - return ban; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLDiscordLinkRepository.java deleted file mode 100644 index 8658e0223..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLDiscordLinkRepository.java +++ /dev/null @@ -1,67 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.UUID; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; - -public class PostgreSQLDiscordLinkRepository implements DiscordLinkRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public PostgreSQLDiscordLinkRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public void insert(UUID adminUuid, String discordUserId) throws SQLException - { - statementHandler.executeUpdate( - "INSERT INTO \"discord_links\" (\"admin_uuid\", \"discord_user_id\", \"linked_at\") VALUES (?, ?, CURRENT_TIMESTAMP)", - adminUuid.toString(), discordUserId); - } - - @Override - public String findDiscordIdByAdminUuid(UUID adminUuid) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT \"discord_user_id\" FROM \"discord_links\" WHERE \"admin_uuid\" = ?", adminUuid.toString())) - { - return rs.next() ? rs.getString(1) : null; - } - } - - @Override - public UUID findAdminUuidByDiscordId(String discordUserId) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT \"admin_uuid\" FROM \"discord_links\" WHERE \"discord_user_id\" = ?", discordUserId)) - { - if (!rs.next()) - { - return null; - } - String raw = rs.getString(1); - return raw == null ? null : UUID.fromString(raw); - } - } - - @Override - public boolean deleteByAdminUuid(UUID adminUuid) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM \"discord_links\" WHERE \"admin_uuid\" = ?", adminUuid.toString()) > 0; - } - - @Override - public boolean deleteByDiscordUserId(String discordUserId) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM \"discord_links\" WHERE \"discord_user_id\" = ?", discordUserId) > 0; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLPermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLPermbanRepository.java deleted file mode 100644 index 50b86901d..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLPermbanRepository.java +++ /dev/null @@ -1,436 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.PermBan; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -/** - * PostgreSQL implementation of PermbanRepository. - * Uses PostgreSQL-specific SQL syntax. - */ -public class PostgreSQLPermbanRepository implements PermbanRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public PostgreSQLPermbanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(PermBan permban) throws SQLException - { - String sql = """ - INSERT INTO "permbans" ("uuid", "username", "reason") - VALUES (?, ?, ?) - RETURNING "id" - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - permban.getUuid() != null ? permban.getUuid().toString() : null, - permban.getUsername(), - permban.getReason()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - int permbanId = rs.getInt("id"); - insertIps(permbanId, permban.getIps()); - return permbanId; - } - } - return -1; - } - - @Override - public void insertIps(int permbanId, List<String> ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - String sql = "INSERT INTO \"permban_ips\" (\"permban_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, permbanId, ip); - } - } - - @Override - public void addIp(int permbanId, String ip) throws SQLException - { - String sql = "INSERT INTO \"permban_ips\" (\"permban_id\", \"ip\") VALUES (?, ?) ON CONFLICT DO NOTHING"; - statementHandler.executeUpdate(sql, permbanId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public List<PermBan> loadAll() throws SQLException - { - List<PermBan> permbans = new ArrayList<>(); - Map<Integer, PermBan> permbanById = new HashMap<>(); - - String sql = "SELECT \"id\", \"uuid\", \"username\", \"reason\" FROM \"permbans\""; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String reason = rs.getString("reason"); - - PermBan permban = new PermBan(); - permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - permban.setUsername(username); - permban.setReason(reason); - - permbans.add(permban); - permbanById.put(id, permban); - } - } - - // Load IPs - String ipSql = "SELECT \"permban_id\", \"ip\" FROM \"permban_ips\""; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int permbanId = rs.getInt("permban_id"); - String ip = rs.getString("ip"); - PermBan permban = permbanById.get(permbanId); - if (permban != null) - { - permban.addIp(ip); - } - } - } - - return permbans; - } - - @Override - public PermBan findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT \"id\", \"uuid\", \"username\", \"reason\" FROM \"permbans\" WHERE \"uuid\" = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public PermBan findByUsername(String username) throws SQLException - { - String sql = "SELECT \"id\", \"uuid\", \"username\", \"reason\" FROM \"permbans\" WHERE \"username\" ILIKE ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public PermBan findByIp(String ip) throws SQLException - { - String sql = """ - SELECT p."id", p."uuid", p."username", p."reason" - FROM "permbans" p - INNER JOIN "permban_ips" pi ON p."id" = pi."permban_id" - WHERE pi."ip" = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public int getPermbanId(UUID uuid) throws SQLException - { - String sql = "SELECT \"id\" FROM \"permbans\" WHERE \"uuid\" = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getPermbanIdByUsername(String username) throws SQLException - { - String sql = "SELECT \"id\" FROM \"permbans\" WHERE \"username\" ILIKE ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List<String> getIps(int permbanId) throws SQLException - { - List<String> ips = new ArrayList<>(); - String sql = "SELECT \"ip\" FROM \"permban_ips\" WHERE \"permban_id\" = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, permbanId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean isPermBanned(UUID uuid) throws SQLException - { - String sql = "SELECT COUNT(*) FROM \"permbans\" WHERE \"uuid\" = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isPermBannedByUsername(String username) throws SQLException - { - String sql = "SELECT COUNT(*) FROM \"permbans\" WHERE \"username\" ILIKE ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isPermBannedByIp(String ip) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM "permbans" p - INNER JOIN "permban_ips" pi ON p."id" = pi."permban_id" - WHERE pi."ip" = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(PermBan permban) throws SQLException - { - String sql = """ - UPDATE "permbans" - SET "username" = ?, "reason" = ? - WHERE "uuid" = ? - """; - - int rows = statementHandler.executeUpdate(sql, - permban.getUsername(), - permban.getReason(), - permban.getUuid().toString()); - - return rows > 0; - } - - @Override - public boolean updateReason(UUID uuid, String reason) throws SQLException - { - String sql = "UPDATE \"permbans\" SET \"reason\" = ? WHERE \"uuid\" = ?"; - return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; - } - - @Override - public void syncIps(int permbanId, List<String> ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM \"permban_ips\" WHERE \"permban_id\" = ?", permbanId); - insertIps(permbanId, ips); - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM \"permbans\" WHERE \"uuid\" = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM \"permbans\" WHERE \"username\" ILIKE ?"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - String selectSql = "SELECT \"permban_id\" FROM \"permban_ips\" WHERE \"ip\" = ?"; - List<Integer> permbanIds = new ArrayList<>(); - try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - permbanIds.add(rs.getInt("permban_id")); - } - } - - for (int permbanId : permbanIds) - { - statementHandler.executeUpdate("DELETE FROM \"permbans\" WHERE \"id\" = ?", permbanId); - } - - return !permbanIds.isEmpty(); - } - - @Override - public boolean removeIp(int permbanId, String ip) throws SQLException - { - String sql = "DELETE FROM \"permban_ips\" WHERE \"permban_id\" = ? AND \"ip\" = ?"; - return statementHandler.executeUpdate(sql, permbanId, ip) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM \"permban_ips\""); - statementHandler.executeUpdate("DELETE FROM \"permbans\""); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public CompletableFuture<List<PermBan>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> insertAsync(PermBan permban) - { - return CompletableFuture.supplyAsync(() -> { - try { return insert(permban); } - catch (SQLException e) { FLog.severe("Failed to insert permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> updateAsync(PermBan permban) - { - return CompletableFuture.supplyAsync(() -> { - try { return update(permban); } - catch (SQLException e) { FLog.severe("Failed to update permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteAsync(UUID uuid) - { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> save(PermBan permban) - { - return CompletableFuture.supplyAsync(() -> { - try - { - if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) - { - update(permban); - return getPermbanId(permban.getUuid()); - } - return insert(permban); - } - catch (SQLException e) { FLog.severe("Failed to save permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<List<PermBan>> findAll() - { - return loadAllAsync(); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - // ============================================ - // Helper Methods - // ============================================ - - private PermBan loadPermbanFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String reason = rs.getString("reason"); - - PermBan permban = new PermBan(); - permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - permban.setUsername(username); - permban.setReason(reason); - - List<String> ips = getIps(id); - permban.setIps(ips); - - return permban; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLStrikeRepository.java deleted file mode 100644 index 6b8b655d1..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/postgresql/PostgreSQLStrikeRepository.java +++ /dev/null @@ -1,106 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.postgresql; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.StrikeRecord; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import me.totalfreedom.totalfreedommod.util.FLog; - -public class PostgreSQLStrikeRepository implements StrikeRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public PostgreSQLStrikeRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public Map<String, StrikeRecord> loadAll() throws SQLException - { - Map<String, StrikeRecord> out = new HashMap<>(); - String sql = "SELECT ip, strike_count, last_strike_unix, last_username FROM strikes"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - String ip = rs.getString("ip"); - int count = rs.getInt("strike_count"); - long last = rs.getLong("last_strike_unix"); - String username = rs.getString("last_username"); - out.put(ip, new StrikeRecord(ip, count, last, username)); - } - } - return out; - } - - @Override - public void upsert(StrikeRecord r) throws SQLException - { - String sql = """ - INSERT INTO strikes (ip, strike_count, last_strike_unix, last_username) - VALUES (?, ?, ?, ?) - ON CONFLICT (ip) DO UPDATE SET - strike_count = EXCLUDED.strike_count, - last_strike_unix = EXCLUDED.last_strike_unix, - last_username = EXCLUDED.last_username - """; - statementHandler.executeUpdate(sql, - r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - return statementHandler.executeUpdate("DELETE FROM strikes WHERE ip = ?", ip) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM strikes"); - } - - @Override - public CompletableFuture<Map<String, StrikeRecord>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Void> upsertAsync(StrikeRecord r) - { - return CompletableFuture.runAsync(() -> { - try { upsert(r); } - catch (SQLException e) { FLog.severe("Failed to upsert strike: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteByIpAsync(String ip) - { - return CompletableFuture.supplyAsync(() -> { - try { return deleteByIp(ip); } - catch (SQLException e) { FLog.severe("Failed to delete strike: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to clear strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java index 68bf84433..7e1b51ab4 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdapter.java @@ -1,18 +1,20 @@ package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.Calendar; +import java.util.TimeZone; +import java.util.stream.Collectors; +import java.util.stream.Stream; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.sql.ConnectionHandler; import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.DatabaseAdapter; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; +import me.totalfreedom.totalfreedommod.sql.adapter.*; +import me.totalfreedom.totalfreedommod.sql.adapter.generic.*; import me.totalfreedom.totalfreedommod.util.FLog; -import java.sql.SQLException; - /** * SQLite-specific database adapter. * Uses SQLite-specific SQL features like: @@ -23,11 +25,24 @@ */ public class SQLiteAdapter extends DatabaseAdapter { - private SQLiteAdminRepository adminRepository; - private SQLiteBanRepository banRepository; - private SQLitePermbanRepository permbanRepository; - private SQLiteStrikeRepository strikeRepository; - private SQLiteDiscordLinkRepository discordLinkRepository; + /** + * Constant stand-in for CURRENT_TIMESTAMP, which SQLite will not accept as the default of a + * column added by ALTER TABLE. Rows carrying it are backfilled immediately after the column + * is added. + */ + private static final String EPOCH_TIMESTAMP = "1970-01-01 00:00:00"; + + private AdminRepository adminRepository; + private BanRepository banRepository; + private PermbanRepository permbanRepository; + private StrikeRepository strikeRepository; + private DiscordLinkRepository discordLinkRepository; + private RankRepository rankRepository; + private TitleRepository titleRepository; + private ProtectedAreaRepository protectedAreaRepository; + private SavedFlagRepository savedFlagRepository; + private PlayerRepository playerRepository; + private MigrationRepository migrationRepository; public SQLiteAdapter(TotalFreedomMod plugin, ConnectionHandler connectionHandler, StatementHandler statementHandler) { @@ -68,12 +83,30 @@ public String booleanType() return "INTEGER"; // SQLite uses 0/1 for boolean } + @Override + public String jsonType() + { + return "TEXT"; // No native JSON type; JSON1 extension functions operate on TEXT + } + + @Override + public String jsonParamPlaceholder() + { + return "?"; + } + @Override public String insertIgnoreSyntax() { return "INSERT OR IGNORE"; } + @Override + public String insertIgnoreSuffix() + { + return ""; + } + @Override public String quoteIdentifier(String identifier) { @@ -86,10 +119,46 @@ public String currentTimestamp() return "CURRENT_TIMESTAMP"; } + /** + * SQLite writes CURRENT_TIMESTAMP as UTC text with no zone, which the driver + * would otherwise read in the JVM's timezone and place hours away from when + * the row was actually written. + */ + @Override + public Long readTimestamp(final ResultSet rs, final int index) throws SQLException + { + final Timestamp ts = rs.getTimestamp(index, Calendar.getInstance(TimeZone.getTimeZone("UTC"))); + + return ts != null ? ts.getTime() : null; + } + + + @Override + public String timestampParamPlaceholder() + { + return "?"; + } + @Override - public String caseInsensitiveLike() + public String caseInsensitiveEquals(String columnRef, String paramPlaceholder) { - return "LIKE"; // SQLite LIKE is case-insensitive by default + return String.format("LOWER(%s) = LOWER(%s)", columnRef, paramPlaceholder); + } + + @Override + public String compareToNow(String columnRef, String operator) + { + // expire_at is stored as formatted TEXT; datetime() normalizes both sides for comparison. + return String.format("datetime(%s) %s datetime('now')", columnRef, operator); + } + + @Override + public String upsertClause(String conflictColumn, String... updateColumns) + { + String assignments = Stream.of(updateColumns) + .map(col -> String.format("%s = EXCLUDED.%s", col, col)) + .collect(Collectors.joining(", ")); + return String.format("ON CONFLICT(%s) DO UPDATE SET %s", conflictColumn, assignments); } // ============================================ @@ -113,6 +182,14 @@ public void runMigrations() throws SQLException createPermbanIpsTable(); createStrikesTable(); createDiscordLinksTable(); + createRanksTable(); + createRankPermissionsTable(); + createTitlesTable(); + createTitlePermissionsTable(); + createProtectedAreasTable(); + createSavedFlagsTable(); + createPlayersTable(); + createPlayerIpsTable(); FLog.info("[SQLite] Database migrations complete."); } @@ -140,20 +217,15 @@ CREATE TABLE IF NOT EXISTS admins ( active INTEGER DEFAULT 1, last_login TEXT, login_message TEXT, - custom_rank TEXT + custom_rank TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); - // Migration for existing tables - try - { - statementHandler.executeUpdate("ALTER TABLE admins ADD COLUMN custom_rank TEXT"); - } - catch (SQLException ignored) - { - // Column already exists or table doesn't exist yet (handled by CREATE TABLE IF NOT EXISTS) - } + // Migration for tables created before custom_rank/updated_at existed. + addColumnIfMissing("admins", "custom_rank", "TEXT"); + addTimestampColumnIfMissing("admins", "updated_at"); // Create indexes statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_admins_username ON admins(username)"); @@ -185,10 +257,12 @@ CREATE TABLE IF NOT EXISTS bans ( banned_by TEXT, banned_by_uuid TEXT, reason TEXT, - expire_at TEXT + expire_at TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + addTimestampColumnIfMissing("bans", "updated_at"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_uuid ON bans(uuid)"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_bans_username ON bans(username)"); @@ -210,17 +284,22 @@ FOREIGN KEY (ban_id) REFERENCES bans(id) ON DELETE CASCADE statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ban_ips_ip ON ban_ips(ip)"); } + /** + * A permban is always keyed by username, ensure it's not null. + */ private void createPermbansTable() throws SQLException { String sql = """ CREATE TABLE IF NOT EXISTS permbans ( id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT, - username TEXT, - reason TEXT + username TEXT NOT NULL, + reason TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + addTimestampColumnIfMissing("permbans", "updated_at"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_uuid ON permbans(uuid)"); statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_permbans_username ON permbans(username)"); @@ -249,10 +328,12 @@ CREATE TABLE IF NOT EXISTS strikes ( strike_count INTEGER NOT NULL DEFAULT 0, last_strike_unix INTEGER NOT NULL DEFAULT 0, last_username TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); + addTimestampColumnIfMissing("strikes", "updated_at"); } private void createDiscordLinksTable() throws SQLException @@ -262,12 +343,203 @@ CREATE TABLE IF NOT EXISTS discord_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, admin_uuid TEXT NOT NULL UNIQUE, discord_user_id TEXT NOT NULL UNIQUE, - linked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + linked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """; + statementHandler.executeUpdate(sql); + addTimestampColumnIfMissing("discord_links", "updated_at"); + } + + private void createRanksTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS ranks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + determiner TEXT NOT NULL DEFAULT 'a', + abbreviation TEXT, + level INTEGER NOT NULL DEFAULT 0, + color TEXT NOT NULL DEFAULT 'white', + admin INTEGER NOT NULL DEFAULT 0, + prefix TEXT, + inherit_from TEXT, + roles TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """; + statementHandler.executeUpdate(sql); + addTimestampColumnIfMissing("ranks", "updated_at"); + addColumnIfMissing("ranks", "roles", "TEXT"); + statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_ranks_level ON ranks(level)"); + } + + private void createRankPermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS rank_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rank_id TEXT NOT NULL, + permission TEXT NOT NULL, + UNIQUE (rank_id, permission), + FOREIGN KEY (rank_id) REFERENCES ranks(id) ON DELETE CASCADE + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createTitlesTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS titles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + determiner TEXT NOT NULL DEFAULT 'a', + abbreviation TEXT, + color TEXT NOT NULL DEFAULT 'white', + prefix TEXT, + weight INTEGER NOT NULL DEFAULT 0, + announce INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """; statementHandler.executeUpdate(sql); } + private void createTitlePermissionsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS title_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title_id TEXT NOT NULL, + permission TEXT NOT NULL, + UNIQUE (title_id, permission), + FOREIGN KEY (title_id) REFERENCES titles(id) ON DELETE CASCADE + ) + """; + statementHandler.executeUpdate(sql); + } + + private void createProtectedAreasTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS protected_areas ( + uuid TEXT PRIMARY KEY, + name TEXT NOT NULL, + min_x INTEGER NOT NULL, + min_y INTEGER NOT NULL, + min_z INTEGER NOT NULL, + max_x INTEGER NOT NULL, + max_y INTEGER NOT NULL, + max_z INTEGER NOT NULL, + world_uuid TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """; + statementHandler.executeUpdate(sql); + addTimestampColumnIfMissing("protected_areas", "updated_at"); + statementHandler.executeUpdate("CREATE INDEX IF NOT EXISTS idx_protected_areas_name ON protected_areas(name)"); + } + + private void createSavedFlagsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS saved_flags ( + flag_name TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """; + statementHandler.executeUpdate(sql); + addTimestampColumnIfMissing("saved_flags", "updated_at"); + } + + private void createPlayersTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS players ( + username TEXT PRIMARY KEY, + first_join_unix INTEGER NOT NULL DEFAULT 0, + last_join_unix INTEGER NOT NULL DEFAULT 0, + potion_spy_mode TEXT NOT NULL DEFAULT 'off', + command_spy_mode TEXT NOT NULL DEFAULT 'off', + sign_spy_mode TEXT NOT NULL DEFAULT 'off', + book_spy_mode TEXT NOT NULL DEFAULT 'off', + muted INTEGER NOT NULL DEFAULT 0, + frozen INTEGER NOT NULL DEFAULT 0, + commands_blocked INTEGER NOT NULL DEFAULT 0, + join_leave_messages INTEGER NOT NULL DEFAULT 1, + strikes INTEGER NOT NULL DEFAULT 0, + saved_tag TEXT, + nickname TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """; + statementHandler.executeUpdate(sql); + addTimestampColumnIfMissing("players", "updated_at"); + addColumnIfMissing("players", "titles", "TEXT"); + addColumnIfMissing("players", "potion_spy_mode", "TEXT NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "sign_spy_mode", "TEXT NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "book_spy_mode", "TEXT NOT NULL DEFAULT 'off'"); + addColumnIfMissing("players", "join_leave_messages", "INTEGER NOT NULL DEFAULT 1"); + } + + private void createPlayerIpsTable() throws SQLException + { + String sql = """ + CREATE TABLE IF NOT EXISTS player_ips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + ip TEXT NOT NULL, + UNIQUE (username, ip), + FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE + ) + """; + statementHandler.executeUpdate(sql); + } + + /** + * Add a column to a table created before that column existed. SQLite has no + * ADD COLUMN IF NOT EXISTS, so presence is checked up front rather than by + * swallowing the resulting error. + */ + private void addColumnIfMissing(final String table, final String column, final String definition) throws SQLException + { + if (columnExists(table, column)) + return; + + statementHandler.executeUpdate(String.format("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)); + } + + /** + * Add a missing timestamp column to a table created before that column existed. + * SQLite rejects a non-constant default such as CURRENT_TIMESTAMP in ALTER TABLE ADD COLUMN, + * so the column is added with a constant default and existing rows are then backfilled with + * the current time. + */ + private void addTimestampColumnIfMissing(final String table, final String column) throws SQLException + { + if (columnExists(table, column)) + return; + + statementHandler.executeUpdate(String.format("ALTER TABLE %s ADD COLUMN %s TEXT NOT NULL DEFAULT '%s'", + table, column, EPOCH_TIMESTAMP)); + statementHandler.executeUpdate(String.format("UPDATE %s SET %s = CURRENT_TIMESTAMP", table, column)); + } + + private boolean columnExists(final String table, final String column) throws SQLException + { + try (ResultSet columns = statementHandler.executeQuery(String.format("PRAGMA table_info(%s)", table))) + { + while (columns.next()) + { + if (column.equalsIgnoreCase(columns.getString("name"))) + return true; + } + } + return false; + } + // ============================================ // Repository Getters // ============================================ @@ -277,7 +549,7 @@ public AdminRepository getAdminRepository() { if (adminRepository == null) { - adminRepository = new SQLiteAdminRepository(plugin, statementHandler); + adminRepository = new GenericAdminRepository(statementHandler, this); } return adminRepository; } @@ -287,7 +559,7 @@ public BanRepository getBanRepository() { if (banRepository == null) { - banRepository = new SQLiteBanRepository(plugin, statementHandler); + banRepository = new GenericBanRepository(statementHandler, this); } return banRepository; } @@ -297,7 +569,7 @@ public PermbanRepository getPermbanRepository() { if (permbanRepository == null) { - permbanRepository = new SQLitePermbanRepository(plugin, statementHandler); + permbanRepository = new GenericPermbanRepository(statementHandler, this); } return permbanRepository; } @@ -307,7 +579,7 @@ public StrikeRepository getStrikeRepository() { if (strikeRepository == null) { - strikeRepository = new SQLiteStrikeRepository(plugin, statementHandler); + strikeRepository = new GenericStrikeRepository(statementHandler, this); } return strikeRepository; } @@ -317,8 +589,68 @@ public DiscordLinkRepository getDiscordLinkRepository() { if (discordLinkRepository == null) { - discordLinkRepository = new SQLiteDiscordLinkRepository(plugin, statementHandler); + discordLinkRepository = new GenericDiscordLinkRepository(statementHandler, this); } return discordLinkRepository; } + + @Override + public TitleRepository getTitleRepository() + { + if (titleRepository == null) + { + titleRepository = new GenericTitleRepository(statementHandler, this); + } + return titleRepository; + } + + @Override + public RankRepository getRankRepository() + { + if (rankRepository == null) + { + rankRepository = new GenericRankRepository(statementHandler, this); + } + return rankRepository; + } + + @Override + public ProtectedAreaRepository getProtectedAreaRepository() + { + if (protectedAreaRepository == null) + { + protectedAreaRepository = new GenericProtectedAreaRepository(statementHandler, this); + } + return protectedAreaRepository; + } + + @Override + public SavedFlagRepository getSavedFlagRepository() + { + if (savedFlagRepository == null) + { + savedFlagRepository = new GenericSavedFlagRepository(statementHandler, this); + } + return savedFlagRepository; + } + + @Override + public PlayerRepository getPlayerRepository() + { + if (playerRepository == null) + { + playerRepository = new GenericPlayerRepository(statementHandler, this); + } + return playerRepository; + } + + @Override + public MigrationRepository getMigrationRepository() + { + if (migrationRepository == null) + { + migrationRepository = new GenericMigrationRepository(statementHandler, this); + } + return migrationRepository; + } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdminRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdminRepository.java deleted file mode 100644 index a23d6de0b..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteAdminRepository.java +++ /dev/null @@ -1,499 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.admin.Admin; -import me.totalfreedom.totalfreedommod.rank.Rank; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.AdminRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -/** - * SQLite implementation of AdminRepository. - * Uses SQLite-specific SQL syntax. - */ -public class SQLiteAdminRepository implements AdminRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public SQLiteAdminRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(UUID uuid, Admin admin) throws SQLException - { - String sql = """ - INSERT INTO admins (uuid, username, rank, active, last_login, login_message, custom_rank) - VALUES (?, ?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - uuid.toString(), - admin.getName(), - admin.getRank().toString(), - admin.isActive() ? 1 : 0, - FUtil.dateToString(admin.getLastLogin()), - admin.getLoginMessage(), - admin.getCustomRankId())) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int adminId = rs.getInt(1); - insertIps(adminId, admin.getIps()); - return adminId; - } - } - } - return -1; - } - - @Override - public void insertIps(int adminId, List<String> ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - // SQLite: INSERT OR IGNORE - String sql = "INSERT OR IGNORE INTO admin_ips (admin_id, ip) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, adminId, ip); - } - } - - @Override - public void addIp(int adminId, String ip) throws SQLException - { - String sql = "INSERT OR IGNORE INTO admin_ips (admin_id, ip) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, adminId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public Map<String, Admin> loadAll() throws SQLException - { - Map<String, Admin> admins = new HashMap<>(); - Map<Integer, Admin> adminById = new HashMap<>(); - - String sql = "SELECT id, uuid, username, rank, active, last_login, login_message, custom_rank FROM admins"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String username = rs.getString("username"); - String rankStr = rs.getString("rank"); - boolean active = rs.getInt("active") == 1; - String lastLoginStr = rs.getString("last_login"); - String loginMessage = rs.getString("login_message"); - String customRankId = rs.getString("custom_rank"); - - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); - admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); - admin.setActive(active); - admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); - admin.setLoginMessage(loginMessage); - admin.setCustomRankId(customRankId); - - UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); - if (dbUuid != null) - { - admin.setUuid(dbUuid); - } - - admins.put(configKey, admin); - adminById.put(id, admin); - } - } - - // Load IPs - String ipSql = "SELECT admin_id, ip FROM admin_ips"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int adminId = rs.getInt("admin_id"); - String ip = rs.getString("ip"); - Admin admin = adminById.get(adminId); - if (admin != null) - { - admin.addIp(ip); - } - } - } - - return admins; - } - - @Override - public Admin findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT id, uuid, username, rank, active, last_login, login_message, custom_rank FROM admins WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public Admin findByUsername(String username) throws SQLException - { - String sql = "SELECT id, uuid, username, rank, active, last_login, login_message, custom_rank FROM admins WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public Admin findByIp(String ip) throws SQLException - { - String sql = """ - SELECT a.id, a.uuid, a.username, a.rank, a.active, a.last_login, a.login_message, a.custom_rank - FROM admins a - INNER JOIN admin_ips ai ON a.id = ai.admin_id - WHERE ai.ip = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadAdminFromResultSet(rs); - } - } - return null; - } - - @Override - public int getAdminId(String username) throws SQLException - { - String sql = "SELECT id FROM admins WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getAdminIdByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT id FROM admins WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List<String> getIps(int adminId) throws SQLException - { - List<String> ips = new ArrayList<>(); - String sql = "SELECT ip FROM admin_ips WHERE admin_id = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, adminId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean exists(String username) throws SQLException - { - String sql = "SELECT COUNT(*) FROM admins WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean existsByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT COUNT(*) FROM admins WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public UUID getUuidByUsername(String username) throws SQLException - { - String sql = "SELECT uuid FROM admins WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return UUID.fromString(rs.getString("uuid")); - } - } - return null; - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(UUID uuid, Admin admin) throws SQLException - { - String sql = """ - UPDATE admins - SET username = ?, rank = ?, active = ?, last_login = ?, login_message = ?, custom_rank = ? - WHERE uuid = ? - """; - - int rows = statementHandler.executeUpdate(sql, - admin.getName(), - admin.getRank().toString(), - admin.isActive() ? 1 : 0, - FUtil.dateToString(admin.getLastLogin()), - admin.getLoginMessage(), - admin.getCustomRankId(), - uuid.toString()); - - return rows > 0; - } - - @Override - public boolean updateRank(String username, String rank) throws SQLException - { - String sql = "UPDATE admins SET rank = ? WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, rank, username) > 0; - } - - @Override - public boolean updateActive(String username, boolean active) throws SQLException - { - String sql = "UPDATE admins SET active = ? WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, active ? 1 : 0, username) > 0; - } - - @Override - public boolean updateLastLogin(String username, Date lastLogin) throws SQLException - { - String sql = "UPDATE admins SET last_login = ? WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, FUtil.dateToString(lastLogin), username) > 0; - } - - @Override - public boolean updateUsername(UUID uuid, String newUsername) throws SQLException - { - String sql = "UPDATE admins SET username = ? WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, newUsername, uuid.toString()) > 0; - } - - @Override - public void syncIps(int adminId, List<String> ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM admin_ips WHERE admin_id = ?", adminId); - insertIps(adminId, ips); - } - - @Override - public int saveOrUpdate(UUID uuid, Admin admin) throws SQLException - { - if (existsByUuid(uuid)) - { - update(uuid, admin); - int adminId = getAdminIdByUuid(uuid); - syncIps(adminId, admin.getIps()); - return adminId; - } - else - { - return insert(uuid, admin); - } - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM admins WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM admins WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean removeIp(int adminId, String ip) throws SQLException - { - String sql = "DELETE FROM admin_ips WHERE admin_id = ? AND ip = ?"; - return statementHandler.executeUpdate(sql, adminId, ip) > 0; - } - - @Override - public boolean clearIps(int adminId) throws SQLException - { - String sql = "DELETE FROM admin_ips WHERE admin_id = ?"; - return statementHandler.executeUpdate(sql, adminId) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM admin_ips"); - statementHandler.executeUpdate("DELETE FROM admins"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public CompletableFuture<Map<String, Admin>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load admins: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> insertAsync(UUID uuid, Admin admin) - { - return CompletableFuture.supplyAsync(() -> { - try { return insert(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to insert admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> updateAsync(UUID uuid, Admin admin) - { - return CompletableFuture.supplyAsync(() -> { - try { return update(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to update admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteAsync(UUID uuid) - { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> save(UUID uuid, Admin admin) - { - return CompletableFuture.supplyAsync(() -> { - try { return saveOrUpdate(uuid, admin); } - catch (SQLException e) { FLog.severe("Failed to save admin: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<List<Admin>> findAll() - { - return CompletableFuture.supplyAsync(() -> { - try { return new ArrayList<>(loadAll().values()); } - catch (SQLException e) { FLog.severe("Failed to find all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteByUuid(UUID uuid) - { - return deleteAsync(uuid); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all admins: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - // ============================================ - // Helper Methods - // ============================================ - - private Admin loadAdminFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String username = rs.getString("username"); - String rankStr = rs.getString("rank"); - boolean active = rs.getInt("active") == 1; - String lastLoginStr = rs.getString("last_login"); - String loginMessage = rs.getString("login_message"); - String customRankId = rs.getString("custom_rank"); - - String configKey = username.toLowerCase(); - Admin admin = new Admin(configKey); - admin.setName(username); - admin.setRank(Rank.findRank(rankStr)); - admin.setActive(active); - admin.setLastLogin(FUtil.stringToDate(lastLoginStr)); - admin.setLoginMessage(loginMessage); - admin.setCustomRankId(customRankId); - - UUID dbUuid = FUtil.parseUuid(rs.getString("uuid")); - if (dbUuid != null) - { - admin.setUuid(dbUuid); - } - - List<String> ips = getIps(id); - admin.addIps(ips); - - return admin; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteBanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteBanRepository.java deleted file mode 100644 index d7bd253cb..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteBanRepository.java +++ /dev/null @@ -1,525 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.Ban; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.BanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.util.FUtil; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -/** - * SQLite implementation of BanRepository. - * Uses SQLite-specific SQL syntax. - */ -public class SQLiteBanRepository implements BanRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public SQLiteBanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(Ban ban) throws SQLException - { - String sql = """ - INSERT INTO bans (uuid, username, banned_by, banned_by_uuid, reason, expire_at) - VALUES (?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - ban.getUuid() != null ? ban.getUuid().toString() : null, - ban.getUsername(), - ban.getBannedBy(), - ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, - ban.getReason(), - ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null)) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int banId = rs.getInt(1); - insertIps(banId, ban.getIps()); - return banId; - } - } - } - return -1; - } - - @Override - public void insertIps(int banId, List<String> ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - String sql = "INSERT OR IGNORE INTO ban_ips (ban_id, ip) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, banId, ip); - } - } - - @Override - public void addIp(int banId, String ip) throws SQLException - { - String sql = "INSERT OR IGNORE INTO ban_ips (ban_id, ip) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, banId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public List<Ban> loadAll() throws SQLException - { - List<Ban> bans = new ArrayList<>(); - Map<Integer, Ban> banById = new HashMap<>(); - - String sql = "SELECT id, uuid, username, banned_by, banned_by_uuid, reason, expire_at FROM bans"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String bannedBy = rs.getString("banned_by"); - String bannedByUuidStr = rs.getString("banned_by_uuid"); - String reason = rs.getString("reason"); - String expireAtStr = rs.getString("expire_at"); - - Ban ban = new Ban(); - ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - ban.setUsername(username); - ban.setBannedBy(bannedBy); - ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); - ban.setReason(reason); - ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - - bans.add(ban); - banById.put(id, ban); - } - } - - // Load IPs - String ipSql = "SELECT ban_id, ip FROM ban_ips"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int banId = rs.getInt("ban_id"); - String ip = rs.getString("ip"); - Ban ban = banById.get(banId); - if (ban != null) - { - ban.addIp(ip); - } - } - } - - return bans; - } - - @Override - public Ban findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT id, uuid, username, banned_by, banned_by_uuid, reason, expire_at FROM bans WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public Ban findByUsername(String username) throws SQLException - { - String sql = "SELECT id, uuid, username, banned_by, banned_by_uuid, reason, expire_at FROM bans WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public Ban findByIp(String ip) throws SQLException - { - String sql = """ - SELECT b.id, b.uuid, b.username, b.banned_by, b.banned_by_uuid, b.reason, b.expire_at - FROM bans b - INNER JOIN ban_ips bi ON b.id = bi.ban_id - WHERE bi.ip = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadBanFromResultSet(rs); - } - } - return null; - } - - @Override - public List<Ban> findActiveBans() throws SQLException - { - // Active bans: expire_at is NULL (permanent) or expire_at > current time - String sql = """ - SELECT id, uuid, username, banned_by, banned_by_uuid, reason, expire_at - FROM bans - WHERE expire_at IS NULL OR datetime(expire_at) > datetime('now') - """; - List<Ban> bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; - } - - @Override - public List<Ban> findExpiredBans() throws SQLException - { - String sql = """ - SELECT id, uuid, username, banned_by, banned_by_uuid, reason, expire_at - FROM bans - WHERE expire_at IS NOT NULL AND datetime(expire_at) <= datetime('now') - """; - List<Ban> bans = new ArrayList<>(); - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - bans.add(loadBanFromResultSet(rs)); - } - } - return bans; - } - - @Override - public int getBanId(UUID uuid) throws SQLException - { - String sql = "SELECT id FROM bans WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getBanIdByUsername(String username) throws SQLException - { - String sql = "SELECT id FROM bans WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List<String> getIps(int banId) throws SQLException - { - List<String> ips = new ArrayList<>(); - String sql = "SELECT ip FROM ban_ips WHERE ban_id = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, banId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean isBanned(UUID uuid) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM bans - WHERE uuid = ? AND (expire_at IS NULL OR datetime(expire_at) > datetime('now')) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isBannedByUsername(String username) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM bans - WHERE LOWER(username) = LOWER(?) AND (expire_at IS NULL OR datetime(expire_at) > datetime('now')) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isBannedByIp(String ip) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM bans b - INNER JOIN ban_ips bi ON b.id = bi.ban_id - WHERE bi.ip = ? AND (b.expire_at IS NULL OR datetime(b.expire_at) > datetime('now')) - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(Ban ban) throws SQLException - { - String sql = """ - UPDATE bans - SET username = ?, banned_by = ?, banned_by_uuid = ?, reason = ?, expire_at = ? - WHERE uuid = ? - """; - - int rows = statementHandler.executeUpdate(sql, - ban.getUsername(), - ban.getBannedBy(), - ban.getBannedByUuid() != null ? ban.getBannedByUuid().toString() : null, - ban.getReason(), - ban.getExpireAt() != null ? FUtil.dateToString(ban.getExpireAt()) : null, - ban.getUuid().toString()); - - return rows > 0; - } - - @Override - public boolean updateReason(UUID uuid, String reason) throws SQLException - { - String sql = "UPDATE bans SET reason = ? WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; - } - - @Override - public boolean updateExpiry(UUID uuid, Date expireAt) throws SQLException - { - String sql = "UPDATE bans SET expire_at = ? WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, expireAt != null ? FUtil.dateToString(expireAt) : null, uuid.toString()) > 0; - } - - @Override - public void syncIps(int banId, List<String> ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM ban_ips WHERE ban_id = ?", banId); - insertIps(banId, ips); - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM bans WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM bans WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - // First get the ban IDs for the given IP - String selectSql = "SELECT ban_id FROM ban_ips WHERE ip = ?"; - List<Integer> banIds = new ArrayList<>(); - try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - banIds.add(rs.getInt("ban_id")); - } - } - - // Delete each ban - for (int banId : banIds) - { - statementHandler.executeUpdate("DELETE FROM bans WHERE id = ?", banId); - } - - return !banIds.isEmpty(); - } - - @Override - public boolean removeIp(int banId, String ip) throws SQLException - { - String sql = "DELETE FROM ban_ips WHERE ban_id = ? AND ip = ?"; - return statementHandler.executeUpdate(sql, banId, ip) > 0; - } - - @Override - public int deleteExpiredBans() throws SQLException - { - String sql = "DELETE FROM bans WHERE expire_at IS NOT NULL AND datetime(expire_at) <= datetime('now')"; - return statementHandler.executeUpdate(sql); - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM ban_ips"); - statementHandler.executeUpdate("DELETE FROM bans"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public CompletableFuture<List<Ban>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load bans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> insertAsync(Ban ban) - { - return CompletableFuture.supplyAsync(() -> { - try { return insert(ban); } - catch (SQLException e) { FLog.severe("Failed to insert ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> updateAsync(Ban ban) - { - return CompletableFuture.supplyAsync(() -> { - try { return update(ban); } - catch (SQLException e) { FLog.severe("Failed to update ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteAsync(UUID uuid) - { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> save(Ban ban) - { - return CompletableFuture.supplyAsync(() -> { - try - { - if (ban.getUuid() != null && getBanId(ban.getUuid()) > 0) - { - update(ban); - return getBanId(ban.getUuid()); - } - return insert(ban); - } - catch (SQLException e) { FLog.severe("Failed to save ban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<List<Ban>> findAll() - { - return loadAllAsync(); - } - - @Override - public CompletableFuture<Boolean> deleteByUuid(UUID uuid) - { - return deleteAsync(uuid); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all bans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - // ============================================ - // Helper Methods - // ============================================ - - private Ban loadBanFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String bannedBy = rs.getString("banned_by"); - String bannedByUuidStr = rs.getString("banned_by_uuid"); - String reason = rs.getString("reason"); - String expireAtStr = rs.getString("expire_at"); - - Ban ban = new Ban(); - ban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - ban.setUsername(username); - ban.setBannedBy(bannedBy); - ban.setBannedByUuid(bannedByUuidStr != null ? UUID.fromString(bannedByUuidStr) : null); - ban.setReason(reason); - ban.setExpireAt(expireAtStr != null ? FUtil.stringToDate(expireAtStr) : null); - - List<String> ips = getIps(id); - ban.setIps(ips); - - return ban; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteDiscordLinkRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteDiscordLinkRepository.java deleted file mode 100644 index 599c6d5c9..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteDiscordLinkRepository.java +++ /dev/null @@ -1,67 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; - -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.UUID; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.DiscordLinkRepository; - -public class SQLiteDiscordLinkRepository implements DiscordLinkRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public SQLiteDiscordLinkRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public void insert(UUID adminUuid, String discordUserId) throws SQLException - { - statementHandler.executeUpdate( - "INSERT INTO discord_links (admin_uuid, discord_user_id, linked_at) VALUES (?, ?, CURRENT_TIMESTAMP)", - adminUuid.toString(), discordUserId); - } - - @Override - public String findDiscordIdByAdminUuid(UUID adminUuid) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT discord_user_id FROM discord_links WHERE admin_uuid = ?", adminUuid.toString())) - { - return rs.next() ? rs.getString(1) : null; - } - } - - @Override - public UUID findAdminUuidByDiscordId(String discordUserId) throws SQLException - { - try (ResultSet rs = statementHandler.executeQuery( - "SELECT admin_uuid FROM discord_links WHERE discord_user_id = ?", discordUserId)) - { - if (!rs.next()) - { - return null; - } - String raw = rs.getString(1); - return raw == null ? null : UUID.fromString(raw); - } - } - - @Override - public boolean deleteByAdminUuid(UUID adminUuid) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM discord_links WHERE admin_uuid = ?", adminUuid.toString()) > 0; - } - - @Override - public boolean deleteByDiscordUserId(String discordUserId) throws SQLException - { - return statementHandler.executeUpdate( - "DELETE FROM discord_links WHERE discord_user_id = ?", discordUserId) > 0; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLitePermbanRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLitePermbanRepository.java deleted file mode 100644 index 4f4bd19d0..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLitePermbanRepository.java +++ /dev/null @@ -1,441 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; - -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.PermBan; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.PermbanRepository; -import me.totalfreedom.totalfreedommod.util.FLog; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -/** - * SQLite implementation of PermbanRepository. - * Uses SQLite-specific SQL syntax. - */ -public class SQLitePermbanRepository implements PermbanRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public SQLitePermbanRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - // ============================================ - // CREATE Operations - // ============================================ - - @Override - public int insert(PermBan permban) throws SQLException - { - String sql = """ - INSERT INTO permbans (uuid, username, reason) - VALUES (?, ?, ?) - """; - - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, - permban.getUuid() != null ? permban.getUuid().toString() : null, - permban.getUsername(), - permban.getReason())) - { - stmt.executeUpdate(); - - try (ResultSet rs = stmt.getGeneratedKeys()) - { - if (rs.next()) - { - int permbanId = rs.getInt(1); - insertIps(permbanId, permban.getIps()); - return permbanId; - } - } - } - return -1; - } - - @Override - public void insertIps(int permbanId, List<String> ips) throws SQLException - { - if (ips == null || ips.isEmpty()) return; - - String sql = "INSERT OR IGNORE INTO permban_ips (permban_id, ip) VALUES (?, ?)"; - for (String ip : ips) - { - statementHandler.executeUpdate(sql, permbanId, ip); - } - } - - @Override - public void addIp(int permbanId, String ip) throws SQLException - { - String sql = "INSERT OR IGNORE INTO permban_ips (permban_id, ip) VALUES (?, ?)"; - statementHandler.executeUpdate(sql, permbanId, ip); - } - - // ============================================ - // READ Operations - // ============================================ - - @Override - public List<PermBan> loadAll() throws SQLException - { - List<PermBan> permbans = new ArrayList<>(); - Map<Integer, PermBan> permbanById = new HashMap<>(); - - String sql = "SELECT id, uuid, username, reason FROM permbans"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String reason = rs.getString("reason"); - - PermBan permban = new PermBan(); - permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - permban.setUsername(username); - permban.setReason(reason); - - permbans.add(permban); - permbanById.put(id, permban); - } - } - - // Load IPs - String ipSql = "SELECT permban_id, ip FROM permban_ips"; - try (ResultSet rs = statementHandler.executeQuery(ipSql)) - { - while (rs.next()) - { - int permbanId = rs.getInt("permban_id"); - String ip = rs.getString("ip"); - PermBan permban = permbanById.get(permbanId); - if (permban != null) - { - permban.addIp(ip); - } - } - } - - return permbans; - } - - @Override - public PermBan findByUuid(UUID uuid) throws SQLException - { - String sql = "SELECT id, uuid, username, reason FROM permbans WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public PermBan findByUsername(String username) throws SQLException - { - String sql = "SELECT id, uuid, username, reason FROM permbans WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public PermBan findByIp(String ip) throws SQLException - { - String sql = """ - SELECT p.id, p.uuid, p.username, p.reason - FROM permbans p - INNER JOIN permban_ips pi ON p.id = pi.permban_id - WHERE pi.ip = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) - { - return loadPermbanFromResultSet(rs); - } - } - return null; - } - - @Override - public int getPermbanId(UUID uuid) throws SQLException - { - String sql = "SELECT id FROM permbans WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public int getPermbanIdByUsername(String username) throws SQLException - { - String sql = "SELECT id FROM permbans WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - if (rs.next()) return rs.getInt("id"); - } - return -1; - } - - @Override - public List<String> getIps(int permbanId) throws SQLException - { - List<String> ips = new ArrayList<>(); - String sql = "SELECT ip FROM permban_ips WHERE permban_id = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, permbanId); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - ips.add(rs.getString("ip")); - } - } - return ips; - } - - @Override - public boolean isPermBanned(UUID uuid) throws SQLException - { - String sql = "SELECT COUNT(*) FROM permbans WHERE uuid = ?"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, uuid.toString()); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isPermBannedByUsername(String username) throws SQLException - { - String sql = "SELECT COUNT(*) FROM permbans WHERE LOWER(username) = LOWER(?)"; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, username); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - @Override - public boolean isPermBannedByIp(String ip) throws SQLException - { - String sql = """ - SELECT COUNT(*) FROM permbans p - INNER JOIN permban_ips pi ON p.id = pi.permban_id - WHERE pi.ip = ? - """; - try (PreparedStatement stmt = statementHandler.prepareStatement(sql, ip); - ResultSet rs = stmt.executeQuery()) - { - return rs.next() && rs.getInt(1) > 0; - } - } - - // ============================================ - // UPDATE Operations - // ============================================ - - @Override - public boolean update(PermBan permban) throws SQLException - { - String sql = """ - UPDATE permbans - SET username = ?, reason = ? - WHERE uuid = ? - """; - - int rows = statementHandler.executeUpdate(sql, - permban.getUsername(), - permban.getReason(), - permban.getUuid().toString()); - - return rows > 0; - } - - @Override - public boolean updateReason(UUID uuid, String reason) throws SQLException - { - String sql = "UPDATE permbans SET reason = ? WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, reason, uuid.toString()) > 0; - } - - @Override - public void syncIps(int permbanId, List<String> ips) throws SQLException - { - statementHandler.executeUpdate("DELETE FROM permban_ips WHERE permban_id = ?", permbanId); - insertIps(permbanId, ips); - } - - // ============================================ - // DELETE Operations - // ============================================ - - @Override - public boolean delete(UUID uuid) throws SQLException - { - String sql = "DELETE FROM permbans WHERE uuid = ?"; - return statementHandler.executeUpdate(sql, uuid.toString()) > 0; - } - - @Override - public boolean deleteByUsername(String username) throws SQLException - { - String sql = "DELETE FROM permbans WHERE LOWER(username) = LOWER(?)"; - return statementHandler.executeUpdate(sql, username) > 0; - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - // First get the permban IDs for the given IP - String selectSql = "SELECT permban_id FROM permban_ips WHERE ip = ?"; - List<Integer> permbanIds = new ArrayList<>(); - try (PreparedStatement stmt = statementHandler.prepareStatement(selectSql, ip); - ResultSet rs = stmt.executeQuery()) - { - while (rs.next()) - { - permbanIds.add(rs.getInt("permban_id")); - } - } - - // Delete each permban - for (int permbanId : permbanIds) - { - statementHandler.executeUpdate("DELETE FROM permbans WHERE id = ?", permbanId); - } - - return !permbanIds.isEmpty(); - } - - @Override - public boolean removeIp(int permbanId, String ip) throws SQLException - { - String sql = "DELETE FROM permban_ips WHERE permban_id = ? AND ip = ?"; - return statementHandler.executeUpdate(sql, permbanId, ip) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM permban_ips"); - statementHandler.executeUpdate("DELETE FROM permbans"); - } - - // ============================================ - // Async Operations - // ============================================ - - @Override - public CompletableFuture<List<PermBan>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> insertAsync(PermBan permban) - { - return CompletableFuture.supplyAsync(() -> { - try { return insert(permban); } - catch (SQLException e) { FLog.severe("Failed to insert permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> updateAsync(PermBan permban) - { - return CompletableFuture.supplyAsync(() -> { - try { return update(permban); } - catch (SQLException e) { FLog.severe("Failed to update permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteAsync(UUID uuid) - { - return CompletableFuture.supplyAsync(() -> { - try { return delete(uuid); } - catch (SQLException e) { FLog.severe("Failed to delete permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Integer> save(PermBan permban) - { - return CompletableFuture.supplyAsync(() -> { - try - { - if (permban.getUuid() != null && getPermbanId(permban.getUuid()) > 0) - { - update(permban); - return getPermbanId(permban.getUuid()); - } - return insert(permban); - } - catch (SQLException e) { FLog.severe("Failed to save permban: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<List<PermBan>> findAll() - { - return loadAllAsync(); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to delete all permbans: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - // ============================================ - // Helper Methods - // ============================================ - - private PermBan loadPermbanFromResultSet(ResultSet rs) throws SQLException - { - int id = rs.getInt("id"); - String uuidStr = rs.getString("uuid"); - String username = rs.getString("username"); - String reason = rs.getString("reason"); - - PermBan permban = new PermBan(); - permban.setUuid(uuidStr != null ? UUID.fromString(uuidStr) : null); - permban.setUsername(username); - permban.setReason(reason); - - List<String> ips = getIps(id); - permban.setIps(ips); - - return permban; - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteStrikeRepository.java b/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteStrikeRepository.java deleted file mode 100644 index ddad905c5..000000000 --- a/src/main/java/me/totalfreedom/totalfreedommod/sql/adapter/sqlite/SQLiteStrikeRepository.java +++ /dev/null @@ -1,107 +0,0 @@ -package me.totalfreedom.totalfreedommod.sql.adapter.sqlite; - -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.banning.StrikeRecord; -import me.totalfreedom.totalfreedommod.sql.StatementHandler; -import me.totalfreedom.totalfreedommod.sql.adapter.StrikeRepository; -import me.totalfreedom.totalfreedommod.util.FLog; - -public class SQLiteStrikeRepository implements StrikeRepository -{ - private final TotalFreedomMod plugin; - private final StatementHandler statementHandler; - - public SQLiteStrikeRepository(TotalFreedomMod plugin, StatementHandler statementHandler) - { - this.plugin = plugin; - this.statementHandler = statementHandler; - } - - @Override - public Map<String, StrikeRecord> loadAll() throws SQLException - { - Map<String, StrikeRecord> out = new HashMap<>(); - String sql = "SELECT ip, strike_count, last_strike_unix, last_username FROM strikes"; - try (ResultSet rs = statementHandler.executeQuery(sql)) - { - while (rs.next()) - { - String ip = rs.getString("ip"); - int count = rs.getInt("strike_count"); - long last = rs.getLong("last_strike_unix"); - String username = rs.getString("last_username"); - out.put(ip, new StrikeRecord(ip, count, last, username)); - } - } - return out; - } - - @Override - public void upsert(StrikeRecord r) throws SQLException - { - String sql = """ - INSERT INTO strikes (ip, strike_count, last_strike_unix, last_username) - VALUES (?, ?, ?, ?) - ON CONFLICT(ip) DO UPDATE SET - strike_count = excluded.strike_count, - last_strike_unix = excluded.last_strike_unix, - last_username = excluded.last_username - """; - statementHandler.executeUpdate(sql, - r.getIp(), r.getCount(), r.getLastStrikeUnix(), r.getLastUsername()); - } - - @Override - public boolean deleteByIp(String ip) throws SQLException - { - return statementHandler.executeUpdate("DELETE FROM strikes WHERE ip = ?", ip) > 0; - } - - @Override - public void deleteAllSync() throws SQLException - { - statementHandler.executeUpdate("DELETE FROM strikes"); - } - - @Override - public CompletableFuture<Map<String, StrikeRecord>> loadAllAsync() - { - return CompletableFuture.supplyAsync(() -> { - try { return loadAll(); } - catch (SQLException e) { FLog.severe("Failed to load strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Void> upsertAsync(StrikeRecord r) - { - return CompletableFuture.runAsync(() -> { - try { upsert(r); } - catch (SQLException e) { FLog.severe("Failed to upsert strike: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Boolean> deleteByIpAsync(String ip) - { - return CompletableFuture.supplyAsync(() -> { - try { return deleteByIp(ip); } - catch (SQLException e) { FLog.severe("Failed to delete strike: " + e.getMessage()); throw new RuntimeException(e); } - }); - } - - @Override - public CompletableFuture<Void> deleteAll() - { - return CompletableFuture.runAsync(() -> { - try { deleteAllSync(); } - catch (SQLException e) { FLog.severe("Failed to clear strikes: " + e.getMessage()); throw new RuntimeException(e); } - }); - } -} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/AttributedConsoleSender.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/AttributedConsoleSender.java index 0fdece568..8f79a22f3 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/AttributedConsoleSender.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/AttributedConsoleSender.java @@ -3,12 +3,6 @@ import java.util.Set; import java.util.UUID; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.audience.ForwardingAudience; -import net.kyori.adventure.audience.MessageType; -import net.kyori.adventure.identity.Identity; -import net.kyori.adventure.text.Component; - import org.bukkit.Server; import org.bukkit.command.CommandSender; import org.bukkit.permissions.Permission; @@ -16,6 +10,12 @@ import org.bukkit.permissions.PermissionAttachmentInfo; import org.bukkit.plugin.Plugin; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.audience.ForwardingAudience; +import net.kyori.adventure.audience.MessageType; +import net.kyori.adventure.identity.Identity; +import net.kyori.adventure.text.Component; + /** * Wraps a console-class {@link CommandSender} so that remote channels (SSH, Discord) can * attribute output to the identity that issued the command, while every other call is diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshCommandCompleter.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshCommandCompleter.java index e8c7f8e06..2ed952696 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshCommandCompleter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshCommandCompleter.java @@ -1,11 +1,14 @@ package me.totalfreedom.totalfreedommod.ssh; import java.util.List; -import java.util.concurrent.ExecutionException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import org.bukkit.Bukkit; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.util.FLog; -import org.bukkit.Bukkit; + import org.jline.reader.Candidate; import org.jline.reader.Completer; import org.jline.reader.LineReader; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleCommandFactory.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleCommandFactory.java index f74acfde0..ddd769e9a 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleCommandFactory.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleCommandFactory.java @@ -1,11 +1,19 @@ package me.totalfreedom.totalfreedommod.ssh; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +import org.bukkit.Bukkit; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; import me.totalfreedom.totalfreedommod.util.CallbackLogAppender; import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.core.Logger; import org.apache.sshd.server.Environment; @@ -13,16 +21,6 @@ import org.apache.sshd.server.channel.ChannelSession; import org.apache.sshd.server.command.Command; import org.apache.sshd.server.command.CommandFactory; -import org.bukkit.Bukkit; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; public class SshConsoleCommandFactory implements CommandFactory { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleShellFactory.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleShellFactory.java index d00987520..526dc080b 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleShellFactory.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshConsoleShellFactory.java @@ -1,37 +1,39 @@ package me.totalfreedom.totalfreedommod.ssh; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +import org.bukkit.Bukkit; + +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchContext; import me.totalfreedom.totalfreedommod.dispatch.RemoteDispatchSession; import me.totalfreedom.totalfreedommod.util.CallbackLogAppender; import me.totalfreedom.totalfreedommod.util.FLog; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.core.Logger; import org.apache.sshd.server.Environment; import org.apache.sshd.server.ExitCallback; -import org.apache.sshd.server.session.ServerSession; import org.apache.sshd.server.channel.ChannelSession; import org.apache.sshd.server.command.Command; +import org.apache.sshd.server.session.ServerSession; import org.apache.sshd.server.shell.ShellFactory; -import org.bukkit.Bukkit; +import org.jline.reader.EndOfFileException; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; import org.jline.reader.UserInterruptException; -import org.jline.reader.EndOfFileException; import org.jline.terminal.Terminal; import org.jline.terminal.impl.ExternalTerminal; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; - /** * Creates interactive console shell sessions for SSH clients. * Each session gets its own JLine LineReader with tab completion and log streaming. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshDaemon.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshDaemon.java index ebfe6f054..7ae0586ce 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshDaemon.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshDaemon.java @@ -3,10 +3,12 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; + import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.sshd.common.AttributeRepository; import org.apache.sshd.server.SshServer; import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshHandshakeAuthenticator.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshHandshakeAuthenticator.java index e76187da5..358db5720 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshHandshakeAuthenticator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshHandshakeAuthenticator.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.ssh; +import java.util.List; + import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.sshd.server.auth.keyboard.InteractiveChallenge; import org.apache.sshd.server.auth.keyboard.KeyboardInteractiveAuthenticator; import org.apache.sshd.server.session.ServerSession; -import java.util.List; - public class SshHandshakeAuthenticator implements KeyboardInteractiveAuthenticator { private final SshIdentityStore identityStore; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java index 8f6cbf927..74d045c37 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshIdentityStore.java @@ -1,11 +1,5 @@ package me.totalfreedom.totalfreedommod.ssh; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import me.totalfreedom.totalfreedommod.util.FLog; - import java.io.File; import java.io.FileReader; import java.io.FileWriter; @@ -18,10 +12,17 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import me.totalfreedom.totalfreedommod.util.FLog; +import me.totalfreedom.totalfreedommod.util.JsonUtil; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + public class SshIdentityStore { private static final DateTimeFormatter LOGIN_FMT = DateTimeFormatter.ofPattern("dd-MM-yy HH:mm"); - private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Gson GSON = JsonUtil.GSON; private final File directory; private final Map<String, SshIdentity> identities = new ConcurrentHashMap<>(); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPasswordAuthenticator.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPasswordAuthenticator.java index 7202f49df..125662ac1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPasswordAuthenticator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPasswordAuthenticator.java @@ -2,8 +2,10 @@ import java.util.HashMap; import java.util.Map; + import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.sshd.server.auth.password.PasswordAuthenticator; import org.apache.sshd.server.session.ServerSession; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPublicKeyAuthenticator.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPublicKeyAuthenticator.java index 067a6eae9..04ce6e41c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPublicKeyAuthenticator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshPublicKeyAuthenticator.java @@ -1,14 +1,15 @@ package me.totalfreedom.totalfreedommod.ssh; +import java.security.PublicKey; + import me.totalfreedom.totalfreedommod.util.FLog; + import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.apache.sshd.common.config.keys.KeyUtils; import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator; import org.apache.sshd.server.session.ServerSession; -import java.security.PublicKey; - public class SshPublicKeyAuthenticator implements PublickeyAuthenticator { private final SshIdentityStore identityStore; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshQrServer.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshQrServer.java index e6063901e..b82b3beda 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshQrServer.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/SshQrServer.java @@ -1,10 +1,5 @@ package me.totalfreedom.totalfreedommod.ssh; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; - import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; @@ -18,6 +13,12 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + /** * A single, shared HTTP daemon that serves one-time TOTP QR setup pages. * Requests are held in a bounded FIFO queue keyed by token; a request is popped and diff --git a/src/main/java/me/totalfreedom/totalfreedommod/ssh/TotpUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/ssh/TotpUtil.java index ddaabb800..2e8361918 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/ssh/TotpUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/ssh/TotpUtil.java @@ -1,12 +1,12 @@ package me.totalfreedom.totalfreedommod.ssh; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; import java.net.URLEncoder; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Arrays; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; public final class TotpUtil { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/tablist/TabList.java b/src/main/java/me/totalfreedom/totalfreedommod/tablist/TabList.java index 31ae92722..17843a396 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/tablist/TabList.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/tablist/TabList.java @@ -1,5 +1,14 @@ package me.totalfreedom.totalfreedommod.tablist; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.scheduler.BukkitTask; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; + import me.totalfreedom.totalfreedommod.ChatManager; import me.totalfreedom.totalfreedommod.FreedomService; import me.totalfreedom.totalfreedommod.TotalFreedomMod; @@ -8,13 +17,6 @@ import me.totalfreedom.totalfreedommod.util.AdventureUtil; import me.totalfreedom.totalfreedommod.util.FTask; import me.totalfreedom.totalfreedommod.util.FUtil; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.TextComponent; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.scheduler.BukkitTask; public class TabList extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java b/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java new file mode 100644 index 000000000..59bd5ea48 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/title/Title.java @@ -0,0 +1,366 @@ +package me.totalfreedom.totalfreedommod.title; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; + +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.util.AdventureUtil; + +/** + * A recognition a player holds alongside their rank, carrying its own display and its own narrow + * set of capabilities. + * <p> + * A title is deliberately <em>not</em> a rank, and the difference is the whole point of the type. + * Ranks form a ladder: they carry a level, they inherit from one another, and holding one implies + * holding everything below it. That is what forced a Master Builder to be seated above Super Admin + * merely to reach the admin world, dragging every intervening permission along with it. + * <p> + * A title has no level and no parent. It grants exactly the nodes listed on it and nothing else, so + * it can hand a trusted operator one specific capability without moving them up the ladder. The + * practical consequences: + * <ul> + * <li>Title permissions are matched <em>exactly</em> (wildcards included), never by tier. Holding + * a title never implies holding another title's grants.</li> + * <li>A player may hold several titles at once; their capabilities are the union.</li> + * <li>{@link #getWeight()} orders titles for <em>display</em> only. A heavier title is shown in + * preference to a lighter one and confers nothing extra.</li> + * </ul> + */ +public class Title implements Displayable, Comparable<Title> +{ + + /** + * Unique identifier for this title (lowercase, no spaces). + */ + private String id; + + /** + * Display name shown in chat and menus. + */ + private String name; + + /** + * Grammatical determiner (a, an, the). + */ + private String determiner = "a"; + + /** + * Short abbreviation shown in tag brackets. + */ + private String abbreviation; + + /** + * Colour used for this title's display. + */ + private NamedTextColor color = NamedTextColor.WHITE; + + /** + * Custom prefix string for chat display, for example {@code "&8[&5Dev&8] "}. + */ + private String prefix = null; + + /** + * Display priority when a player holds more than one title. Higher wins. This orders + * appearance only and grants nothing: see the class javadoc. + */ + private int weight = 0; + + /** + * Capabilities this title grants, as internal TFM permission strings. Matched exactly, never + * inherited from anything, which is what keeps a title from escalating its holder. + */ + private Set<String> permissions = new HashSet<>(); + + /** + * Whether this title may be announced in the join message in place of the player's rank. + */ + private boolean announce = true; + + private transient Component cachedColoredTag; + private transient Component cachedColoredName; + private transient Component cachedColoredLoginMessage; + + public Title(String id) + { + this.id = normalizeId(id); + this.name = id; + this.abbreviation = id.length() > 3 ? id.substring(0, 3).toUpperCase() : id.toUpperCase(); + invalidateCache(); + } + + /** + * Gson deserialises through this constructor rather than allocating the object unsafely, which + * is what keeps the field initialisers above running. Without it an entry that omits + * {@code announce} or {@code permissions} comes back as {@code false} and {@code null} instead + * of carrying its documented default. + * <p> + * The id is deliberately left unset here: a title carries its id as the key it is filed under + * rather than as a property of the entry, so {@link #assignId(String)} stamps it after the read. + */ + private Title() {} + + /** + * Stamps the id this title was filed under, normalising it exactly as the public constructor + * does so that a title's id always matches the key it is stored against. + */ + public void assignId(final String key) + { + this.id = normalizeId(key); + invalidateCache(); + } + + /** + * Ids reach SQL as a primary key and reach {@code players.titles} as one entry in a + * comma-separated list, so anything that would split that list is stripped rather than stored. + */ + public static String normalizeId(final String raw) + { + return raw.toLowerCase() + .replace(' ', '_') + .replaceAll("[^a-z0-9_\\-]", ""); + } + + /** + * Whether this title grants {@code permission}. + * <p> + * Exact and wildcard matches only. There is deliberately no tier comparison here: a title's + * grants are a closed list, so a node absent from it is denied no matter what else the holder + * has. + */ + public boolean grants(String permission) + { + if (permission == null || permission.isEmpty()) + return false; + + final String node = permission.toLowerCase(); + + if (permissions.contains(node) || permissions.contains("*")) + return true; + + final String[] parts = node.split("\\."); + final StringBuilder prefixBuilder = new StringBuilder(); + + for (int i = 0; i < parts.length - 1; i++) + { + prefixBuilder.append(parts[i]).append('.'); + if (permissions.contains(prefixBuilder + "*")) + return true; + } + + return false; + } + + public void addPermission(String permission) + { + permissions.add(permission.toLowerCase()); + } + + public void removePermission(String permission) + { + permissions.remove(permission.toLowerCase()); + } + + /** + * Invalidate cached components. Call after any property change. + */ + public final void invalidateCache() + { + cachedColoredTag = null; + cachedColoredName = null; + cachedColoredLoginMessage = null; + } + + @Override + public String getName() + { + return name; + } + + @Override + public String getTag() + { + return abbreviation == null || abbreviation.isEmpty() ? "" : "[" + abbreviation + "]"; + } + + @Override + public NamedTextColor getColor() + { + return color; + } + + @Override + public Component getColoredName() + { + if (cachedColoredName == null) + { + cachedColoredName = AdventureUtil.format(name).colorIfAbsent(color); + } + return cachedColoredName; + } + + @Override + public Component getColoredTag() + { + if (cachedColoredTag == null) + { + if (prefix != null && !prefix.isEmpty()) + { + cachedColoredTag = AdventureUtil.format(prefix.trim()); + } + else if (abbreviation == null || abbreviation.isEmpty()) + { + cachedColoredTag = Component.empty(); + } + else + { + cachedColoredTag = Component.text("[") + .color(NamedTextColor.DARK_GRAY) + .append(AdventureUtil.format(abbreviation).colorIfAbsent(color)) + .append(Component.text("]").color(NamedTextColor.DARK_GRAY)); + } + } + return cachedColoredTag; + } + + @Override + public Component getColoredLoginMessage() + { + if (cachedColoredLoginMessage == null) + { + cachedColoredLoginMessage = Component.text(determiner + " ") + .append(AdventureUtil.format(name) + .colorIfAbsent(color) + .decorate(TextDecoration.ITALIC)); + } + return cachedColoredLoginMessage; + } + + /** + * Orders by display weight, heaviest first, so the natural order is the order titles should be + * preferred in when a player holds several. + */ + @Override + public int compareTo(Title other) + { + final int byWeight = Integer.compare(other.weight, this.weight); + + // Tie-break on id so that ordering only calls two titles equal when equals() does. A sorted + // set would otherwise treat every same-weight title as a duplicate and keep just one. + return byWeight != 0 ? byWeight : id.compareTo(other.id); + } + + @Override + public boolean equals(Object obj) + { + if (this == obj) return true; + if (!(obj instanceof Title other)) return false; + return id.equals(other.id); + } + + @Override + public int hashCode() + { + return id.hashCode(); + } + + @Override + public String toString() + { + return String.format("Title{id=%s, name=%s, weight=%d}", id, name, weight); + } + + public String getId() + { + return id; + } + + public void setName(String name) + { + this.name = name; + invalidateCache(); + } + + public String getDeterminer() + { + return determiner; + } + + public void setDeterminer(String determiner) + { + this.determiner = determiner; + invalidateCache(); + } + + public String getAbbreviation() + { + return abbreviation; + } + + public void setAbbreviation(String abbreviation) + { + this.abbreviation = abbreviation; + invalidateCache(); + } + + public void setColor(NamedTextColor color) + { + this.color = color; + invalidateCache(); + } + + public String getPrefix() + { + return prefix; + } + + public void setPrefix(String prefix) + { + this.prefix = prefix; + invalidateCache(); + } + + public int getWeight() + { + return weight; + } + + public void setWeight(int weight) + { + this.weight = weight; + } + + public Set<String> getPermissions() + { + return Collections.unmodifiableSet(permissions); + } + + /** + * Replaces the grant list, lower-casing as {@link #addPermission(String)} does so that a set + * installed through here still matches in {@link #grants(String)}. + */ + public void setPermissions(Set<String> permissions) + { + this.permissions = permissions == null + ? new HashSet<>() + : permissions.stream() + .map(String::toLowerCase) + .collect(Collectors.toCollection(HashSet::new)); + } + + public boolean isAnnounce() + { + return announce; + } + + public void setAnnounce(boolean announce) + { + this.announce = announce; + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java new file mode 100644 index 000000000..6858788a4 --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/title/TitleManager.java @@ -0,0 +1,571 @@ +package me.totalfreedom.totalfreedommod.title; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.display.Displayable; +import me.totalfreedom.totalfreedommod.player.PlayerData; +import me.totalfreedom.totalfreedommod.sql.PersistenceQueue; +import me.totalfreedom.totalfreedommod.sql.adapter.TitleRepository; +import me.totalfreedom.totalfreedommod.util.*; + +import com.google.common.collect.Maps; +import com.google.gson.reflect.TypeToken; + +/** + * Owns the title registry and answers what a player's titles let them do and how they are shown. + * <p> + * Titles sit alongside ranks rather than inside them. Where {@code RankManager} resolves a ladder, + * this resolves a flat set: a player holds zero or more titles, their capabilities are the union of + * what those titles list, and nothing here compares levels or walks a parent chain. That is what + * lets an ordinary operator be handed one specific capability without being promoted to reach it. + * <p> + * Storage mirrors ranks: SQL is authoritative when available, with {@code titles.json} kept as a + * readable snapshot and used as the source when there is no database. + */ +public class TitleManager extends FreedomService +{ + + public static final String TITLES_FILENAME = "titles.json"; + + private static final Type TITLE_MAP_TYPE = new TypeToken<Map<String, Title>>() {}.getType(); + + private static final long SHUTDOWN_FLUSH_TIMEOUT_MS = 10L * 1000L; + + /** + * All titles, keyed by id. + */ + private final Map<String, Title> titles = Maps.newLinkedHashMap(); + + private final PersistenceQueue writes = new PersistenceQueue("title"); + + private File titlesFile; + + private boolean usingSql = false; + + public TitleManager(TotalFreedomMod plugin) + { + super(plugin); + } + + @Override + protected void onStart() + { + loadTitles(); + plugin.dm.whenReady(this::loadTitles); + } + + @Override + protected void onStop() + { + saveTitles(); + writes.await(SHUTDOWN_FLUSH_TIMEOUT_MS); + } + + /** + * Load titles from SQL, falling back to {@code titles.json}. Never blocks: the SQL read runs + * off-thread and its result is applied back on the main thread. + */ + public void loadTitles() + { + titlesFile = new File(plugin.getDataFolder(), TITLES_FILENAME); + + if (plugin.dm != null && plugin.dm.isInitialized()) + { + loadFromSqlAsync(); + return; + } + + loadFromJsonOrDefaults(); + } + + // ======================================================================== + // Lookup + // ======================================================================== + + public Title getTitle(String id) + { + return id == null ? null : titles.get(id.toLowerCase()); + } + + public Map<String, Title> getTitles() + { + return Collections.unmodifiableMap(titles); + } + + /** + * All titles ordered for display, heaviest first. + */ + public List<Title> getTitlesSorted() + { + return titles.values() + .stream() + .sorted() + .toList(); + } + + public boolean hasTitle(String id) + { + return id != null && titles.containsKey(id.toLowerCase()); + } + + /** + * The titles {@code player} actually holds, skipping any id that no longer resolves so that a + * deleted title simply stops applying rather than breaking every lookup for its holders. + */ + public List<Title> getHeldTitles(Player player) + { + if (player == null) + return List.of(); + + final PlayerData data = plugin.pl.getData(player); + + return data == null ? List.of() : resolve(data.getTitles()); + } + + /** + * Resolves a set of stored ids into titles, ordered for display. + */ + public List<Title> resolve(Collection<String> ids) + { + return ids == null + ? List.of() + : ids.stream() + .map(this::getTitle) + .filter(title -> title != null) + .sorted() + .toList(); + } + + /** + * The title that should represent {@code player} on screen, or {@code null} when they hold + * none. The heaviest held title wins; see {@link Title#getWeight()}. + */ + public Title getDisplayTitle(Player player) + { + return getHeldTitles(player).stream() + .filter(Title::isAnnounce) + .findFirst() + .orElse(null); + } + + // ======================================================================== + // Permissions + // ======================================================================== + + /** + * Whether any title {@code sender} holds grants {@code permission}. + * <p> + * Only players can hold titles: a title is attached to a player profile, and a console channel + * has no profile to attach one to. Console standing comes from its {@code host_senders:} + * binding instead, which is a rank question rather than a title one. + */ + public boolean grants(CommandSender sender, String permission) + { + return sender instanceof Player player && grants(player, permission); + } + + public boolean grants(Player player, String permission) + { + return getHeldTitles(player).stream() + .anyMatch(title -> title.grants(permission)); + } + + /** + * Grants a title to a player. Returns false when the title does not exist or is already held. + */ + public boolean grantTitle(Player player, String titleId) + { + final Title title = getTitle(titleId); + if (title == null) + return false; + + final PlayerData data = plugin.pl.getData(player); + if (data == null || !data.addTitle(title.getId())) + return false; + + plugin.pl.saveData(data); + refreshDisplay(player); + + return true; + } + + /** + * Revokes a title from a player. Returns false when they did not hold it. + */ + public boolean revokeTitle(Player player, String titleId) + { + final PlayerData data = plugin.pl.getData(player); + if (data == null || titleId == null || !data.removeTitle(titleId.toLowerCase())) + return false; + + plugin.pl.saveData(data); + refreshDisplay(player); + + return true; + } + + /** + * Re-applies the player's tag after their titles changed. + * <p> + * A tag the player set by hand is left alone: they chose it deliberately, and having a title + * grant silently overwrite it would be surprising. + */ + private void refreshDisplay(Player player) + { + if (plugin.rm == null) + return; + + plugin.rm.updatePlayerTeam(player); + + // A title can open the admin world, and that check is cached per player for 30s. Drop the + // cache now so a grant or revoke takes effect immediately rather than on the next sweep. + if (plugin.wm != null && plugin.wm.adminworld != null) + { + plugin.wm.adminworld.wipeAccessCache(); + } + + final PlayerData data = plugin.pl.getData(player); + if (data == null || data.getSavedTag() != null) + return; + + final Displayable display = plugin.rm.getDisplay(player); + if (display != null) + { + plugin.pl.getPlayer(player) + .setTag(AdventureUtil.componentToLegacySection(display.getColoredTag())); + } + } + + // ======================================================================== + // Mutation + // ======================================================================== + + public void setTitle(Title title) + { + titles.put(title.getId(), title); + saveTitles(); + } + + public boolean removeTitle(String id) + { + if (id == null) + return false; + + final Title removed = titles.remove(Title.normalizeId(id)); + if (removed == null) + return false; + + // saveTitles() only writes the titles that survive, so without an explicit delete the row + // stays behind in SQL and the title returns on the next load. + if (usingSql && plugin.dm != null && plugin.dm.isInitialized()) + { + writes.enqueue(plugin.dm.getTitleRepository() + .deleteAsync(removed.getId()) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not delete title %s from SQL: %s", + removed.getId(), ex.getMessage())); + return Mono.empty(); + }) + .then()); + } + + saveTitles(); + + return true; + } + + // ======================================================================== + // Persistence + // ======================================================================== + + private void loadFromSqlAsync() + { + final TitleRepository repo = plugin.dm.getTitleRepository(); + plugin.dm.readAsync("TitleManager/loadFromSql", repo.loadAllAsync(), + loaded -> applyLoadedTitles(repo, loaded), + () -> + { + usingSql = false; + loadFromJsonOrDefaults(); + }); + } + + private void applyLoadedTitles(final TitleRepository repo, final Map<String, Title> loaded) + { + usingSql = true; + + if (loaded.isEmpty() && !titlesFile.exists()) + { + installBundledTitles(); + + // The bundled set only reached memory. Push it so a database that started empty ends + // up holding the shipped titles rather than staying empty until someone edits one. + saveTitles(); + return; + } + + titles.clear(); + titles.putAll(loaded); + FLog.info(String.format("Loaded %d titles from SQL database.", titles.size())); + + reconcileFromJsonIfNewer(repo); + } + + /** + * Re-imports {@code titles.json} when it is newer than anything in the table, which is also + * what seeds a database that has never held a title. Without it an empty table plus an existing + * snapshot loads as zero titles, and the shipped set is silently lost on the second start. + */ + private void reconcileFromJsonIfNewer(final TitleRepository repo) + { + if (!titlesFile.exists()) + { + // Nothing to reconcile against, but SQL now has rows that no snapshot covers. + writes.enqueue(writeJsonAsync()); + return; + } + + final Map<String, Title> jsonTitles; + try + { + jsonTitles = readJsonTitles(); + } + catch (IOException ex) + { + FLog.warning(String.format("Failed to read %s: %s", TITLES_FILENAME, ex.getMessage())); + return; + } + + if (jsonTitles.isEmpty()) + { + writes.enqueue(writeJsonAsync()); + return; + } + + final long fileModified = titlesFile.lastModified(); + + writes.enqueue(Mono.fromCallable(() -> + { + final Long sqlUpdatedAt = repo.getMaxUpdatedAt(); + return FUtil.isSnapshotNewer(fileModified, sqlUpdatedAt); + }) + .subscribeOn(Schedulers.boundedElastic()) + .filter(Boolean::booleanValue) + .flatMap(ignored -> + { + FLog.info(String.format("%s is newer than the database; rebuilding it from the file's %d title(s).", + TITLES_FILENAME, jsonTitles.size())); + return repo.deleteAll() + .thenMany(Flux.fromIterable(jsonTitles.values()) + .concatMap(repo::save)) + .then(Mono.<Void>fromRunnable(() -> plugin.dm.sync("TitleManager/applyReconciled", + () -> applyReconciledTitles(jsonTitles)))); + }) + .onErrorResume(ex -> + { + FLog.warning(String.format("Failed to reconcile %s into the database: %s", + TITLES_FILENAME, ex.getMessage())); + return Mono.empty(); + }) + .then()); + } + + private void applyReconciledTitles(final Map<String, Title> jsonTitles) + { + titles.clear(); + titles.putAll(jsonTitles); + } + + private void loadFromJsonOrDefaults() + { + if (!titlesFile.exists()) + { + installBundledTitles(); + return; + } + + loadFromJson(); + } + + /** + * Writes the bundled {@code titles.json} into the data folder and loads it. Titles are defined + * entirely by configuration, so a first run copies the shipped file rather than synthesising + * anything; if the resource is missing the registry stays empty, which grants nothing. + */ + private void installBundledTitles() + { + try + { + plugin.saveResource(TITLES_FILENAME, false); + } + catch (IllegalArgumentException ex) + { + FLog.severe(String.format("No bundled %s to install: %s", TITLES_FILENAME, ex.getMessage())); + return; + } + + if (!titlesFile.exists()) + { + FLog.severe(String.format("Could not install a default %s; no titles will be available.", + TITLES_FILENAME)); + return; + } + + FLog.info(String.format("Installed the default %s.", TITLES_FILENAME)); + loadFromJson(); + } + + private void loadFromJson() + { + titles.clear(); + + try + { + titles.putAll(readJsonTitles()); + } + catch (IOException ex) + { + FLog.severe(String.format("Could not read %s: %s", TITLES_FILENAME, ex.getMessage())); + } + + FLog.info(String.format("Loaded %d titles.", titles.size())); + } + + private Map<String, Title> readJsonTitles() throws IOException + { + try (FileReader reader = new FileReader(titlesFile)) + { + return stampIds(JsonUtil.GSON.fromJson(reader, TITLE_MAP_TYPE)); + } + } + + /** + * Re-files deserialised titles under their own normalised id. + * <p> + * A JSON entry carries its id as the key it sits under rather than as a field, so a freshly + * deserialised title has none. Stamping it here and re-keying the map on the result keeps the + * key and the title's own id in agreement even where normalisation rewrites the key. + */ + private static Map<String, Title> stampIds(final Map<String, Title> loaded) + { + if (loaded == null) + return Map.of(); + + final Map<String, Title> keyed = new LinkedHashMap<>(); + + loaded.forEach((key, title) -> + { + title.assignId(key); + keyed.put(title.getId(), title); + }); + + return keyed; + } + + /** + * Queue a write of every title to SQL, followed by a refresh of the JSON snapshot. Falls back + * to a JSON-only write when SQL is unavailable. Safe from a command handler: the SQL round + * trips run off the main thread. + */ + public void saveTitles() + { + if (!usingSql || plugin.dm == null || !plugin.dm.isInitialized()) + { + writes.enqueue(writeJsonAsync()); + return; + } + + final TitleRepository repo = plugin.dm.getTitleRepository(); + final List<Title> snapshot = new ArrayList<>(titles.values()); + + writes.enqueue(Flux.fromIterable(snapshot) + .concatMap(title -> repo.save(title) + .onErrorResume(ex -> + { + FLog.severe(String.format("Could not save title %s to SQL: %s", + title.getId(), ex.getMessage())); + return Mono.empty(); + })) + .then(writeJsonAsync())); + } + + public void awaitPendingWrites(long timeoutMs) + { + writes.await(timeoutMs); + } + + private Mono<Void> writeJsonAsync() + { + final Map<String, Title> snapshot = new LinkedHashMap<>(titles); + + return Mono.<Void>fromRunnable(() -> writeJson(snapshot)) + .subscribeOn(Schedulers.boundedElastic()); + } + + private void writeJson(final Map<String, Title> snapshot) + { + if (titlesFile == null) + { + titlesFile = new File(plugin.getDataFolder(), TITLES_FILENAME); + } + + try (FileWriter writer = new FileWriter(titlesFile)) + { + JsonUtil.GSON.toJson(snapshot, TITLE_MAP_TYPE, writer); + } + catch (IOException ex) + { + FLog.severe(String.format("Could not save %s: %s", TITLES_FILENAME, ex.getMessage())); + } + } + + /** + * Every title id currently registered, for tab completion. + */ + public Set<String> getTitleIds() + { + return Collections.unmodifiableSet(titles.keySet()); + } + + /** + * The ids a player holds that still resolve to a registered title. + */ + public Set<String> getHeldTitleIds(Player player) + { + return getHeldTitles(player).stream() + .map(Title::getId) + .collect(Collectors.toSet()); + } + + /** + * The title filling a given id, wrapped for callers that would rather not null-check. + */ + public Optional<Title> find(String id) + { + return Optional.ofNullable(getTitle(id)); + } + +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/AdventureUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/AdventureUtil.java index 3f29203ec..154b5ab0f 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/AdventureUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/AdventureUtil.java @@ -1,6 +1,7 @@ package me.totalfreedom.totalfreedommod.util; import java.util.regex.Pattern; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java b/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java index e9a1154c8..d20ab4a27 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/CallbackLogAppender.java @@ -2,7 +2,10 @@ import java.text.SimpleDateFormat; import java.util.Date; +import java.util.Objects; +import java.util.Optional; import java.util.stream.Stream; + import org.apache.logging.log4j.Level; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.appender.AbstractAppender; @@ -29,28 +32,26 @@ public interface LogLineConsumer public CallbackLogAppender(String name, LogLineConsumer consumer) { super(name, null, PatternLayout.createDefaultLayout(), true, Property.EMPTY_ARRAY); - this.consumer = consumer; + this.consumer = Objects.requireNonNull(consumer, "consumer"); } /** * Drop events from loggers whose name starts with any of {@code prefixes}. - * <p> - * Needed when the consumer's own delivery path logs: a relay that ships log lines to a remote - * service and whose client library logs its failures into the same root logger will keep - * feeding itself, and each failure enqueues the evidence of the previous one. * * @return this appender, so the exclusions can be set where it is constructed */ public CallbackLogAppender excludeLoggers(String... prefixes) { - this.excludedLoggerPrefixes = prefixes == null ? new String[0] : prefixes.clone(); + this.excludedLoggerPrefixes = Optional.ofNullable(prefixes) + .map(String[]::clone) + .orElseGet(() -> new String[0]); return this; } @Override public void append(LogEvent event) { - if (consumer == null || isExcluded(event.getLoggerName())) + if (isExcluded(event.getLoggerName())) { return; } @@ -78,12 +79,8 @@ public void append(LogEvent event) private boolean isExcluded(String loggerName) { - if (loggerName == null) - { - return false; - } - - return Stream.of(excludedLoggerPrefixes) - .anyMatch(loggerName::startsWith); + return Optional.ofNullable(loggerName) + .filter(name -> Stream.of(excludedLoggerPrefixes).anyMatch(name::startsWith)) + .isPresent(); } } diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/ChatMentionUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/ChatMentionUtil.java index 334141799..2d75c5404 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/ChatMentionUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/ChatMentionUtil.java @@ -1,23 +1,18 @@ package me.totalfreedom.totalfreedommod.util; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.regex.Pattern; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import net.kyori.adventure.text.Component; + import org.bukkit.Bukkit; import org.bukkit.Sound; import org.bukkit.SoundCategory; import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public final class ChatMentionUtil { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/ComponentScanner.java b/src/main/java/me/totalfreedom/totalfreedommod/util/ComponentScanner.java index e320f94ec..0b7d46efb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/ComponentScanner.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/ComponentScanner.java @@ -3,6 +3,7 @@ import java.util.ArrayDeque; import java.util.Deque; import java.util.IdentityHashMap; + import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TranslatableComponent; import net.kyori.adventure.text.TranslationArgument; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/ConfigInterfaces.java b/src/main/java/me/totalfreedom/totalfreedommod/util/ConfigInterfaces.java index 9f3e9be41..ec25de1e0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/ConfigInterfaces.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/ConfigInterfaces.java @@ -22,19 +22,6 @@ public interface ConfigLoadable void loadFrom(ConfigurationSection cs); } - /** - * Interface for objects that can be saved to a configuration section. - */ - public interface ConfigSavable - { - /** - * Saves data to a configuration section. - * - * @param cs The configuration section to save to - */ - void saveTo(ConfigurationSection cs); - } - /** * Interface for objects that can validate their own state. */ diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/DetectionReporter.java b/src/main/java/me/totalfreedom/totalfreedommod/util/DetectionReporter.java index ab144b684..3d39c8e88 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/DetectionReporter.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/DetectionReporter.java @@ -2,12 +2,15 @@ import java.util.function.Consumer; import java.util.function.LongSupplier; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public final class DetectionReporter { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/FSync.java b/src/main/java/me/totalfreedom/totalfreedommod/util/FSync.java index e7a9c8a39..9d252fe13 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/FSync.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/FSync.java @@ -1,12 +1,14 @@ package me.totalfreedom.totalfreedommod.util; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + public class FSync { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java index b4cffce23..1b38f6769 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/FUtil.java @@ -1,7 +1,5 @@ package me.totalfreedom.totalfreedommod.util; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import java.io.BufferedReader; import java.io.File; import java.io.InputStreamReader; @@ -25,14 +23,7 @@ import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; -import me.totalfreedom.totalfreedommod.PluginProvider; -import me.totalfreedom.totalfreedommod.cmd.MessageUtils; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; -import net.kyori.adventure.text.serializer.ansi.ANSIComponentSerializer; -import org.apache.commons.io.FileUtils; + import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.OfflinePlayer; @@ -40,6 +31,19 @@ import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitTask; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import net.kyori.adventure.text.serializer.ansi.ANSIComponentSerializer; + +import me.totalfreedom.totalfreedommod.PluginProvider; +import me.totalfreedom.totalfreedommod.cmd.MessageUtils; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.commons.io.FileUtils; + public class FUtil { @@ -282,6 +286,26 @@ public static Date stringToDate(String dateString) } } + /** + * The resolution at which the databases store timestamps. SQLite and MySQL both keep whole seconds, + * so anything finer than this is an artifact of truncation rather than a real difference. + */ + private static final long TIMESTAMP_RESOLUTION_MS = 1000L; + + /** + * Whether a JSON snapshot should be treated as newer than the database it mirrors. + * <p> + * A write-through save writes its row and then its snapshot, so the file is always a few + * milliseconds later than the row, and the stored timestamp is truncated to the second on + * top of that. Comparing them directly therefore reports "newer" after almost every save, + * which would rebuild the whole domain on each startup. Only a gap wider than the storage + * resolution means the file was actually edited or restored behind the plugin's back. + */ + public static boolean isSnapshotNewer(final long fileModified, final Long sqlUpdatedAt) + { + return sqlUpdatedAt == null || fileModified > sqlUpdatedAt + TIMESTAMP_RESOLUTION_MS; + } + public static boolean fuzzyIpMatch(String a, String b, int octets) { boolean match = true; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java new file mode 100644 index 000000000..f0102fe5e --- /dev/null +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/JsonUtil.java @@ -0,0 +1,46 @@ +package me.totalfreedom.totalfreedommod.util; + +import java.util.Date; +import java.util.UUID; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.rank.RankRole; + +import com.google.gson.*; + +/** + * Shared Gson instance for the plugin's JSON-backed persistence (SQL write-through + * snapshots, bundled resource files). Registers adapters for the types Gson can't + * serialize sensibly on its own via reflection. + */ +public final class JsonUtil +{ + public static final Gson GSON = new GsonBuilder() + .setPrettyPrinting() + .registerTypeAdapter(UUID.class, (JsonSerializer<UUID>) (src, type, ctx) -> new JsonPrimitive(src.toString())) + .registerTypeAdapter(UUID.class, (JsonDeserializer<UUID>) (json, type, ctx) -> UUID.fromString(json.getAsString())) + .registerTypeAdapter(NamedTextColor.class, (JsonSerializer<NamedTextColor>) (src, type, ctx) -> + new JsonPrimitive(NamedTextColor.NAMES.keyOrThrow(src))) + .registerTypeAdapter(NamedTextColor.class, (JsonDeserializer<NamedTextColor>) (json, type, ctx) -> + { + NamedTextColor color = NamedTextColor.NAMES.value(json.getAsString().toLowerCase()); + return color != null ? color : NamedTextColor.WHITE; + }) + .registerTypeAdapter(Date.class, (JsonSerializer<Date>) (src, type, ctx) -> new JsonPrimitive(FUtil.dateToString(src))) + .registerTypeAdapter(Date.class, (JsonDeserializer<Date>) (json, type, ctx) -> FUtil.stringToDate(json.getAsString())) + .registerTypeAdapter(Component.class, (JsonSerializer<Component>) (src, type, ctx) -> + new JsonPrimitive(AdventureUtil.componentToLegacy(src))) + .registerTypeAdapter(Component.class, (JsonDeserializer<Component>) (json, type, ctx) -> + AdventureUtil.legacyToComponent(json.getAsString())) + .registerTypeAdapter(RankRole.class, (JsonSerializer<RankRole>) (src, type, ctx) -> + new JsonPrimitive(src.getId())) + .registerTypeAdapter(RankRole.class, (JsonDeserializer<RankRole>) (json, type, ctx) -> + RankRole.fromId(json.getAsString()).orElse(null)) + .create(); + + private JsonUtil() + { + } +} diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/MaterialHelper.java b/src/main/java/me/totalfreedom/totalfreedommod/util/MaterialHelper.java index 4ce34df35..1da3848c0 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/MaterialHelper.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/MaterialHelper.java @@ -2,6 +2,7 @@ import java.util.HashMap; import java.util.Map; + import org.bukkit.Material; /** diff --git a/src/main/java/me/totalfreedom/totalfreedommod/util/PlayerListUtil.java b/src/main/java/me/totalfreedom/totalfreedommod/util/PlayerListUtil.java index ddde3e04c..1e38bc757 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/util/PlayerListUtil.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/util/PlayerListUtil.java @@ -12,7 +12,7 @@ import me.totalfreedom.totalfreedommod.PluginProvider; import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.rank.CustomRank; -import me.totalfreedom.totalfreedommod.rank.Rank; +import me.totalfreedom.totalfreedommod.rank.RankRole; /** * Plaintext online player list shared by automated console contexts. @@ -49,7 +49,12 @@ public static String buildRankList() // Build a map from ranks to players that have that rank, with OP as a default final Map<CustomRank, List<Player>> playerRanks = new HashMap<>(); - final var defaultRank = CustomRank.fromLegacyRank(Rank.OP); + final var defaultRank = plugin.rm.getRegistry().byRole(RankRole.DEFAULT_OP).orElse(null); + // Without an op rank in the registry there is no bucket to default anyone into, so fall + // back to the plain listing rather than dropping unranked players from the output. + if (defaultRank == null) + return buildPlainList(); + playerRanks.put(defaultRank, new ArrayList<>()); for (final var player : players) { @@ -57,9 +62,7 @@ public static String buildRankList() // If they're an admin, we can pull their rank if (admin != null) { - var rank = plugin.rm.getCustomRank(admin.getCustomRankId()); - if (rank == null) - rank = CustomRank.fromLegacyRank(admin.getRank()); + final var rank = plugin.rm.getCustomRank(admin.getRankId()); if (rank == null) { // Give them OP if somehow they don't have a rank diff --git a/src/main/java/me/totalfreedom/totalfreedommod/vault/ChatService.java b/src/main/java/me/totalfreedom/totalfreedommod/vault/ChatService.java index 44875029d..36e808f0c 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/vault/ChatService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/vault/ChatService.java @@ -1,12 +1,14 @@ package me.totalfreedom.totalfreedommod.vault; -import me.totalfreedom.totalfreedommod.ChatManager; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import net.milkbowl.vault.chat.Chat; -import net.milkbowl.vault.permission.Permission; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; +import net.milkbowl.vault.chat.Chat; +import net.milkbowl.vault.permission.Permission; + +import me.totalfreedom.totalfreedommod.ChatManager; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; + /** * Vault Chat Provider implementation for TotalFreedomMod. * Provides prefixes to other plugins via the Vault Chat API. diff --git a/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java b/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java index fdecddfb2..1e84d7fa1 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/vault/PermissionService.java @@ -1,12 +1,13 @@ package me.totalfreedom.totalfreedommod.vault; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.rank.Rank; -import net.milkbowl.vault.permission.Permission; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; -import java.util.Arrays; +import net.milkbowl.vault.permission.Permission; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.rank.CustomRank; + public class PermissionService extends Permission { private final TotalFreedomMod plugin; @@ -68,8 +69,8 @@ public boolean groupRemove(String world, String group, String permission) { public boolean playerInGroup(String world, String player, String group) { Player p = plugin.getServer().getPlayerExact(player); if (p != null && p.isOnline()) { - Rank rank = plugin.rm.getRank(p); - return rank != null && rank.name().equalsIgnoreCase(group); + CustomRank rank = plugin.rm.getEffectiveRank(p); + return rank != null && rank.getId().equalsIgnoreCase(group); } return false; } @@ -88,9 +89,9 @@ public boolean playerRemoveGroup(String world, String player, String group) { public String[] getPlayerGroups(String world, String player) { Player p = plugin.getServer().getPlayerExact(player); if (p != null && p.isOnline()) { - Rank rank = plugin.rm.getRank(p); + CustomRank rank = plugin.rm.getEffectiveRank(p); if (rank != null) { - return new String[] { rank.name() }; + return new String[] { rank.getId() }; } } return new String[0]; @@ -100,9 +101,9 @@ public String[] getPlayerGroups(String world, String player) { public String getPrimaryGroup(String world, String player) { Player p = plugin.getServer().getPlayerExact(player); if (p != null && p.isOnline()) { - Rank rank = plugin.rm.getRank(p); + CustomRank rank = plugin.rm.getEffectiveRank(p); if (rank != null) { - return rank.name(); + return rank.getId(); } } return "default"; @@ -110,9 +111,9 @@ public String getPrimaryGroup(String world, String player) { @Override public String[] getGroups() { - return Arrays.stream(Rank.values()) - .map(Rank::name) - .toArray(String[]::new); + // Groups are whatever ranks.json defines, so an operator-defined rank is visible to Vault + // consumers on the same footing as one the plugin ships with. + return plugin.rm.getCustomRanks().keySet().toArray(String[]::new); } @Override @@ -124,9 +125,9 @@ public boolean hasGroupSupport() { public String getPrimaryGroup(String world, OfflinePlayer player) { Player p = plugin.getServer().getPlayer(player.getUniqueId()); if (p != null && p.isOnline()) { - Rank rank = plugin.rm.getRank(p); + CustomRank rank = plugin.rm.getEffectiveRank(p); if (rank != null) { - return rank.name(); + return rank.getId(); } } return "default"; @@ -136,9 +137,9 @@ public String getPrimaryGroup(String world, OfflinePlayer player) { public String[] getPlayerGroups(String world, OfflinePlayer player) { Player p = plugin.getServer().getPlayer(player.getUniqueId()); if (p != null && p.isOnline()) { - Rank rank = plugin.rm.getRank(p); + CustomRank rank = plugin.rm.getEffectiveRank(p); if (rank != null) { - return new String[] { rank.name() }; + return new String[] { rank.getId() }; } } return new String[0]; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/vault/VaultProviderRegistry.java b/src/main/java/me/totalfreedom/totalfreedommod/vault/VaultProviderRegistry.java index 1847c7329..d7218d173 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/vault/VaultProviderRegistry.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/vault/VaultProviderRegistry.java @@ -1,11 +1,12 @@ package me.totalfreedom.totalfreedommod.vault; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FLog; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.RegisteredServiceProvider; import org.bukkit.plugin.ServicePriority; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.util.FLog; + public final class VaultProviderRegistry { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java index 1891d81b2..d5e5f68cb 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/AdminWorld.java @@ -1,14 +1,8 @@ package me.totalfreedom.totalfreedommod.world; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.Map.Entry; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.util.FLog; + import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.WorldCreator; @@ -17,10 +11,21 @@ import org.bukkit.entity.Player; import org.bukkit.event.player.PlayerMoveEvent; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.util.FLog; + public final class AdminWorld extends CustomWorld { private static final long CACHE_CLEAR_FREQUENCY = 30L * 1000L; //30 seconds, milliseconds + + /** + * The permission that opens the admin world. Held by staff through their rank and by trusted + * players through a title, which is the whole reason entry is a permission check rather than an + * admin check. + */ + private static final String ADMIN_WORLD_NODE = "tfm.world.adminworld"; private static final long TP_COOLDOWN_TIME = 500L; //0.5 seconds, milliseconds private static final String GENERATION_PARAMETERS = ConfigEntry.FLATLANDS_GENERATE_PARAMS.getString(); // @@ -187,7 +192,10 @@ public boolean canAccessWorld(final Player player) Boolean cached = accessCache.get(player); if (cached == null) { - boolean canAccess = plugin.al.isAdmin(player); + // Asked as a permission rather than as "is this an admin", so that a title can open the + // world to someone trusted without promoting them. Admins still pass on their rank; + // a titled player passes on the title's own grant and gains nothing else by it. + boolean canAccess = plugin.rm.hasPermission(player, ADMIN_WORLD_NODE); if (!canAccess) { Player supervisor = guestList.get(player); diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java b/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java index ec15e0fc0..ae90be088 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/CleanroomChunkGenerator.java @@ -19,6 +19,7 @@ import java.util.Random; import java.util.logging.Logger; + import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java b/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java index 7e158cd67..adaaff3cc 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/CustomWorld.java @@ -1,11 +1,5 @@ package me.totalfreedom.totalfreedommod.world; -import lombok.Getter; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.util.FLog; -import me.totalfreedom.totalfreedommod.framework.PluginComponent; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.World; @@ -13,6 +7,15 @@ import org.bukkit.block.BlockFace; import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.framework.PluginComponent; +import me.totalfreedom.totalfreedommod.util.FLog; + +import lombok.Getter; + public abstract class CustomWorld extends PluginComponent<TotalFreedomMod> { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java b/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java index 3141b94e0..2c3e9b097 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/Flatlands.java @@ -1,19 +1,19 @@ package me.totalfreedom.totalfreedommod.world; import java.io.File; + +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + import me.totalfreedom.totalfreedommod.TotalFreedomMod; import me.totalfreedom.totalfreedommod.config.ConfigEntry; import me.totalfreedom.totalfreedommod.util.FLog; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; + import org.apache.commons.io.FileUtils; -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.World; -import org.bukkit.WorldCreator; -import org.bukkit.WorldType; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; public class Flatlands extends CustomWorld { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java index 9ca8ec2f2..cd4287143 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldManager.java @@ -1,11 +1,5 @@ package me.totalfreedom.totalfreedommod.world; -import me.totalfreedom.totalfreedommod.FreedomService; -import me.totalfreedom.totalfreedommod.TotalFreedomMod; -import me.totalfreedom.totalfreedommod.config.ConfigEntry; -import me.totalfreedom.totalfreedommod.player.FPlayer; -import static me.totalfreedom.totalfreedommod.util.FUtil.playerMsg; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.entity.Player; @@ -16,6 +10,15 @@ import org.bukkit.event.weather.ThunderChangeEvent; import org.bukkit.event.weather.WeatherChangeEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import me.totalfreedom.totalfreedommod.FreedomService; +import me.totalfreedom.totalfreedommod.TotalFreedomMod; +import me.totalfreedom.totalfreedommod.config.ConfigEntry; +import me.totalfreedom.totalfreedommod.player.FPlayer; + +import static me.totalfreedom.totalfreedommod.util.FUtil.playerMsg; + public class WorldManager extends FreedomService { diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java index aeda737ef..71a3842a9 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldTime.java @@ -2,6 +2,7 @@ import java.util.Arrays; import java.util.List; + import org.bukkit.World; public enum WorldTime diff --git a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldWeather.java b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldWeather.java index 0bfa3c787..dc450e426 100644 --- a/src/main/java/me/totalfreedom/totalfreedommod/world/WorldWeather.java +++ b/src/main/java/me/totalfreedom/totalfreedommod/world/WorldWeather.java @@ -2,6 +2,7 @@ import java.util.Arrays; import java.util.List; + import org.bukkit.World; public enum WorldWeather diff --git a/src/main/resources/admins.json b/src/main/resources/admins.json new file mode 100644 index 000000000..7e6fb5249 --- /dev/null +++ b/src/main/resources/admins.json @@ -0,0 +1,33 @@ +{ + "prozza": { + "username": "Prozza", + "active": true, + "rank": "SENIOR_ADMIN", + "ips": [ + "127.0.0.1" + ], + "last_login": "Wed, 2 Apr 2016 16:08:39 +0200", + "login_message": "the &5Lead Developer&b!" + }, + "madgeek1450": { + "username": "Madgeek1450", + "active": true, + "rank": "SENIOR_ADMIN", + "ips": [ + "1.2.3.4", + "8.8.8.8" + ], + "last_login": "Wed, 2 Apr 2016 16:08:39 +0200", + "login_message": "the &4Co-Founder&b and &6Master-ass-kicker&b." + }, + "markbyron": { + "username": "markbyron", + "active": true, + "rank": "SENIOR_ADMIN", + "ips": [ + "8.8.4.4" + ], + "last_login": "Wed, 2 Apr 2016 16:08:39 +0200", + "login_message": "the &dOwner&b." + } +} diff --git a/src/main/resources/admins.yml b/src/main/resources/admins.yml deleted file mode 100644 index 970288111..000000000 --- a/src/main/resources/admins.yml +++ /dev/null @@ -1,31 +0,0 @@ -# -# TotalFreedomMod 5.0 Admin List -# - -prozza: - username: Prozza - active: true - rank: SENIOR_ADMIN - ips: - - 127.0.0.1 - last_login: Wed, 2 Apr 2016 16:08:39 +0200 - login_message: 'the &5Lead Developer&b!' - -madgeek1450: - username: Madgeek1450 - active: true - rank: SENIOR_ADMIN - ips: - - 1.2.3.4 - - 8.8.8.8 - last_login: Wed, 2 Apr 2016 16:08:39 +0200 - login_message: 'the &4Co-Founder&b and &6Master-ass-kicker&b.' - -markbyron: - username: markbyron - active: true - rank: SENIOR_ADMIN - ips: - - 8.8.4.4 - last_login: Wed, 2 Apr 2016 16:08:39 +0200 - login_message: 'the &dOwner&b.' diff --git a/src/main/resources/bans.json b/src/main/resources/bans.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/src/main/resources/bans.json @@ -0,0 +1 @@ +{} diff --git a/src/main/resources/bans.yml b/src/main/resources/bans.yml deleted file mode 100644 index cea37eadb..000000000 --- a/src/main/resources/bans.yml +++ /dev/null @@ -1,3 +0,0 @@ -# -# TotalFreedomMod 5.0 Player Bans -# diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 8e949e882..0ba71a2ed 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -662,8 +662,7 @@ discord: # Console-channel log flush interval, in milliseconds (ms). console_flush: 1500 - # Maximum console lines held while waiting to be flushed. Past this the oldest are dropped and - # the next message says how many were lost, so a log burst cannot grow the queue without bound. + # Maximum console lines held while waiting to be flushed. console_queue_limit: 2000 # How long an in-game /link code remains valid before expiring, in seconds. @@ -674,8 +673,7 @@ discord: # Seconds between reconnect attempts. interval_seconds: 30 - # Consecutive failed attempts before the bridge gives up and stays down until the server is - # restarted. Reset by any successful connection. + # Consecutive failed attempts before the bridge gives up and stays down until the server is restarted. max_attempts: 5 # Messages posted to the public relay for server and player events. diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index d2087bb4a..21672591c 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -16,7 +16,7 @@ dependencies: required: false join-classpath: true WorldEdit: - load: BEFORE + load: AFTER required: false join-classpath: true TF-WorldEdit: diff --git a/src/main/resources/permbans.json b/src/main/resources/permbans.json new file mode 100644 index 000000000..e4b5568f6 --- /dev/null +++ b/src/main/resources/permbans.json @@ -0,0 +1,10 @@ +{ + "badplayer1": [ + "123.123.123.123", + "321.321.321.321" + ], + "badplayer2": [ + "111.111.111.111" + ], + "badplayer3": [] +} diff --git a/src/main/resources/permbans.yml b/src/main/resources/permbans.yml deleted file mode 100644 index 012f8d580..000000000 --- a/src/main/resources/permbans.yml +++ /dev/null @@ -1,10 +0,0 @@ -# -# TotalFreedomMod 5.0 Permanent Bans -# - -badplayer1: - - 123.123.123.123 - - 321.321.321.321 -badplayer2: - - 111.111.111.111 -badplayer3: [] diff --git a/src/main/resources/ranks.json b/src/main/resources/ranks.json new file mode 100644 index 000000000..91050b21d --- /dev/null +++ b/src/main/resources/ranks.json @@ -0,0 +1,141 @@ +{ + "impostor": { + "name": "Impostor", + "abbreviation": "Imp", + "prefix": "&8[&eImp&8] ", + "level": -1, + "color": "dark_gray", + "determiner": "an", + "admin": false, + "roles": [ + "impostor" + ], + "permissions": [ + "tfm.player.list" + ] + }, + "non_op": { + "name": "Player", + "abbreviation": "", + "prefix": "", + "level": 0, + "color": "white", + "determiner": "a", + "admin": false, + "roles": [ + "default" + ], + "permissions": [ + "tfm.player.localspawn", + "tfm.player.radar", + "tfm.player.rank", + "tfm.player.spawn", + "tfm.player.list", + "tfm.player.joinmessages", + "tfm.world.flatlands", + "tfm.server.info" + ] + }, + "op": { + "name": "Operator", + "abbreviation": "OP", + "prefix": "&8[&cOP&8] ", + "level": 1, + "color": "green", + "determiner": "an", + "admin": false, + "roles": [ + "default_op" + ], + "inherit": "non_op", + "permissions": [ + "tfm.player.*", + "tfm.admin.banlist", + "tfm.fun.hack", + "tfm.fun.landmine", + "tfm.fun.mp44", + "tfm.fun.spawnmob", + "tfm.fun.tossmob", + "tfm.server.whitelist" + ] + }, + "super_admin": { + "name": "Super Admin", + "abbreviation": "SA", + "prefix": "&8[&bSA&8] ", + "level": 2, + "color": "gold", + "determiner": "a", + "admin": true, + "roles": [ + "admin_default" + ], + "inherit": "op", + "permissions": [ + "tfm.fun.*", + "tfm.world.*", + "tfm.server.*", + "tfm.admin.adminchat", + "tfm.admin.aeclear", + "tfm.admin.announce", + "tfm.admin.autoclear", + "tfm.admin.autotp", + "tfm.admin.ban", + "tfm.admin.baninfo", + "tfm.admin.blockcmd", + "tfm.admin.bookspy", + "tfm.admin.cage", + "tfm.admin.cleanchat", + "tfm.admin.cmdspy", + "tfm.admin.consolesay", + "tfm.admin.deop", + "tfm.admin.discordlink", + "tfm.admin.disguisetoggle", + "tfm.admin.findip", + "tfm.admin.freeze", + "tfm.admin.fuckup", + "tfm.admin.gamemode", + "tfm.admin.gchat", + "tfm.admin.gcmd", + "tfm.admin.invis", + "tfm.admin.kick", + "tfm.admin.mute", + "tfm.admin.myadmin", + "tfm.admin.nickclean", + "tfm.admin.potspy", + "tfm.admin.premium", + "tfm.admin.protectregion", + "tfm.admin.purgeall", + "tfm.admin.ro", + "tfm.admin.saconfig", + "tfm.admin.say", + "tfm.admin.signspy", + "tfm.admin.sqlstatus", + "tfm.admin.strike", + "tfm.admin.undisguiseall", + "tfm.admin.warn", + "tfm.admin.whohas", + "tfm.admin.wildcard" + ] + }, + "senior_admin": { + "name": "Senior Admin", + "abbreviation": "SrA", + "prefix": "&8[&6SrA&8] ", + "level": 3, + "color": "light_purple", + "determiner": "a", + "admin": true, + "roles": [ + "console_floor" + ], + "inherit": "super_admin", + "permissions": [ + "tfm.admin.senior.*", + "tfm.manage.*", + "tfm.admin.ban.perm", + "tfm.admin.banlist.purge", + "tfm.ssh.totp" + ] + } +} diff --git a/src/main/resources/ranks.yml b/src/main/resources/ranks.yml deleted file mode 100644 index b17b6c284..000000000 --- a/src/main/resources/ranks.yml +++ /dev/null @@ -1,191 +0,0 @@ -# TotalFreedomMod Ranks Configuration -# This file defines all custom ranks and their properties. -# -# Format: -# rankid: -# name: Display Name -# abbreviation: TAG -# prefix: "&8[&bTAG&8] " (chat prefix with color codes) -# level: <number> (higher = more authority) -# color: <color name> -# determiner: a/an -# admin: true/false -# console_only: true/false -# inherit: <rank_id> (inherit all permissions from another rank) -# permissions: -# - permission.node -# - another.permission -# -# Available colors: -# black, dark_blue, dark_green, dark_aqua, dark_red, dark_purple, -# gold, gray, dark_gray, blue, green, aqua, red, light_purple, yellow, white -# -# Permission nodes are TFM-internal only (NOT Bukkit permission nodes). -# Since all players have OP, Bukkit permissions don't apply. -# Use these to restrict TFM features based on rank. -# -# Permission Categories: -# tfm.player.* - Basic player commands (NON_OP/OP level) -# tfm.fun.* - Fun commands (smite, doom, orbit, etc.) -# tfm.admin.* - Admin actions (ban, kick, mute, freeze, cage) -# tfm.admin.senior.* - Senior admin only actions -# tfm.server.* - Server management commands -# tfm.world.* - World management commands -# tfm.manage.* - Management commands (ranks, config) -# - -impostor: - name: Impostor - abbreviation: Imp - prefix: "&8[&eImp&8] " - level: -1 - color: dark_gray - determiner: an - admin: false - console_only: false - permissions: - - tfm.player.list - - tfm.admin.overlord - -non_op: - name: Player - abbreviation: "" - prefix: "" - level: 0 - color: white - determiner: a - admin: false - console_only: false - permissions: - - tfm.player.localspawn - - tfm.player.radar - - tfm.player.rank - - tfm.player.spawn - - tfm.player.list - - tfm.player.joinmessages - - tfm.world.flatlands - - tfm.server.info - -op: - name: Operator - abbreviation: OP - prefix: "&8[&cOP&8] " - level: 1 - color: green - determiner: an - admin: false - console_only: false - inherit: non_op - permissions: - - tfm.player.* - - tfm.world.adminworld - - tfm.admin.banlist - - tfm.fun.hack - - tfm.fun.landmine - - tfm.fun.mp44 - - tfm.fun.spawnmob - - tfm.fun.tossmob - - tfm.server.whitelist - - tfm.manage.saconfig - -super_admin: - name: Super Admin - abbreviation: SA - prefix: "&8[&bSA&8] " - level: 2 - color: gold - determiner: a - admin: true - console_only: false - inherit: op - permissions: - - tfm.fun.* - - tfm.world.* - - tfm.admin.adminchat - - tfm.admin.announce - - tfm.admin.ban - - tfm.admin.baninfo - - tfm.admin.banlist - - tfm.admin.blockcmd - - tfm.admin.blockredstone - - tfm.admin.cage - - tfm.admin.cmdspy - - tfm.admin.consolesay - - tfm.admin.denick - - tfm.admin.deop - - tfm.admin.disguisetoggle - - tfm.admin.findip - - tfm.admin.freeze - - tfm.admin.gcmd - - tfm.admin.invis - - tfm.admin.kick - - tfm.admin.mute - - tfm.admin.myadmin - - tfm.admin.nickclean - - tfm.admin.opall - - tfm.admin.opme - - tfm.admin.potspy - - tfm.admin.premium - - tfm.admin.purgeall - - tfm.admin.ro - - tfm.admin.say - - tfm.admin.signspy - - tfm.admin.strike - - tfm.admin.undisguiseall - - tfm.admin.warn - - tfm.admin.whohas - - tfm.admin.wildcard - - tfm.admin.gamemode - - tfm.admin.protectregion - - tfm.server.* - - tfm.manage.saconfig - -senior_admin: - name: Senior Admin - abbreviation: SrA - prefix: "&8[&6SrA&8] " - level: 3 - color: light_purple - determiner: a - admin: true - console_only: false - inherit: super_admin - permissions: - - tfm.admin.senior.* - - tfm.admin.telnet.* - - tfm.manage.* - - tfm.manage.ssh - - tfm.manage.telnet - -developer: - name: Developer - abbreviation: Dev - prefix: "&8[&5Dev&8] " - level: 4 - color: dark_purple - determiner: a - admin: true - console_only: false - inherit: senior_admin - -owner: - name: Owner - abbreviation: Owner - prefix: "&8[&9Owner&8] " - level: 4 - color: blue - determiner: the - admin: true - console_only: false - inherit: senior_admin - -executive: - name: Executive - abbreviation: Exec - prefix: "&8[&eExec&8] " - level: 4 - color: yellow - determiner: an - admin: true - console_only: false - inherit: senior_admin diff --git a/src/main/resources/titles.json b/src/main/resources/titles.json new file mode 100644 index 000000000..c8a72cc1d --- /dev/null +++ b/src/main/resources/titles.json @@ -0,0 +1,44 @@ +{ + "master_builder": { + "name": "Master Builder", + "abbreviation": "MB", + "prefix": "&8[&aMB&8] ", + "color": "green", + "determiner": "a", + "weight": 10, + "announce": true, + "permissions": [ + "tfm.world.adminworld" + ] + }, + "developer": { + "name": "Developer", + "abbreviation": "Dev", + "prefix": "&8[&5Dev&8] ", + "color": "dark_purple", + "determiner": "a", + "weight": 20, + "announce": true, + "permissions": [] + }, + "executive": { + "name": "Executive", + "abbreviation": "Exec", + "prefix": "&8[&eExec&8] ", + "color": "yellow", + "determiner": "an", + "weight": 30, + "announce": true, + "permissions": [] + }, + "owner": { + "name": "Owner", + "abbreviation": "Owner", + "prefix": "&8[&9Owner&8] ", + "color": "blue", + "determiner": "the", + "weight": 40, + "announce": true, + "permissions": [] + } +} diff --git a/src/main/resources/version.json b/src/main/resources/version.json new file mode 100644 index 000000000..61a2092b1 --- /dev/null +++ b/src/main/resources/version.json @@ -0,0 +1,3 @@ +{ + "version": 1 +} diff --git a/src/main/resources/version.yml b/src/main/resources/version.yml deleted file mode 100644 index 012544e68..000000000 --- a/src/main/resources/version.yml +++ /dev/null @@ -1,4 +0,0 @@ -# -# Config version -# -version: 1