15 KiB
15 KiB
package org.itqop.whitelist;
import com.google.gson.*;
import com.mojang.logging.LogUtils;
import org.slf4j.Logger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
public class WhitelistApiClient {
private static final WhitelistApiClient INSTANCE = new WhitelistApiClient();
public static WhitelistApiClient get() { return INSTANCE; }
public static void refreshConfigFromSpec() { INSTANCE.refreshFromConfig(); }
private static final Logger LOGGER = LogUtils.getLogger();
private static final Gson GSON = new GsonBuilder().create();
// Circuit breaker to prevent log spam
private volatile long lastErrorLogTime = 0;
private volatile int consecutiveErrors = 0;
private static final long ERROR_LOG_COOLDOWN_MS = 60_000; // 1 minute
private static final int ERROR_THRESHOLD = 3;
private volatile HttpClient http;
private volatile String baseUrl;
public WhitelistApiClient() {
this.http = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(Math.max(1, Config.requestTimeout)))
.build();
this.baseUrl = normalizeBaseUrl(Config.apiBaseUrl);
}
public void refreshFromConfig() {
this.baseUrl = normalizeBaseUrl(Config.apiBaseUrl);
}
private static String normalizeBaseUrl(String url) {
if (url == null || url.isBlank()) return "";
String u = url.trim();
if (u.endsWith("/")) u = u.substring(0, u.length() - 1);
return u;
}
private HttpRequest.Builder baseBuilder(String path) {
return HttpRequest.newBuilder(URI.create(baseUrl + path))
.timeout(Duration.ofSeconds(Math.max(1, Config.requestTimeout)))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-API-Key", Config.apiKey);
}
public CompletableFuture<Boolean> addPlayer(String playerName, String reason) {
return addPlayer(playerName, null, null);
}
public CompletableFuture<Boolean> addPlayer(String playerName, String addedBy, String addedAtIso) {
Objects.requireNonNull(playerName, "playerName");
JsonObject body = new JsonObject();
body.addProperty("player_name", playerName);
if (addedBy != null && !addedBy.isBlank()) body.addProperty("added_by", addedBy);
if (addedAtIso != null && !addedAtIso.isBlank()) body.addProperty("added_at", addedAtIso);
return makeRequest("POST", "/add", HttpRequest.BodyPublishers.ofString(GSON.toJson(body)))
.thenApply(resp -> {
if (resp == null) return false;
int code = resp.statusCode();
if (code == 201) {
resetErrorCounter();
return true;
}
logHttpError("POST /add", code, resp.body());
return false;
})
.exceptionally(ex -> {
LOGGER.error("Failed to add player '{}': {}", playerName, ex.getMessage(), ex);
return false;
});
}
public CompletableFuture<WhitelistEntry> addPlayer(String playerName,
String addedBy,
Instant addedAt,
Instant expiresAt,
Boolean isActive,
String reason) {
Objects.requireNonNull(playerName, "playerName");
JsonObject body = new JsonObject();
body.addProperty("player_name", playerName);
if (addedBy != null && !addedBy.isBlank()) body.addProperty("added_by", addedBy);
if (addedAt != null) body.addProperty("added_at", addedAt.toString());
if (expiresAt != null) body.addProperty("expires_at", expiresAt.toString());
if (isActive != null) body.addProperty("is_active", isActive);
if (reason != null && !reason.isBlank()) body.addProperty("reason", reason);
return makeRequest("POST", "/add", HttpRequest.BodyPublishers.ofString(GSON.toJson(body)))
.thenApply(resp -> {
if (resp == null) return null;
int code = resp.statusCode();
if (code != 201) {
logHttpError("POST /add", code, resp.body());
return null;
}
try {
resetErrorCounter();
JsonObject json = JsonParser.parseString(resp.body()).getAsJsonObject();
return WhitelistEntry.fromJson(json);
} catch (Exception e) {
LOGGER.error("Failed to parse addPlayer response for '{}': {}", playerName, e.getMessage(), e);
return null;
}
})
.exceptionally(ex -> {
LOGGER.error("Failed to add player '{}': {}", playerName, ex.getMessage(), ex);
return null;
});
}
public CompletableFuture<Boolean> removePlayer(String playerName) {
Objects.requireNonNull(playerName, "playerName");
JsonObject body = new JsonObject();
body.addProperty("player_name", playerName);
return makeRequest("POST", "/remove", HttpRequest.BodyPublishers.ofString(GSON.toJson(body)))
.thenApply(resp -> {
if (resp == null) return false;
int code = resp.statusCode();
if (code == 204) {
resetErrorCounter();
return true;
}
logHttpError("POST /remove", code, resp.body());
return false;
})
.exceptionally(ex -> {
LOGGER.error("Failed to remove player '{}': {}", playerName, ex.getMessage(), ex);
return false;
});
}
public CompletableFuture<CheckResponse> checkPlayer(String playerName) {
Objects.requireNonNull(playerName, "playerName");
JsonObject body = new JsonObject();
body.addProperty("player_name", playerName);
return makeRequest("POST", "/check", HttpRequest.BodyPublishers.ofString(GSON.toJson(body)))
.thenApply(response -> {
if (response == null) return new CheckResponse(false, false);
try {
int code = response.statusCode();
if (code < 200 || code >= 300) {
logHttpError("POST /check", code, response.body());
return new CheckResponse(false, false);
}
resetErrorCounter();
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
boolean isWhitelisted = json.has("is_whitelisted") && json.get("is_whitelisted").getAsBoolean();
return new CheckResponse(true, isWhitelisted);
} catch (Exception e) {
LOGGER.warn("Failed to parse checkPlayer response for '{}': {}", playerName, e.getMessage());
return new CheckResponse(false, false);
}
})
.exceptionally(ex -> {
logApiError("Failed to check player '" + playerName + "'", ex);
return new CheckResponse(false, false);
});
}
public CompletableFuture<WhitelistListResponse> listAll() {
return makeRequest("GET", "/", HttpRequest.BodyPublishers.noBody())
.thenApply(resp -> {
if (resp == null) return null;
int code = resp.statusCode();
if (code < 200 || code >= 300) {
logHttpError("GET /", code, resp.body());
return null;
}
try {
JsonObject json = JsonParser.parseString(resp.body()).getAsJsonObject();
JsonArray arr = json.getAsJsonArray("entries");
int total = json.has("total") ? json.get("total").getAsInt() : 0;
WhitelistEntry[] entries = new WhitelistEntry[arr.size()];
for (int i = 0; i < arr.size(); i++) {
entries[i] = WhitelistEntry.fromJson(arr.get(i).getAsJsonObject());
}
return new WhitelistListResponse(List.of(entries), total);
} catch (Exception e) {
LOGGER.error("Failed to parse listAll response: {}", e.getMessage(), e);
return null;
}
})
.exceptionally(ex -> {
LOGGER.error("Failed to list whitelist entries: {}", ex.getMessage(), ex);
return null;
});
}
public CompletableFuture<Integer> count() {
return makeRequest("GET", "/count", HttpRequest.BodyPublishers.noBody())
.thenApply(resp -> {
if (resp == null) return null;
int code = resp.statusCode();
if (code < 200 || code >= 300) {
logHttpError("GET /count", code, resp.body());
return null;
}
try {
JsonObject json = JsonParser.parseString(resp.body()).getAsJsonObject();
return json.has("total") ? json.get("total").getAsInt() : 0;
} catch (Exception e) {
LOGGER.error("Failed to parse count response: {}", e.getMessage(), e);
return null;
}
})
.exceptionally(ex -> {
LOGGER.error("Failed to get whitelist count: {}", ex.getMessage(), ex);
return null;
});
}
private CompletableFuture<HttpResponse<String>> makeRequest(String method, String path, HttpRequest.BodyPublisher body) {
HttpRequest.Builder b = baseBuilder(path);
if ("POST".equalsIgnoreCase(method)) b.POST(body);
else if ("PUT".equalsIgnoreCase(method)) b.PUT(body);
else if ("DELETE".equalsIgnoreCase(method)) b.method("DELETE", body);
else b.method(method, body);
return http.sendAsync(b.build(), HttpResponse.BodyHandlers.ofString());
}
private void logHttpError(String endpoint, int statusCode, String responseBody) {
consecutiveErrors++;
// Log only first error and then once per minute to prevent spam
long now = System.currentTimeMillis();
if (consecutiveErrors == 1 || (now - lastErrorLogTime) > ERROR_LOG_COOLDOWN_MS) {
if (Config.enableLogging) {
LOGGER.warn("API request failed: {} returned {} - Response: {}", endpoint, statusCode, responseBody);
} else {
LOGGER.warn("API request failed: {} returned {}", endpoint, statusCode);
}
lastErrorLogTime = now;
if (consecutiveErrors > ERROR_THRESHOLD) {
LOGGER.warn("API has failed {} times consecutively. Further errors will be throttled.", consecutiveErrors);
}
}
}
private void logApiError(String message, Throwable ex) {
consecutiveErrors++;
// Log only first error and then once per minute to prevent spam
long now = System.currentTimeMillis();
if (consecutiveErrors == 1 || (now - lastErrorLogTime) > ERROR_LOG_COOLDOWN_MS) {
LOGGER.warn("{}: {} (errors: {})", message, ex.getMessage(), consecutiveErrors);
lastErrorLogTime = now;
if (consecutiveErrors > ERROR_THRESHOLD) {
LOGGER.warn("API has failed {} times consecutively. Further errors will be throttled.", consecutiveErrors);
}
}
}
private void resetErrorCounter() {
if (consecutiveErrors > 0) {
LOGGER.info("API connection restored after {} consecutive errors", consecutiveErrors);
consecutiveErrors = 0;
}
}
public static final class CheckResponse {
private final boolean success;
private final boolean isWhitelisted;
public CheckResponse(boolean success, boolean isWhitelisted) {
this.success = success;
this.isWhitelisted = isWhitelisted;
}
public boolean isSuccess() { return success; }
public boolean isWhitelisted() { return isWhitelisted; }
}
public static final class WhitelistEntry {
public final String id;
public final String playerName;
public final String addedBy;
public final String addedAt;
public final String expiresAt;
public final boolean isActive;
public final String reason;
public WhitelistEntry(String id, String playerName, String addedBy, String addedAt, String expiresAt, boolean isActive, String reason) {
this.id = id;
this.playerName = playerName;
this.addedBy = addedBy;
this.addedAt = addedAt;
this.expiresAt = expiresAt;
this.isActive = isActive;
this.reason = reason;
}
public static WhitelistEntry fromJson(JsonObject json) {
String id = json.has("id") && !json.get("id").isJsonNull() ? json.get("id").getAsString() : null;
String pn = json.has("player_name") && !json.get("player_name").isJsonNull() ? json.get("player_name").getAsString() : null;
String by = json.has("added_by") && !json.get("added_by").isJsonNull() ? json.get("added_by").getAsString() : null;
String at = json.has("added_at") && !json.get("added_at").isJsonNull() ? json.get("added_at").getAsString() : null;
String exp = json.has("expires_at") && !json.get("expires_at").isJsonNull() ? json.get("expires_at").getAsString() : null;
boolean active = json.has("is_active") && !json.get("is_active").isJsonNull() && json.get("is_active").getAsBoolean();
String rsn = json.has("reason") && !json.get("reason").isJsonNull() ? json.get("reason").getAsString() : null;
return new WhitelistEntry(id, pn, by, at, exp, active, rsn);
}
}
public static final class WhitelistListResponse {
public final List<WhitelistEntry> entries;
public final int total;
public WhitelistListResponse(List<WhitelistEntry> entries, int total) {
this.entries = entries;
this.total = total;
}
}
}
```