|
| 1 | +package dev.zenith.ppapi.api; |
| 2 | + |
| 3 | +import com.fasterxml.jackson.databind.DeserializationFeature; |
| 4 | +import com.google.common.cache.Cache; |
| 5 | +import com.google.common.cache.CacheBuilder; |
| 6 | +import com.zenith.Globals; |
| 7 | +import com.zenith.command.api.CommandContext; |
| 8 | +import dev.zenith.ppapi.api.model.ApiErrorResponse; |
| 9 | +import dev.zenith.ppapi.api.model.PearlLoadRequest; |
| 10 | +import dev.zenith.ppapi.api.model.PearlLoadResponse; |
| 11 | +import dev.zenith.ppapi.api.model.PearlStatusRequest; |
| 12 | +import dev.zenith.ppapi.api.model.PearlStatusResponse; |
| 13 | +import io.javalin.Javalin; |
| 14 | +import io.javalin.json.JavalinJackson; |
| 15 | +import org.eclipse.jetty.util.thread.ExecutorThreadPool; |
| 16 | + |
| 17 | +import java.util.ArrayList; |
| 18 | +import java.util.LinkedHashSet; |
| 19 | +import java.util.List; |
| 20 | +import java.util.Set; |
| 21 | +import java.util.concurrent.TimeUnit; |
| 22 | + |
| 23 | +import static dev.zenith.ppapi.PPApiPlugin.LOG; |
| 24 | +import static dev.zenith.ppapi.PPApiPlugin.PLUGIN_CONFIG; |
| 25 | + |
| 26 | +public class PPApiServer { |
| 27 | + private static final String REQUIRED_PLUGIN_ID = "pearlplus"; |
| 28 | + private Javalin server; |
| 29 | + private final Cache<String, Integer> rateLimitCache = CacheBuilder.newBuilder() |
| 30 | + .expireAfterWrite(1, TimeUnit.MINUTES) |
| 31 | + .build(); |
| 32 | + |
| 33 | + public synchronized void start() { |
| 34 | + if (server != null) { |
| 35 | + stop(); |
| 36 | + } |
| 37 | + if (!isRequiredPluginAvailable()) { |
| 38 | + LOG.warn("PP API not started. Required plugin '{}' is not loaded.", REQUIRED_PLUGIN_ID); |
| 39 | + return; |
| 40 | + } |
| 41 | + server = createServer(); |
| 42 | + server.start(PLUGIN_CONFIG.port); |
| 43 | + LOG.info("PP API started on port {}", PLUGIN_CONFIG.port); |
| 44 | + LOG.info("Auth token: {}", PLUGIN_CONFIG.authToken); |
| 45 | + } |
| 46 | + |
| 47 | + public synchronized void stop() { |
| 48 | + if (server != null) { |
| 49 | + server.stop(); |
| 50 | + server = null; |
| 51 | + LOG.info("PP API stopped"); |
| 52 | + } |
| 53 | + } |
| 54 | + |
| 55 | + public synchronized boolean isRunning() { |
| 56 | + return server != null && server.jettyServer().started(); |
| 57 | + } |
| 58 | + |
| 59 | + private Javalin createServer() { |
| 60 | + return Javalin.create(config -> { |
| 61 | + var threadPool = new ExecutorThreadPool(); |
| 62 | + threadPool.setDaemon(true); |
| 63 | + threadPool.setName("ZenithProxy-PPApi-%d"); |
| 64 | + config.jetty.threadPool = threadPool; |
| 65 | + config.http.defaultContentType = "application/json"; |
| 66 | + var objectMapper = JavalinJackson.defaultMapper() |
| 67 | + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); |
| 68 | + config.jsonMapper(new JavalinJackson(objectMapper, false)); |
| 69 | + }) |
| 70 | + .beforeMatched(ctx -> { |
| 71 | + if (PLUGIN_CONFIG.rateLimiter) { |
| 72 | + String ip = ctx.ip(); |
| 73 | + synchronized (this) { |
| 74 | + int reqCount = rateLimitCache.get(ip, () -> 0); |
| 75 | + rateLimitCache.put(ip, reqCount + 1); |
| 76 | + if (reqCount >= PLUGIN_CONFIG.rateLimitRequestsPerMinute) { |
| 77 | + ctx.status(429); |
| 78 | + ctx.json(new ApiErrorResponse("Rate limit exceeded")); |
| 79 | + ctx.skipRemainingHandlers(); |
| 80 | + LOG.warn("Rate limit exceeded for IP: {}", ip); |
| 81 | + return; |
| 82 | + } |
| 83 | + } |
| 84 | + } |
| 85 | + if (!isRequiredPluginAvailable()) { |
| 86 | + ctx.status(503); |
| 87 | + ctx.json(new ApiErrorResponse("Required plugin is not loaded")); |
| 88 | + ctx.skipRemainingHandlers(); |
| 89 | + LOG.warn("Denied request from {}: required plugin '{}' not loaded", ctx.ip(), REQUIRED_PLUGIN_ID); |
| 90 | + return; |
| 91 | + } |
| 92 | + var authHeaderValue = ctx.header("Authorization"); |
| 93 | + if (authHeaderValue != null) { |
| 94 | + var expectedHeaderValue = PLUGIN_CONFIG.authToken; |
| 95 | + if (authHeaderValue.equals(expectedHeaderValue)) { |
| 96 | + // ok |
| 97 | + return; |
| 98 | + } |
| 99 | + } |
| 100 | + String reason = authHeaderValue == null |
| 101 | + ? "Authorization header missing" |
| 102 | + : "Invalid auth token"; |
| 103 | + ctx.json(new ApiErrorResponse(reason)); |
| 104 | + ctx.status(401); |
| 105 | + ctx.skipRemainingHandlers(); |
| 106 | + LOG.warn("Denied request from {}: {}", ctx.ip(), reason); |
| 107 | + }) |
| 108 | + .post("/pearlplus/status", ctx -> { |
| 109 | + var req = ctx.bodyAsClass(PearlStatusRequest.class); |
| 110 | + var playerName = req.playerName(); |
| 111 | + if (playerName == null || playerName.isBlank()) { |
| 112 | + ctx.status(400); |
| 113 | + ctx.json(new ApiErrorResponse("playerName is required")); |
| 114 | + return; |
| 115 | + } |
| 116 | + var result = readPearlsFromConfig(playerName); |
| 117 | + if (result.error() != null) { |
| 118 | + ctx.status(500); |
| 119 | + ctx.json(new ApiErrorResponse(result.error())); |
| 120 | + return; |
| 121 | + } |
| 122 | + ctx.json(new PearlStatusResponse(result.pearls(), result.output())); |
| 123 | + ctx.status(200); |
| 124 | + }) |
| 125 | + .post("/pearlplus/load", ctx -> { |
| 126 | + var req = ctx.bodyAsClass(PearlLoadRequest.class); |
| 127 | + var playerName = req.playerName(); |
| 128 | + var pearlId = req.pearlId(); |
| 129 | + if (playerName == null || playerName.isBlank() || pearlId == null || pearlId.isBlank()) { |
| 130 | + ctx.status(400); |
| 131 | + ctx.json(new ApiErrorResponse("playerName and pearlId are required")); |
| 132 | + return; |
| 133 | + } |
| 134 | + var command = "pp load " + playerName + " " + pearlId; |
| 135 | + var context = executeCommand(command); |
| 136 | + ctx.json(new PearlLoadResponse("queued", context.getMultiLineOutput())); |
| 137 | + ctx.status(200); |
| 138 | + }); |
| 139 | + } |
| 140 | + |
| 141 | + private boolean isRequiredPluginAvailable() { |
| 142 | + return Globals.PLUGIN_MANAGER.getPlugin(REQUIRED_PLUGIN_ID) != null; |
| 143 | + } |
| 144 | + |
| 145 | + private CommandContext executeCommand(String command) { |
| 146 | + var context = CommandContext.create(command, PPApiCommandSource.INSTANCE); |
| 147 | + LOG.info("PP API executed command: {}", command); |
| 148 | + Globals.COMMAND.execute(context); |
| 149 | + context.getSource().logEmbed(context, context.getEmbed()); |
| 150 | + return context; |
| 151 | + } |
| 152 | + |
| 153 | + private ConfigPearlResult readPearlsFromConfig(String playerName) { |
| 154 | + var plugin = Globals.PLUGIN_MANAGER.getPlugin(REQUIRED_PLUGIN_ID); |
| 155 | + if (plugin == null) { |
| 156 | + return new ConfigPearlResult(List.of(), List.of(), "PearlPlus plugin is not loaded"); |
| 157 | + } |
| 158 | + try { |
| 159 | + var pluginClass = plugin.getClass(); |
| 160 | + var configField = pluginClass.getDeclaredField("PLUGIN_CONFIG"); |
| 161 | + configField.setAccessible(true); |
| 162 | + var config = configField.get(null); |
| 163 | + if (config == null) { |
| 164 | + return new ConfigPearlResult(List.of(), List.of(), "PearlPlus config not available"); |
| 165 | + } |
| 166 | + var playersField = config.getClass().getDeclaredField("players"); |
| 167 | + playersField.setAccessible(true); |
| 168 | + var playersObj = playersField.get(config); |
| 169 | + if (!(playersObj instanceof java.util.Map<?, ?> players)) { |
| 170 | + return new ConfigPearlResult(List.of(), List.of(), "PearlPlus config players map missing"); |
| 171 | + } |
| 172 | + Set<String> pearls = new LinkedHashSet<>(); |
| 173 | + for (var entry : players.values()) { |
| 174 | + if (entry == null) { |
| 175 | + continue; |
| 176 | + } |
| 177 | + var entryClass = entry.getClass(); |
| 178 | + var nameField = entryClass.getDeclaredField("playerName"); |
| 179 | + nameField.setAccessible(true); |
| 180 | + var nameObj = nameField.get(entry); |
| 181 | + if (nameObj == null) { |
| 182 | + continue; |
| 183 | + } |
| 184 | + var name = nameObj.toString(); |
| 185 | + if (!name.equalsIgnoreCase(playerName)) { |
| 186 | + continue; |
| 187 | + } |
| 188 | + var pearlsField = entryClass.getDeclaredField("pearls"); |
| 189 | + pearlsField.setAccessible(true); |
| 190 | + var pearlsObj = pearlsField.get(entry); |
| 191 | + if (pearlsObj instanceof java.util.Map<?, ?> pearlMap) { |
| 192 | + for (var pearlEntry : pearlMap.values()) { |
| 193 | + if (pearlEntry == null) { |
| 194 | + continue; |
| 195 | + } |
| 196 | + var pearlIdField = pearlEntry.getClass().getDeclaredField("pearlId"); |
| 197 | + pearlIdField.setAccessible(true); |
| 198 | + var pearlIdObj = pearlIdField.get(pearlEntry); |
| 199 | + if (pearlIdObj != null) { |
| 200 | + pearls.add(pearlIdObj.toString()); |
| 201 | + } |
| 202 | + } |
| 203 | + if (pearlMap.isEmpty()) { |
| 204 | + for (var key : pearlMap.keySet()) { |
| 205 | + if (key != null) { |
| 206 | + pearls.add(key.toString()); |
| 207 | + } |
| 208 | + } |
| 209 | + } |
| 210 | + } |
| 211 | + } |
| 212 | + List<String> output = List.of("Loaded pearls from PearlPlus config"); |
| 213 | + return new ConfigPearlResult(new ArrayList<>(pearls), output, null); |
| 214 | + } catch (ReflectiveOperationException e) { |
| 215 | + LOG.error("Failed to read PearlPlus config", e); |
| 216 | + return new ConfigPearlResult(List.of(), List.of(), "Failed to read PearlPlus config"); |
| 217 | + } |
| 218 | + } |
| 219 | + |
| 220 | + private record ConfigPearlResult( |
| 221 | + List<String> pearls, |
| 222 | + List<String> output, |
| 223 | + String error |
| 224 | + ) { } |
| 225 | +} |
0 commit comments