diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8ec7807 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,18 @@ +name: Build + +on: + pull_request: + push: + branches: [master, main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + - uses: gradle/actions/setup-gradle@v4 + - run: bash gradlew test shadowJar --no-daemon diff --git a/README.md b/README.md index 86cb08e..42128a6 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,55 @@ -## What Is RestPlaceholderAPI? -RestPlaceholderAPI (RestPAPI) is a small lightweight plugin, that allows you to easily parse placeholders from an external application, like a Discord bot, or Forums. +# RestPlaceholderAPI -> :warning: **Warning:** RestPAPI Does nothing by itself, it just allows for external applications to parse placeholders via a simple Rest (http) API +RestPAPI exposes PlaceholderAPI values from each Bukkit/Paper backend over HTTP. Install PlaceholderAPI and this plugin on **each** backend you want to query. Velocity forwards Minecraft traffic, not these HTTP requests. -### Spigot Page / Downloads -https://www.spigotmc.org/resources/rest-placeholderapi.90266/ +## Configuration -### How Does it Work? -RestPAPI parses a specific placeholder, as a specific player when you make a Get request to your server, in the format: -http://backend.ip: port//, so an example would be http://example.com:8080/da8a8993-adfa-4d29-99b1-9d0f62fbb78d/player_name (returns json containing "Fredthedoggy") +On first start, `plugins/RestPAPI/config.yml` is created with two random UUID tokens. Keep them private. Example: -### Security: -RestPAPI has a List of "Tokens" in the config (It starts with 2 randomly generated Java UUIDs, but can be changed). You must send the header "Token" with the value of one of the tokens in the config, or you will get a 401 (unauthorized) message. +```yaml +port: 11001 +bind: 0.0.0.0 +tokens: + - "replace-with-a-long-random-secret" +timeout-ms: 3000 +max-concurrent: 16 +rate-limit: + requests: 60 + window-seconds: 60 +allowed-ips: [] +``` -### Plugin Support: -While it supports placeholderAPI, allowing it to support most PlaceholderAPI supported plugins, some placeholders will return an empty string, due to the fact that they cannot parse as an offline player, but will work when the player is online +Tokens must contain at least 16 characters, cannot be blank or padded with spaces, and must be unique. A missing or invalid token configuration prevents startup. To rotate tokens, temporarily include old and new tokens, run `/restpapi reload`, update clients, then remove the old token and reload again. Never print the tokens or put them in browser JavaScript. -Example Responses: +`bind` selects the interface **inside the container** (default `0.0.0.0`). `allowed-ips` is an exact match list of socket peer IPs; an empty list permits any peer with a valid token. Behind Nginx it will normally see the proxy address, not the original client. It deliberately ignores `X-Forwarded-For`. The rate limit is per socket peer and uses a fixed window; no more than 4096 distinct peers are tracked. The concurrency limit returns HTTP 503 instead of queuing unbounded lookups. Placeholder evaluation runs on the Minecraft main thread; clients receive HTTP 504 if it takes longer than `timeout-ms`. A lookup already running on the main thread cannot be interrupted. -```json -{"status":"401","message":"Unauthorized"} -``` -Without Authorization Header +The command `/restpapi reload` reads the file again, validates it before stopping the old listener, and attempts to restore the old listener if binding the new one fails. A failed rollback disables the plugin. Listener shutdown and startup take place off the Minecraft main thread; the command reports the result after they finish. In-flight lookups receive 503 during shutdown. A successful reload updates both the port and token set. + +## Requests -```json -{"status":"404","message":"Invalid URI"} +Both routes require the `Token` header and return JSON with string fields `status` and `message`: + +```bash +curl -H "Token: YOUR_SECRET" "http://127.0.0.1:11001/da8a8993-adfa-4d29-99b1-9d0f62fbb78d/player_name" +curl -H "Token: YOUR_SECRET" "http://127.0.0.1:11001/server/server_online" ``` -With The Wrong URL Scheme -```json -{"status": "200", "message":"Fredthedoggy"} +Use the placeholder name without percent signs. Unknown placeholders return 406, missing player data 400, bad UUID 400, unauthorized requests 401, blocked peers 403, unknown routes 404, excess requests 429, overload or shutdown 503, and timed out lookups 504. Empty resolved values are valid. Offline results depend on each PlaceholderAPI expansion's support for offline players. + +## Docker, Pterodactyl and external bots + +Allocate a dedicated HTTP port to **each backend** in Pterodactyl, separate from its Minecraft port. Set the plugin's `port` to that allocation's container port. For example, Minecraft may use `172.18.0.1:10001` while the same backend's REST service uses `172.18.0.1:11001`. The `172.18.0.1` gateway belongs to a Docker node and is not reachable from an external bot. The bot needs a routed private connection (for example VPN) or an HTTPS reverse proxy on the node. Do not publish the token over plain public HTTP. + +For a proxy on the same node, assign REST ports to a private/interface allocation reachable from the proxy. Confirm the effective Docker/host firewall rules restrict direct access. Example Nginx location within a TLS server: + +```nginx +location /survival/ { + proxy_pass http://172.18.0.1:11001/; +} ``` -Valid Placeholder (%player_name%) + +Then query `https://api.example.com/survival/UUID/player_name` with the `Token` header. Use separate allocations and tokens for other backends. The trailing slashes strip `/survival/` before forwarding. Restrict the proxy by the bot's IP and/or additional authentication and keep the per-backend token. Configure `allowed-ips` with the proxy's **socket peer** address if needed; when Docker publishes a port, verify which source IP reaches the container. + +## Build + +Use JDK 25 and `bash gradlew test shadowJar` (Gradle 9.8.0). The plugin targets Paper API 1.21.11; the shaded JAR is written under `build/libs`. diff --git a/build.gradle b/build.gradle index 10303fb..a686ab5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,21 +1,24 @@ import org.apache.tools.ant.filters.ReplaceTokens plugins { - id 'com.github.johnrengelman.shadow' version '7.1.2' // Reverted shadow plugin version + id 'com.gradleup.shadow' version '9.6.1' id 'java' } -sourceCompatibility = targetCompatibility = JavaVersion.VERSION_1_8 -compileJava.options.encoding 'UTF-8' - group 'me.fredthedoggy' version '1.0.5' java { + toolchain.languageVersion = JavaLanguageVersion.of(25) withJavadocJar() withSourcesJar() } +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.release = 25 +} + // Disable the default 'jar' task to ensure only the shadowJar is produced tasks.jar { enabled = false @@ -24,6 +27,7 @@ tasks.jar { shadowJar { relocate 'org.bstats', 'me.fredthedoggy.restpapi.libs.bstats' relocate 'spark', 'me.fredthedoggy.restpapi.libs.spark' + relocate 'com.google.gson', 'me.fredthedoggy.restpapi.libs.gson' archiveBaseName.set('Restpapi') archiveVersion.set('1.0.5') archiveClassifier.set('') @@ -32,8 +36,6 @@ shadowJar { // Explicitly include the main source set output for relocation from sourceSets.main.output - // Minimize the JAR to remove unused classes and potentially fix relocation issues - minimize() } // Make sure 'build' depends on 'shadowJar' @@ -41,16 +43,25 @@ build.dependsOn shadowJar repositories { mavenCentral() - maven { url = 'https://oss.sonatype.org/content/groups/public/' } - maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } + maven { url = 'https://repo.papermc.io/repository/maven-public/' } maven { url = 'https://repo.extendedclip.com/content/repositories/placeholderapi/' } } dependencies { - compileOnly 'org.spigotmc:spigot-api:1.20.4-R0.1-SNAPSHOT' - compileOnly 'me.clip:placeholderapi:2.11.5' + compileOnly 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' + compileOnly 'me.clip:placeholderapi:2.12.3' implementation 'org.bstats:bstats-bukkit:3.0.2' implementation 'com.sparkjava:spark-core:2.9.4' + implementation 'com.google.code.gson:gson:2.11.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.mockito:mockito-core:5.24.0' + testImplementation 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' + testImplementation 'me.clip:placeholderapi:2.12.3' +} + +test { + useJUnitPlatform() } processResources { @@ -61,4 +72,4 @@ processResources { exclude 'plugin.yml' filter ReplaceTokens, tokens: [version: version] } -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index 9d2780e..e1a61f9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -org.gradle.java.home=C:/Program Files/Java/jdk-17 +# Use the JDK selected by JAVA_HOME (or the CI setup-java action). diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a595206..79f3414 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/me/fredthedoggy/restpapi/RequestLimiter.java b/src/main/java/me/fredthedoggy/restpapi/RequestLimiter.java new file mode 100644 index 0000000..c44dc99 --- /dev/null +++ b/src/main/java/me/fredthedoggy/restpapi/RequestLimiter.java @@ -0,0 +1,40 @@ +package me.fredthedoggy.restpapi; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +final class RequestLimiter { + private final int limit; + private final long windowNanos; + private final Map windows = new HashMap<>(); + + RequestLimiter(int limit, int windowSeconds) { + this.limit = limit; + windowNanos = TimeUnit.SECONDS.toNanos(windowSeconds); + } + + synchronized boolean allow(String ip) { + long now = System.nanoTime(); + Window window = windows.get(ip); + if (window == null || now - window.start >= windowNanos) { + if (windows.size() >= 4096) { + Iterator iterator = windows.values().iterator(); + while (iterator.hasNext()) { + if (now - iterator.next().start >= windowNanos) iterator.remove(); + } + if (windows.size() >= 4096) return false; + } + windows.put(ip, new Window(now)); + return true; + } + return ++window.count <= limit; + } + + private static final class Window { + private final long start; + private int count = 1; + private Window(long start) { this.start = start; } + } +} diff --git a/src/main/java/me/fredthedoggy/restpapi/RestConfig.java b/src/main/java/me/fredthedoggy/restpapi/RestConfig.java new file mode 100644 index 0000000..fed3a46 --- /dev/null +++ b/src/main/java/me/fredthedoggy/restpapi/RestConfig.java @@ -0,0 +1,45 @@ +package me.fredthedoggy.restpapi; + +import org.bukkit.configuration.file.FileConfiguration; + +import java.util.HashSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +final class RestConfig { + private final int port, timeoutMillis, rateLimit, rateWindowSeconds, maxConcurrent; + private final String bind; + private final List tokens; + private final Set allowedIps; + + RestConfig(FileConfiguration yaml) { + port = yaml.getInt("port", 8080); + bind = yaml.getString("bind", "0.0.0.0"); + timeoutMillis = yaml.getInt("timeout-ms", 3000); + rateLimit = yaml.getInt("rate-limit.requests", 60); + rateWindowSeconds = yaml.getInt("rate-limit.window-seconds", 60); + maxConcurrent = yaml.getInt("max-concurrent", 16); + tokens = Collections.unmodifiableList(new ArrayList<>(yaml.getStringList("tokens"))); + allowedIps = Collections.unmodifiableSet(new HashSet<>(yaml.getStringList("allowed-ips"))); + if (port < 1 || port > 65535 || bind == null || bind.trim().isEmpty() + || timeoutMillis < 100 || timeoutMillis > 30000 + || rateLimit < 1 || rateLimit > 10000 || rateWindowSeconds < 1 + || rateWindowSeconds > 3600 || maxConcurrent < 1 || maxConcurrent > 24 + || tokens.isEmpty() || tokens.stream().anyMatch(t -> t == null || t.length() < 16 || !t.equals(t.trim())) + || new HashSet<>(tokens).size() != tokens.size() + || allowedIps.stream().anyMatch(ip -> ip == null || ip.trim().isEmpty() || !ip.equals(ip.trim()))) { + throw new IllegalArgumentException("Invalid REST configuration (port, bind, limits, tokens or allowed-ips)"); + } + } + + int port() { return port; } + String bind() { return bind; } + int timeoutMillis() { return timeoutMillis; } + int rateLimit() { return rateLimit; } + int rateWindowSeconds() { return rateWindowSeconds; } + int maxConcurrent() { return maxConcurrent; } + List tokens() { return tokens; } + Set allowedIps() { return allowedIps; } +} diff --git a/src/main/java/me/fredthedoggy/restpapi/RestPapiCommand.java b/src/main/java/me/fredthedoggy/restpapi/RestPapiCommand.java index a11b68c..294b5ef 100644 --- a/src/main/java/me/fredthedoggy/restpapi/RestPapiCommand.java +++ b/src/main/java/me/fredthedoggy/restpapi/RestPapiCommand.java @@ -1,41 +1,42 @@ package me.fredthedoggy.restpapi; -import org.bukkit.Bukkit; import org.bukkit.ChatColor; +import org.bukkit.Bukkit; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - -import java.util.logging.Level; +import java.util.concurrent.CompletableFuture; -public class RestPapiCommand implements CommandExecutor { +public final class RestPapiCommand implements CommandExecutor { + private final Restpapi plugin; - private final Restpapi restpapi; - - public RestPapiCommand(Restpapi restpapi) { - this.restpapi = restpapi; - } + RestPapiCommand(Restpapi plugin) { this.plugin = plugin; } - // This method is called, when somebody uses our command @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - if (args.length >= 1 && args[0].equals("reload")) { - restpapi.webServer.destroy(); - this.restpapi.getLoader().loadWebServer(); - if (sender instanceof Player) { - Player player = (Player) sender; - player.sendMessage(ChatColor.GREEN + "RestPAPI Config is being reloaded."); - } - Bukkit.getLogger().log(Level.INFO, "[RestPAPI] Reloading Config"); - } else { - if (sender instanceof Player) { - Player player = (Player) sender; - player.sendMessage(ChatColor.GREEN + "Run /restpapi reload to Reload RestPAPI"); + if (args.length == 1 && args[0].equalsIgnoreCase("reload")) { + CompletableFuture result = plugin.getLoader().reload(); + if (result.isDone()) { + sendResult(sender, result.getNow(false)); } else { - Bukkit.getLogger().log(Level.INFO, "[RestPAPI] Run /restpapi reload to Reload RestPAPI"); + sender.sendMessage(ChatColor.YELLOW + "RestPAPI reload in progress."); + result.whenComplete((success, error) -> { + if (!plugin.isEnabled()) return; + try { + Bukkit.getScheduler().runTask(plugin, () -> sendResult(sender, error == null && success)); + } catch (RuntimeException exception) { + plugin.getLogger().warning("Could not deliver REST reload result: " + exception.getMessage()); + } + }); } + } else { + sender.sendMessage(ChatColor.GREEN + "Usage: /" + label + " reload"); } return true; } + + private void sendResult(CommandSender sender, boolean success) { + sender.sendMessage((success ? ChatColor.GREEN : ChatColor.RED) + + (success ? "RestPAPI configuration reloaded." : "RestPAPI reload failed; check console.")); + } } diff --git a/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java b/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java index 378698a..e19a8fd 100644 --- a/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java +++ b/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java @@ -1,41 +1,121 @@ package me.fredthedoggy.restpapi; import org.bukkit.Bukkit; +import org.bukkit.configuration.file.FileConfiguration; +import java.io.File; import java.util.Arrays; -import java.util.List; import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.logging.Level; -import static java.util.UUID.randomUUID; +public final class RestPapiLoader { + private final Restpapi plugin; + private SparkWrapper webServer; + private RestConfig runningConfig; + private boolean reloading; + private boolean disabled; -public class RestPapiLoader { - private final Restpapi parent; + RestPapiLoader(Restpapi plugin) { this.plugin = plugin; } - public RestPapiLoader(Restpapi parent) { - this.parent = parent; + void enable() { + if (!new File(plugin.getDataFolder(), "config.yml").exists()) { + FileConfiguration yaml = plugin.getConfig(); + yaml.set("port", 8080); + yaml.set("bind", "0.0.0.0"); + yaml.set("tokens", Arrays.asList(UUID.randomUUID().toString(), UUID.randomUUID().toString())); + yaml.set("timeout-ms", 3000); + yaml.set("max-concurrent", 16); + yaml.set("rate-limit.requests", 60); + yaml.set("rate-limit.window-seconds", 60); + yaml.set("allowed-ips", java.util.Collections.emptyList()); + plugin.saveConfig(); + } + Objects.requireNonNull(plugin.getCommand("restpapi")).setExecutor(new RestPapiCommand(plugin)); + Objects.requireNonNull(plugin.getCommand("rpapi")).setExecutor(new RestPapiCommand(plugin)); + try { + RestConfig config = new RestConfig(plugin.getConfig()); + SparkWrapper server = new SparkWrapper(plugin, config); + server.start(); + webServer = server; + runningConfig = config; + plugin.getLogger().info("REST listening on " + config.bind() + ":" + config.port()); + } catch (RuntimeException exception) { + plugin.getLogger().log(Level.SEVERE, "REST could not start; disabling plugin", exception); + Bukkit.getPluginManager().disablePlugin(plugin); + } } - public void enable() { - this.parent.webServer = new SparkWrapper(); - Objects.requireNonNull(this.parent.getCommand("restpapi")).setExecutor(new RestPapiCommand(this.parent)); - Objects.requireNonNull(this.parent.getCommand("rpapi")).setExecutor(new RestPapiCommand(this.parent)); - loadWebServer(); + synchronized CompletableFuture reload() { + if (disabled || reloading) return CompletableFuture.completedFuture(false); + plugin.reloadConfig(); + final RestConfig next; + try { + next = new RestConfig(plugin.getConfig()); + } catch (RuntimeException exception) { + plugin.getLogger().log(Level.WARNING, "Invalid REST config; previous service remains active", exception); + return CompletableFuture.completedFuture(false); + } + reloading = true; + SparkWrapper previous = webServer; + RestConfig previousConfig = runningConfig; + return CompletableFuture.supplyAsync(() -> replace(previous, previousConfig, next)); } - public void disable() { - this.parent.webServer.destroy(); - Bukkit.getLogger().log(Level.SEVERE,"[RestPAPI] Shutting down plugin."); + private boolean replace(SparkWrapper previous, RestConfig previousConfig, RestConfig next) { + try { + if (previous != null) { + previous.stop(); + previous.awaitStop(); // Never wait for Spark workers on the Bukkit tick thread. + } + synchronized (this) { + if (disabled) return false; + } + SparkWrapper replacement = new SparkWrapper(plugin, next); + replacement.start(); + synchronized (this) { + if (disabled) { + replacement.stop(); + return false; + } + webServer = replacement; + runningConfig = next; + } + plugin.getLogger().info("REST listening on " + next.bind() + ":" + next.port()); + return true; + } catch (RuntimeException exception) { + plugin.getLogger().log(Level.SEVERE, "REST reload failed; restoring previous service", exception); + if (previousConfig != null && !isDisabled()) { + try { + SparkWrapper restored = new SparkWrapper(plugin, previousConfig); + restored.start(); + synchronized (this) { + if (disabled) restored.stop(); + else webServer = restored; + } + } catch (RuntimeException rollbackException) { + plugin.getLogger().log(Level.SEVERE, "REST rollback failed; disabling plugin", rollbackException); + Bukkit.getScheduler().runTask(plugin, () -> Bukkit.getPluginManager().disablePlugin(plugin)); + } + } + return false; + } finally { + synchronized (this) { + reloading = false; + } + } } - public void loadWebServer() { - this.parent.config.addDefault("port", 8080); - List defaultTokens = Arrays.asList(randomUUID().toString(), randomUUID().toString()); - this.parent.config.addDefault("tokens", defaultTokens); - this.parent.config.options().copyDefaults(true); - this.parent.saveConfig(); - this.parent.webServer.create(this.parent.config.getInt("port"), this.parent.config.getStringList("tokens")); - Bukkit.getLogger().log(Level.INFO,"[RestPAPI] Enabled On Port " + this.parent.config.getInt("port")); + private synchronized boolean isDisabled() { return disabled; } + + void disable() { + SparkWrapper server; + synchronized (this) { + disabled = true; + server = webServer; + webServer = null; + } + if (server != null) server.stop(); } } - diff --git a/src/main/java/me/fredthedoggy/restpapi/Restpapi.java b/src/main/java/me/fredthedoggy/restpapi/Restpapi.java index 6752dc2..dceca6f 100644 --- a/src/main/java/me/fredthedoggy/restpapi/Restpapi.java +++ b/src/main/java/me/fredthedoggy/restpapi/Restpapi.java @@ -1,26 +1,20 @@ package me.fredthedoggy.restpapi; -import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.plugin.java.JavaPlugin; public final class Restpapi extends JavaPlugin { - private RestPapiLoader loader; - FileConfiguration config = getConfig(); - SparkWrapper webServer; @Override public void onEnable() { - this.loader = new RestPapiLoader(this); - this.loader.enable(); + loader = new RestPapiLoader(this); + loader.enable(); } @Override public void onDisable() { - this.loader.disable(); + if (loader != null) loader.disable(); } - public RestPapiLoader getLoader() { - return loader; - } + public RestPapiLoader getLoader() { return loader; } } diff --git a/src/main/java/me/fredthedoggy/restpapi/SparkWrapper.java b/src/main/java/me/fredthedoggy/restpapi/SparkWrapper.java index 2b7dfb3..aec252a 100644 --- a/src/main/java/me/fredthedoggy/restpapi/SparkWrapper.java +++ b/src/main/java/me/fredthedoggy/restpapi/SparkWrapper.java @@ -1,130 +1,197 @@ package me.fredthedoggy.restpapi; +import com.google.gson.Gson; import me.clip.placeholderapi.PlaceholderAPI; import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.scheduler.BukkitTask; +import spark.Request; +import spark.Response; import spark.Service; -import java.util.List; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.UUID; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import static spark.Service.ignite; -class SparkWrapper { - Service http; - - void create(int port, List tokens) { - http = ignite().port(port); - - // New endpoint for server-wide placeholders - http.get("/server/:placeholder", (request, response) -> { - response.type("application/json"); - - String placeholderResult = PlaceholderAPI.setPlaceholders(null, "%" + request.params(":placeholder") + "%"); +final class SparkWrapper { + private static final Gson JSON = new Gson(); + private final Restpapi plugin; + private final RestConfig config; + private final Semaphore concurrent; + private final RequestLimiter limiter; + private final AtomicBoolean accepting = new AtomicBoolean(true); + private final AtomicBoolean stopped = new AtomicBoolean(false); + private final Set> pending = ConcurrentHashMap.newKeySet(); + private Service http; + + SparkWrapper(Restpapi plugin, RestConfig config) { + this.plugin = plugin; + this.config = config; + concurrent = new Semaphore(config.maxConcurrent()); + limiter = new RequestLimiter(config.rateLimit(), config.rateWindowSeconds()); + } - if (placeholderResult.equals("%" + request.params(":placeholder") + "%")) { - response.status(406); - return "{\"status\":\"406\",\"message\":\"Invalid Placeholder\"}"; - } else { - response.status(200); - return "{\"status\":\"200\",\"message\":\"" + placeholderResult + "\"}"; + void start() { + Service service = ignite(); + http = service; + AtomicReference startFailure = new AtomicReference<>(); + boolean routesRegistered = false; + try { + service.initExceptionHandler(startFailure::set); + service.untrustForwardHeaders(); + service.ipAddress(config.bind()); + service.port(config.port()); + service.threadPool(32, 4, 30000); + service.get("/server/:placeholder", (request, response) -> handle(request, response, false)); + routesRegistered = true; + service.get("/:uuid/:placeholder", (request, response) -> handle(request, response, true)); + service.notFound((request, response) -> json(response, 404, "Invalid URI")); + service.internalServerError((request, response) -> json(response, 500, "Internal Server Error")); + service.awaitInitialization(); + if (startFailure.get() != null) { + throw new IllegalStateException("HTTP listener could not bind", startFailure.get()); } - }); - - http.get("/:uuid/:placeholder", (request, response) -> { - - Bukkit.getLogger().log(Level.SEVERE, " !!! Using insecure version TOKEN DISABLED! !!!"); - Bukkit.getLogger().log(Level.SEVERE, "Token received: " + request.headers("token")); - /* - // todo: add back later - // Avoid someone spamming PlaceholderAPI requests. - - if (request.headers("token") == null) { - response.type("application/json"); - response.status(401); - return "{\"status\":\"401\",\"message\":\"Unauthorized\"}"; - } else if (tokens.stream().noneMatch(request.headers("token")::contains)) { - response.type("application/json"); - response.status(401); - return "{\"status\":\"401\",\"message\":\"Unauthorized\"}"; - } else { - */ - response.type("application/json"); - UUID specifiedUUID; - try { - specifiedUUID = UUID.fromString(request.params(":uuid")); + } catch (RuntimeException exception) { + if (routesRegistered) { + stop(); + awaitStop(); } - catch(Exception e) { - response.type("application/json"); - response.status(400); - return "{\"status\":\"400\",\"message\":\"Invalid UUID\"}"; + else { + accepting.set(false); + http = null; } - if (Bukkit.getOfflinePlayer(specifiedUUID).hasPlayedBefore()) { - - response.type("application/json"); - response.status(200); - - String placeholderResult = PlaceholderAPI.setPlaceholders( - Bukkit.getOfflinePlayer(UUID.fromString(request.params(":uuid"))), - "%" + request.params(":placeholder") + "%" - ); - - String placeholder = "{\"status\":\"200\",\"message\":\"" + placeholderResult + "\"}"; - - if (placeholderResult.equals("%" + request.params(":placeholder") + "%")) { - - response.type("application/json"); - response.status(406); - return "{\"status\":\"406\",\"message\":\"Invalid Placeholder\"}"; - - } else { - - return placeholder; + throw exception; + } + } + private String handle(Request request, Response response, boolean playerRoute) { + response.type("application/json"); + if (!accepting.get() || !plugin.isEnabled()) return json(response, 503, "Service Unavailable"); + if (!authorized(request.headers("Token"))) return json(response, 401, "Unauthorized"); + // Use the socket peer; forwarded headers can be forged unless a trusted proxy strips them. + if (!config.allowedIps().isEmpty() && !config.allowedIps().contains(request.ip())) { + return json(response, 403, "Forbidden"); + } + if (!limiter.allow(request.ip())) return json(response, 429, "Too Many Requests"); + if (!concurrent.tryAcquire()) return json(response, 503, "Service Busy"); + CompletableFuture future = new CompletableFuture<>(); + try { + String name = request.params(":placeholder"); + if (name == null || name.isEmpty() || name.length() > 256 || name.indexOf('%') >= 0) { + return json(response, 400, "Invalid Placeholder"); + } + UUID uuid = null; + if (playerRoute) { + try { + uuid = UUID.fromString(request.params(":uuid")); + } catch (IllegalArgumentException exception) { + return json(response, 400, "Invalid UUID"); } - } else { - - response.type("application/json"); - response.status(400); - return "{\"status\":\"400\",\"message\":\"Player Has Not Played Before\"}"; - } - //} - - }); - http.get("/*", (request, response) -> { - - response.status(404); - response.type("application/json"); - return "{\"status\":\"404\",\"message\":\"Invalid URI\"}"; - - }); - http.get("/*/*/*", (request, response) -> { + final UUID playerId = uuid; + final String expression = "%" + name + "%"; + track(future); + BukkitTask task = Bukkit.getScheduler().runTask(plugin, () -> { + if (!accepting.get() || future.isDone()) return; + try { + OfflinePlayer player = playerId == null ? null : Bukkit.getOfflinePlayer(playerId); + if (player != null && !player.hasPlayedBefore() && !player.isOnline()) { + future.complete(new Lookup(400, "Player Has Not Played Before")); + return; + } + String result = PlaceholderAPI.setPlaceholders(player, expression); + future.complete(resolveResult(expression, result)); + } catch (Exception exception) { + plugin.getLogger().log(Level.WARNING, "Placeholder lookup failed", exception); + future.complete(new Lookup(500, "Internal Server Error")); + } + }); + try { + Lookup result = future.get(config.timeoutMillis(), TimeUnit.MILLISECONDS); + return json(response, Integer.parseInt(result.status), result.message); + } catch (TimeoutException exception) { + future.cancel(false); + task.cancel(); + return json(response, 504, "Lookup Timed Out"); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + task.cancel(); + return json(response, 503, "Service Unavailable"); + } catch (ExecutionException exception) { + plugin.getLogger().log(Level.WARNING, "Placeholder lookup failed", exception); + return json(response, 500, "Internal Server Error"); + } + } catch (RuntimeException exception) { + plugin.getLogger().log(Level.WARNING, "Could not schedule placeholder lookup", exception); + return json(response, 503, "Service Unavailable"); + } finally { + pending.remove(future); + concurrent.release(); + } + } - response.status(404); - response.type("application/json"); - return "{\"status\":\"404\",\"message\":\"Invalid URI\"}"; + boolean authorized(String provided) { + if (provided == null || provided.isEmpty()) return false; + byte[] candidate = provided.getBytes(StandardCharsets.UTF_8); + boolean matched = false; + for (String token : config.tokens()) { + matched |= MessageDigest.isEqual(candidate, token.getBytes(StandardCharsets.UTF_8)); + } + return matched; + } - }); - http.get("/*/*/*/*", (request, response) -> { + static int placeholderStatus(String expression, String result) { + return expression.equals(result) ? 406 : 200; + } - response.status(404); - response.type("application/json"); - return "{\"status\":\"404\",\"message\":\"Invalid URI\"}"; + private static Lookup resolveResult(String expression, String result) { + int status = placeholderStatus(expression, result); + return new Lookup(status, status == 406 ? "Invalid Placeholder" : result); + } - }); - http.notFound((request, response) -> { + void track(CompletableFuture future) { + pending.add(future); + if (!accepting.get()) future.complete(new Lookup(503, "Service Unavailable")); + } - response.status(404); - response.type("application/json"); - return "{\"status\":\"404\",\"message\":\"Invalid URI\"}"; + static String json(Response response, int status, String message) { + response.status(status); + response.type("application/json"); + return JSON.toJson(new Lookup(status, message)); + } - }); + void stop() { + if (!stopped.compareAndSet(false, true)) return; + accepting.set(false); + for (CompletableFuture future : pending) { + future.complete(new Lookup(503, "Service Unavailable")); + } + if (http != null) http.stop(); } - void destroy() { - http.stop(); - Bukkit.getLogger().log(Level.WARNING, "[RestPAPI] Disabled Webserver"); + void awaitStop() { + if (http != null) http.awaitStop(); } -} \ No newline at end of file + static final class Lookup { + private final String status; + private final String message; + + Lookup(int status, String message) { + this.status = Integer.toString(status); + this.message = message; + } + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index ecbba46..910f33a 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,7 +1,7 @@ name: RestPAPI version: 9.9 main: me.fredthedoggy.restpapi.Restpapi -api-version: 1.13 +api-version: '1.21.11' prefix: RestPAPI load: STARTUP authors: [ Fredthedoggy ] @@ -14,4 +14,4 @@ commands: rpapi: description: Rest Papi Main Command usage: /rpapi - permission: restpapi.admin \ No newline at end of file + permission: restpapi.admin diff --git a/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java b/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java new file mode 100644 index 0000000..ce86656 --- /dev/null +++ b/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java @@ -0,0 +1,95 @@ +package me.fredthedoggy.restpapi; + +import com.google.gson.JsonParser; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import spark.Response; + +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RestSecurityTest { + private YamlConfiguration config() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("tokens", Collections.singletonList("12345678-1234-1234-1234-123456789abc")); + return yaml; + } + + @Test + void tokensMustMatchExactly() { + SparkWrapper server = new SparkWrapper(mock(Restpapi.class), new RestConfig(config())); + assertTrue(server.authorized("12345678-1234-1234-1234-123456789abc")); + assertFalse(server.authorized("prefix12345678-1234-1234-1234-123456789abc")); + assertFalse(server.authorized("12345678-1234-1234-1234-123456789abc suffix")); + assertFalse(server.authorized(null)); + assertFalse(server.authorized("")); + } + + @Test + void invalidOrMissingTokensFailClosed() { + YamlConfiguration yaml = config(); + yaml.set("tokens", Arrays.asList("valid-valid-valid-valid", "")); + assertThrows(IllegalArgumentException.class, () -> new RestConfig(yaml)); + yaml.set("tokens", Collections.emptyList()); + assertThrows(IllegalArgumentException.class, () -> new RestConfig(yaml)); + yaml.set("tokens", Collections.singletonList("short")); + assertThrows(IllegalArgumentException.class, () -> new RestConfig(yaml)); + } + + @Test + void serializesPlaceholderTextAndKeepsLegacyStatus() { + Response response = mock(Response.class); + String message = "Name: \"Thiago\" \\ test\nline"; + String payload = SparkWrapper.json(response, 200, message); + assertEquals(message, JsonParser.parseString(payload).getAsJsonObject().get("message").getAsString()); + assertEquals("200", JsonParser.parseString(payload).getAsJsonObject().get("status").getAsString()); + verify(response).type("application/json"); + verify(response).status(200); + assertEquals("", JsonParser.parseString(SparkWrapper.json(response, 200, "")) + .getAsJsonObject().get("message").getAsString()); + } + + @Test + void limitsRequestsPerPeer() { + RequestLimiter limiter = new RequestLimiter(2, 60); + assertTrue(limiter.allow("192.0.2.1")); + assertTrue(limiter.allow("192.0.2.1")); + assertFalse(limiter.allow("192.0.2.1")); + assertTrue(limiter.allow("192.0.2.2")); + } + + @Test + void distinguishesUnknownPlaceholderFromEmptyValue() { + assertEquals(406, SparkWrapper.placeholderStatus("%unknown%", "%unknown%")); + assertEquals(200, SparkWrapper.placeholderStatus("%known%", "")); + assertEquals(200, SparkWrapper.placeholderStatus("%known%", "value")); + } + + @Test + void rejectsOutOfRangeNetworkSettings() { + YamlConfiguration yaml = config(); + yaml.set("port", 65536); + assertThrows(IllegalArgumentException.class, () -> new RestConfig(yaml)); + yaml.set("port", 8080); + yaml.set("max-concurrent", 1000); + assertThrows(IllegalArgumentException.class, () -> new RestConfig(yaml)); + } + + @Test + void shutdownUnblocksAnAwaitingHttpRequestWithoutRunningItsBukkitTask() throws Exception { + SparkWrapper server = new SparkWrapper(mock(Restpapi.class), new RestConfig(config())); + CompletableFuture lookup = new CompletableFuture<>(); + server.track(lookup); + server.stop(); + assertNotNull(lookup.get(100, TimeUnit.MILLISECONDS)); + + CompletableFuture lateLookup = new CompletableFuture<>(); + server.track(lateLookup); + assertNotNull(lateLookup.get(100, TimeUnit.MILLISECONDS)); + } +}