Compare commits
4 Commits
1f02781d07
...
998157c3c9
| Author | SHA1 | Date |
|---|---|---|
|
|
998157c3c9 | |
|
|
4e5682c6b1 | |
|
|
be901a31d8 | |
|
|
0c70a4696d |
|
|
@ -29,7 +29,7 @@ mod_name=whitelist
|
|||
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||
mod_license=All Rights Reserved
|
||||
# The mod version. See https://semver.org/
|
||||
mod_version=0.1-BETA
|
||||
mod_version=0.2-BETA
|
||||
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
||||
# This should match the base package used for the mod sources.
|
||||
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ public final class Config {
|
|||
.define("apiKey", "your-secret-api-key");
|
||||
|
||||
private static final ModConfigSpec.IntValue REQUEST_TIMEOUT = BUILDER
|
||||
.comment("Request timeout in seconds")
|
||||
.defineInRange("requestTimeout", 30, 5, 300);
|
||||
.comment("Request timeout in seconds (recommended: 3-5 to prevent server lag)")
|
||||
.defineInRange("requestTimeout", 5, 1, 60);
|
||||
|
||||
private static final ModConfigSpec.BooleanValue ENABLE_LOGGING = BUILDER
|
||||
.comment("Enable detailed API logging")
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ public class WhitelistApiClient {
|
|||
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;
|
||||
|
||||
|
|
@ -63,7 +69,20 @@ public class WhitelistApiClient {
|
|||
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 -> resp != null && resp.statusCode() == 201);
|
||||
.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,
|
||||
|
|
@ -82,13 +101,24 @@ public class WhitelistApiClient {
|
|||
if (reason != null && !reason.isBlank()) body.addProperty("reason", reason);
|
||||
return makeRequest("POST", "/add", HttpRequest.BodyPublishers.ofString(GSON.toJson(body)))
|
||||
.thenApply(resp -> {
|
||||
if (resp == null || resp.statusCode() != 201) return null;
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +127,20 @@ public class WhitelistApiClient {
|
|||
JsonObject body = new JsonObject();
|
||||
body.addProperty("player_name", playerName);
|
||||
return makeRequest("POST", "/remove", HttpRequest.BodyPublishers.ofString(GSON.toJson(body)))
|
||||
.thenApply(resp -> resp != null && resp.statusCode() == 204);
|
||||
.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) {
|
||||
|
|
@ -109,21 +152,34 @@ public class WhitelistApiClient {
|
|||
if (response == null) return new CheckResponse(false, false);
|
||||
try {
|
||||
int code = response.statusCode();
|
||||
if (code < 200 || code >= 300) return new CheckResponse(false, false);
|
||||
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 -> 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");
|
||||
|
|
@ -134,8 +190,13 @@ public class WhitelistApiClient {
|
|||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -143,12 +204,22 @@ public class WhitelistApiClient {
|
|||
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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +232,47 @@ public class WhitelistApiClient {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -114,7 +114,13 @@ public class WhitelistCommands {
|
|||
Component.literal("Failed to add " + player));
|
||||
}
|
||||
})
|
||||
);
|
||||
).exceptionally(ex -> {
|
||||
dispatchBack(ctx, () ->
|
||||
ctx.getSource().sendFailure(
|
||||
Component.literal("Error adding player: " + ex.getMessage()))
|
||||
);
|
||||
return null;
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +149,13 @@ public class WhitelistCommands {
|
|||
Component.literal("Failed to remove " + player));
|
||||
}
|
||||
})
|
||||
);
|
||||
).exceptionally(ex -> {
|
||||
dispatchBack(ctx, () ->
|
||||
ctx.getSource().sendFailure(
|
||||
Component.literal("Error removing player: " + ex.getMessage()))
|
||||
);
|
||||
return null;
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -162,7 +174,13 @@ public class WhitelistCommands {
|
|||
Component.literal("Check failed for " + player));
|
||||
}
|
||||
})
|
||||
);
|
||||
).exceptionally(ex -> {
|
||||
dispatchBack(ctx, () ->
|
||||
ctx.getSource().sendFailure(
|
||||
Component.literal("Error checking player: " + ex.getMessage()))
|
||||
);
|
||||
return null;
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,45 +1,88 @@
|
|||
package org.itqop.whitelist;
|
||||
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.HoverEvent;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.neoforged.bus.api.EventPriority;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import org.slf4j.Logger;
|
||||
import com.mojang.logging.LogUtils;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@EventBusSubscriber(modid = Whitelist.MODID)
|
||||
public class WhitelistEventHandler {
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
||||
@SubscribeEvent
|
||||
public static void registerCommands(RegisterCommandsEvent event) {
|
||||
WhitelistCommands.register(event.getDispatcher());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
// Check whitelist when player logs in
|
||||
@SubscribeEvent(priority = EventPriority.HIGHEST)
|
||||
public static void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) {
|
||||
if (!Config.enableWhitelist) return;
|
||||
if (!(event.getEntity() instanceof ServerPlayer sp)) return;
|
||||
if (!(event.getEntity() instanceof ServerPlayer player)) return;
|
||||
|
||||
String name = sp.getGameProfile().getName();
|
||||
WhitelistApiClient.get().checkPlayer(name).thenAccept(resp -> {
|
||||
if (resp != null && resp.isSuccess() && !resp.isWhitelisted()) {
|
||||
sp.server.execute(() -> {
|
||||
Component link = Component.literal("https://hubmc.org/shop")
|
||||
.withStyle(style -> style
|
||||
.withUnderlined(true)
|
||||
.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://hubmc.org/shop"))
|
||||
.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.literal("Открыть в браузере")))
|
||||
);
|
||||
GameProfile profile = player.getGameProfile();
|
||||
String playerName = profile.getName();
|
||||
|
||||
Component msg = Component.literal("Вас нету в белом списке сервера.\n")
|
||||
.append(Component.literal("Для входа необходимо приобрести проходку на сайте "))
|
||||
.append(link);
|
||||
// Check whitelist synchronously with timeout protection
|
||||
CompletableFuture<WhitelistApiClient.CheckResponse> checkFuture =
|
||||
WhitelistApiClient.get().checkPlayer(playerName);
|
||||
|
||||
sp.connection.disconnect(msg);
|
||||
});
|
||||
try {
|
||||
// Block login with timeout
|
||||
WhitelistApiClient.CheckResponse response = checkFuture
|
||||
.completeOnTimeout(
|
||||
new WhitelistApiClient.CheckResponse(false, false),
|
||||
Config.requestTimeout,
|
||||
TimeUnit.SECONDS
|
||||
)
|
||||
.join();
|
||||
|
||||
// If check failed or player not whitelisted, disconnect immediately
|
||||
if (!response.isSuccess() || !response.isWhitelisted()) {
|
||||
disconnectPlayer(player, createKickMessage(response.isSuccess()));
|
||||
}
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
// On any error, deny access
|
||||
LOGGER.error("Whitelist check error for '{}': {}", playerName, e.getMessage());
|
||||
disconnectPlayer(player, Component.literal("Whitelist service error. Please contact administrator."));
|
||||
}
|
||||
}
|
||||
|
||||
private static void disconnectPlayer(ServerPlayer player, Component message) {
|
||||
if (player.connection == null) return;
|
||||
|
||||
// Disconnect player - mixin will suppress all join/leave messages
|
||||
player.connection.disconnect(message);
|
||||
LOGGER.info("Player '{}' denied access (not in whitelist)", player.getGameProfile().getName());
|
||||
}
|
||||
|
||||
private static Component createKickMessage(boolean apiAvailable) {
|
||||
if (!apiAvailable) {
|
||||
return Component.literal("Whitelist service unavailable. Please try again later.");
|
||||
}
|
||||
|
||||
Component link = Component.literal("https://hubmc.org/shop")
|
||||
.withStyle(style -> style
|
||||
.withUnderlined(true)
|
||||
.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://hubmc.org/shop"))
|
||||
.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT,
|
||||
Component.literal("Открыть в браузере")))
|
||||
);
|
||||
|
||||
return Component.literal("Вас нету в белом списке сервера.\n")
|
||||
.append(Component.literal("Для входа необходимо приобрести проходку на сайте "))
|
||||
.append(link);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package org.itqop.whitelist.mixin;
|
||||
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.players.PlayerList;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||
|
||||
@Mixin(PlayerList.class)
|
||||
public abstract class PlayerListMixin {
|
||||
|
||||
@Redirect(
|
||||
method = "placeNewPlayer",
|
||||
at = @At(
|
||||
value = "INVOKE",
|
||||
target = "Lnet/minecraft/server/players/PlayerList;broadcastSystemMessage(Lnet/minecraft/network/chat/Component;Z)V"
|
||||
)
|
||||
)
|
||||
private void itqop$skipJoinBroadcast(PlayerList self, Component message, boolean bypassHiddenChat) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package org.itqop.whitelist.mixin;
|
||||
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.server.players.PlayerList;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||
|
||||
@Mixin(ServerGamePacketListenerImpl.class)
|
||||
public abstract class ServerGamePacketListenerMixin {
|
||||
|
||||
@Redirect(
|
||||
method = "removePlayerFromWorld",
|
||||
at = @At(
|
||||
value = "INVOKE",
|
||||
target = "Lnet/minecraft/server/players/PlayerList;broadcastSystemMessage(Lnet/minecraft/network/chat/Component;Z)V"
|
||||
)
|
||||
)
|
||||
private void itqop$skipLeaveBroadcast(PlayerList list, Component message, boolean overlay) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"required": true,
|
||||
"minVersion": "0.8",
|
||||
"package": "org.itqop.whitelist.mixin",
|
||||
"compatibilityLevel": "JAVA_21",
|
||||
"refmap": "whitelist.refmap.json",
|
||||
"mixins": [
|
||||
"PlayerListMixin",
|
||||
"ServerGamePacketListenerMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -35,8 +35,8 @@ authors = "${mod_authors}" #optional
|
|||
description = '''${mod_description}'''
|
||||
|
||||
# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded.
|
||||
#[[mixins]]
|
||||
#config="${mod_id}.mixins.json"
|
||||
[[mixins]]
|
||||
config="${mod_id}.mixins.json"
|
||||
|
||||
# The [[accessTransformers]] block allows you to declare where your AT file is.
|
||||
# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg
|
||||
|
|
|
|||
Loading…
Reference in New Issue