diff --git a/README.md b/README.md index 5606f9b..5148db2 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ 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. +## HTTP server + +RestPAPI embeds **Jetty 12.1** directly. Spark Java is not used. + +Jetty keeps its internal server work on its platform-thread pool and dispatches blocking application work through a Java virtual-thread executor. The REST handlers are declared as blocking handlers, so offline-player resolution and PlaceholderAPI evaluation can run on virtual threads without occupying the Minecraft server thread. + +Routing uses Jetty's native `PathMappingsHandler` and `UriTemplatePathSpec`. Request concurrency is enforced by `QoSHandler`, and source-address allowlisting is enforced by `InetAccessHandler`. + ## Configuration On first start, `plugins/RestPAPI/config.yml` is created with two random UUID tokens. Keep them private. Example: @@ -12,6 +20,7 @@ bind: 0.0.0.0 tokens: - "replace-with-a-long-random-secret" max-concurrent: 16 +shutdown-timeout-ms: 5000 rate-limit: requests: 60 window-seconds: 60 @@ -20,11 +29,17 @@ allowed-ips: [] 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. -`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. +`bind` selects the interface **inside the container** (default `0.0.0.0`). `allowed-ips` is enforced by Jetty against the real remote address of the connection rather than forwarded headers. An empty list permits any peer with a valid token. Values may use Jetty address patterns such as an exact address or CIDR range. Behind Nginx, RestPAPI will normally see the proxy address unless the network topology preserves the original peer address; it deliberately does not trust `X-Forwarded-For` for access control. + +`max-concurrent` is enforced by Jetty's `QoSHandler`. Requests beyond the configured number are rejected immediately with HTTP 503 instead of accumulating an unbounded queue. Jetty's virtual-thread executor has its own resource guard, while `max-concurrent` remains the application-level limit for REST requests. `shutdown-timeout-ms` controls how long Jetty waits for in-flight requests to finish during reload or shutdown; new requests are rejected with HTTP 503 once graceful shutdown begins. + +The fixed-window rate limit remains implemented by RestPAPI because Jetty's built-in DoS rate limiting uses per-second/leaky-bucket semantics rather than the existing `requests` plus arbitrary `window-seconds` contract. No more than 4096 distinct peers are tracked by the fixed-window limiter. + +Placeholder evaluation, including `Bukkit.getOfflinePlayer(UUID)`, is never scheduled onto the Minecraft main thread. Both player and server placeholder routes execute from Jetty request handling. Third-party PlaceholderAPI expansions queried through this API must therefore support off-thread evaluation; PlaceholderAPI does not make expansion code thread-safe automatically. -Placeholder evaluation, including `Bukkit.getOfflinePlayer(UUID)`, runs directly on Spark's Jetty worker thread and is not scheduled onto the Minecraft main thread. This keeps offline-player lookups and PlaceholderAPI evaluation off the server tick thread, but it also means every expansion queried through this API must support off-thread execution. PlaceholderAPI does not make third-party expansions thread-safe automatically. There is no server-side lookup timeout because a synchronous expansion running on the current Spark worker cannot be safely preempted; use `max-concurrent` to bound concurrent evaluations and configure request timeouts in the HTTP client or reverse proxy. +There is no server-side placeholder timeout because synchronous third-party expansion code cannot be safely preempted. Configure request timeouts in the HTTP client or reverse proxy and use `max-concurrent` to bound simultaneous evaluations. -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. Requests that begin after shutdown starts receive 503. A successful reload updates both the port and token set. +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. Reload lifecycle work runs on a dedicated Java virtual-thread executor. Jetty's `GracefulHandler` drains in-flight requests for up to `shutdown-timeout-ms` while rejecting new requests with 503. A failed rollback disables the plugin. A successful reload updates the port, bind address, tokens, limits, graceful-shutdown timeout, and allowlist. ## Requests @@ -53,4 +68,4 @@ Then query `https://api.example.com/survival/UUID/player_name` with the `Token` ## 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`. +Use JDK 25 and `bash gradlew test shadowJar` (Gradle 9.8.0). The plugin targets Paper API 1.21.11 and embeds Jetty 12.1.13; the shaded JAR is written under `build/libs`. diff --git a/build.gradle b/build.gradle index a686ab5..e853b9c 100644 --- a/build.gradle +++ b/build.gradle @@ -26,16 +26,14 @@ tasks.jar { shadowJar { relocate 'org.bstats', 'me.fredthedoggy.restpapi.libs.bstats' - relocate 'spark', 'me.fredthedoggy.restpapi.libs.spark' + relocate 'org.eclipse.jetty', 'me.fredthedoggy.restpapi.libs.jetty' relocate 'com.google.gson', 'me.fredthedoggy.restpapi.libs.gson' archiveBaseName.set('Restpapi') archiveVersion.set('1.0.5') archiveClassifier.set('') - // Removed manifest block as it's not needed for Bukkit plugins // Explicitly include the main source set output for relocation from sourceSets.main.output - } // Make sure 'build' depends on 'shadowJar' @@ -51,7 +49,9 @@ dependencies { 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('org.eclipse.jetty:jetty-server:12.1.13') { + exclude group: 'org.slf4j', module: 'slf4j-api' + } 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' diff --git a/src/main/java/me/fredthedoggy/restpapi/JettyServer.java b/src/main/java/me/fredthedoggy/restpapi/JettyServer.java new file mode 100644 index 0000000..51af67b --- /dev/null +++ b/src/main/java/me/fredthedoggy/restpapi/JettyServer.java @@ -0,0 +1,246 @@ +package me.fredthedoggy.restpapi; + +import com.google.gson.Gson; +import me.clip.placeholderapi.PlaceholderAPI; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.pathmap.UriTemplatePathSpec; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.GracefulHandler; +import org.eclipse.jetty.server.handler.InetAccessHandler; +import org.eclipse.jetty.server.handler.PathMappingsHandler; +import org.eclipse.jetty.server.handler.QoSHandler; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.eclipse.jetty.util.thread.VirtualThreadPool; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; + +final class JettyServer { + private static final Gson JSON = new Gson(); + private static final UriTemplatePathSpec SERVER_ROUTE = new UriTemplatePathSpec("/server/{placeholder}"); + private static final UriTemplatePathSpec PLAYER_ROUTE = new UriTemplatePathSpec("/{uuid}/{placeholder}"); + + private final Restpapi plugin; + private final RestConfig config; + private final RequestLimiter limiter; + private final AtomicBoolean stopped = new AtomicBoolean(false); + private Server server; + + JettyServer(Restpapi plugin, RestConfig config) { + this.plugin = plugin; + this.config = config; + limiter = new RequestLimiter(config.rateLimit(), config.rateWindowSeconds()); + } + + void start() { + Server jetty = null; + try { + VirtualThreadPool virtualThreads = new VirtualThreadPool(); + virtualThreads.setName("restpapi-virtual"); + + QueuedThreadPool platformThreads = new QueuedThreadPool(); + platformThreads.setName("restpapi-jetty"); + platformThreads.setVirtualThreadsExecutor(virtualThreads); + + jetty = new Server(platformThreads); + jetty.addBean(virtualThreads); + jetty.setStopTimeout(config.shutdownTimeoutMillis()); + + ServerConnector connector = new ServerConnector(jetty); + connector.setHost(config.bind()); + connector.setPort(config.port()); + jetty.addConnector(connector); + + PathMappingsHandler routes = new PathMappingsHandler.NoContext(); + routes.addMapping(SERVER_ROUTE, new PlaceholderHandler(SERVER_ROUTE, false)); + routes.addMapping(PLAYER_ROUTE, new PlaceholderHandler(PLAYER_ROUTE, true)); + + Handler routed = new Handler.Sequence(routes, new NotFoundHandler()); + + JsonQoSHandler qos = new JsonQoSHandler(routed); + qos.setMaxRequestCount(config.maxConcurrent()); + qos.setMaxSuspendedRequestCount(0); + qos.setRejectStatusCode(HttpStatus.SERVICE_UNAVAILABLE_503); + + Handler protectedHandler = qos; + if (!config.allowedIps().isEmpty()) { + JsonInetAccessHandler access = new JsonInetAccessHandler(protectedHandler); + access.include(config.allowedIps().toArray(String[]::new)); + protectedHandler = access; + } + + jetty.setHandler(new JsonGracefulHandler(protectedHandler)); + jetty.start(); + server = jetty; + } catch (Exception exception) { + if (jetty != null) { + try { + jetty.stop(); + } catch (Exception stopException) { + exception.addSuppressed(stopException); + } + } + throw new IllegalStateException("Jetty HTTP server could not start", exception); + } + } + + private final class PlaceholderHandler extends Handler.Abstract { + private final UriTemplatePathSpec route; + private final boolean playerRoute; + + private PlaceholderHandler(UriTemplatePathSpec route, boolean playerRoute) { + this.route = route; + this.playerRoute = playerRoute; + } + + @Override + public boolean handle(Request request, Response response, Callback callback) { + if (!"GET".equals(request.getMethod())) { + return writeJson(response, callback, 404, "Invalid URI"); + } + if (!plugin.isEnabled()) { + return writeJson(response, callback, 503, "Service Unavailable"); + } + if (!authorized(request.getHeaders().get("Token"))) { + return writeJson(response, callback, 401, "Unauthorized"); + } + + String peer = Request.getRemoteAddr(request); + if (!limiter.allow(peer)) { + return writeJson(response, callback, 429, "Too Many Requests"); + } + + try { + Map params = route.getPathParams(Request.getPathInContext(request)); + String name = params.get("placeholder"); + if (name == null || name.isEmpty() || name.length() > 256 || name.indexOf('%') >= 0) { + return writeJson(response, callback, 400, "Invalid Placeholder"); + } + + OfflinePlayer player = null; + if (playerRoute) { + UUID playerId; + try { + playerId = UUID.fromString(params.get("uuid")); + } catch (IllegalArgumentException | NullPointerException exception) { + return writeJson(response, callback, 400, "Invalid UUID"); + } + + player = Bukkit.getOfflinePlayer(playerId); + if (!player.hasPlayedBefore() && !player.isOnline()) { + return writeJson(response, callback, 400, "Player Has Not Played Before"); + } + } + + String expression = "%" + name + "%"; + String result = PlaceholderAPI.setPlaceholders(player, expression); + if (!plugin.isEnabled()) { + return writeJson(response, callback, 503, "Service Unavailable"); + } + + int status = placeholderStatus(expression, result); + return writeJson(response, callback, status, status == 406 ? "Invalid Placeholder" : result); + } catch (Exception exception) { + plugin.getLogger().log(Level.WARNING, "Placeholder lookup failed", exception); + return writeJson(response, callback, 500, "Internal Server Error"); + } + } + } + + 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; + } + + static int placeholderStatus(String expression, String result) { + return expression.equals(result) ? 406 : 200; + } + + static String jsonPayload(int status, String message) { + return JSON.toJson(new Lookup(status, message)); + } + + private static boolean writeJson(Response response, Callback callback, int status, String message) { + response.setStatus(status); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "application/json; charset=UTF-8"); + Content.Sink.write(response, true, jsonPayload(status, message), callback); + return true; + } + + void stop() { + if (!stopped.compareAndSet(false, true)) return; + Server current = server; + server = null; + if (current == null) return; + + try { + current.stop(); + } catch (Exception exception) { + plugin.getLogger().log(Level.WARNING, "Jetty HTTP server did not stop cleanly", exception); + } + } + + private static final class JsonGracefulHandler extends GracefulHandler { + private JsonGracefulHandler(Handler handler) { + super(handler); + } + + @Override + protected void handleShutdownRejection(Request request, Response response, Callback callback) { + writeJson(response, callback, 503, "Service Unavailable"); + } + } + + private static final class JsonQoSHandler extends QoSHandler { + private JsonQoSHandler(Handler handler) { + super(handler); + } + + @Override + protected void reject(Request request, Response response, Callback callback, int status) { + writeJson(response, callback, status, "Service Busy"); + } + } + + private static final class JsonInetAccessHandler extends InetAccessHandler { + private JsonInetAccessHandler(Handler handler) { + super(handler); + } + + @Override + protected boolean onConditionsNotMet(Request request, Response response, Callback callback) { + return writeJson(response, callback, 403, "Forbidden"); + } + } + + private static final class NotFoundHandler extends Handler.Abstract.NonBlocking { + @Override + public boolean handle(Request request, Response response, Callback callback) { + return writeJson(response, callback, 404, "Invalid URI"); + } + } + + private record Lookup(String status, String message) { + private Lookup(int status, String message) { + this(Integer.toString(status), message); + } + } +} diff --git a/src/main/java/me/fredthedoggy/restpapi/NoLogging.java b/src/main/java/me/fredthedoggy/restpapi/NoLogging.java deleted file mode 100644 index 02c3dd1..0000000 --- a/src/main/java/me/fredthedoggy/restpapi/NoLogging.java +++ /dev/null @@ -1,54 +0,0 @@ -package me.fredthedoggy.restpapi; - -import org.eclipse.jetty.util.log.Logger; - -public class NoLogging implements Logger { - public String getName() { - return "no"; - } - - public void warn(String msg, Object... args) { - } - - public void warn(Throwable thrown) { - } - - public void warn(String msg, Throwable thrown) { - } - - public void info(String msg, Object... args) { - } - - public void info(Throwable thrown) { - } - - public void info(String msg, Throwable thrown) { - } - - public boolean isDebugEnabled() { - return false; - } - - public void setDebugEnabled(boolean enabled) { - } - - public void debug(String msg, Object... args) { - } - - @Override - public void debug(String msg, long value) { - } - - public void debug(Throwable thrown) { - } - - public void debug(String msg, Throwable thrown) { - } - - public Logger getLogger(String name) { - return this; - } - - public void ignore(Throwable ignored) { - } -} diff --git a/src/main/java/me/fredthedoggy/restpapi/RestConfig.java b/src/main/java/me/fredthedoggy/restpapi/RestConfig.java index 07a429b..9881419 100644 --- a/src/main/java/me/fredthedoggy/restpapi/RestConfig.java +++ b/src/main/java/me/fredthedoggy/restpapi/RestConfig.java @@ -9,7 +9,7 @@ import java.util.Set; final class RestConfig { - private final int port, rateLimit, rateWindowSeconds, maxConcurrent; + private final int port, rateLimit, rateWindowSeconds, maxConcurrent, shutdownTimeoutMillis; private final String bind; private final List tokens; private final Set allowedIps; @@ -20,11 +20,13 @@ final class RestConfig { rateLimit = yaml.getInt("rate-limit.requests", 60); rateWindowSeconds = yaml.getInt("rate-limit.window-seconds", 60); maxConcurrent = yaml.getInt("max-concurrent", 16); + shutdownTimeoutMillis = yaml.getInt("shutdown-timeout-ms", 5000); 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() || rateLimit < 1 || rateLimit > 10000 || rateWindowSeconds < 1 || rateWindowSeconds > 3600 || maxConcurrent < 1 || maxConcurrent > 24 + || shutdownTimeoutMillis < 100 || shutdownTimeoutMillis > 30000 || 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()))) { @@ -37,6 +39,7 @@ final class RestConfig { int rateLimit() { return rateLimit; } int rateWindowSeconds() { return rateWindowSeconds; } int maxConcurrent() { return maxConcurrent; } + int shutdownTimeoutMillis() { return shutdownTimeoutMillis; } List tokens() { return tokens; } Set allowedIps() { return allowedIps; } } diff --git a/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java b/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java index d3523c8..5c17177 100644 --- a/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java +++ b/src/main/java/me/fredthedoggy/restpapi/RestPapiLoader.java @@ -8,11 +8,15 @@ import java.util.Objects; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.logging.Level; public final class RestPapiLoader { private final Restpapi plugin; - private SparkWrapper webServer; + private final ExecutorService lifecycleExecutor = + Executors.newSingleThreadExecutor(Thread.ofVirtual().name("restpapi-lifecycle-", 0).factory()); + private JettyServer webServer; private RestConfig runningConfig; private boolean reloading; private boolean disabled; @@ -26,6 +30,7 @@ void enable() { yaml.set("bind", "0.0.0.0"); yaml.set("tokens", Arrays.asList(UUID.randomUUID().toString(), UUID.randomUUID().toString())); yaml.set("max-concurrent", 16); + yaml.set("shutdown-timeout-ms", 5000); yaml.set("rate-limit.requests", 60); yaml.set("rate-limit.window-seconds", 60); yaml.set("allowed-ips", java.util.Collections.emptyList()); @@ -35,7 +40,7 @@ void enable() { Objects.requireNonNull(plugin.getCommand("rpapi")).setExecutor(new RestPapiCommand(plugin)); try { RestConfig config = new RestConfig(plugin.getConfig()); - SparkWrapper server = new SparkWrapper(plugin, config); + JettyServer server = new JettyServer(plugin, config); server.start(); webServer = server; runningConfig = config; @@ -57,21 +62,19 @@ synchronized CompletableFuture reload() { return CompletableFuture.completedFuture(false); } reloading = true; - SparkWrapper previous = webServer; + JettyServer previous = webServer; RestConfig previousConfig = runningConfig; - return CompletableFuture.supplyAsync(() -> replace(previous, previousConfig, next)); + return CompletableFuture.supplyAsync(() -> replace(previous, previousConfig, next), lifecycleExecutor); } - private boolean replace(SparkWrapper previous, RestConfig previousConfig, RestConfig next) { + private boolean replace(JettyServer previous, RestConfig previousConfig, RestConfig next) { try { - if (previous != null) { - previous.stop(); - previous.awaitStop(); // Never wait for Spark workers on the Bukkit tick thread. - } + if (previous != null) previous.stop(); synchronized (this) { if (disabled) return false; } - SparkWrapper replacement = new SparkWrapper(plugin, next); + + JettyServer replacement = new JettyServer(plugin, next); replacement.start(); synchronized (this) { if (disabled) { @@ -87,7 +90,7 @@ private boolean replace(SparkWrapper previous, RestConfig previousConfig, RestCo plugin.getLogger().log(Level.SEVERE, "REST reload failed; restoring previous service", exception); if (previousConfig != null && !isDisabled()) { try { - SparkWrapper restored = new SparkWrapper(plugin, previousConfig); + JettyServer restored = new JettyServer(plugin, previousConfig); restored.start(); synchronized (this) { if (disabled) restored.stop(); @@ -109,12 +112,13 @@ private boolean replace(SparkWrapper previous, RestConfig previousConfig, RestCo private synchronized boolean isDisabled() { return disabled; } void disable() { - SparkWrapper server; + JettyServer server; synchronized (this) { disabled = true; server = webServer; webServer = null; } if (server != null) server.stop(); + lifecycleExecutor.shutdown(); } } diff --git a/src/main/java/me/fredthedoggy/restpapi/SparkWrapper.java b/src/main/java/me/fredthedoggy/restpapi/SparkWrapper.java deleted file mode 100644 index 6ab9ec1..0000000 --- a/src/main/java/me/fredthedoggy/restpapi/SparkWrapper.java +++ /dev/null @@ -1,165 +0,0 @@ -package me.fredthedoggy.restpapi; - -import com.google.gson.Gson; -import me.clip.placeholderapi.PlaceholderAPI; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; -import spark.Request; -import spark.Response; -import spark.Service; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.UUID; -import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.logging.Level; - -import static spark.Service.ignite; - -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 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()); - } - - 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()); - } - } catch (RuntimeException exception) { - if (routesRegistered) { - stop(); - awaitStop(); - } else { - accepting.set(false); - http = null; - } - 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"); - - try { - String name = request.params(":placeholder"); - if (name == null || name.isEmpty() || name.length() > 256 || name.indexOf('%') >= 0) { - return json(response, 400, "Invalid Placeholder"); - } - - UUID playerId = null; - if (playerRoute) { - try { - playerId = UUID.fromString(request.params(":uuid")); - } catch (IllegalArgumentException exception) { - return json(response, 400, "Invalid UUID"); - } - } - - String expression = "%" + name + "%"; - OfflinePlayer player = playerId == null ? null : Bukkit.getOfflinePlayer(playerId); - if (player != null && !player.hasPlayedBefore() && !player.isOnline()) { - return json(response, 400, "Player Has Not Played Before"); - } - - // Spark invokes routes on its Jetty worker pool. Keep both OfflinePlayer lookup and - // PlaceholderAPI evaluation on that worker instead of moving the request onto the - // Minecraft main thread. Placeholder expansions queried through this endpoint must - // therefore support off-thread evaluation. - String result = PlaceholderAPI.setPlaceholders(player, expression); - if (!accepting.get() || !plugin.isEnabled()) { - return json(response, 503, "Service Unavailable"); - } - - Lookup lookup = resolveResult(expression, result); - return json(response, Integer.parseInt(lookup.status), lookup.message); - } catch (Exception exception) { - plugin.getLogger().log(Level.WARNING, "Placeholder lookup failed", exception); - return json(response, 500, "Internal Server Error"); - } finally { - concurrent.release(); - } - } - - 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; - } - - static int placeholderStatus(String expression, String result) { - return expression.equals(result) ? 406 : 200; - } - - private static Lookup resolveResult(String expression, String result) { - int status = placeholderStatus(expression, result); - return new Lookup(status, status == 406 ? "Invalid Placeholder" : result); - } - - 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); - if (http != null) http.stop(); - } - - void awaitStop() { - if (http != null) http.awaitStop(); - } - - 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/log4j.properties b/src/main/resources/log4j.properties deleted file mode 100644 index 9e45be3..0000000 --- a/src/main/resources/log4j.properties +++ /dev/null @@ -1,4 +0,0 @@ -log4j.rootLogger=ERROR, Console, LogFile -log4j.appender.Console=org.apache.log4j.ConsoleAppender -log4j.appender.Console.layout=org.apache.log4j.PatternLayout -log4j.appender.Console.layout.ConversionPattern=%d{ISO8601} [%t] %-5p %C - %m%n \ No newline at end of file diff --git a/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java b/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java index a444ecf..481b3fd 100644 --- a/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java +++ b/src/test/java/me/fredthedoggy/restpapi/RestSecurityTest.java @@ -3,13 +3,12 @@ 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 static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; class RestSecurityTest { private YamlConfiguration config() { @@ -20,7 +19,7 @@ private YamlConfiguration config() { @Test void tokensMustMatchExactly() { - SparkWrapper server = new SparkWrapper(mock(Restpapi.class), new RestConfig(config())); + JettyServer server = new JettyServer(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")); @@ -41,14 +40,11 @@ void invalidOrMissingTokensFailClosed() { @Test void serializesPlaceholderTextAndKeepsLegacyStatus() { - Response response = mock(Response.class); String message = "Name: \"Thiago\" \\ test\nline"; - String payload = SparkWrapper.json(response, 200, message); + String payload = JettyServer.jsonPayload(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, "")) + assertEquals("", JsonParser.parseString(JettyServer.jsonPayload(200, "")) .getAsJsonObject().get("message").getAsString()); } @@ -63,9 +59,9 @@ void limitsRequestsPerPeer() { @Test void distinguishesUnknownPlaceholderFromEmptyValue() { - assertEquals(406, SparkWrapper.placeholderStatus("%unknown%", "%unknown%")); - assertEquals(200, SparkWrapper.placeholderStatus("%known%", "")); - assertEquals(200, SparkWrapper.placeholderStatus("%known%", "value")); + assertEquals(406, JettyServer.placeholderStatus("%unknown%", "%unknown%")); + assertEquals(200, JettyServer.placeholderStatus("%known%", "")); + assertEquals(200, JettyServer.placeholderStatus("%known%", "value")); } @Test @@ -76,11 +72,14 @@ void rejectsOutOfRangeNetworkSettings() { yaml.set("port", 8080); yaml.set("max-concurrent", 1000); assertThrows(IllegalArgumentException.class, () -> new RestConfig(yaml)); + yaml.set("max-concurrent", 16); + yaml.set("shutdown-timeout-ms", 0); + assertThrows(IllegalArgumentException.class, () -> new RestConfig(yaml)); } @Test void shutdownIsIdempotentBeforeHttpStartup() { - SparkWrapper server = new SparkWrapper(mock(Restpapi.class), new RestConfig(config())); + JettyServer server = new JettyServer(mock(Restpapi.class), new RestConfig(config())); assertDoesNotThrow(server::stop); assertDoesNotThrow(server::stop); }