Compare commits

...

4 Commits

Author SHA1 Message Date
itqop 998157c3c9 feat: add mixin login/leave messages
Build and Release / build (push) Successful in 20m11s Details
2025-11-11 22:47:38 +03:00
itqop 4e5682c6b1 feat: first mixins attempt 2025-11-09 15:54:20 +03:00
itqop be901a31d8 refactor: refactor code 2025-11-09 15:30:56 +03:00
itqop 0c70a4696d refactor: refactor code 2025-11-09 02:41:10 +03:00
9 changed files with 265 additions and 35 deletions

View File

@ -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

View File

@ -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")

View File

@ -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;

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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) {
}
}

View File

@ -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) {
}
}

View File

@ -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
}
}

View File

@ -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