From 8f59eb652f24bdbc9646b9702a2236de0d883c31 Mon Sep 17 00:00:00 2001 From: duckietm Date: Fri, 1 May 2026 16:59:34 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=99=20As=20NAcho=20wants=20it,=20add?= =?UTF-8?q?=20effect=20on=20disconnected=20user=20&=20small=20security=20u?= =?UTF-8?q?pdate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gameclients/SessionResumeManager.java | 96 ++++++++++++------- .../users/custombadge/CustomBadgeManager.java | 9 ++ .../gameserver/badges/BadgeHttpHandler.java | 58 ++++++++--- 3 files changed, 113 insertions(+), 50 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java index f2724578..1f654d35 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java @@ -1,23 +1,16 @@ package com.eu.habbo.habbohotel.gameclients; import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.rooms.Room; +import com.eu.habbo.habbohotel.rooms.RoomUnit; import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.messages.outgoing.rooms.users.RoomUserEffectComposer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; -/** - * Manages a grace period for disconnected users. Instead of immediately - * disposing a Habbo when their WebSocket drops, the Habbo is held in - * a "ghost" state for a configurable number of seconds. If the same - * user reconnects (via SSO ticket) within the grace window, their - * existing Habbo object is resumed on the new connection — keeping - * them in their room, preserving inventory state, etc. - * - * Config key: session.reconnect.grace.seconds (default: 30) - */ public class SessionResumeManager { private static final Logger LOGGER = LoggerFactory.getLogger(SessionResumeManager.class); @@ -37,12 +30,10 @@ public class SessionResumeManager { return Emulator.getConfig().getInt("session.reconnect.grace.seconds", 30); } - /** - * Park a disconnected Habbo in ghost mode. Their room presence is - * preserved, but the old GameClient channel is closed. - * - * @return true if the habbo was parked (grace period > 0), false if immediate dispose should happen - */ + public int getPausedEffectId() { + return Emulator.getConfig().getInt("session.reconnect.effect.id", 170); + } + public boolean parkHabbo(Habbo habbo, String ssoTicket) { int graceSeconds = getGracePeriodSeconds(); if (graceSeconds <= 0) { @@ -51,7 +42,6 @@ public class SessionResumeManager { int userId = habbo.getHabboInfo().getId(); - // Cancel any existing ghost session for this user GhostSession existing = ghostSessions.remove(userId); if (existing != null && existing.disposeFuture != null) { existing.disposeFuture.cancel(false); @@ -60,12 +50,18 @@ public class SessionResumeManager { LOGGER.info("[SessionResume] Parking {} (id={}) for {}s grace period", habbo.getHabboInfo().getUsername(), userId, graceSeconds); - // Restore the SSO ticket so the client can reconnect with the same ticket if (ssoTicket != null && !ssoTicket.isEmpty()) { restoreSsoTicket(userId, ssoTicket); } - // Schedule the final disconnect after the grace period + int previousEffectId = 0; + int previousEffectEnd = 0; + RoomUnit unit = habbo.getRoomUnit(); + if (unit != null) { + previousEffectId = unit.getEffectId(); + previousEffectEnd = unit.getEffectEndTimestamp(); + } + ScheduledFuture future = Emulator.getThreading().run(() -> { GhostSession ghost = ghostSessions.remove(userId); if (ghost != null) { @@ -75,22 +71,19 @@ public class SessionResumeManager { } }, graceSeconds * 1000); - ghostSessions.put(userId, new GhostSession(habbo, ssoTicket, future)); + ghostSessions.put(userId, new GhostSession(habbo, ssoTicket, future, previousEffectId, previousEffectEnd)); + + applyPausedEffect(habbo); + return true; } - /** - * Try to resume a ghost session for the given user ID. - * - * @return the parked Habbo if found within grace period, null otherwise - */ public Habbo resumeSession(int userId) { GhostSession ghost = ghostSessions.remove(userId); if (ghost == null) { return null; } - // Cancel the scheduled dispose if (ghost.disposeFuture != null) { ghost.disposeFuture.cancel(false); } @@ -98,19 +91,15 @@ public class SessionResumeManager { LOGGER.info("[SessionResume] Resuming session for {} (id={})", ghost.habbo.getHabboInfo().getUsername(), userId); + restorePausedEffect(ghost); + return ghost.habbo; } - /** - * Check if a user has a ghost session (is in grace period). - */ public boolean hasGhostSession(int userId) { return ghostSessions.containsKey(userId); } - /** - * Immediately expire all ghost sessions (e.g. on emulator shutdown). - */ public void disposeAll() { for (GhostSession ghost : ghostSessions.values()) { if (ghost.disposeFuture != null) { @@ -121,9 +110,6 @@ public class SessionResumeManager { ghostSessions.clear(); } - /** - * Perform the actual full disconnect that normally happens in Habbo.disconnect(). - */ private void performFullDisconnect(Habbo habbo) { try { habbo.getHabboInfo().setOnline(false); @@ -132,7 +118,6 @@ public class SessionResumeManager { LOGGER.error("[SessionResume] Error during deferred disconnect", e); } - // Clear the SSO ticket now that the grace period is truly over clearSsoTicket(habbo.getHabboInfo().getId()); } @@ -148,6 +133,38 @@ public class SessionResumeManager { } } + private void applyPausedEffect(Habbo habbo) { + int effectId = getPausedEffectId(); + if (effectId <= 0) return; + try { + RoomUnit unit = habbo.getRoomUnit(); + Room room = habbo.getHabboInfo() == null ? null : habbo.getHabboInfo().getCurrentRoom(); + if (unit == null || room == null) return; + int endTimestamp = Emulator.getIntUnixTimestamp() + getGracePeriodSeconds() + 10; + unit.setEffectId(effectId, endTimestamp); + room.sendComposer(new RoomUserEffectComposer(unit).compose()); + } catch (Exception e) { + LOGGER.error("[SessionResume] Failed to apply paused effect", e); + } + } + + private void restorePausedEffect(GhostSession ghost) { + try { + Habbo habbo = ghost.habbo; + RoomUnit unit = habbo.getRoomUnit(); + Room room = habbo.getHabboInfo() == null ? null : habbo.getHabboInfo().getCurrentRoom(); + if (unit == null || room == null) return; + + int pausedEffectId = getPausedEffectId(); + if (unit.getEffectId() == pausedEffectId) { + unit.setEffectId(ghost.previousEffectId, ghost.previousEffectEnd); + room.sendComposer(new RoomUserEffectComposer(unit).compose()); + } + } catch (Exception e) { + LOGGER.error("[SessionResume] Failed to restore previous effect", e); + } + } + private void clearSsoTicket(int userId) { try (var connection = Emulator.getDatabase().getDataSource().getConnection(); var statement = connection.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? LIMIT 1")) { @@ -163,11 +180,16 @@ public class SessionResumeManager { final Habbo habbo; final String ssoTicket; final ScheduledFuture disposeFuture; + final int previousEffectId; + final int previousEffectEnd; - GhostSession(Habbo habbo, String ssoTicket, ScheduledFuture disposeFuture) { + GhostSession(Habbo habbo, String ssoTicket, ScheduledFuture disposeFuture, + int previousEffectId, int previousEffectEnd) { this.habbo = habbo; this.ssoTicket = ssoTicket; this.disposeFuture = disposeFuture; + this.previousEffectId = previousEffectId; + this.previousEffectEnd = previousEffectEnd; } } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeManager.java index af6113a9..bc1b2cad 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeManager.java @@ -54,6 +54,7 @@ public class CustomBadgeManager { private final SecureRandom random = new SecureRandom(); private final Map rateBuckets = new ConcurrentHashMap<>(); private final Map textCache = new ConcurrentHashMap<>(); + private final java.util.concurrent.atomic.AtomicLong textCacheVersion = new java.util.concurrent.atomic.AtomicLong(); private volatile CustomBadgeSettings settings; @@ -74,6 +75,10 @@ public class CustomBadgeManager { return java.util.Collections.unmodifiableMap(this.textCache); } + public long getTextCacheVersion() { + return this.textCacheVersion.get(); + } + private void loadTextCache() { Map next = new java.util.HashMap<>(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); @@ -93,6 +98,7 @@ public class CustomBadgeManager { } this.textCache.clear(); this.textCache.putAll(next); + this.textCacheVersion.incrementAndGet(); LOGGER.info("CustomBadgeManager -> loaded {} custom badge texts into memory.", next.size()); } @@ -219,6 +225,7 @@ public class CustomBadgeManager { } this.textCache.put(badgeId, new BadgeText(safeName, safeDesc)); + this.textCacheVersion.incrementAndGet(); issueBadgeToInventory(userId, badgeId); return new CustomBadge(generatedId, userId, badgeId, safeName, safeDesc, now, now); @@ -264,6 +271,7 @@ public class CustomBadgeManager { String safeDesc = sanitize(description, 255); this.textCache.remove(oldBadgeId); this.textCache.put(newBadgeId, new BadgeText(safeName, safeDesc)); + this.textCacheVersion.incrementAndGet(); renameBadgeInInventory(userId, oldBadgeId, newBadgeId); deleteBadgeFileQuietly(oldBadgeId); return new CustomBadge(existing.getId(), userId, newBadgeId, safeName, safeDesc, existing.getDateCreated(), now); @@ -288,6 +296,7 @@ public class CustomBadgeManager { } this.textCache.remove(badgeId); + this.textCacheVersion.incrementAndGet(); revokeBadgeFromInventory(userId, badgeId); deleteBadgeFileQuietly(badgeId); } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/badges/BadgeHttpHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/badges/BadgeHttpHandler.java index e1e803cb..e06b9cae 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/badges/BadgeHttpHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/badges/BadgeHttpHandler.java @@ -6,6 +6,7 @@ import com.eu.habbo.habbohotel.users.custombadge.CustomBadgeException; import com.eu.habbo.habbohotel.users.custombadge.CustomBadgeManager; import com.eu.habbo.networking.gameserver.GameServerAttributes; import com.eu.habbo.networking.gameserver.auth.AccessTokenService; +import com.eu.habbo.networking.gameserver.auth.AuthRateLimiter; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -30,6 +31,9 @@ public class BadgeHttpHandler extends ChannelInboundHandlerAdapter { private static final String BASE_PATH = "/api/badges/custom"; private static final int MAX_BODY_BYTES = 128 * 1024; + private static volatile JsonObject cachedTextsResponse = null; + private static volatile long cachedTextsVersion = -1L; + @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (!(msg instanceof FullHttpRequest req)) { @@ -58,6 +62,13 @@ public class BadgeHttpHandler extends ChannelInboundHandlerAdapter { if (path.equals(BASE_PATH + "/texts")) { if (req.method() == HttpMethod.GET || req.method() == HttpMethod.HEAD) { + String ip = resolveClientIp(ctx, req); + if (!AuthRateLimiter.tryProbe(ip)) { + long secs = AuthRateLimiter.secondsUntilProbeReset(ip); + sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS, + error("Too many requests. Try again in " + secs + "s.")); + return; + } handleTexts(ctx, req); return; } @@ -116,20 +127,26 @@ public class BadgeHttpHandler extends ChannelInboundHandlerAdapter { private void handleTexts(ChannelHandlerContext ctx, FullHttpRequest req) { CustomBadgeManager manager = Emulator.getGameEnvironment().getCustomBadgeManager(); - java.util.Map cache = manager.getTextCache(); - - JsonObject texts = new JsonObject(); - for (java.util.Map.Entry entry : cache.entrySet()) { - String badgeId = entry.getKey(); - CustomBadgeManager.BadgeText value = entry.getValue(); - texts.addProperty("badge_name_" + badgeId, value.name); - texts.addProperty("badge_desc_" + badgeId, value.description); + long version = manager.getTextCacheVersion(); + JsonObject ok = cachedTextsResponse; + if (ok == null || cachedTextsVersion != version) { + java.util.Map cache = manager.getTextCache(); + JsonObject texts = new JsonObject(); + for (java.util.Map.Entry entry : cache.entrySet()) { + String badgeId = entry.getKey(); + CustomBadgeManager.BadgeText value = entry.getValue(); + texts.addProperty("badge_name_" + badgeId, value.name); + texts.addProperty("badge_desc_" + badgeId, value.description); + } + JsonObject built = new JsonObject(); + built.add("texts", texts); + built.addProperty("count", cache.size()); + built.addProperty("version", version); + cachedTextsResponse = built; + cachedTextsVersion = version; + ok = built; } - - JsonObject ok = new JsonObject(); - ok.add("texts", texts); - ok.addProperty("count", cache.size()); - sendJson(ctx, req, HttpResponseStatus.OK, ok); + sendJsonCached(ctx, req, HttpResponseStatus.OK, ok); } private void handleList(ChannelHandlerContext ctx, FullHttpRequest req, int userId) { @@ -287,6 +304,21 @@ public class BadgeHttpHandler extends ChannelInboundHandlerAdapter { return obj; } + private static void sendJsonCached(ChannelHandlerContext ctx, FullHttpRequest req, + HttpResponseStatus status, JsonObject body) { + byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8); + FullHttpResponse response = new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, status, Unpooled.wrappedBuffer(bytes)); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8"); + response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length); + response.headers().set(HttpHeaderNames.CACHE_CONTROL, "public, max-age=30"); + applyCors(req, response); + boolean keepAlive = isKeepAlive(req); + if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE); + var future = ctx.writeAndFlush(response); + if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE); + } + private static void sendJson(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, JsonObject body) { byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);