diff --git a/build.gradle.kts b/build.gradle.kts index e9ff33c..354f1c7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.conventions.java) + alias(libs.plugins.shadow) } repositories { @@ -40,9 +41,22 @@ dependencies { compileOnly(libs.quickshop) { exclude("*") } + implementation(libs.hikaricp) { + exclude(group = "org.slf4j", module = "slf4j-api") + } } tasks { + assemble { + dependsOn(shadowJar) + } + + shadowJar { + archiveClassifier.set("") + + relocate("com.zaxxer.hikari", "net.earthmc.emcapi.libs.hikari") + } + processResources { val shortCommitId = providers.exec { commandLine("git", "rev-parse", "--short", "HEAD") }.standardOutput.asText.get().trim() val commitId = providers.exec { commandLine("git", "rev-parse", "HEAD") }.standardOutput.asText.get().trim() diff --git a/docs/keys.md b/docs/keys.md new file mode 100644 index 0000000..18e855d --- /dev/null +++ b/docs/keys.md @@ -0,0 +1,9 @@ +# API Keys +API keys are an extra layer of authorization, granting a higher level of access to players & API users. + +They can be generated in-game using `/api key create`. They are persistent & linked to the player until deleted using `/api key delete`. +You may at anytime copy your existing API key with `/api key copy` + +Keys currently serve 2 purposes: +1. Allowing access to the server's SSE endpoint (`/events`) +2. Allowing players to query their own data - Players can query their own resident information even if they have opted out, and they may view information about their shops in the `/shop` endpoint. diff --git a/docs/shop.md b/docs/shop.md new file mode 100644 index 0000000..1c6b0cb --- /dev/null +++ b/docs/shop.md @@ -0,0 +1,40 @@ +# Shop Endpoint +Accessed at https://api.earthmc.net/v4/aurora/shop + +The shop endpoint provides information about player-owned QuickShops. +It is important to note that the information here is not public, and players can only access their own shops' information, using their API key. + +Each shop object has the following properties: +- `item` - The Material/name of the item being traded +- `price` - The price of one transaction +- `amount` - The amount of items in one transaction +- `type` - Whether it is selling or buying items +- `stock` - The remaining stock if it's a selling shop, otherwise the remaining space in the buying shop. + +The JSON element returned carries a dictionary of numbers (counter/shop id) and their respective shop objects. +The counter begins at 1. + +Example **POST** request +```json5 +{ + "query": ["PLAYER_UUID"], + "key": "API_KEY" +} +``` +The player UUID must match the API key's owner, otherwise an empty list is returned. + +Example **POST** response +```json5 +[ + { + "1": { + "item": "COPPER_BLOCK", + "price": 2, + "amount": 4, + "type": "selling", + "stock": 5 + } + } +] +``` +This shop is selling 4 copper blocks for 2 gold, and there are 5 of these transactions in stock (A total of 20 copper blocks) diff --git a/docs/sse.md b/docs/sse.md new file mode 100644 index 0000000..5d15c73 --- /dev/null +++ b/docs/sse.md @@ -0,0 +1,30 @@ +# Server Sent Events (SSE) +Accessed at https://api.earthmc.net/v4/aurora/events + +Server sent events allow clients to connect & listen to specific events sent by the server. + +Connecting requires an API key in the Authorization header, which can be generated on EarthMC in-game using `/api key create` +Only one client may connect per API key. + +Format: `Authorization Bearer ` + +## Events +These are the current events available: +``` +"NewDay", +"NationCreated", "NationDeleted", "NationRenamed", "NationKingChanged", "NationMerged", +"TownCreated", "TownDeleted", "TownRenamed", "TownMayorChanged", "TownMerged", "TownRuined", "TownReclaimed", +"TownJoinedNation", "TownLeftNation", +"ResidentJoinedTown", "ResidentLeftTown", +"ShopSoldItem", "ShopBoughtItem", "ShopOutOfStock", "ShopOutOfSpace", "ShopOutOfGold" +``` +Clients must specify which events to listen to by specifying a `?listen=` query parameter in the URL. +Example: `https://api.earthmc.net/v4/aurora/events?listen=NewDay,TownDeleted,NationRenamed`. This would make it so only these events are sent to the client. + +Most Town events include a `town` field with the name & UUID of the town. The same applies to nations with a `nation` field. +### Authorized player events +Some events are only sent to the relevant player. These are: +- TownJoinedNation, TownLeftNation - Sent to the leader of the nation +- ResidentJoinedTown, ResidentLeftTown - Sent to the mayor of the town +- ShopSoldItem, ShopBoughtItem, ShopOutOfStock, ShopOutOfSpace, ShopOutOfGold - Sent to the shop owner + diff --git a/gradle.properties b/gradle.properties index be58298..8050299 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ group = net.earthmc.emcapi -version = 3.1.0-SNAPSHOT +version = 4.0.0-SNAPSHOT description = EMCAPI org.gradle.configuration-cache=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 969fd0b..ded0384 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,8 +6,12 @@ quarters = "e9ed8a133a" towny = "0.102.0.0" mysterymaster-api = "1.0.0" superbvote = "0.6.0" -conventions = "1.0.8" quickshop = "5.1.2.5-SNAPSHOT" +hikaricp = "7.0.2" + +# plugins +conventions = "1.0.8" +shadow = "9.3.0" [libraries] discordsrv = { group = "com.discordsrv", name = "discordsrv", version.ref = "discordsrv" } @@ -18,6 +22,8 @@ paper = { group = "io.papermc.paper", name = "paper-api", version.ref = "paper" mysterymaster-api = { group = "net.earthmc.mysterymaster", name = "mysterymaster-api", version.ref = "mysterymaster-api" } superbvote = { group = "net.earthmc.superbvote", name = "SuperbVote", version.ref = "superbvote" } quickshop = { group = "org.maxgamer", name = "QuickShop", version.ref = "quickshop" } +hikaricp = { group = "com.zaxxer", name = "HikariCP", version.ref = "hikaricp" } [plugins] conventions-java = { id = "net.earthmc.conventions.java", version.ref = "conventions" } +shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } diff --git a/src/main/java/net/earthmc/emcapi/EMCAPI.java b/src/main/java/net/earthmc/emcapi/EMCAPI.java index 1531b1e..5ca9a95 100644 --- a/src/main/java/net/earthmc/emcapi/EMCAPI.java +++ b/src/main/java/net/earthmc/emcapi/EMCAPI.java @@ -1,17 +1,24 @@ package net.earthmc.emcapi; +import com.google.gson.Gson; +import com.zaxxer.hikari.HikariConfig; import io.javalin.Javalin; +import io.javalin.json.JsonMapper; import io.javalin.util.JavalinLogger; +import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import net.earthmc.emcapi.database.APIDatabase; +import net.earthmc.emcapi.database.DatabaseSchema; import net.earthmc.emcapi.integration.Integrations; import net.earthmc.emcapi.manager.EndpointManager; +import net.earthmc.emcapi.manager.KeyManager; +import net.earthmc.emcapi.manager.LegacyEndpointManager; +import net.earthmc.emcapi.manager.OptOut; import net.earthmc.emcapi.sse.SSEManager; import net.earthmc.emcapi.sse.listeners.ShopSSEListener; import net.earthmc.emcapi.sse.listeners.TownySSEListener; -import net.earthmc.emcapi.util.EndpointUtils; import net.earthmc.emcapi.command.ApiCommand; -import org.bukkit.command.PluginCommand; import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; import org.eclipse.jetty.server.Connector; @@ -21,8 +28,12 @@ import org.eclipse.jetty.servlet.ErrorPageErrorHandler; import org.eclipse.jetty.webapp.WebAppContext; import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.NonNull; import java.io.IOException; +import java.lang.reflect.Type; +import java.sql.Connection; +import java.sql.SQLException; public final class EMCAPI extends JavaPlugin { @@ -30,6 +41,8 @@ public final class EMCAPI extends JavaPlugin { private Javalin javalin; private Integrations pluginIntegrations; private SSEManager sseManager; + private final APIDatabase database = new APIDatabase(); + private final OptOut optOut = new OptOut(this); @Override public void onLoad() { @@ -42,27 +55,20 @@ public void onEnable() { instance = this; loadConfig(); + loadDatabase(); initialiseJavalin(); this.pluginIntegrations = new Integrations(this); getServer().getPluginManager().registerEvents(this.pluginIntegrations, this); - EndpointManager endpointManager = new EndpointManager(this); - endpointManager.loadEndpoints(); - - PluginCommand apiCommand = getCommand("api"); - if (apiCommand == null) { - getLogger().warning("API command not found."); - } else { - ApiCommand cmd = new ApiCommand(); - apiCommand.setExecutor(cmd); - apiCommand.setTabCompleter(cmd); - } - try { - EndpointUtils.loadOptOut(getDataFolder().toPath()); - } catch (IOException e) { - getLogger().warning("IOException while loading opted-out players: " + e); + if (getConfig().getBoolean("behavior.load_legacy")) { + new LegacyEndpointManager(this).loadEndpoints(); // Load retired endpoints and still serve current endpoints at /v3/aurora/ } + new EndpointManager(this).loadEndpoints(); + + this.getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, event -> event.registrar().register(ApiCommand.create(this), "Allows you to opt in or out of your information being visible in the API.")); + + optOut.loadOptOut(); sseManager = new SSEManager(this); sseManager.loadSSE(); @@ -73,27 +79,19 @@ public void onEnable() { if (pm.isPluginEnabled("QuickShop")) { pm.registerEvents(new ShopSSEListener(sseManager), this); } + try { - EndpointUtils.loadApiKeys(getDataFolder().toPath()); - } catch (IOException e) { - getSLF4JLogger().warn("IOException while loading API keys: ", e); + KeyManager.loadApiKeys(this); + } catch (SQLException e) { + getSLF4JLogger().warn("exception while loading API keys: ", e); } } @Override public void onDisable() { - javalin.stop(); - try { - EndpointUtils.saveOptOut(getDataFolder().toPath()); - } catch (IOException e) { - getLogger().warning("IOException while saving opted-out players: " + e); - } sseManager.shutdown(); - try { - EndpointUtils.saveApiKeys(getDataFolder().toPath()); - } catch (IOException e) { - getSLF4JLogger().warn("IOException while saving API keys: ", e); - } + javalin.stop(); + database.close(); } private void initialiseJavalin() { @@ -108,6 +106,19 @@ private void initialiseJavalin() { server.setHandler(context); }); + + final Gson gson = new Gson(); + config.jsonMapper(new JsonMapper() { + @Override + public @NonNull String toJsonString(@NonNull Object obj, @NonNull Type type) { + return gson.toJson(obj, type); + } + + @Override + public @NonNull T fromJsonString(@NonNull String json, @NonNull Type targetType) { + return gson.fromJson(json, targetType); + } + }); }); javalin.start(getConfig().getString("networking.host"), getConfig().getInt("networking.port")); @@ -118,6 +129,36 @@ private void loadConfig() { reloadConfig(); } + private void loadDatabase() { + final HikariConfig config = new HikariConfig(); + config.setJdbcUrl("jdbc:mysql://" + getConfig().getString("database.host") + ":" + getConfig().getString("database.port") + "/" + getConfig().getString("database.name") + getConfig().getString("database.flags")); + final String username = getConfig().getString("database.username"); + final String password = getConfig().getString("database.password"); + + config.setUsername(username); + config.setPassword(password); + + config.setMaximumPoolSize(Math.min(1, getConfig().getInt("database.max-pool-size", 1))); + config.setMinimumIdle(Math.min(0, getConfig().getInt("database.min-pool-size", 0))); + config.setPoolName("EMCAPI"); + + try { + database.start(config); + + try (final Connection connection = database.getConnection()) { + DatabaseSchema.createTables(connection); + } catch (SQLException e) { + getSLF4JLogger().warn("Failed to create default tables", e); + } + } catch (SQLException e) { + if (!"root".equals(username) || !"".equals(password)) { + getSLF4JLogger().warn("Failed to start datasource", e); + } + } catch (ReflectiveOperationException e) { + getSLF4JLogger().warn("Failed to find embedded sql driver", e); + } + } + private void disableServerVersionHeader(final Server server) { for (Connector conn : server.getConnectors()) { conn.getConnectionFactories().stream() @@ -151,4 +192,12 @@ public String getURLPath() { String version = getConfig().getString("networking.api_version", "3"); return "v" + version + "/" + getConfig().getString("networking.url_path"); } + + public APIDatabase getDatabase() { + return database; + } + + public OptOut getOptOut() { + return optOut; + } } diff --git a/src/main/java/net/earthmc/emcapi/command/ApiCommand.java b/src/main/java/net/earthmc/emcapi/command/ApiCommand.java index ff48b0a..e798683 100644 --- a/src/main/java/net/earthmc/emcapi/command/ApiCommand.java +++ b/src/main/java/net/earthmc/emcapi/command/ApiCommand.java @@ -1,24 +1,30 @@ package net.earthmc.emcapi.command; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.tree.LiteralCommandNode; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.Commands; +import net.earthmc.emcapi.EMCAPI; +import net.earthmc.emcapi.manager.KeyManager; import net.earthmc.emcapi.sse.SSEManager; -import net.earthmc.emcapi.util.EndpointUtils; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.bukkit.command.TabExecutor; import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import java.util.List; +import java.time.Duration; +import java.time.Instant; import java.util.UUID; -import java.util.stream.Stream; +import java.util.concurrent.TimeUnit; -public class ApiCommand implements TabExecutor { - private static final Component infoMessage = Component.text() +public class ApiCommand { + private static final Cache COOLDOWNS = CacheBuilder.newBuilder().expireAfterWrite(1L, TimeUnit.MINUTES).build(); + + private static final Component INFO_MESSAGE = Component.text() .append(Component.text("- The API provides real-time information about players, towns, and nations. The API can be accessed ", NamedTextColor.AQUA)) .append(Component.text("here", NamedTextColor.AQUA, TextDecoration.UNDERLINED).clickEvent(ClickEvent.openUrl("https://api.earthmc.net/"))) .appendNewline() @@ -30,92 +36,123 @@ public class ApiCommand implements TabExecutor { .append(Component.text("- If you'd like to opt out of your information being public on the API, you can use /api opt-out", NamedTextColor.RED)) .build(); - @Override - public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { - if (!(commandSender instanceof Player player)) { - commandSender.sendMessage(Component.text("Only players may use this command.", NamedTextColor.RED)); - return true; - } + private ApiCommand() {} - if (args.length < 1) { - player.sendMessage(infoMessage); - return true; - } - String action = args[0].toLowerCase(); - switch (action) { - case "opt-in" -> { - player.sendMessage(Component.text("You have opted back in for your information being public on the API.", NamedTextColor.GREEN)); - EndpointUtils.setOptedOut(player.getUniqueId(), false); - } - case "opt-out" -> { - player.sendMessage(Component.text("You have opted out of your information being public on the API", NamedTextColor.RED)); - EndpointUtils.setOptedOut(player.getUniqueId(), true); - } - case "key" -> handleKey(player, args); - default -> player.sendMessage(Component.text("Usage: /api [opt-in|opt-out|key]", NamedTextColor.RED)); - } + public static LiteralCommandNode create(final EMCAPI plugin) { + return Commands.literal("api") + .requires(ctx -> ctx.getSender().hasPermission("emcapi.command")) + .executes(ctx -> { + ctx.getSource().getSender().sendMessage(INFO_MESSAGE); + return Command.SINGLE_SUCCESS; + }) + .then(Commands.literal("opt-out") + .requires(ctx -> ctx.getSender() instanceof Player) + .executes(ctx -> { + final Player player = (Player) ctx.getSource().getSender(); + + if (plugin.getOptOut().playerOptedOut(player.getUniqueId())) { + player.sendMessage(Component.text("You have already opted out previously!", NamedTextColor.RED)); + } else { + if (isOnCooldown(player, CooldownType.OPT_OUT_CHANGE)) { + return Command.SINGLE_SUCCESS; + } + + player.sendMessage(Component.text("You have opted out of having your information being public on the API.", NamedTextColor.GREEN)); + plugin.getOptOut().setOptedOut(player.getUniqueId(), true); + } + return Command.SINGLE_SUCCESS; + })) + .then(Commands.literal("opt-in") + .requires(ctx -> ctx.getSender() instanceof Player) + .executes(ctx -> { + final Player player = (Player) ctx.getSource().getSender(); + + if (plugin.getOptOut().playerOptedOut(player.getUniqueId())) { + if (isOnCooldown(player, CooldownType.OPT_OUT_CHANGE)) { + return Command.SINGLE_SUCCESS; + } + + player.sendMessage(Component.text("You have opted back in to your information being public on the API.", NamedTextColor.GREEN)); + plugin.getOptOut().setOptedOut(player.getUniqueId(), false); + } else { + player.sendMessage(Component.text("You are currently not opted out!", NamedTextColor.RED)); + } + return Command.SINGLE_SUCCESS; + })) + .then(Commands.literal("key") + .requires(ctx -> ctx.getSender() instanceof Player player && player.hasPermission("emcapi.key")) + .executes(ApiCommand::copyKey) + .then(Commands.literal("create") + .executes(ctx -> { + final Player player = (Player) ctx.getSource().getSender(); + if (KeyManager.getPlayerKey(player.getUniqueId()) != null) { + player.sendMessage(Component.text("You already have an API key! Use /api key to view it.", NamedTextColor.RED)); + } else { + if (isOnCooldown(player, CooldownType.MODIFY_KEY)) { + return Command.SINGLE_SUCCESS; + } + + final String key = KeyManager.createApiKey(player.getUniqueId()); + player.sendMessage(Component.text("Key created! Click to copy.", NamedTextColor.GREEN).clickEvent(ClickEvent.copyToClipboard(key))); + } + return Command.SINGLE_SUCCESS; + })) + .then(Commands.literal("delete") + .executes(ctx -> { + final Player player = (Player) ctx.getSource().getSender(); + final String key = KeyManager.getPlayerKey(player.getUniqueId()); + if (key != null) { + if (isOnCooldown(player, CooldownType.MODIFY_KEY)) { + return Command.SINGLE_SUCCESS; + } + + SSEManager.deleteKey(key); + KeyManager.deletePlayerKey(player.getUniqueId()); + player.sendMessage(Component.text("Successfully deleted your API key", NamedTextColor.GREEN)); + } else { + player.sendMessage(Component.text("You do not have an API key.", NamedTextColor.RED)); + } - return true; + return Command.SINGLE_SUCCESS; + })) + .then(Commands.literal("copy") + .executes(ApiCommand::copyKey))) + .build(); } - @Override - public @Nullable List onTabComplete(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { - if (args.length == 1) { - return Stream.of("opt-in", "opt-out", "key").filter(str -> str.toLowerCase().startsWith(args[0].toLowerCase())).toList(); - } - if (args.length == 2 && args[0].equalsIgnoreCase("key")) { - return Stream.of("create", "delete", "copy").filter(str -> str.toLowerCase().startsWith(args[1].toLowerCase())).toList(); + private static int copyKey(final CommandContext ctx) { + final Player player = (Player) ctx.getSource().getSender(); + final String key = KeyManager.getPlayerKey(player.getUniqueId()); + + if (key != null) { + player.sendMessage(Component.text("Click to copy your API key.", NamedTextColor.GREEN).clickEvent(ClickEvent.copyToClipboard(key))); + } else { + player.sendMessage(Component.text("You do not have an API key. Use /api key create to create one.", NamedTextColor.RED)); } - return List.of(); + return Command.SINGLE_SUCCESS; } - private void handleKey(Player player, String[] args) { - if (!player.hasPermission("emcapi.key")) { - player.sendMessage(Component.text("You do not have permission to use this command", NamedTextColor.RED)); - return; - } + private static boolean isOnCooldown(final Player player, final CooldownType type) { + final Instant now = Instant.now(); + final Instant lastCommandUse = COOLDOWNS.asMap().putIfAbsent(new CommandCooldown(player.getUniqueId(), type), now); - UUID playerID = player.getUniqueId(); - UUID key; - if (args.length == 1) { - key = EndpointUtils.getPlayerKey(playerID); - if (key != null) { - player.sendMessage(Component.text("Click to copy your API key.", NamedTextColor.GREEN).clickEvent(ClickEvent.copyToClipboard(key.toString()))); - } else { - player.sendMessage(Component.text("You do not have an API key. Use /api key create to create one.", NamedTextColor.RED)); - } - return; - } - String action = args[1].toLowerCase(); - switch (action) { - case "create" -> { - if (EndpointUtils.getPlayerKey(playerID) != null) { - player.sendMessage(Component.text("You already have an API key! Use /api key to get it.", NamedTextColor.RED)); - } else { - key = EndpointUtils.createApiKey(playerID); - player.sendMessage(Component.text("Key created! Click to copy.", NamedTextColor.GREEN).clickEvent(ClickEvent.copyToClipboard(key.toString()))); - } - } - case "delete" -> { - key = EndpointUtils.getPlayerKey(playerID); - if (key != null) { - SSEManager.deleteKey(key); - EndpointUtils.deletePlayerKey(playerID); - player.sendMessage(Component.text("Successfully deleted your API key", NamedTextColor.GREEN)); - } else { - player.sendMessage(Component.text("You do not have an API key.", NamedTextColor.RED)); - } - } - case "copy" -> { - key = EndpointUtils.getPlayerKey(playerID); - if (key != null) { - player.sendMessage(Component.text("Click to copy your API key.", NamedTextColor.GREEN).clickEvent(ClickEvent.copyToClipboard(key.toString()))); - } else { - player.sendMessage(Component.text("You do not have an API key. Use /api key create to create one.", NamedTextColor.RED)); - } + if (lastCommandUse != null) { + final long seconds = Duration.between(now, lastCommandUse.plusSeconds(60)).getSeconds(); + + if (seconds > 0) { + player.sendMessage(Component.text("Please wait " + seconds + " more second" + (seconds == 1 ? "" : "s") + " before trying this command again.", NamedTextColor.RED)); + return true; } - default -> player.sendMessage(Component.text("Usage: /api key ", NamedTextColor.RED)); } + + return false; } + + private enum CooldownType { + MODIFY_KEY, + OPT_OUT_CHANGE + } + + private record CommandCooldown(UUID uuid, CooldownType type) {} } diff --git a/src/main/java/net/earthmc/emcapi/database/APIDatabase.java b/src/main/java/net/earthmc/emcapi/database/APIDatabase.java new file mode 100644 index 0000000..a7463ee --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/database/APIDatabase.java @@ -0,0 +1,48 @@ +package net.earthmc.emcapi.database; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import java.io.Closeable; +import java.sql.Connection; +import java.sql.Driver; +import java.sql.DriverManager; +import java.sql.SQLException; + +public class APIDatabase implements Closeable { + private HikariDataSource dataSource; + + /** + * Attempts to register drivers and starts the datasource. + * @param config The config to start the data source with. + * @throws ReflectiveOperationException If the driver class could not be found/instantiated. + * @throws SQLException If a connection to the datasource couldn't be established. + */ + public void start(final HikariConfig config) throws ReflectiveOperationException, SQLException { + DriverManager.registerDriver((Driver) Class.forName("com.mysql.cj.jdbc.Driver").getDeclaredConstructor().newInstance()); + + this.dataSource = new HikariDataSource(config); + } + + /** + * Attempts to close the datasource if it's running. + */ + @Override + public void close() { + if (this.dataSource != null) { + try { + this.dataSource.close(); + } finally { + this.dataSource = null; + } + } + } + + public Connection getConnection() throws SQLException { + return this.dataSource.getConnection(); + } + + public boolean ready() { + return this.dataSource != null && this.dataSource.isRunning(); + } +} diff --git a/src/main/java/net/earthmc/emcapi/database/DatabaseSchema.java b/src/main/java/net/earthmc/emcapi/database/DatabaseSchema.java new file mode 100644 index 0000000..d2a0a4a --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/database/DatabaseSchema.java @@ -0,0 +1,37 @@ +package net.earthmc.emcapi.database; + +import net.earthmc.emcapi.manager.KeyManager; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; + +public class DatabaseSchema { + public static void createTables(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(createApiKeys()); + + for (String column : getApiKeysColumns()) { + try { + statement.executeUpdate("alter table api_keys add column " + column); + } catch (SQLException ignored) {} + } + + statement.executeUpdate("CREATE TABLE IF NOT EXISTS opt_out(`uuid` CHAR(36) NOT NULL, PRIMARY KEY (`uuid`))"); + } + } + + private static String createApiKeys() { + return "CREATE TABLE IF NOT EXISTS api_keys(" + + "`uuid` CHAR(36) NOT NULL," + + "PRIMARY KEY (`uuid`)" + + ")"; + } + + private static List getApiKeysColumns() { + return List.of( + "`api_key` VARCHAR(" + KeyManager.MAX_KEY_LENGTH + ") NOT NULL" + ); + } +} diff --git a/src/main/java/net/earthmc/emcapi/endpoint/DiscordEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/DiscordEndpoint.java index 0dddcff..73d6277 100644 --- a/src/main/java/net/earthmc/emcapi/endpoint/DiscordEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/endpoint/DiscordEndpoint.java @@ -4,12 +4,13 @@ import com.google.gson.JsonObject; import github.scarsz.discordsrv.DiscordSRV; import io.javalin.http.BadRequestResponse; +import net.earthmc.emcapi.EMCAPI; import net.earthmc.emcapi.object.endpoint.PostEndpoint; import net.earthmc.emcapi.object.nearby.DiscordContext; import net.earthmc.emcapi.object.nearby.DiscordType; -import net.earthmc.emcapi.util.EndpointUtils; import net.earthmc.emcapi.util.HttpExceptions; import net.earthmc.emcapi.util.JSONUtil; +import org.jetbrains.annotations.Nullable; import java.util.UUID; import java.util.regex.Matcher; @@ -20,8 +21,12 @@ public class DiscordEndpoint extends PostEndpoint { private static final BadRequestResponse MISSING_TYPE_TARGET = new BadRequestResponse("Your JSON query is missing a type or target"); private static final BadRequestResponse INVALID_TYPE_TARGET = new BadRequestResponse("Your JSON query has an invalid type or target"); + public DiscordEndpoint(final EMCAPI plugin) { + super(plugin); + } + @Override - public DiscordContext getObjectOrNull(JsonElement element) { + public DiscordContext getObjectOrNull(JsonElement element, @Nullable String key) { JsonObject jsonObject = JSONUtil.getJsonElementAsJsonObjectOrNull(element); if (jsonObject == null) { throw HttpExceptions.NOT_A_JSON_OBJECT; @@ -47,7 +52,8 @@ public DiscordContext getObjectOrNull(JsonElement element) { uuid = getUUIDFromDiscordId(target); } catch (BadRequestResponse ignored1) {} } - if (uuid != null && EndpointUtils.playerOptedOut(uuid)) { + + if (uuid != null && plugin.getOptOut().playerOptedOut(uuid)) { return null; } @@ -60,7 +66,7 @@ public DiscordContext getObjectOrNull(JsonElement element) { } @Override - public JsonElement getJsonElement(DiscordContext context) { + public JsonElement getJsonElement(DiscordContext context, @Nullable String key) { DiscordType type = context.getType(); String target = context.getTarget(); diff --git a/src/main/java/net/earthmc/emcapi/endpoint/LocationEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/LocationEndpoint.java index d01d78d..49b800f 100644 --- a/src/main/java/net/earthmc/emcapi/endpoint/LocationEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/endpoint/LocationEndpoint.java @@ -7,16 +7,22 @@ import com.palmergames.bukkit.towny.object.Town; import io.javalin.http.BadRequestResponse; import kotlin.Pair; +import net.earthmc.emcapi.EMCAPI; import net.earthmc.emcapi.object.endpoint.PostEndpoint; import net.earthmc.emcapi.util.EndpointUtils; import net.earthmc.emcapi.util.JSONUtil; import org.bukkit.Bukkit; import org.bukkit.Location; +import org.jetbrains.annotations.Nullable; public class LocationEndpoint extends PostEndpoint> { + public LocationEndpoint(final EMCAPI plugin) { + super(plugin); + } + @Override - public Pair getObjectOrNull(JsonElement element) { + public Pair getObjectOrNull(JsonElement element, @Nullable String key) { JsonArray jsonArray = JSONUtil.getJsonElementAsJsonArrayOrNull(element); if (jsonArray == null) throw new BadRequestResponse("Your query contains a value that is not a JSON array"); @@ -40,7 +46,7 @@ public Pair getObjectOrNull(JsonElement element) { } @Override - public JsonElement getJsonElement(Pair pair) { + public JsonElement getJsonElement(Pair pair, @Nullable String key) { int x = pair.getFirst(); int z = pair.getSecond(); diff --git a/src/main/java/net/earthmc/emcapi/endpoint/NearbyEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/NearbyEndpoint.java index 83c533c..e4a9e95 100644 --- a/src/main/java/net/earthmc/emcapi/endpoint/NearbyEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/endpoint/NearbyEndpoint.java @@ -11,6 +11,7 @@ import com.palmergames.util.MathUtil; import io.javalin.http.BadRequestResponse; import kotlin.Pair; +import net.earthmc.emcapi.EMCAPI; import net.earthmc.emcapi.object.endpoint.PostEndpoint; import net.earthmc.emcapi.object.nearby.NearbyContext; import net.earthmc.emcapi.object.nearby.NearbyType; @@ -18,14 +19,19 @@ import net.earthmc.emcapi.util.JSONUtil; import org.bukkit.Bukkit; import org.bukkit.Location; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; public class NearbyEndpoint extends PostEndpoint { + public NearbyEndpoint(final EMCAPI plugin) { + super(plugin); + } + @Override - public NearbyContext getObjectOrNull(JsonElement element) { + public NearbyContext getObjectOrNull(JsonElement element, @Nullable String key) { JsonObject jsonObject = JSONUtil.getJsonElementAsJsonObjectOrNull(element); if (jsonObject == null) throw new BadRequestResponse("Your query contains a value that is not a JSON object"); @@ -64,7 +70,7 @@ public NearbyContext getObjectOrNull(JsonElement element) { } @Override - public JsonElement getJsonElement(NearbyContext context) { + public JsonElement getJsonElement(NearbyContext context, @Nullable String key) { NearbyType targetType = context.getTargetType(); int radius = context.getRadius(); switch (targetType) { diff --git a/src/main/java/net/earthmc/emcapi/endpoint/ServerEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/ServerEndpoint.java index 5a30f8c..d83814e 100644 --- a/src/main/java/net/earthmc/emcapi/endpoint/ServerEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/endpoint/ServerEndpoint.java @@ -3,13 +3,14 @@ import com.google.gson.JsonObject; import com.palmergames.bukkit.towny.TownyAPI; import com.palmergames.bukkit.towny.TownySettings; +import com.palmergames.bukkit.towny.object.Resident; import net.earthmc.emcapi.EMCAPI; import net.earthmc.emcapi.integration.QuartersIntegration; import net.earthmc.emcapi.integration.SuperbVoteIntegration; import net.earthmc.emcapi.object.endpoint.GetEndpoint; -import net.earthmc.emcapi.util.EndpointUtils; import org.bukkit.Bukkit; import org.bukkit.World; +import org.bukkit.entity.Player; import java.time.LocalTime; import java.util.concurrent.TimeUnit; @@ -62,9 +63,9 @@ public JsonObject getJsonElement() { JsonObject statsObject = new JsonObject(); statsObject.addProperty("time", overworld.getTime()); statsObject.addProperty("fullTime", overworld.getFullTime()); - statsObject.addProperty("maxPlayers", Bukkit.getMaxPlayers()); - statsObject.addProperty("numOnlinePlayers", Bukkit.getOnlinePlayers().size()); - statsObject.addProperty("numOnlineNomads", EndpointUtils.getNumOnlineNomads()); + statsObject.addProperty("maxPlayers", plugin.getServer().getMaxPlayers()); + statsObject.addProperty("numOnlinePlayers", plugin.getServer().getOnlinePlayers().size()); + statsObject.addProperty("numOnlineNomads", getNumOnlineNomads()); statsObject.addProperty("numResidents", townyAPI.getResidents().size()); statsObject.addProperty("numNomads", townyAPI.getResidentsWithoutTown().size()); statsObject.addProperty("numTowns", townyAPI.getTowns().size()); @@ -95,4 +96,17 @@ public JsonObject getJsonElement() { return serverObject; } + + private static int getNumOnlineNomads() { + int numOnlineNomads = 0; + + for (Player player : Bukkit.getOnlinePlayers()) { + Resident resident = TownyAPI.getInstance().getResident(player); + if (resident == null || !resident.hasTown()) { + numOnlineNomads++; + } + } + + return numOnlineNomads; + } } diff --git a/src/main/java/net/earthmc/emcapi/endpoint/ShopEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/ShopEndpoint.java new file mode 100644 index 0000000..fbcdd59 --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/endpoint/ShopEndpoint.java @@ -0,0 +1,54 @@ +package net.earthmc.emcapi.endpoint; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.javalin.http.BadRequestResponse; +import net.earthmc.emcapi.EMCAPI; +import net.earthmc.emcapi.integration.QuickShopIntegration; +import net.earthmc.emcapi.manager.KeyManager; +import net.earthmc.emcapi.object.endpoint.PostEndpoint; +import net.earthmc.emcapi.util.EndpointUtils; +import net.earthmc.emcapi.util.JSONUtil; +import org.jetbrains.annotations.Nullable; +import org.maxgamer.quickshop.api.shop.Shop; + +import java.util.List; +import java.util.UUID; + +public class ShopEndpoint extends PostEndpoint> { + private final QuickShopIntegration integration; + + public ShopEndpoint(EMCAPI plugin) { + super(plugin); + this.integration = plugin.integrations().quickShopIntegration(); + } + + @Override + public List getObjectOrNull(JsonElement element, @Nullable String key) { + String string = JSONUtil.getJsonElementAsStringOrNull(element); + if (string == null) throw new BadRequestResponse("Your query contains a value that is not a string"); + + UUID player; + try { + player = UUID.fromString(string); + } catch (IllegalArgumentException ignored) { + return null; + } + + integration.throwIfDisabled(); + return integration.getPlayerShops(player, key); + } + + @Override + public JsonElement getJsonElement(List object, @Nullable String key) { + JsonObject shopsObject = new JsonObject(); + int counter = 1; + UUID keyOwner = KeyManager.getKeyOwner(key); + for (Shop shop : object) { + if (!shop.getOwner().equals(keyOwner)) continue; + shopsObject.add(String.valueOf(counter++), EndpointUtils.getShopObject(shop)); + } + + return shopsObject; + } +} diff --git a/src/main/java/net/earthmc/emcapi/endpoint/DocumentationEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/legacy/DocumentationEndpoint.java similarity index 96% rename from src/main/java/net/earthmc/emcapi/endpoint/DocumentationEndpoint.java rename to src/main/java/net/earthmc/emcapi/endpoint/legacy/DocumentationEndpoint.java index bba2703..f673011 100644 --- a/src/main/java/net/earthmc/emcapi/endpoint/DocumentationEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/endpoint/legacy/DocumentationEndpoint.java @@ -1,4 +1,4 @@ -package net.earthmc.emcapi.endpoint; +package net.earthmc.emcapi.endpoint.legacy; import com.google.gson.JsonObject; import net.earthmc.emcapi.object.endpoint.GetEndpoint; diff --git a/src/main/java/net/earthmc/emcapi/endpoint/MudkipEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/legacy/MudkipEndpoint.java similarity index 99% rename from src/main/java/net/earthmc/emcapi/endpoint/MudkipEndpoint.java rename to src/main/java/net/earthmc/emcapi/endpoint/legacy/MudkipEndpoint.java index 0e5e00c..efcece2 100644 --- a/src/main/java/net/earthmc/emcapi/endpoint/MudkipEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/endpoint/legacy/MudkipEndpoint.java @@ -1,4 +1,4 @@ -package net.earthmc.emcapi.endpoint; +package net.earthmc.emcapi.endpoint.legacy; public class MudkipEndpoint { diff --git a/src/main/java/net/earthmc/emcapi/endpoint/PlayerStatsEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/legacy/PlayerStatsEndpoint.java similarity index 98% rename from src/main/java/net/earthmc/emcapi/endpoint/PlayerStatsEndpoint.java rename to src/main/java/net/earthmc/emcapi/endpoint/legacy/PlayerStatsEndpoint.java index 2bdd10c..e5bcb73 100644 --- a/src/main/java/net/earthmc/emcapi/endpoint/PlayerStatsEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/endpoint/legacy/PlayerStatsEndpoint.java @@ -1,4 +1,4 @@ -package net.earthmc.emcapi.endpoint; +package net.earthmc.emcapi.endpoint.legacy; import com.google.gson.JsonObject; import io.papermc.paper.threadedregions.scheduler.ScheduledTask; @@ -16,7 +16,6 @@ import java.nio.file.StandardOpenOption; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.TimeUnit; public class PlayerStatsEndpoint { private final EMCAPI plugin; diff --git a/src/main/java/net/earthmc/emcapi/endpoint/towny/NationsEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/towny/NationsEndpoint.java index 8a76ba9..2269ff0 100644 --- a/src/main/java/net/earthmc/emcapi/endpoint/towny/NationsEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/endpoint/towny/NationsEndpoint.java @@ -8,18 +8,24 @@ import com.palmergames.bukkit.towny.object.Resident; import com.palmergames.bukkit.towny.permissions.TownyPerms; import io.javalin.http.BadRequestResponse; +import net.earthmc.emcapi.EMCAPI; import net.earthmc.emcapi.manager.NationMetadataManager; import net.earthmc.emcapi.object.endpoint.PostEndpoint; import net.earthmc.emcapi.util.EndpointUtils; import net.earthmc.emcapi.util.JSONUtil; +import org.jetbrains.annotations.Nullable; import java.util.List; import java.util.UUID; public class NationsEndpoint extends PostEndpoint { + public NationsEndpoint(final EMCAPI plugin) { + super(plugin); + } + @Override - public Nation getObjectOrNull(JsonElement element) { + public Nation getObjectOrNull(JsonElement element, @Nullable String key) { String string = JSONUtil.getJsonElementAsStringOrNull(element); if (string == null) throw new BadRequestResponse("Your query contains a value that is not a string"); @@ -34,7 +40,7 @@ public Nation getObjectOrNull(JsonElement element) { } @Override - public JsonElement getJsonElement(Nation nation) { + public JsonElement getJsonElement(Nation nation, @Nullable String key) { JsonObject nationObject = new JsonObject(); nationObject.addProperty("name", nation.getName()); diff --git a/src/main/java/net/earthmc/emcapi/endpoint/towny/PlayersEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/towny/PlayersEndpoint.java index a4c232d..9262094 100644 --- a/src/main/java/net/earthmc/emcapi/endpoint/towny/PlayersEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/endpoint/towny/PlayersEndpoint.java @@ -7,17 +7,24 @@ import com.palmergames.bukkit.towny.TownyEconomyHandler; import com.palmergames.bukkit.towny.object.Resident; import io.javalin.http.BadRequestResponse; +import net.earthmc.emcapi.EMCAPI; +import net.earthmc.emcapi.manager.KeyManager; import net.earthmc.emcapi.object.endpoint.PostEndpoint; import net.earthmc.emcapi.util.EndpointUtils; import net.earthmc.emcapi.util.JSONUtil; +import org.jetbrains.annotations.Nullable; import java.util.List; import java.util.UUID; public class PlayersEndpoint extends PostEndpoint { + public PlayersEndpoint(final EMCAPI plugin) { + super(plugin); + } + @Override - public Resident getObjectOrNull(JsonElement element) { + public Resident getObjectOrNull(JsonElement element, @Nullable String key) { String string = JSONUtil.getJsonElementAsStringOrNull(element); if (string == null) throw new BadRequestResponse("Your query contains a value that is not a string"); @@ -28,7 +35,7 @@ public Resident getObjectOrNull(JsonElement element) { resident = TownyAPI.getInstance().getResident(string); } - if (resident != null && EndpointUtils.playerOptedOut(resident.getUUID())) { + if (resident != null && plugin.getOptOut().playerOptedOut(resident.getUUID()) && !resident.getUUID().equals(KeyManager.getKeyOwner(key))) { return null; } @@ -36,7 +43,7 @@ public Resident getObjectOrNull(JsonElement element) { } @Override - public JsonElement getJsonElement(Resident resident) { + public JsonElement getJsonElement(Resident resident, @Nullable String key) { JsonObject playerObject = new JsonObject(); playerObject.addProperty("name", resident.getName()); diff --git a/src/main/java/net/earthmc/emcapi/endpoint/towny/QuartersEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/towny/QuartersEndpoint.java index 9c58dbf..5ed45fd 100644 --- a/src/main/java/net/earthmc/emcapi/endpoint/towny/QuartersEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/endpoint/towny/QuartersEndpoint.java @@ -7,18 +7,24 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import io.javalin.http.BadRequestResponse; +import net.earthmc.emcapi.EMCAPI; import net.earthmc.emcapi.object.endpoint.PostEndpoint; import net.earthmc.emcapi.util.EndpointUtils; import net.earthmc.emcapi.util.JSONUtil; import org.bukkit.Location; +import org.jetbrains.annotations.Nullable; import java.awt.*; import java.util.UUID; public class QuartersEndpoint extends PostEndpoint { + public QuartersEndpoint(final EMCAPI plugin) { + super(plugin); + } + @Override - public Quarter getObjectOrNull(JsonElement element) { + public Quarter getObjectOrNull(JsonElement element, @Nullable String key) { String string = JSONUtil.getJsonElementAsStringOrNull(element); if (string == null) throw new BadRequestResponse("Your query contains a value that is not a string"); @@ -33,7 +39,7 @@ public Quarter getObjectOrNull(JsonElement element) { } @Override - public JsonElement getJsonElement(Quarter quarter) { + public JsonElement getJsonElement(Quarter quarter, @Nullable String key) { JsonObject quarterObject = new JsonObject(); quarterObject.addProperty("name", quarter.getName()); diff --git a/src/main/java/net/earthmc/emcapi/endpoint/towny/TownsEndpoint.java b/src/main/java/net/earthmc/emcapi/endpoint/towny/TownsEndpoint.java index 50e7aa9..98fe171 100644 --- a/src/main/java/net/earthmc/emcapi/endpoint/towny/TownsEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/endpoint/towny/TownsEndpoint.java @@ -15,18 +15,18 @@ import net.earthmc.emcapi.object.endpoint.PostEndpoint; import net.earthmc.emcapi.util.EndpointUtils; import net.earthmc.emcapi.util.JSONUtil; +import org.jetbrains.annotations.Nullable; import java.util.UUID; public class TownsEndpoint extends PostEndpoint { - private final EMCAPI plugin; public TownsEndpoint(EMCAPI plugin) { - this.plugin = plugin; + super(plugin); } @Override - public Town getObjectOrNull(JsonElement element) { + public Town getObjectOrNull(JsonElement element, @Nullable String key) { String string = JSONUtil.getJsonElementAsStringOrNull(element); if (string == null) throw new BadRequestResponse("Your query contains a value that is not a string"); @@ -41,7 +41,7 @@ public Town getObjectOrNull(JsonElement element) { } @Override - public JsonElement getJsonElement(Town town) { + public JsonElement getJsonElement(Town town, @Nullable String key) { JsonObject townObject = new JsonObject(); townObject.addProperty("name", town.getName()); diff --git a/src/main/java/net/earthmc/emcapi/integration/Integrations.java b/src/main/java/net/earthmc/emcapi/integration/Integrations.java index 8b383c6..075ef3c 100644 --- a/src/main/java/net/earthmc/emcapi/integration/Integrations.java +++ b/src/main/java/net/earthmc/emcapi/integration/Integrations.java @@ -17,6 +17,7 @@ public class Integrations implements Listener { private final QuartersIntegration quartersIntegration; private final SuperbVoteIntegration superbVoteIntegration; private final MysteryMasterIntegration mysteryMasterIntegration; + private final QuickShopIntegration quickShopIntegration; public Integrations(final EMCAPI plugin) { this.plugin = plugin; @@ -25,6 +26,7 @@ public Integrations(final EMCAPI plugin) { this.quartersIntegration = addIntegration(new QuartersIntegration()); this.superbVoteIntegration = addIntegration(new SuperbVoteIntegration()); this.mysteryMasterIntegration = addIntegration(new MysteryMasterIntegration()); + this.quickShopIntegration = addIntegration(new QuickShopIntegration()); } private T addIntegration(final T integration) { @@ -50,6 +52,10 @@ public MysteryMasterIntegration mysteryMasterIntegration() { return this.mysteryMasterIntegration; } + public QuickShopIntegration quickShopIntegration() { + return quickShopIntegration; + } + @EventHandler public void onPluginEnable(final PluginEnableEvent event) { final Integration integration = integrations.get(event.getPlugin().getName()); diff --git a/src/main/java/net/earthmc/emcapi/integration/QuickShopIntegration.java b/src/main/java/net/earthmc/emcapi/integration/QuickShopIntegration.java new file mode 100644 index 0000000..bd235cc --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/integration/QuickShopIntegration.java @@ -0,0 +1,25 @@ +package net.earthmc.emcapi.integration; + +import net.earthmc.emcapi.manager.KeyManager; +import org.maxgamer.quickshop.QuickShop; +import org.maxgamer.quickshop.api.shop.Shop; + +import java.util.List; +import java.util.UUID; + +public class QuickShopIntegration extends Integration { + + public QuickShopIntegration() { + super("QuickShop"); + } + + public List getPlayerShops(UUID player, String key) { + if (!isEnabled()) { + return List.of(); + } + if (!player.equals(KeyManager.getKeyOwner(key))) { + return List.of(); + } + return QuickShop.getInstance().getShopManager().getPlayerAllShops(player); + } +} diff --git a/src/main/java/net/earthmc/emcapi/manager/EndpointManager.java b/src/main/java/net/earthmc/emcapi/manager/EndpointManager.java index d218f9a..2c56c15 100644 --- a/src/main/java/net/earthmc/emcapi/manager/EndpointManager.java +++ b/src/main/java/net/earthmc/emcapi/manager/EndpointManager.java @@ -5,9 +5,14 @@ import com.google.gson.JsonObject; import io.javalin.Javalin; import io.javalin.http.BadRequestResponse; -import kotlin.Pair; import net.earthmc.emcapi.EMCAPI; -import net.earthmc.emcapi.endpoint.*; +import net.earthmc.emcapi.endpoint.DiscordEndpoint; +import net.earthmc.emcapi.endpoint.LocationEndpoint; +import net.earthmc.emcapi.endpoint.MysteryMasterEndpoint; +import net.earthmc.emcapi.endpoint.NearbyEndpoint; +import net.earthmc.emcapi.endpoint.OnlineEndpoint; +import net.earthmc.emcapi.endpoint.ServerEndpoint; +import net.earthmc.emcapi.endpoint.ShopEndpoint; import net.earthmc.emcapi.endpoint.towny.NationsEndpoint; import net.earthmc.emcapi.endpoint.towny.PlayersEndpoint; import net.earthmc.emcapi.endpoint.towny.QuartersEndpoint; @@ -17,8 +22,11 @@ import net.earthmc.emcapi.endpoint.towny.list.QuartersListEndpoint; import net.earthmc.emcapi.endpoint.towny.list.TownsListEndpoint; import net.earthmc.emcapi.integration.DiscordIntegration; +import net.earthmc.emcapi.integration.MysteryMasterIntegration; import net.earthmc.emcapi.integration.QuartersIntegration; +import net.earthmc.emcapi.integration.QuickShopIntegration; import net.earthmc.emcapi.util.JSONUtil; +import org.jetbrains.annotations.Nullable; public class EndpointManager { @@ -33,24 +41,9 @@ public EndpointManager(EMCAPI plugin) { } public void loadEndpoints() { - DocumentationEndpoint documentationEndpoint = new DocumentationEndpoint(); - javalin.get("/", ctx -> ctx.json(documentationEndpoint.lookup())); - ServerEndpoint serverEndpoint = new ServerEndpoint(plugin); javalin.get(URLPath, ctx -> ctx.json(serverEndpoint.lookup())); - MysteryMasterEndpoint mysteryMasterEndpoint = new MysteryMasterEndpoint(plugin); - javalin.get(URLPath + "/mm", ctx -> { - plugin.integrations().mysteryMasterIntegration().throwIfDisabled(); - ctx.json(mysteryMasterEndpoint.lookup()); - }); - - MudkipEndpoint mudkipEndpoint = new MudkipEndpoint(); - javalin.get("/mudkip", ctx -> { - ctx.contentType("text/plain; charset=UTF-8"); - ctx.result(mudkipEndpoint.lookup()); - }); - loadPlayersEndpoint(); loadTownsEndpoint(); loadNationsEndpoint(); @@ -58,11 +51,12 @@ public void loadEndpoints() { loadLocationEndpoint(); loadNearbyEndpoint(); loadDiscordEndpoint(); - loadPlayerStatsEndpoint(); loadOnlinePlayersEndpoint(); + loadMysteryMasterEndpoint(); + loadShopsEndpoint(); } - private Pair parseBody(String body) { + private QueryBody parseBody(String body) { JsonObject jsonObject = JSONUtil.getJsonObjectFromString(body); JsonElement queryElement = jsonObject.get("query"); @@ -71,21 +65,24 @@ private Pair parseBody(String body) { JsonArray queryArray = queryElement.getAsJsonArray(); JsonElement templateElement = jsonObject.get("template"); - JsonObject templateObject = templateElement != null && templateElement.isJsonObject() - ? templateElement.getAsJsonObject() - : null; + JsonObject templateObject = templateElement != null && templateElement.isJsonObject() ? templateElement.getAsJsonObject() : null; + + JsonElement keyElement = jsonObject.get("key"); + String key = keyElement != null && keyElement.isJsonPrimitive() ? keyElement.getAsString() : null; - return new Pair<>(queryArray, templateObject); + return new QueryBody(queryArray, templateObject, key); } + private record QueryBody(JsonArray query, @Nullable JsonObject template, @Nullable String key) {} + private void loadPlayersEndpoint() { PlayersListEndpoint ple = new PlayersListEndpoint(); javalin.get(URLPath + "/players", ctx -> ctx.json(ple.lookup())); - PlayersEndpoint playersEndpoint = new PlayersEndpoint(); + PlayersEndpoint playersEndpoint = new PlayersEndpoint(plugin); javalin.post(URLPath + "/players", ctx -> { - Pair parsedBody = parseBody(ctx.body()); - ctx.json(playersEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); + QueryBody parsedBody = parseBody(ctx.body()); + ctx.json(playersEndpoint.lookup(parsedBody.query, parsedBody.template, parsedBody.key)); }); } @@ -95,8 +92,8 @@ private void loadTownsEndpoint() { TownsEndpoint townsEndpoint = new TownsEndpoint(plugin); javalin.post(URLPath + "/towns", ctx -> { - Pair parsedBody = parseBody(ctx.body()); - ctx.json(townsEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); + QueryBody parsedBody = parseBody(ctx.body()); + ctx.json(townsEndpoint.lookup(parsedBody.query, parsedBody.template, parsedBody.key)); }); } @@ -104,10 +101,10 @@ private void loadNationsEndpoint() { NationsListEndpoint nle = new NationsListEndpoint(); javalin.get(URLPath + "/nations", ctx -> ctx.json(nle.lookup())); - NationsEndpoint nationsEndpoint = new NationsEndpoint(); + NationsEndpoint nationsEndpoint = new NationsEndpoint(plugin); javalin.post(URLPath + "/nations", ctx -> { - Pair parsedBody = parseBody(ctx.body()); - ctx.json(nationsEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); + QueryBody parsedBody = parseBody(ctx.body()); + ctx.json(nationsEndpoint.lookup(parsedBody.query, parsedBody.template, parsedBody.key)); }); } @@ -120,54 +117,65 @@ private void loadQuartersEndpoint() { ctx.json(qle.lookup()); }); - QuartersEndpoint quartersEndpoint = new QuartersEndpoint(); + QuartersEndpoint quartersEndpoint = new QuartersEndpoint(plugin); javalin.post(URLPath + "/quarters", ctx -> { quartersIntegration.throwIfDisabled(); - Pair parsedBody = parseBody(ctx.body()); - ctx.json(quartersEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); + QueryBody parsedBody = parseBody(ctx.body()); + ctx.json(quartersEndpoint.lookup(parsedBody.query, parsedBody.template, parsedBody.key)); }); } private void loadLocationEndpoint() { - LocationEndpoint locationEndpoint = new LocationEndpoint(); + LocationEndpoint locationEndpoint = new LocationEndpoint(plugin); javalin.post(URLPath + "/location", ctx -> { - Pair parsedBody = parseBody(ctx.body()); - ctx.json(locationEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); + QueryBody parsedBody = parseBody(ctx.body()); + ctx.json(locationEndpoint.lookup(parsedBody.query, parsedBody.template, parsedBody.key)); }); } private void loadNearbyEndpoint() { - NearbyEndpoint nearbyEndpoint = new NearbyEndpoint(); + NearbyEndpoint nearbyEndpoint = new NearbyEndpoint(plugin); javalin.post(URLPath + "/nearby", ctx -> { - Pair parsedBody = parseBody(ctx.body()); - ctx.json(nearbyEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); + QueryBody parsedBody = parseBody(ctx.body()); + ctx.json(nearbyEndpoint.lookup(parsedBody.query, parsedBody.template, parsedBody.key)); }); } private void loadDiscordEndpoint() { - DiscordEndpoint discordEndpoint = new DiscordEndpoint(); + DiscordEndpoint discordEndpoint = new DiscordEndpoint(plugin); final DiscordIntegration discordIntegration = plugin.integrations().discordIntegration(); javalin.post(URLPath + "/discord", ctx -> { discordIntegration.throwIfDisabled(); - Pair parsedBody = parseBody(ctx.body()); - ctx.json(discordEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond())); + QueryBody parsedBody = parseBody(ctx.body()); + ctx.json(discordEndpoint.lookup(parsedBody.query, parsedBody.template, parsedBody.key)); }); } - private void loadPlayerStatsEndpoint() { - PlayerStatsEndpoint playerStatsEndpoint = new PlayerStatsEndpoint(this.plugin); - playerStatsEndpoint.initialize(); - javalin.get(URLPath + "/player-stats", ctx -> { - ctx.json(playerStatsEndpoint.latestCachedStatistics()); + private void loadOnlinePlayersEndpoint() { + OnlineEndpoint onlineEndpoint = new OnlineEndpoint(); + javalin.get(URLPath + "/online", ctx -> ctx.json(onlineEndpoint.lookup())); + } + + private void loadMysteryMasterEndpoint() { + MysteryMasterEndpoint mysteryMasterEndpoint = new MysteryMasterEndpoint(plugin); + MysteryMasterIntegration mysteryMasterIntegration = plugin.integrations().mysteryMasterIntegration(); + javalin.get(URLPath + "/mm", ctx -> { + mysteryMasterIntegration.throwIfDisabled(); + + ctx.json(mysteryMasterEndpoint.lookup()); }); } - private void loadOnlinePlayersEndpoint() { - OnlineEndpoint onlineEndpoint = new OnlineEndpoint(); - javalin.get(URLPath + "/online", ctx -> { - ctx.json(onlineEndpoint.lookup()); + private void loadShopsEndpoint() { + ShopEndpoint shopEndpoint = new ShopEndpoint(plugin); + QuickShopIntegration quickShopIntegration = plugin.integrations().quickShopIntegration();; + javalin.post(URLPath + "/shop", ctx -> { + quickShopIntegration.throwIfDisabled(); + + QueryBody parsedBody = parseBody(ctx.body()); + ctx.json(shopEndpoint.lookup(parsedBody.query, parsedBody.template, parsedBody.key)); }); } } diff --git a/src/main/java/net/earthmc/emcapi/manager/KeyManager.java b/src/main/java/net/earthmc/emcapi/manager/KeyManager.java new file mode 100644 index 0000000..8a7f2a1 --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/manager/KeyManager.java @@ -0,0 +1,119 @@ +package net.earthmc.emcapi.manager; + +import net.earthmc.emcapi.EMCAPI; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.security.SecureRandom; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Base64; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@NullMarked +public class KeyManager { + private static final Random RANDOM = new SecureRandom(); + public static final int MAX_KEY_LENGTH = 256; + public static final int KEY_BYTES = 128; + + private static final Map PLAYER_KEY_MAP = new ConcurrentHashMap<>(); + private static final Map KEY_PLAYER_MAP = new ConcurrentHashMap<>(); + + public static void loadApiKeys(final EMCAPI plugin) throws SQLException { + if (!plugin.getDatabase().ready()) { + plugin.getSLF4JLogger().warn("Unable to load API keys, database is not ready."); + return; + } + + try (final Connection connection = plugin.getDatabase().getConnection(); PreparedStatement ps = connection.prepareStatement("SELECT uuid, api_key FROM api_keys"); final ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + final UUID uuid; + try { + uuid = UUID.fromString(rs.getString("uuid")); + } catch (IllegalArgumentException e) { + plugin.getSLF4JLogger().warn("Invalid UUID format '{}' for row in the api_keys table", rs.getString("uuid")); + continue; + } + + final String key = rs.getString("api_key"); + + PLAYER_KEY_MAP.put(uuid, key); + KEY_PLAYER_MAP.put(key, uuid); + } + } + } + + public static @Nullable String getPlayerKey(UUID player) { + return PLAYER_KEY_MAP.get(player); + } + + public static String createApiKey(UUID player) { + byte[] array = new byte[KEY_BYTES]; + RANDOM.nextBytes(array); + String key = Base64.getUrlEncoder().withoutPadding().encodeToString(array); + + if (key.length() > MAX_KEY_LENGTH) { + key = key.substring(0, MAX_KEY_LENGTH); + } + + PLAYER_KEY_MAP.put(player, key); + KEY_PLAYER_MAP.put(key, player); + + final EMCAPI plugin = EMCAPI.instance; + if (!plugin.getDatabase().ready()) { + plugin.getSLF4JLogger().warn("The database has not been properly configured yet, API keys will not persist across restarts."); + return key; + } + + final String finalKey = key; + plugin.getServer().getAsyncScheduler().runNow(plugin, t -> { + try (final Connection connection = plugin.getDatabase().getConnection(); final PreparedStatement ps = connection.prepareStatement("INSERT INTO api_keys (uuid, api_key) VALUES (?, ?) ON DUPLICATE KEY UPDATE api_key = VALUES(api_key)")) { + ps.setString(1, player.toString()); + ps.setString(2, finalKey); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getSLF4JLogger().warn("Failed to insert api key for player {} into database", player, e); + } + }); + + return key; + } + + public static void deletePlayerKey(UUID player) { + String key = PLAYER_KEY_MAP.remove(player); + if (key == null) { + return; + } + + KEY_PLAYER_MAP.remove(key); + + final EMCAPI plugin = EMCAPI.instance; + if (!plugin.getDatabase().ready()) { + return; + } + + plugin.getServer().getAsyncScheduler().runNow(plugin, t -> { + try (final Connection connection = plugin.getDatabase().getConnection(); final PreparedStatement ps = connection.prepareStatement("DELETE FROM api_keys WHERE uuid = ? LIMIT 1")) { + ps.setString(1, player.toString()); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getSLF4JLogger().warn("Failed delete api key for player {} from database", player, e); + } + }); + } + + @Contract("null -> null") + public static @Nullable UUID getKeyOwner(@Nullable String key) { + if (key == null) { + return null; + } + + return KEY_PLAYER_MAP.get(key); + } +} diff --git a/src/main/java/net/earthmc/emcapi/manager/LegacyEndpointManager.java b/src/main/java/net/earthmc/emcapi/manager/LegacyEndpointManager.java new file mode 100644 index 0000000..b569ab8 --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/manager/LegacyEndpointManager.java @@ -0,0 +1,168 @@ +package net.earthmc.emcapi.manager; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.javalin.Javalin; +import io.javalin.http.BadRequestResponse; +import kotlin.Pair; +import net.earthmc.emcapi.EMCAPI; +import net.earthmc.emcapi.endpoint.DiscordEndpoint; +import net.earthmc.emcapi.endpoint.legacy.DocumentationEndpoint; +import net.earthmc.emcapi.endpoint.LocationEndpoint; +import net.earthmc.emcapi.endpoint.legacy.MudkipEndpoint; +import net.earthmc.emcapi.endpoint.MysteryMasterEndpoint; +import net.earthmc.emcapi.endpoint.NearbyEndpoint; +import net.earthmc.emcapi.endpoint.OnlineEndpoint; +import net.earthmc.emcapi.endpoint.ServerEndpoint; +import net.earthmc.emcapi.endpoint.towny.NationsEndpoint; +import net.earthmc.emcapi.endpoint.towny.PlayersEndpoint; +import net.earthmc.emcapi.endpoint.towny.QuartersEndpoint; +import net.earthmc.emcapi.endpoint.towny.TownsEndpoint; +import net.earthmc.emcapi.endpoint.towny.list.NationsListEndpoint; +import net.earthmc.emcapi.endpoint.towny.list.PlayersListEndpoint; +import net.earthmc.emcapi.endpoint.towny.list.QuartersListEndpoint; +import net.earthmc.emcapi.endpoint.towny.list.TownsListEndpoint; +import net.earthmc.emcapi.integration.DiscordIntegration; +import net.earthmc.emcapi.integration.QuartersIntegration; +import net.earthmc.emcapi.util.JSONUtil; + +public class LegacyEndpointManager { + + private final EMCAPI plugin; + private final Javalin javalin; + private final String URLPath = "v3/aurora"; + + public LegacyEndpointManager(EMCAPI plugin) { + this.plugin = plugin; + this.javalin = plugin.getJavalin(); + } + + public void loadEndpoints() { + DocumentationEndpoint documentationEndpoint = new DocumentationEndpoint(); + javalin.get("/", ctx -> ctx.json(documentationEndpoint.lookup())); + + ServerEndpoint serverEndpoint = new ServerEndpoint(plugin); + javalin.get(URLPath, ctx -> ctx.json(serverEndpoint.lookup())); + + MysteryMasterEndpoint mysteryMasterEndpoint = new MysteryMasterEndpoint(plugin); + javalin.get(URLPath + "/mm", ctx -> { + plugin.integrations().mysteryMasterIntegration().throwIfDisabled(); + ctx.json(mysteryMasterEndpoint.lookup()); + }); + + MudkipEndpoint mudkipEndpoint = new MudkipEndpoint(); + javalin.get("/mudkip", ctx -> { + ctx.contentType("text/plain; charset=UTF-8"); + ctx.result(mudkipEndpoint.lookup()); + }); + + loadPlayersEndpoint(); + loadTownsEndpoint(); + loadNationsEndpoint(); + loadQuartersEndpoint(); + loadLocationEndpoint(); + loadNearbyEndpoint(); + loadDiscordEndpoint(); + loadOnlinePlayersEndpoint(); + } + + private Pair parseBody(String body) { + JsonObject jsonObject = JSONUtil.getJsonObjectFromString(body); + + JsonElement queryElement = jsonObject.get("query"); + if (queryElement == null) throw new BadRequestResponse("No query array provided"); + if (!queryElement.isJsonArray()) throw new BadRequestResponse("Provided query is not an array"); + JsonArray queryArray = queryElement.getAsJsonArray(); + + JsonElement templateElement = jsonObject.get("template"); + JsonObject templateObject = templateElement != null && templateElement.isJsonObject() + ? templateElement.getAsJsonObject() + : null; + + return new Pair<>(queryArray, templateObject); + } + + private void loadPlayersEndpoint() { + PlayersListEndpoint ple = new PlayersListEndpoint(); + javalin.get(URLPath + "/players", ctx -> ctx.json(ple.lookup())); + + PlayersEndpoint playersEndpoint = new PlayersEndpoint(plugin); + javalin.post(URLPath + "/players", ctx -> { + Pair parsedBody = parseBody(ctx.body()); + ctx.json(playersEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond(), null)); + }); + } + + private void loadTownsEndpoint() { + TownsListEndpoint tle = new TownsListEndpoint(); + javalin.get(URLPath + "/towns", ctx -> ctx.json(tle.lookup())); + + TownsEndpoint townsEndpoint = new TownsEndpoint(plugin); + javalin.post(URLPath + "/towns", ctx -> { + Pair parsedBody = parseBody(ctx.body()); + ctx.json(townsEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond(), null)); + }); + } + + private void loadNationsEndpoint() { + NationsListEndpoint nle = new NationsListEndpoint(); + javalin.get(URLPath + "/nations", ctx -> ctx.json(nle.lookup())); + + NationsEndpoint nationsEndpoint = new NationsEndpoint(plugin); + javalin.post(URLPath + "/nations", ctx -> { + Pair parsedBody = parseBody(ctx.body()); + ctx.json(nationsEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond(), null)); + }); + } + + private void loadQuartersEndpoint() { + QuartersIntegration quartersIntegration = plugin.integrations().quartersIntegration(); + QuartersListEndpoint qle = new QuartersListEndpoint(quartersIntegration); + + javalin.get(URLPath + "/quarters", ctx -> { + quartersIntegration.throwIfDisabled(); + ctx.json(qle.lookup()); + }); + + QuartersEndpoint quartersEndpoint = new QuartersEndpoint(plugin); + javalin.post(URLPath + "/quarters", ctx -> { + quartersIntegration.throwIfDisabled(); + Pair parsedBody = parseBody(ctx.body()); + ctx.json(quartersEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond(), null)); + }); + } + + private void loadLocationEndpoint() { + LocationEndpoint locationEndpoint = new LocationEndpoint(plugin); + javalin.post(URLPath + "/location", ctx -> { + Pair parsedBody = parseBody(ctx.body()); + ctx.json(locationEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond(), null)); + }); + } + + private void loadNearbyEndpoint() { + NearbyEndpoint nearbyEndpoint = new NearbyEndpoint(plugin); + javalin.post(URLPath + "/nearby", ctx -> { + Pair parsedBody = parseBody(ctx.body()); + ctx.json(nearbyEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond(), null)); + }); + } + + private void loadDiscordEndpoint() { + DiscordEndpoint discordEndpoint = new DiscordEndpoint(plugin); + final DiscordIntegration discordIntegration = plugin.integrations().discordIntegration(); + + javalin.post(URLPath + "/discord", ctx -> { + discordIntegration.throwIfDisabled(); + + Pair parsedBody = parseBody(ctx.body()); + ctx.json(discordEndpoint.lookup(parsedBody.getFirst(), parsedBody.getSecond(), null)); + }); + } + + private void loadOnlinePlayersEndpoint() { + OnlineEndpoint onlineEndpoint = new OnlineEndpoint(); + javalin.get(URLPath + "/online", ctx -> ctx.json(onlineEndpoint.lookup())); + } +} diff --git a/src/main/java/net/earthmc/emcapi/manager/OptOut.java b/src/main/java/net/earthmc/emcapi/manager/OptOut.java new file mode 100644 index 0000000..a18d0bd --- /dev/null +++ b/src/main/java/net/earthmc/emcapi/manager/OptOut.java @@ -0,0 +1,120 @@ +package net.earthmc.emcapi.manager; + +import net.earthmc.emcapi.EMCAPI; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class OptOut { + private static final String LEGACY_OPT_OUT_FILE = "opt-out.txt"; + private final Set optedOut = ConcurrentHashMap.newKeySet(); + private final EMCAPI plugin; + + public OptOut(final EMCAPI plugin) { + this.plugin = plugin; + } + + public boolean playerOptedOut(UUID uuid) { + return optedOut.contains(uuid); + } + + public void setOptedOut(UUID uuid, boolean optedOut) { + boolean modified; + + if (optedOut) { + modified = this.optedOut.add(uuid); + } else { + modified = this.optedOut.remove(uuid); + } + + if (!modified) { + return; + } + + if (!plugin.getDatabase().ready()) { + plugin.getSLF4JLogger().warn("The database has not been properly configured yet, opt out status will not persist across restarts."); + return; + } + + plugin.getServer().getAsyncScheduler().runNow(plugin, t -> { + try (final Connection connection = plugin.getDatabase().getConnection(); final PreparedStatement ps = connection.prepareStatement(optedOut + ? "INSERT IGNORE INTO opt_out (uuid) VALUES (?)" + : "DELETE FROM opt_out WHERE uuid = ?" + )) { + ps.setString(1, uuid.toString()); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getSLF4JLogger().warn("Failed to update opt out status to {} for {}", optedOut, uuid, e); + } + }); + } + + public void loadOptOut() { + loadLegacyOptOuts(); + + try (final Connection connection = plugin.getDatabase().getConnection(); PreparedStatement ps = connection.prepareStatement("SELECT uuid FROM opt_out"); final ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + final UUID uuid; + try { + uuid = UUID.fromString(rs.getString("uuid")); + } catch (IllegalArgumentException e) { + plugin.getSLF4JLogger().warn("Invalid uuid format '{}' for value in row of table opt_out", rs.getString("uuid")); + continue; + } + + optedOut.add(uuid); + } + } catch (SQLException e) { + plugin.getSLF4JLogger().warn("Failed to load opted out players", e); + } + } + + private void loadLegacyOptOuts() { + final Path file = plugin.getDataPath().resolve(LEGACY_OPT_OUT_FILE); + if (!Files.exists(file)) { + return; + } + + if (!plugin.getDatabase().ready()) { + plugin.getSLF4JLogger().warn("Found an existing {} to migrate, but the database is not configured properly yet.", LEGACY_OPT_OUT_FILE); + return; + } + + try { + Files.readAllLines(file).forEach(playerStr -> { + try { + optedOut.add(UUID.fromString(playerStr)); + } catch (IllegalArgumentException ignored) {} + }); + } catch (IOException e) { + plugin.getSLF4JLogger().error("Failed to migrate legacy {} file", LEGACY_OPT_OUT_FILE, e); + return; + } + + try (final Connection conn = plugin.getDatabase().getConnection(); final PreparedStatement ps = conn.prepareStatement("INSERT IGNORE INTO opt_out (uuid) VALUES(?)")) { + for (final UUID uuid : optedOut) { + ps.setString(1, uuid.toString()); + ps.addBatch(); + } + + ps.executeBatch(); + } catch (SQLException e) { + plugin.getSLF4JLogger().warn("Failed to insert legacy opt outs", e); + return; + } + + try { + Files.delete(file); + } catch (IOException e) { + plugin.getSLF4JLogger().warn("Failed to delete {} after successful migration", LEGACY_OPT_OUT_FILE, e); + } + } +} diff --git a/src/main/java/net/earthmc/emcapi/object/endpoint/PostEndpoint.java b/src/main/java/net/earthmc/emcapi/object/endpoint/PostEndpoint.java index cdd4202..d20c642 100644 --- a/src/main/java/net/earthmc/emcapi/object/endpoint/PostEndpoint.java +++ b/src/main/java/net/earthmc/emcapi/object/endpoint/PostEndpoint.java @@ -5,38 +5,40 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import net.earthmc.emcapi.EMCAPI; +import org.jetbrains.annotations.Nullable; import java.util.Map; public abstract class PostEndpoint { + protected final EMCAPI plugin; - public String lookup(JsonArray queryArray, JsonObject template) { + protected PostEndpoint(final EMCAPI plugin) { + this.plugin = plugin; + } + + public String lookup(JsonArray queryArray, @Nullable JsonObject template, @Nullable String key) { JsonArray jsonArray = new JsonArray(); int numLoops = Math.min(EMCAPI.instance.getConfig().getInt("behaviour.max_lookup_size"), queryArray.size()); for (int i = 0; i < numLoops; i++) { JsonElement element = queryArray.get(i); - T object = getObjectOrNull(element); + T object = getObjectOrNull(element, key); - JsonElement innerObject; if (object == null) { continue; - } else { - innerObject = getTemplateJsonElement(object, template); } - - jsonArray.add(innerObject); + jsonArray.add(getTemplateJsonElement(object, template, key)); } return jsonArray.toString(); } - public abstract T getObjectOrNull(JsonElement element); + public abstract T getObjectOrNull(JsonElement element, @Nullable String key); - public abstract JsonElement getJsonElement(T object); + public abstract JsonElement getJsonElement(T object, @Nullable String key); - public JsonElement getTemplateJsonElement(T object, JsonObject template) { - JsonElement fullJson = getJsonElement(object); + public JsonElement getTemplateJsonElement(T object, JsonObject template, @Nullable String key) { + JsonElement fullJson = getJsonElement(object, key); if (!(fullJson instanceof JsonObject) || template == null || template.entrySet().isEmpty()) { return fullJson; @@ -46,9 +48,9 @@ public JsonElement getTemplateJsonElement(T object, JsonObject template) { JsonObject filteredJson = new JsonObject(); for (Map.Entry entry : template.entrySet()) { - String key = entry.getKey(); - if (entry.getValue() instanceof JsonPrimitive primitive && primitive.getAsBoolean() && fullJsonObject.has(key)) { - filteredJson.add(key, fullJsonObject.get(key)); + String entryKey = entry.getKey(); + if (entry.getValue() instanceof JsonPrimitive primitive && primitive.getAsBoolean() && fullJsonObject.has(entryKey)) { + filteredJson.add(entryKey, fullJsonObject.get(entryKey)); } } diff --git a/src/main/java/net/earthmc/emcapi/sse/SSEManager.java b/src/main/java/net/earthmc/emcapi/sse/SSEManager.java index 3166308..de92350 100644 --- a/src/main/java/net/earthmc/emcapi/sse/SSEManager.java +++ b/src/main/java/net/earthmc/emcapi/sse/SSEManager.java @@ -5,22 +5,27 @@ import io.javalin.http.Context; import io.javalin.http.sse.SseClient; import net.earthmc.emcapi.EMCAPI; -import net.earthmc.emcapi.util.EndpointUtils; +import net.earthmc.emcapi.manager.KeyManager; +import net.earthmc.emcapi.util.JSONUtil; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; -import javax.annotation.Nullable; import java.time.Instant; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Predicate; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; public class SSEManager { private final EMCAPI plugin; private final Javalin javalin; - private static final Map clientsMap = new ConcurrentHashMap<>(); - private static final Set clients = ConcurrentHashMap.newKeySet(); + private static final Map CLIENTS = new ConcurrentHashMap<>(); + private static final Map> CLIENTS_BY_EVENT = new ConcurrentHashMap<>(); + private static final Map CLIENTS_BY_UUID = new ConcurrentHashMap<>(); + private static final Set ALLOWED_EVENTS = Set.of( "NewDay", "NationCreated", "NationDeleted", "NationRenamed", "NationKingChanged", "NationMerged", @@ -41,30 +46,39 @@ public void loadSSE() { String auth = ctx.header("Authorization"); if (auth == null || !auth.startsWith("Bearer ")) { - ctx.status(401).result("Missing API key"); + client.sendEvent("error", msg("Missing API key")); client.close(); return; } - UUID key; - try { - key = UUID.fromString(auth.substring("Bearer ".length())); - } catch (IllegalArgumentException ignored) { - ctx.status(401).result("Invalid API key format"); - client.close(); - return; - } - - UUID owner = EndpointUtils.getKeyOwner(key); + String key = auth.substring("Bearer ".length()); + UUID owner = KeyManager.getKeyOwner(key); if (owner == null) { - ctx.status(403).result("Invalid API key"); + client.sendEvent("error", msg("Invalid API key")); client.close(); return; } - if (clientsMap.containsKey(key)) { - ctx.status(403).result("This API key is already in use."); - client.close(); - return; + + final ClientData existingClient = CLIENTS.get(key); + if (existingClient != null) { + // check if the other client is still active + // prevent sending more than one keepalive per second + final long now = System.currentTimeMillis(); + final long lastKeepAlive = existingClient.lastManualKeepAlive.getAndUpdate(prev -> + (prev == 0 || now - prev > 1000) ? now : prev + ); + + final boolean sendKeepAlive = lastKeepAlive == 0 || now - lastKeepAlive > 1000; + + if (sendKeepAlive) { + existingClient.client.sendComment("keepalive"); + } + + if (!sendKeepAlive || !existingClient.client.terminated()) { + client.sendEvent("error", msg("This API key is already in use.")); + client.close(); + return; + } } Set events = new HashSet<>(); @@ -72,6 +86,12 @@ public void loadSSE() { String listenStr = ctx.queryParam("listen"); if (listenStr != null) { + if (listenStr.length() > 10_000) { + client.sendEvent("error", msg("Attempted to listen to too many events.")); + client.close(); + return; + } + for (String event : listenStr.split(",")) { if (ALLOWED_EVENTS.contains(event)) { events.add(event); @@ -79,72 +99,113 @@ public void loadSSE() { invalid.add(event); } } - if (events.isEmpty()) { - ctx.status(400).result("No valid events specified"); - client.close(); - return; - } - } else { - events.addAll(new HashSet<>(ALLOWED_EVENTS)); } - ClientData data = new ClientData(client, events, owner); - client.keepAlive(); - client.sendEvent("open", "Connected to the EarthMC API."); - client.sendEvent("listening", "Listening to the following events: " + String.join(", ", events)); - if (!invalid.isEmpty()) { - client.sendEvent("invalid", "The following events are invalid: " + String.join(", ", invalid)); + + if (events.isEmpty()) { + client.sendEvent("error", msg("No valid events specified through the 'listen' query param.")); + client.close(); + return; } + + ClientData data = new ClientData(client, Set.copyOf(events), owner); + client.keepAlive(); + client.sendEvent("open", msg("Connected to the EarthMC API.")); + + final JsonObject listening = new JsonObject(); + listening.add("valid", JSONUtil.toJsonArray(events)); + listening.add("invalid", JSONUtil.toJsonArray(invalid)); + client.sendEvent("listening", listening); + client.onClose(() -> { - clients.remove(client); - clientsMap.remove(key, data); + CLIENTS.remove(key, data); + CLIENTS_BY_UUID.remove(data.playerID); + + for (final String event : data.events()) { + final Set dataSet = CLIENTS_BY_EVENT.get(event); + + if (dataSet != null) { + dataSet.remove(data); + } + } }); - clients.add(client); - clientsMap.put(key, data); + CLIENTS.put(key, data); + CLIENTS_BY_UUID.put(owner, data); + + for (final String event : events) { + CLIENTS_BY_EVENT.computeIfAbsent(event, k -> ConcurrentHashMap.newKeySet()).add(data); + } }); + + // when a client disconnects, we don't really know about it until the next time we try to send something to them. + // so to keep the list of clients tidy, we can periodically send a keepalive event. + plugin.getServer().getAsyncScheduler().runAtFixedRate(plugin, task -> { + for (final ClientData data : CLIENTS.values()) { + data.client.sendComment("keepalive"); + } + }, 5L, 5L, TimeUnit.MINUTES); } public void shutdown() { - for (SseClient client : clients) { - client.sendEvent("close", "EarthMC API shut down."); - client.close(); + for (ClientData data : CLIENTS.values()) { + data.client.sendEvent("close", msg("EarthMC API shutting down.")); + data.client.close(); } - clientsMap.clear(); + + CLIENTS.clear(); + CLIENTS_BY_EVENT.clear(); + CLIENTS_BY_UUID.clear(); } public void sendEvent(String event, JsonObject data) { - sendEvent(event, data, (Predicate) null); + sendEvent(event, data, null); } - public void sendEvent(String event, JsonObject data, UUID targetPlayerID) { - sendEvent(event, data, client -> client.playerID.equals(targetPlayerID)); - } + public void sendEvent(String event, JsonObject data, @Nullable UUID targetPlayerId) { + final ClientData targetClient = targetPlayerId != null ? CLIENTS_BY_UUID.get(targetPlayerId) : null; + if (targetPlayerId != null && (targetClient == null || !targetClient.events.contains(event))) { + return; // No client is active to hear it + } - public void sendEvent(String event, JsonObject data, @Nullable Predicate predicate) { - long timestamp = Instant.now().getEpochSecond(); - data.addProperty("timestamp", timestamp); + data.addProperty("timestamp", Instant.now().getEpochSecond()); String message = data.toString(); plugin.getServer().getAsyncScheduler().runNow(plugin, t -> { - for (ClientData clientData : clientsMap.values()) { - if (!clientData.events.contains(event)) { - continue; - } - if (predicate != null && !predicate.test(clientData)) - continue; + if (targetClient != null) { + targetClient.client.sendEvent(event, message); + return; + } + + final Set listeningClients = CLIENTS_BY_EVENT.getOrDefault(event, Set.of()); + + for (ClientData clientData : listeningClients) { clientData.client.sendEvent(event, message); } }); } - public static void deleteKey(UUID uuid) { - ClientData data = clientsMap.remove(uuid); + public static void deleteKey(String key) { + ClientData data = CLIENTS.remove(key); if (data != null) { SseClient client = data.client; - client.sendEvent("disconnected", "This API key was deleted by the owner"); + client.sendEvent("close", msg("This API key was deleted by the owner")); client.close(); } } - public record ClientData(SseClient client, Set events, UUID playerID) {} + private static JsonObject msg(final String message) { + final JsonObject object = new JsonObject(); + object.addProperty("message", message); + return object; + } + + public record ClientData(SseClient client, @Unmodifiable Set events, UUID playerID, AtomicLong lastManualKeepAlive) { + public ClientData(final SseClient client, final Set events, final UUID playerID) { + this(client, events, playerID, new AtomicLong()); + } + + public ClientData { + events = Set.copyOf(events); + } + } } diff --git a/src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListener.java b/src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListener.java index 2f4b885..dbbbf38 100644 --- a/src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListener.java +++ b/src/main/java/net/earthmc/emcapi/sse/listeners/TownySSEListener.java @@ -25,6 +25,9 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import java.util.Objects; +import java.util.UUID; + public class TownySSEListener extends AbstractSSEListener { public TownySSEListener(SSEManager sse) { @@ -149,12 +152,21 @@ public void onTownJoin(NationAddTownEvent event) { JsonObject message = new JsonObject(); message.add("nation", EndpointUtils.getNationJsonObject(nation)); message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); - sse.sendEvent("TownJoinedNation", message, nation.getKing().getUUID()); + try { + UUID leader = nation.getCapital() != null + ? nation.getKing().getUUID() + : event.getTown().getMayor().getUUID(); // NationAddTown is fired when new nations are created, before the capital is set. In this case, this town will be the capital anyway. + sse.sendEvent("TownJoinedNation", message, Objects.requireNonNull(leader)); + } catch (NullPointerException ignored) {} } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onTownLeave(NationRemoveTownEvent event) { Nation nation = event.getNation(); + if (nation.getKing() == null) { + return; + } + JsonObject message = new JsonObject(); message.add("nation", EndpointUtils.getNationJsonObject(nation)); message.add("town", EndpointUtils.getTownJsonObject(event.getTown())); @@ -164,6 +176,10 @@ public void onTownLeave(NationRemoveTownEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onTownJoin(TownAddResidentEvent event) { Town town = event.getTown(); + if (town.getMayor() == null) { + return; + } + JsonObject message = new JsonObject(); message.add("town", EndpointUtils.getTownJsonObject(town)); message.add("resident", EndpointUtils.getResidentJsonObject(event.getResident())); @@ -173,6 +189,10 @@ public void onTownJoin(TownAddResidentEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onTownLeave(TownRemoveResidentEvent event) { Town town = event.getTown(); + if (town.getMayor() == null) { + return; + } + JsonObject message = new JsonObject(); message.add("town", EndpointUtils.getTownJsonObject(town)); message.add("resident", EndpointUtils.getResidentJsonObject(event.getResident())); diff --git a/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java b/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java index f9b412c..164d583 100644 --- a/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java +++ b/src/main/java/net/earthmc/emcapi/util/EndpointUtils.java @@ -2,52 +2,22 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; -import com.palmergames.bukkit.towny.TownyAPI; import com.palmergames.bukkit.towny.object.Nation; import com.palmergames.bukkit.towny.object.Resident; import com.palmergames.bukkit.towny.object.Town; import com.palmergames.bukkit.towny.object.TownyPermission; -import org.bukkit.Bukkit; +import net.earthmc.emcapi.EMCAPI; import org.bukkit.Location; import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.maxgamer.quickshop.api.shop.Shop; -import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.UUID; -import java.util.Set; -import java.util.HashSet; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.concurrent.ConcurrentHashMap; public class EndpointUtils { - private static final Set optedOut = new HashSet<>(); - private static final String optOutFile = "opt-out.txt"; - private static final Map playerKeyMap = new ConcurrentHashMap<>(); - private static final Map keyPlayerMap = new ConcurrentHashMap<>(); - private static final Set apiKeys = ConcurrentHashMap.newKeySet(); - private static final String apiKeyFile = "api_keys.txt"; - - public static int getNumOnlineNomads() { - int numOnlineNomads = 0; - - for (Player player : Bukkit.getOnlinePlayers()) { - Resident resident = TownyAPI.getInstance().getResident(player); - if (resident != null && !resident.hasTown() && resident.isOnline()) { - numOnlineNomads++; - } - } - - return numOnlineNomads; - } - public static JsonObject getPermsObject(TownyPermission permissions) { JsonObject permsObject = new JsonObject(); @@ -174,7 +144,10 @@ public static JsonObject getOnlinePlayerArray(List players) { JsonArray jsonArray = new JsonArray(); for (Player player : players) { - if (playerOptedOut(player.getUniqueId())) continue; + if (EMCAPI.instance.getOptOut().playerOptedOut(player.getUniqueId())) { + continue; + } + jsonArray.add(getOnlinePlayerObject(player)); } jsonObject.addProperty("count", players.size()); @@ -192,86 +165,6 @@ public static JsonObject getOnlinePlayerObject(Player player) { return jsonObject; } - public static boolean playerOptedOut(UUID uuid) { - return optedOut.contains(uuid); - } - - public static void setOptedOut(UUID uuid, boolean status) { - if (status) { - optedOut.add(uuid); - } else { - optedOut.remove(uuid); - } - } - - public static void loadOptOut(Path path) throws IOException { - final Path file = path.resolve(optOutFile); - if (!Files.exists(file)) { - return; - } - - Files.readAllLines(file).forEach(playerStr -> { - try { - optedOut.add(UUID.fromString(playerStr)); - } catch (IllegalArgumentException ignored) {} - }); - } - - public static void saveOptOut(Path path) throws IOException { - final List lines = optedOut.stream().map(UUID::toString).toList(); - - Files.write(path.resolve(optOutFile), lines, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - } - - public static void loadApiKeys(Path path) throws IOException { - final Path file = path.resolve(apiKeyFile); - if (!Files.exists(file)) { - return; - } - - Files.readAllLines(file).forEach(result -> { - try { - String[] split = result.split(","); - if (split.length != 2) return; - UUID player = UUID.fromString(split[0]); - UUID key = UUID.fromString(split[1]); - playerKeyMap.put(player, key); - keyPlayerMap.put(key, player); - apiKeys.add(key); - } catch (IllegalArgumentException ignored) {} - }); - } - - public static void saveApiKeys(Path path) throws IOException { - final List lines = playerKeyMap.entrySet().stream().map(entry -> entry.getKey() + "," + entry.getValue()).toList(); - - Files.write(path.resolve(apiKeyFile), lines, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - } - - public static @Nullable UUID getPlayerKey(UUID player) { - return playerKeyMap.get(player); - } - - public static @NotNull UUID createApiKey(UUID player) { - UUID newID = UUID.randomUUID(); - playerKeyMap.put(player, newID); - keyPlayerMap.put(newID, player); - apiKeys.add(newID); - return newID; - } - - public static void deletePlayerKey(UUID player) { - UUID key = playerKeyMap.remove(player); - if (key != null) { - keyPlayerMap.remove(key); - apiKeys.remove(key); - } - } - - public static UUID getKeyOwner(UUID key) { - return keyPlayerMap.get(key); - } - public static JsonObject generateNameUUIDJsonObject(String name, UUID uuid) { JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("name", name); @@ -282,6 +175,7 @@ public static JsonObject generateNameUUIDJsonObject(String name, UUID uuid) { public static JsonObject getShopObject(Shop shop) { JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("item", shop.getItem().getType().name()); + jsonObject.addProperty("price", shop.getPrice()); jsonObject.addProperty("amount", shop.getItem().getAmount()); jsonObject.addProperty("type", shop.isSelling() ? "selling" : "buying"); jsonObject.addProperty("stock", shop.isSelling() ? shop.getRemainingStock() : shop.getRemainingSpace()); diff --git a/src/main/java/net/earthmc/emcapi/util/JSONUtil.java b/src/main/java/net/earthmc/emcapi/util/JSONUtil.java index 38acc3f..17284dd 100644 --- a/src/main/java/net/earthmc/emcapi/util/JSONUtil.java +++ b/src/main/java/net/earthmc/emcapi/util/JSONUtil.java @@ -1,10 +1,13 @@ package net.earthmc.emcapi.util; -import com.google.gson.*; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; import io.javalin.http.BadRequestResponse; -import java.util.List; - public class JSONUtil { public static JsonObject getJsonObjectFromString(String string) { @@ -15,16 +18,6 @@ public static JsonObject getJsonObjectFromString(String string) { } } - public static JsonArray getJsonArrayFromStringList(List stringList) { - JsonArray jsonArray = new JsonArray(); - if (stringList == null) return jsonArray; - - for (String item : stringList) { - jsonArray.add(item); - } - return jsonArray; - } - public static String getJsonElementAsStringOrNull(JsonElement element) { if (element == null) return null; @@ -60,4 +53,22 @@ public static JsonObject getJsonElementAsJsonObjectOrNull(JsonElement element) { if (!element.isJsonObject()) return null; return element.getAsJsonObject(); } + + public static JsonArray toJsonArray(final Iterable collection) { + final JsonArray array = new JsonArray(); + + for (final Object element : collection) { + switch (element) { + case Boolean bool -> array.add(bool); + case Number number -> array.add(number); + case Character character -> array.add(character); + case String string -> array.add(string); + case JsonElement jsonElement -> array.add(jsonElement); + case null -> array.add(JsonNull.INSTANCE); + default -> throw new IllegalArgumentException("unsupported collection type '" + element.getClass().getName() + "'"); + } + } + + return array; + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c026564..e8cb92b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -19,3 +19,16 @@ behaviour: # Setting for testing reasons developer_mode: false + + # Should legacy endpoints (if any) be loaded? + load_legacy: true + +database: + host: 127.0.0.1 + port: 3306 + username: 'root' + password: '' + name: emcapi + min-pool-size: 0 + max-pool-size: 1 + flags: '' diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 22f083b..def65d5 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,16 +3,20 @@ version: "${version}" main: net.earthmc.emcapi.EMCAPI api-version: "1.20" depend: [Towny] -softdepend: [MysteryMaster, Quarters, DiscordSRV, SuperbVote] +softdepend: [MysteryMaster, Quarters, DiscordSRV, SuperbVote, QuickShop] authors: [Fruitloopins] -contributors: [Warriorrr, Yoditi] +contributors: [Warriorrr, Yoditi, Veyronity] description: API for EarthMC using Javalin website: "https://github.com/EarthMC/EMCAPI/commit/${commit}" folia-supported: true -commands: - api: - description: Allows you to opt in or out of your information being visible in the API. +permissions: + emcapi.command: + default: true + description: Allow access to the /api command + emcapi.key: + default: true + description: Allow players to create API keys libraries: - io.javalin:javalin:${javalin_version}