You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-20 15:36:17 +00:00
Merge remote-tracking branch 'duckie/main' into duckie-live-merge-2026-04-21
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<groupId>com.eu.habbo</groupId>
|
<groupId>com.eu.habbo</groupId>
|
||||||
<artifactId>Habbo</artifactId>
|
<artifactId>Habbo</artifactId>
|
||||||
<version>4.1.5</version>
|
<version>4.1.3</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
|||||||
+126
-11
@@ -25,14 +25,18 @@ import java.util.regex.Pattern;
|
|||||||
public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpHandler.class);
|
private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpHandler.class);
|
||||||
|
|
||||||
private static final String LOGIN_PATH = "/api/auth/login";
|
private static final String LOGIN_PATH = "/api/auth/login";
|
||||||
private static final String REGISTER_PATH = "/api/auth/register";
|
private static final String REGISTER_PATH = "/api/auth/register";
|
||||||
private static final String FORGOT_PATH = "/api/auth/forgot-password";
|
private static final String FORGOT_PATH = "/api/auth/forgot-password";
|
||||||
private static final String LOGOUT_PATH = "/api/auth/logout";
|
private static final String LOGOUT_PATH = "/api/auth/logout";
|
||||||
|
private static final String CHECK_EMAIL_PATH = "/api/auth/check-email";
|
||||||
|
private static final String CHECK_USERNAME_PATH = "/api/auth/check-username";
|
||||||
|
private static final String HEALTH_PATH = "/api/health";
|
||||||
|
|
||||||
private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$");
|
private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$");
|
||||||
private static final Pattern EMAIL_RE = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
|
private static final Pattern EMAIL_RE = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
|
||||||
private static final SecureRandom RNG = new SecureRandom();
|
private static final SecureRandom RNG = new SecureRandom();
|
||||||
|
private static final int MAX_BODY_BYTES = 8 * 1024;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
|
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
|
||||||
@@ -44,7 +48,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
String path = new QueryStringDecoder(req.uri()).path();
|
String path = new QueryStringDecoder(req.uri()).path();
|
||||||
|
|
||||||
if (!path.equals(LOGIN_PATH) && !path.equals(REGISTER_PATH)
|
if (!path.equals(LOGIN_PATH) && !path.equals(REGISTER_PATH)
|
||||||
&& !path.equals(FORGOT_PATH) && !path.equals(LOGOUT_PATH)) {
|
&& !path.equals(FORGOT_PATH) && !path.equals(LOGOUT_PATH)
|
||||||
|
&& !path.equals(CHECK_EMAIL_PATH) && !path.equals(CHECK_USERNAME_PATH)
|
||||||
|
&& !path.equals(HEALTH_PATH)) {
|
||||||
super.channelRead(ctx, msg);
|
super.channelRead(ctx, msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -62,6 +68,17 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path.equals(HEALTH_PATH)) {
|
||||||
|
if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use GET."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonObject ok = new JsonObject();
|
||||||
|
ok.addProperty("status", "ok");
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method() != HttpMethod.POST) {
|
if (req.method() != HttpMethod.POST) {
|
||||||
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use POST."));
|
sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, errorPayload("Use POST."));
|
||||||
return;
|
return;
|
||||||
@@ -76,6 +93,11 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.content().readableBytes() > MAX_BODY_BYTES) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE, errorPayload("Payload too large."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
JsonObject body;
|
JsonObject body;
|
||||||
try {
|
try {
|
||||||
String text = req.content().toString(StandardCharsets.UTF_8);
|
String text = req.content().toString(StandardCharsets.UTF_8);
|
||||||
@@ -90,6 +112,15 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path.equals(CHECK_EMAIL_PATH)) {
|
||||||
|
handleCheckEmail(ctx, req, body, ip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.equals(CHECK_USERNAME_PATH)) {
|
||||||
|
handleCheckUsername(ctx, req, body, ip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String turnstileToken = readString(body, "turnstileToken");
|
String turnstileToken = readString(body, "turnstileToken");
|
||||||
if (!TurnstileVerifier.verify(turnstileToken, ip)) {
|
if (!TurnstileVerifier.verify(turnstileToken, ip)) {
|
||||||
AuthRateLimiter.recordFailure(ip);
|
AuthRateLimiter.recordFailure(ip);
|
||||||
@@ -104,7 +135,88 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Logout ────────────────────────────────────────────────────────── */
|
/* ─── Availability probes ─── */
|
||||||
|
|
||||||
|
private void handleCheckEmail(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||||
|
if (!AuthRateLimiter.tryProbe(ip)) {
|
||||||
|
long secs = AuthRateLimiter.secondsUntilProbeReset(ip);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
|
||||||
|
errorPayload("Too many requests. Try again in " + secs + "s."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String email = readString(body, "email").trim();
|
||||||
|
if (email.isEmpty() || email.length() > 254 || !EMAIL_RE.matcher(email).matches()) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Invalid email address."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Boolean cached = AvailabilityCache.lookupEmail(email);
|
||||||
|
boolean taken;
|
||||||
|
if (cached != null) {
|
||||||
|
taken = !cached;
|
||||||
|
} else {
|
||||||
|
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"SELECT 1 FROM users WHERE mail = ? LIMIT 1")) {
|
||||||
|
stmt.setString(1, email);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
taken = rs.next();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("check-email failed", e);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AvailabilityCache.storeEmail(email, !taken);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject res = new JsonObject();
|
||||||
|
res.addProperty("available", !taken);
|
||||||
|
if (taken) res.addProperty("error", "This email is already in use.");
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.OK, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleCheckUsername(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||||
|
if (!AuthRateLimiter.tryProbe(ip)) {
|
||||||
|
long secs = AuthRateLimiter.secondsUntilProbeReset(ip);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
|
||||||
|
errorPayload("Too many requests. Try again in " + secs + "s."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String username = readString(body, "username").trim();
|
||||||
|
if (!USERNAME_RE.matcher(username).matches()) {
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST,
|
||||||
|
errorPayload("Username must be 3-32 chars (letters, numbers, . _ -)."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Boolean cached = AvailabilityCache.lookupUsername(username);
|
||||||
|
boolean taken;
|
||||||
|
if (cached != null) {
|
||||||
|
taken = !cached;
|
||||||
|
} else {
|
||||||
|
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"SELECT 1 FROM users WHERE username = ? LIMIT 1")) {
|
||||||
|
stmt.setString(1, username);
|
||||||
|
try (ResultSet rs = stmt.executeQuery()) {
|
||||||
|
taken = rs.next();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("check-username failed", e);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AvailabilityCache.storeUsername(username, !taken);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject res = new JsonObject();
|
||||||
|
res.addProperty("available", !taken);
|
||||||
|
if (taken) res.addProperty("error", "This Habbo name is already taken.");
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.OK, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Logout ─── */
|
||||||
|
|
||||||
private void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) {
|
private void handleLogout(ChannelHandlerContext ctx, FullHttpRequest req, com.google.gson.JsonObject body) {
|
||||||
String ssoTicket = readString(body, "ssoTicket");
|
String ssoTicket = readString(body, "ssoTicket");
|
||||||
@@ -148,7 +260,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Login ─────────────────────────────────────────────────────────── */
|
/* ─── Login ─── */
|
||||||
|
|
||||||
private void handleLogin(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
private void handleLogin(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||||
String username = readString(body, "username").trim();
|
String username = readString(body, "username").trim();
|
||||||
@@ -210,7 +322,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Register ──────────────────────────────────────────────────────── */
|
/* ─── Register ─── */
|
||||||
|
|
||||||
private void handleRegister(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
private void handleRegister(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||||
if (!Emulator.getConfig().getBoolean("login.register.enabled", true)) {
|
if (!Emulator.getConfig().getBoolean("login.register.enabled", true)) {
|
||||||
@@ -299,6 +411,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
ins.executeUpdate();
|
ins.executeUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AvailabilityCache.invalidateEmail(email);
|
||||||
|
AvailabilityCache.invalidateUsername(username);
|
||||||
|
|
||||||
JsonObject ok = new JsonObject();
|
JsonObject ok = new JsonObject();
|
||||||
ok.addProperty("message", "Welcome aboard, " + username + "! Your account is ready — log in below with the password you just chose.");
|
ok.addProperty("message", "Welcome aboard, " + username + "! Your account is ready — log in below with the password you just chose.");
|
||||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||||
@@ -308,7 +423,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Forgot password ───────────────────────────────────────────────── */
|
/* ─── Forgot password ─── */
|
||||||
|
|
||||||
private void handleForgot(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
private void handleForgot(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||||
String email = readString(body, "email").trim();
|
String email = readString(body, "email").trim();
|
||||||
@@ -363,7 +478,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Helpers ───────────────────────────────────────────────────────── */
|
/* ─── Helpers ─── */
|
||||||
|
|
||||||
private static boolean checkPassword(String plain, String stored) {
|
private static boolean checkPassword(String plain, String stored) {
|
||||||
String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored;
|
String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored;
|
||||||
@@ -449,7 +564,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
response.headers().set("Vary", "Origin");
|
response.headers().set("Vary", "Origin");
|
||||||
response.headers().set("Access-Control-Allow-Credentials", "true");
|
response.headers().set("Access-Control-Allow-Credentials", "true");
|
||||||
}
|
}
|
||||||
response.headers().set("Access-Control-Allow-Methods", "POST, OPTIONS");
|
response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
|
||||||
response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
|
response.headers().set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||||||
public final class AuthRateLimiter {
|
public final class AuthRateLimiter {
|
||||||
|
|
||||||
private static final Map<String, AtomicReference<State>> STATE = new ConcurrentHashMap<>();
|
private static final Map<String, AtomicReference<State>> STATE = new ConcurrentHashMap<>();
|
||||||
|
private static final Map<String, AtomicReference<ProbeState>> PROBE_STATE = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private AuthRateLimiter() {}
|
private AuthRateLimiter() {}
|
||||||
|
|
||||||
@@ -58,6 +59,35 @@ public final class AuthRateLimiter {
|
|||||||
STATE.remove(ip);
|
STATE.remove(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean tryProbe(String ip) {
|
||||||
|
if (!isEnabled() || ip == null || ip.isEmpty()) return true;
|
||||||
|
if (isLocked(ip)) return false;
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long windowMs = configInt("login.probe.window_sec", 60) * 1000L;
|
||||||
|
int maxAttempts = configInt("login.probe.max_attempts", 20);
|
||||||
|
|
||||||
|
ProbeState next = PROBE_STATE.computeIfAbsent(ip, k -> new AtomicReference<>(new ProbeState(0, now)))
|
||||||
|
.updateAndGet(prev -> {
|
||||||
|
if (prev == null || (now - prev.windowStartMillis) > windowMs) {
|
||||||
|
return new ProbeState(1, now);
|
||||||
|
}
|
||||||
|
return new ProbeState(prev.count + 1, prev.windowStartMillis);
|
||||||
|
});
|
||||||
|
|
||||||
|
return next.count <= maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long secondsUntilProbeReset(String ip) {
|
||||||
|
AtomicReference<ProbeState> ref = PROBE_STATE.get(ip);
|
||||||
|
if (ref == null) return 0;
|
||||||
|
ProbeState current = ref.get();
|
||||||
|
if (current == null) return 0;
|
||||||
|
long windowMs = configInt("login.probe.window_sec", 60) * 1000L;
|
||||||
|
long remainingMs = (current.windowStartMillis + windowMs) - System.currentTimeMillis();
|
||||||
|
return remainingMs > 0 ? (remainingMs / 1000L) + 1L : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean isEnabled() {
|
private static boolean isEnabled() {
|
||||||
return Emulator.getConfig() != null
|
return Emulator.getConfig() != null
|
||||||
&& Emulator.getConfig().getBoolean("login.ratelimit.enabled", true);
|
&& Emulator.getConfig().getBoolean("login.ratelimit.enabled", true);
|
||||||
@@ -68,4 +98,5 @@ public final class AuthRateLimiter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private record State(int attempts, long windowStartMillis, long lockedUntilMillis) {}
|
private record State(int attempts, long windowStartMillis, long lockedUntilMillis) {}
|
||||||
|
private record ProbeState(int count, long windowStartMillis) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.eu.habbo.networking.gameserver.auth;
|
||||||
|
|
||||||
|
import com.eu.habbo.Emulator;
|
||||||
|
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
public final class AvailabilityCache {
|
||||||
|
|
||||||
|
private static final Map<String, Entry> EMAIL_CACHE = new ConcurrentHashMap<>();
|
||||||
|
private static final Map<String, Entry> USERNAME_CACHE = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private AvailabilityCache() {}
|
||||||
|
|
||||||
|
public static Boolean lookupEmail(String email) {
|
||||||
|
return read(EMAIL_CACHE, key(email));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Boolean lookupUsername(String username) {
|
||||||
|
return read(USERNAME_CACHE, key(username));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void storeEmail(String email, boolean available) {
|
||||||
|
write(EMAIL_CACHE, key(email), available);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void storeUsername(String username, boolean available) {
|
||||||
|
write(USERNAME_CACHE, key(username), available);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void invalidateEmail(String email) {
|
||||||
|
EMAIL_CACHE.remove(key(email));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void invalidateUsername(String username) {
|
||||||
|
USERNAME_CACHE.remove(key(username));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String key(String value) {
|
||||||
|
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Boolean read(Map<String, Entry> cache, String key) {
|
||||||
|
if (!isEnabled() || key.isEmpty()) return null;
|
||||||
|
Entry entry = cache.get(key);
|
||||||
|
if (entry == null) return null;
|
||||||
|
if (entry.expiresAt < System.currentTimeMillis()) {
|
||||||
|
cache.remove(key, entry);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.available;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void write(Map<String, Entry> cache, String key, boolean available) {
|
||||||
|
if (!isEnabled() || key.isEmpty()) return;
|
||||||
|
|
||||||
|
int maxEntries = configInt("login.probe.cache_max_entries", 10_000);
|
||||||
|
if (cache.size() >= maxEntries) evict(cache, maxEntries);
|
||||||
|
|
||||||
|
long ttlMs = configInt("login.probe.cache_ttl_sec", 60) * 1000L;
|
||||||
|
cache.put(key, new Entry(available, System.currentTimeMillis() + ttlMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void evict(Map<String, Entry> cache, int maxEntries) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
cache.values().removeIf(e -> e.expiresAt < now);
|
||||||
|
|
||||||
|
if (cache.size() < maxEntries) return;
|
||||||
|
|
||||||
|
int overflow = cache.size() - maxEntries + 1;
|
||||||
|
Iterator<String> it = cache.keySet().iterator();
|
||||||
|
while (overflow > 0 && it.hasNext()) {
|
||||||
|
it.next();
|
||||||
|
it.remove();
|
||||||
|
overflow--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isEnabled() {
|
||||||
|
return Emulator.getConfig() == null
|
||||||
|
|| Emulator.getConfig().getBoolean("login.probe.cache_enabled", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int configInt(String key, int fallback) {
|
||||||
|
return Emulator.getConfig() != null ? Emulator.getConfig().getInt(key, fallback) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Entry(boolean available, long expiresAt) {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user