diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 022cec42..ada1fd06 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -66,7 +66,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.2.5 + 3.5.2 @@ -83,21 +83,21 @@ io.netty netty-all - 4.1.115.Final + 4.2.15.Final com.google.code.gson gson - 2.11.0 + 2.14.0 org.mariadb.jdbc mariadb-java-client - 3.5.1 + 3.5.8 runtime @@ -113,7 +113,7 @@ com.zaxxer HikariCP - 6.2.1 + 7.0.2 compile @@ -121,7 +121,7 @@ org.apache.commons commons-lang3 - 3.17.0 + 3.20.0 compile @@ -137,7 +137,7 @@ org.jsoup jsoup - 1.18.3 + 1.22.2 compile @@ -145,14 +145,14 @@ org.slf4j slf4j-api - 2.0.16 + 2.0.18 ch.qos.logback logback-classic - 1.5.15 + 1.5.34 compile @@ -160,14 +160,7 @@ org.fusesource.jansi jansi - 2.4.1 - - - - - joda-time - joda-time - 2.13.0 + 2.4.3 org.junit.jupiter junit-jupiter - 5.10.2 + 6.1.0 test diff --git a/Emulator/src/main/java/com/eu/habbo/database/DatabasePool.java b/Emulator/src/main/java/com/eu/habbo/database/DatabasePool.java index acd50764..813f8f15 100644 --- a/Emulator/src/main/java/com/eu/habbo/database/DatabasePool.java +++ b/Emulator/src/main/java/com/eu/habbo/database/DatabasePool.java @@ -79,6 +79,14 @@ class DatabasePool { databaseConfiguration.addDataSourceProperty("useLocalSessionState", "true"); databaseConfiguration.addDataSourceProperty("cacheResultSetMetadata", "true"); databaseConfiguration.addDataSourceProperty("elideSetAutoCommits", "true"); + + // Fail fast instead of pinning a pooled connection (and its worker + // thread) indefinitely on a stalled/slow MariaDB. HikariCP's + // connectionTimeout only bounds the pool *borrow*; these bound the + // actual socket/connect round-trip. Overridable via db.params. + databaseConfiguration.addDataSourceProperty("socketTimeout", "30000"); + databaseConfiguration.addDataSourceProperty("connectTimeout", "10000"); + databaseConfiguration.addDataSourceProperty("tcpKeepAlive", "true"); databaseConfiguration.addDataSourceProperty("maintainTimeStats", "false"); databaseConfiguration.setPoolName("HabboHikariPool"); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/achievements/AchievementManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/achievements/AchievementManager.java index 7e5ecdb8..fcc5ba99 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/achievements/AchievementManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/achievements/AchievementManager.java @@ -100,9 +100,9 @@ public class AchievementManager { if (oldLevel != null && (oldLevel.level == achievement.levels.size() && currentProgress >= oldLevel.progress)) //Maximum achievement gotten. return; - habbo.getHabboStats().setProgress(achievement, currentProgress + amount); + int newProgress = habbo.getHabboStats().incrementProgress(achievement, amount); - AchievementLevel newLevel = achievement.getLevelForProgress(currentProgress + amount); + AchievementLevel newLevel = achievement.getLevelForProgress(newProgress); if (AchievementManager.TALENTTRACK_ENABLED) { for (TalentTrackType type : TalentTrackType.values()) { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java index ff2b1d72..7fd3ee93 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/bots/BotManager.java @@ -188,7 +188,11 @@ public class BotManager { if (pickedUpEvent.isCancelled()) return; - if (habbo == null || (bot.getOwnerId() == habbo.getHabboInfo().getId() || habbo.hasPermission(Permission.ACC_ANYROOMOWNER))) { + Room currentRoom = habbo != null ? habbo.getHabboInfo().getCurrentRoom() : null; + if (habbo == null + || bot.getOwnerId() == habbo.getHabboInfo().getId() + || habbo.hasPermission(Permission.ACC_ANYROOMOWNER) + || (currentRoom != null && (currentRoom.getOwnerId() == habbo.getHabboInfo().getId() || habbo.hasPermission(Permission.ACC_PLACEFURNI)))) { if (habbo != null && !habbo.hasPermission(Permission.ACC_UNLIMITED_BOTS) && habbo.getInventory().getBotsComponent().getBots().size() >= BotManager.MAXIMUM_BOT_INVENTORY_SIZE) { habbo.alert(Emulator.getTexts().getValue("error.bots.max.inventory").replace("%amount%", BotManager.MAXIMUM_BOT_INVENTORY_SIZE + "")); return; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java index edbcd4a3..e7952892 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlace.java @@ -171,8 +171,9 @@ public class MarketPlace { statement.setInt(paramIndex++, maxPrice); } if (!search.isEmpty()) { - statement.setString(paramIndex++, "%" + search + "%"); - statement.setString(paramIndex++, "%" + search + "%"); + String likeSearch = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(search) + "%"; + statement.setString(paramIndex++, likeSearch); + statement.setString(paramIndex++, likeSearch); } try (ResultSet set = statement.executeQuery()) { @@ -278,8 +279,9 @@ public class MarketPlace { return; } + int soldTimestamp = Emulator.getIntUnixTimestamp(); try (PreparedStatement updateOffer = connection.prepareStatement("UPDATE marketplace_items SET state = 2, sold_timestamp = ? WHERE id = ? AND state = 1")) { - updateOffer.setInt(1, Emulator.getIntUnixTimestamp()); + updateOffer.setInt(1, soldTimestamp); updateOffer.setInt(2, offerId); int updated = updateOffer.executeUpdate(); if (updated == 0) { @@ -306,7 +308,11 @@ public class MarketPlace { client.sendResponse(new MarketplaceBuyErrorComposer(MarketplaceBuyErrorComposer.REFRESH, 0, offerId, price)); if (habbo != null) { - habbo.getInventory().getOffer(offerId).setState(MarketPlaceState.SOLD); + MarketPlaceOffer offer = habbo.getInventory().getOffer(offerId); + if (offer != null) { + offer.setState(MarketPlaceState.SOLD); + offer.setSoldTimestamp(soldTimestamp); + } } } } @@ -368,6 +374,11 @@ public class MarketPlace { event.item.setFromGift(false); MarketPlaceOffer offer = new MarketPlaceOffer(event.item, event.price, client.getHabbo()); + if (!offer.isPersisted()) { + LOGGER.warn("Marketplace offer insert failed for user {} item {}", client.getHabbo().getHabboInfo().getId(), event.item.getId()); + return false; + } + client.getHabbo().getInventory().addMarketplaceOffer(offer); client.getHabbo().getInventory().getItemsComponent().removeHabboItem(event.item); item.setUserId(-1); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOffer.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOffer.java index 37bd2ab1..ccf0dda1 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOffer.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOffer.java @@ -98,6 +98,10 @@ public class MarketPlaceOffer implements Runnable { return this.offerId; } + public boolean isPersisted() { + return this.offerId > 0; + } + public void setOfferId(int offerId) { this.offerId = offerId; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/BanCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/BanCommand.java index c95a5b9a..90b2ea57 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/BanCommand.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/BanCommand.java @@ -9,6 +9,8 @@ import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.habbohotel.users.HabboInfo; import com.eu.habbo.habbohotel.users.HabboManager; +import java.util.List; + public class BanCommand extends Command { public BanCommand() { super("cmd_ban", Emulator.getTexts().getValue("commands.keys.cmd_ban").split(";")); @@ -72,7 +74,13 @@ public class BanCommand extends Command { } } - ModToolBan ban = Emulator.getGameEnvironment().getModToolManager().ban(target.getId(), gameClient.getHabbo(), reason.toString(), banTime, ModToolBanType.ACCOUNT, -1).get(0); + List bans = Emulator.getGameEnvironment().getModToolManager().ban(target.getId(), gameClient.getHabbo(), reason.toString(), banTime, ModToolBanType.ACCOUNT, -1); + if (bans == null || bans.isEmpty()) { + gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_ban.user_offline"), RoomChatMessageBubbles.ALERT); + return true; + } + + ModToolBan ban = bans.get(0); gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_ban.ban_issued").replace("%user%", target.getUsername()).replace("%time%", ban.expireDate - Emulator.getIntUnixTimestamp() + "").replace("%reason%", ban.reason), RoomChatMessageBubbles.ALERT); return true; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/IPBanCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/IPBanCommand.java index e37bb1e0..8622cec9 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/IPBanCommand.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/IPBanCommand.java @@ -8,6 +8,8 @@ import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.habbohotel.users.HabboInfo; import com.eu.habbo.habbohotel.users.HabboManager; +import java.util.List; + public class IPBanCommand extends Command { public final static int TEN_YEARS = 315569260; @@ -50,12 +52,12 @@ public class IPBanCommand extends Command { return true; } - Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1); - count++; + List bans = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1); + count += bans != null ? bans.size() : 0; for (Habbo h : Emulator.getGameServer().getGameClientManager().getHabbosWithIP(habbo.getIpLogin())) { if (h != null) { - count++; - Emulator.getGameEnvironment().getModToolManager().ban(h.getHabboInfo().getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1); + bans = Emulator.getGameEnvironment().getModToolManager().ban(h.getHabboInfo().getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1); + count += bans != null ? bans.size() : 0; } } } else { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/MachineBanCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/MachineBanCommand.java index 56f5b0fb..29918bb9 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/MachineBanCommand.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/MachineBanCommand.java @@ -8,6 +8,8 @@ import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.habbohotel.users.HabboInfo; import com.eu.habbo.habbohotel.users.HabboManager; +import java.util.List; + public class MachineBanCommand extends Command { public MachineBanCommand() { super("cmd_machine_ban", Emulator.getTexts().getValue("commands.keys.cmd_machine_ban").split(";")); @@ -46,7 +48,8 @@ public class MachineBanCommand extends Command { return true; } - count = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), IPBanCommand.TEN_YEARS, ModToolBanType.MACHINE, -1).size(); + List bans = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), IPBanCommand.TEN_YEARS, ModToolBanType.MACHINE, -1); + count = bans != null ? bans.size() : 0; } else { @@ -58,4 +61,4 @@ public class MachineBanCommand extends Command { return true; } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClient.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClient.java index 7ffe5228..87d5fb3d 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClient.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClient.java @@ -149,6 +149,10 @@ public class GameClient { } public void dispose() { + this.dispose(true); + } + + public void dispose(boolean allowSessionResume) { try { this.channel.close(); @@ -161,7 +165,7 @@ public class GameClient { // appena ripristinata (era la causa del "Bye"/kick al 2° reconnect). if (this.habbo.getClient() == this && this.habbo.isOnline()) { // Try to park the habbo in the grace period instead of immediate disconnect - boolean parked = SessionResumeManager.getInstance().parkHabbo(this.habbo, this.ssoTicket); + boolean parked = allowSessionResume && SessionResumeManager.getInstance().parkHabbo(this.habbo, this.ssoTicket); if (!parked) { // No grace period configured — immediate disconnect as before @@ -177,4 +181,4 @@ public class GameClient { LOGGER.error("Caught exception", e); } } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClientManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClientManager.java index cd0602cb..d07e33b7 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClientManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/GameClientManager.java @@ -43,14 +43,34 @@ public class GameClientManager { public void disposeClient(GameClient client) { - this.disposeClient(client.getChannel()); + if (client == null) { + return; + } + + this.disposeClient(client.getChannel(), true); + } + + public void forceDisposeClient(GameClient client) { + if (client == null) { + return; + } + + this.disposeClient(client.getChannel(), false); } private void disposeClient(Channel channel) { + this.disposeClient(channel, true); + } + + private void disposeClient(Channel channel, boolean allowSessionResume) { + if (channel == null) { + return; + } + GameClient client = channel.attr(GameServerAttributes.CLIENT).get(); if (client != null) { - client.dispose(); + client.dispose(allowSessionResume); } channel.deregister(); channel.attr(GameServerAttributes.CLIENT).set(null); @@ -190,4 +210,4 @@ public class GameClientManager { CFKeepAlive(); }, 30000); } -} \ No newline at end of file +} 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 e8099bbc..5052168f 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 @@ -71,6 +71,15 @@ public class SessionResumeManager { } }, graceSeconds * 1000); + if (future == null) { + // The scheduler refused the grace-expiry task (pool saturated or + // shutting down). Parking now would leave a GhostSession that nothing + // can ever reap (the Habbo + room refs pinned for the JVM lifetime), + // so disconnect immediately instead. + performFullDisconnect(habbo); + return false; + } + ghostSessions.put(userId, new GhostSession(habbo, ssoTicket, future, previousEffectId, previousEffectEnd)); applyPausedEffect(habbo); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java index 85349452..b835a5f5 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/GuildManager.java @@ -421,9 +421,9 @@ public class GuildManager { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT users.username, users.look, guilds_members.* FROM guilds_members INNER JOIN users ON guilds_members.user_id = users.id WHERE guilds_members.guild_id = ? " + (rankQuery(levelId)) + " AND users.username LIKE ? ORDER BY level_id, member_since ASC LIMIT ?, ?")) { statement.setInt(1, guild.getId()); - statement.setString(2, "%" + query + "%"); + statement.setString(2, "%" + com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%"); statement.setInt(3, page * 14); - statement.setInt(4, (page * 14) + 14); + statement.setInt(4, 14); try (ResultSet set = statement.executeQuery()) { while (set.next()) { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThread.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThread.java index 0bc0c265..54517e4e 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThread.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThread.java @@ -101,21 +101,27 @@ public class ForumThread implements Runnable, ISerialize { if (statement.executeUpdate() < 1) return null; - ResultSet set = statement.getGeneratedKeys(); - if (set.next()) { - int threadId = set.getInt(1); - createdThread = new ForumThread(threadId, guild.getId(), opener.getHabboInfo().getId(), subject, 0, timestamp, timestamp, ForumThreadState.OPEN, false, false, 0, null); - cacheThread(createdThread); - - ForumThreadComment comment = ForumThreadComment.create(createdThread, opener, message); - createdThread.addComment(comment); - - Emulator.getPluginManager().fireEvent(new GuildForumThreadCreated(createdThread)); + try (ResultSet set = statement.getGeneratedKeys()) { + if (set.next()) { + int threadId = set.getInt(1); + createdThread = new ForumThread(threadId, guild.getId(), opener.getHabboInfo().getId(), subject, 0, timestamp, timestamp, ForumThreadState.OPEN, false, false, 0, null); + cacheThread(createdThread); + } } } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); } + // ForumThreadComment.create() opens its OWN connection; do it after the + // thread's connection has been released to avoid holding two pooled + // connections simultaneously per forum-thread creation. + if (createdThread != null) { + ForumThreadComment comment = ForumThreadComment.create(createdThread, opener, message); + createdThread.addComment(comment); + + Emulator.getPluginManager().fireEvent(new GuildForumThreadCreated(createdThread)); + } + return createdThread; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThreadComment.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThreadComment.java index c404ddb5..99b6cf61 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThreadComment.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/guilds/forums/ForumThreadComment.java @@ -98,12 +98,13 @@ public class ForumThreadComment implements Runnable, ISerialize { if (statement.executeUpdate() < 1) return null; - ResultSet set = statement.getGeneratedKeys(); - if (set.next()) { - int commentId = set.getInt(1); - createdComment = new ForumThreadComment(commentId, thread.getThreadId(), poster.getHabboInfo().getId(), message, timestamp, ForumThreadState.OPEN, 0); + try (ResultSet set = statement.getGeneratedKeys()) { + if (set.next()) { + int commentId = set.getInt(1); + createdComment = new ForumThreadComment(commentId, thread.getThreadId(), poster.getHabboInfo().getId(), message, timestamp, ForumThreadState.OPEN, 0); - Emulator.getPluginManager().fireEvent(new GuildForumThreadCommentCreated(createdComment)); + Emulator.getPluginManager().fireEvent(new GuildForumThreadCommentCreated(createdComment)); + } } } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java index 0b3aa615..cc844510 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java @@ -115,32 +115,39 @@ public class FurnidataWatcher { } } - private void onChange() { + private void onChange() throws InterruptedException { + // Re-index under the shared furnidata lock so the watcher and editor + // writes never swap the index concurrently. The lock is released before + // the throttle/broadcast below so a slow broadcast can't stall editor saves. + List delta; FurnidataLock.LOCK.lock(); try { Path source = this.provider.getSource(); if (source == null) return; - - List delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read()); - if (delta.isEmpty()) return; - - long now = System.currentTimeMillis(); - if (now - this.lastBroadcast < this.minIntervalMs) { - LOGGER.info("FurnidataWatcher: {} changes indexed but broadcast skipped (min interval) — clients update on next change or reconnect", delta.size()); - return; - } - this.lastBroadcast = now; - - FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap) - ? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of()) - : new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta); - - broadcast(composer); - LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)", - delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size()); + delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read()); } finally { FurnidataLock.LOCK.unlock(); } + if (delta.isEmpty()) return; + + // Min-interval throttle: the index has already been swapped, so we must + // not drop this delta (the next reindex would diff against the updated + // index and never re-emit it). Instead, defer the broadcast until the + // interval elapses. Running on a dedicated daemon thread, sleeping is + // safe; file events arriving meanwhile coalesce into the next cycle. + long sinceLast = System.currentTimeMillis() - this.lastBroadcast; + if (sinceLast < this.minIntervalMs) { + Thread.sleep(this.minIntervalMs - sinceLast); + } + this.lastBroadcast = System.currentTimeMillis(); + + FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap) + ? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of()) + : new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta); + + broadcast(composer); + LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)", + delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size()); } private void broadcast(FurnitureDataReloadComposer composer) { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionGift.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionGift.java index f00252f2..8a7ec640 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionGift.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionGift.java @@ -13,11 +13,13 @@ import org.slf4j.LoggerFactory; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.concurrent.atomic.AtomicBoolean; public class InteractionGift extends HabboItem { private static final Logger LOGGER = LoggerFactory.getLogger(InteractionGift.class); public boolean explode = false; + private final AtomicBoolean opening = new AtomicBoolean(false); private int[] itemId; private int colorId = 0; private int ribbonId = 0; @@ -46,6 +48,15 @@ public class InteractionGift extends HabboItem { } } + /** + * Claims the right to open this gift, returning true exactly once. Guards + * against two near-simultaneous OpenRecycleBox packets both scheduling an + * (async, delayed) OpenGift before the wrapper is removed from the room. + */ + public boolean tryStartOpening() { + return this.opening.compareAndSet(false, true); + } + @Override public void serializeExtradata(ServerMessage serverMessage) { //serverMessage.appendInt(this.colorId * 1000 + this.ribbonId); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionWired.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionWired.java index 6054066e..ffbf6996 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionWired.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/InteractionWired.java @@ -18,6 +18,7 @@ import java.sql.SQLException; import java.util.Iterator; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; /** * Base abstract class for all wired furniture items (triggers, effects, conditions, extras). @@ -61,7 +62,11 @@ public abstract class InteractionWired extends InteractionDefault { */ private static final long CACHE_EXPIRY_MS = 5 * 60 * 1000; - private long cooldown; + private volatile long cooldown; + // Ensures one box is processed by a single thread at a time, so the + // cooldown check-and-set in WiredHandler can't double-fire when a packet + // thread and the room cycle thread trigger the same box concurrently. + private final AtomicBoolean processing = new AtomicBoolean(false); private final ConcurrentHashMap userExecutionCache = new ConcurrentHashMap<>(); InteractionWired(ResultSet set, Item baseItem) throws SQLException { @@ -149,6 +154,15 @@ public abstract class InteractionWired extends InteractionDefault { this.cooldown = newMillis; } + /** Claims exclusive processing of this box; returns false if another thread is already in it. */ + public boolean tryBeginProcessing() { + return this.processing.compareAndSet(false, true); + } + + public void endProcessing() { + this.processing.set(false); + } + @Override public boolean allowWiredResetState() { return false; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/pets/InteractionPetBreedingNest.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/pets/InteractionPetBreedingNest.java index 78880aab..075efac1 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/pets/InteractionPetBreedingNest.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/pets/InteractionPetBreedingNest.java @@ -190,6 +190,14 @@ public class InteractionPetBreedingNest extends HabboItem { } public void breed(Habbo habbo, String name, int petOneId, int petTwoId) { + // Guard before the destructive delete below: a crafted packet can call + // this on a nest that isn't full, which would delete the nest furni and + // then NPE on petOne/petTwo in the async runnable (losing the furni). + if (habbo == null || this.petOne == null || this.petTwo == null + || habbo.getHabboInfo().getCurrentRoom() == null) { + return; + } + Emulator.getThreading().run(new QueryDeleteHabboItem(this.getId())); this.setExtradata("2"); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionHabboCount.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionHabboCount.java index 6cd6e951..98010557 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionHabboCount.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionHabboCount.java @@ -65,8 +65,14 @@ public class WiredConditionHabboCount extends InteractionWiredCondition { } else { String[] data = wiredData.split(":"); - this.lowerLimit = Integer.parseInt(data[0]); - this.upperLimit = Integer.parseInt(data[1]); + if (data.length >= 2) { + try { + this.lowerLimit = Integer.parseInt(data[0].trim()); + this.upperLimit = Integer.parseInt(data[1].trim()); + } catch (NumberFormatException ignored) { + // malformed legacy data — keep the constructed defaults + } + } this.userSource = WiredSourceUtil.SOURCE_TRIGGER; } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionMatchStatePosition.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionMatchStatePosition.java index ddc0477e..36e721fc 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionMatchStatePosition.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionMatchStatePosition.java @@ -263,22 +263,29 @@ public class WiredConditionMatchStatePosition extends InteractionWiredCondition } else { String[] data = wiredData.split(":"); - int itemCount = Integer.parseInt(data[0]); + if (data.length >= 5) { + try { + int itemCount = Integer.parseInt(data[0]); - String[] items = data[1].split(";"); + String[] items = data[1].split(";"); - for (int i = 0; i < itemCount; i++) { - String[] stuff = items[i].split("-"); + for (int i = 0; i < itemCount && i < items.length; i++) { + String[] stuff = items[i].split("-"); - if (stuff.length >= 6) - this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]), Double.parseDouble(stuff[5]))); - else if (stuff.length >= 5) - this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]))); + if (stuff.length >= 6) + this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]), Double.parseDouble(stuff[5]))); + else if (stuff.length >= 5) + this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]))); + } + + this.state = data[2].equals("1"); + this.direction = data[3].equals("1"); + this.position = data[4].equals("1"); + } catch (NumberFormatException ignored) { + // malformed legacy data — keep whatever was parsed plus defaults + } } - this.state = data[2].equals("1"); - this.direction = data[3].equals("1"); - this.position = data[4].equals("1"); this.altitude = false; this.furniSource = this.settings.isEmpty() ? WiredSourceUtil.SOURCE_TRIGGER : WiredSourceUtil.SOURCE_SELECTED; this.quantifier = QUANTIFIER_ALL; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionNotHabboCount.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionNotHabboCount.java index a2bb7056..e993d19f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionNotHabboCount.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/conditions/WiredConditionNotHabboCount.java @@ -64,8 +64,14 @@ public class WiredConditionNotHabboCount extends InteractionWiredCondition { this.userSource = data.userSource; } else { String[] data = wiredData.split(":"); - this.lowerLimit = Integer.parseInt(data[0]); - this.upperLimit = Integer.parseInt(data[1]); + if (data.length >= 2) { + try { + this.lowerLimit = Integer.parseInt(data[0].trim()); + this.upperLimit = Integer.parseInt(data[1].trim()); + } catch (NumberFormatException ignored) { + // malformed legacy data — keep the constructed defaults + } + } this.userSource = WiredSourceUtil.SOURCE_TRIGGER; } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectMoveRotateFurni.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectMoveRotateFurni.java index 52bbe142..8ef38fe1 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectMoveRotateFurni.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectMoveRotateFurni.java @@ -190,10 +190,15 @@ public class WiredEffectMoveRotateFurni extends InteractionWiredEffect implement } for (String s : data[3].split("\r")) { - HabboItem item = room.getHabboItem(Integer.parseInt(s)); + if (s.trim().isEmpty()) continue; + try { + HabboItem item = room.getHabboItem(Integer.parseInt(s.trim())); - if (item != null) - this.items.add(item); + if (item != null) + this.items.add(item); + } catch (NumberFormatException ignored) { + // skip malformed furni id token + } } } this.furniSource = this.items.isEmpty() ? WiredSourceUtil.SOURCE_TRIGGER : WiredSourceUtil.SOURCE_SELECTED; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectTeleport.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectTeleport.java index 92c60e46..6a5e4b59 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectTeleport.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectTeleport.java @@ -151,7 +151,7 @@ public class WiredEffectTeleport extends InteractionWiredEffect { @Override public boolean execute(InteractionWiredTrigger object) { if (!object.isTriggeredByRoomUnit()) { - invalidTriggers.add(object.getId()); + invalidTriggers.add(object.getBaseItem().getSpriteId()); } return true; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectUserFurniBase.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectUserFurniBase.java index 359e463b..7973090d 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectUserFurniBase.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/effects/WiredEffectUserFurniBase.java @@ -252,7 +252,7 @@ public abstract class WiredEffectUserFurniBase extends InteractionWiredEffect { @Override public boolean execute(InteractionWiredTrigger object) { if (!object.isTriggeredByRoomUnit()) { - invalidTriggers.add(object.getId()); + invalidTriggers.add(object.getBaseItem().getSpriteId()); } return true; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java index d8de5ab4..344524dc 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java @@ -227,6 +227,18 @@ public final class WiredVariableReferenceSupport { USER_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); } + /** + * Drops all cached shared-variable assignments belonging to a room. Both + * caches are keyed "roomId:itemId[:userId]", so the trailing colon makes the + * prefix match the exact room id. Called on room dispose so the static caches + * don't retain entries for the JVM lifetime. + */ + public static void invalidateRoom(int roomId) { + String prefix = roomId + ":"; + USER_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); + ROOM_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); + } + public static SharedRoomAssignment getSharedRoomAssignment(WiredExtraVariableReference reference) { if (reference == null || !reference.isRoomReference()) { return null; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java index f60c1b49..5dacc0cc 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/messenger/Messenger.java @@ -53,7 +53,7 @@ public class Messenger { public static THashSet searchUsers(String username) { THashSet users = new THashSet<>(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT * FROM users WHERE username LIKE ? ORDER BY username ASC LIMIT " + Emulator.getConfig().getInt("hotel.messenger.search.maxresults"))) { - statement.setString(1, username + "%"); + statement.setString(1, com.eu.habbo.util.SqlLikeEscaper.escape(username) + "%"); try (ResultSet set = statement.executeQuery()) { while (set.next()) { users.add(new MessengerBuddy(set, false)); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLog.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLog.java new file mode 100644 index 00000000..0507115e --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/HousekeepingAuditLog.java @@ -0,0 +1,97 @@ +package com.eu.habbo.habbohotel.modtool; + +import com.eu.habbo.Emulator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; + +/** + * Append-only audit trail for privileged housekeeping/admin actions (rank grants, + * currency grants, etc.). There was previously no record of which operator did + * what to whom. Writes are dispatched off the calling thread; the backing table + * is created on first use so no manual migration is required. + */ +public final class HousekeepingAuditLog { + + private static final Logger LOGGER = LoggerFactory.getLogger(HousekeepingAuditLog.class); + + private static volatile boolean tableReady = false; + + private HousekeepingAuditLog() { + } + + /** + * Records a privileged action asynchronously. + * + * @param operatorId the acting staff member's user id + * @param operatorName the acting staff member's username + * @param action a short action key, e.g. {@code "user.set_rank"} + * @param targetUserId the affected user's id (0 if not applicable) + * @param detail free-form detail, e.g. {@code "rankId=6"} (capped to 512 chars) + * @param ip the operator's IP, for correlation + */ + public static void log(int operatorId, String operatorName, String action, int targetUserId, String detail, String ip) { + Emulator.getThreading().run(() -> writeEntry(operatorId, operatorName, action, targetUserId, detail, ip)); + } + + private static void writeEntry(int operatorId, String operatorName, String action, int targetUserId, String detail, String ip) { + ensureTable(); + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO housekeeping_log (operator_id, operator_name, action, target_user_id, detail, ip, timestamp) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)")) { + statement.setInt(1, operatorId); + statement.setString(2, operatorName != null ? operatorName : ""); + statement.setString(3, action != null ? action : ""); + statement.setInt(4, targetUserId); + statement.setString(5, truncate(detail)); + statement.setString(6, ip != null ? ip : ""); + statement.setInt(7, Emulator.getIntUnixTimestamp()); + statement.execute(); + } catch (SQLException e) { + LOGGER.error("Failed to write housekeeping audit log entry", e); + } + } + + private static String truncate(String detail) { + if (detail == null) return ""; + return detail.length() > 512 ? detail.substring(0, 512) : detail; + } + + private static void ensureTable() { + if (tableReady) { + return; + } + synchronized (HousekeepingAuditLog.class) { + if (tableReady) { + return; + } + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + Statement statement = connection.createStatement()) { + statement.execute( + "CREATE TABLE IF NOT EXISTS housekeeping_log (" + + "id INT UNSIGNED NOT NULL AUTO_INCREMENT, " + + "operator_id INT NOT NULL, " + + "operator_name VARCHAR(64) NOT NULL DEFAULT '', " + + "action VARCHAR(64) NOT NULL, " + + "target_user_id INT NOT NULL DEFAULT 0, " + + "detail VARCHAR(512) NOT NULL DEFAULT '', " + + "ip VARCHAR(64) NOT NULL DEFAULT '', " + + "timestamp INT NOT NULL, " + + "PRIMARY KEY (id), " + + "KEY idx_operator (operator_id), " + + "KEY idx_target (target_user_id), " + + "KEY idx_timestamp (timestamp)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + tableReady = true; + } catch (SQLException e) { + LOGGER.error("Failed to create housekeeping_log table", e); + } + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java index 03546504..0f59478d 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/modtool/ModToolManager.java @@ -378,7 +378,9 @@ public class ModToolManager { statement.setString(6, reason); statement.setString(7, type.getType()); - try (ResultSet set = statement.executeQuery()) { + statement.executeUpdate(); + + try (ResultSet set = statement.getGeneratedKeys()) { if (set.next()) { try (PreparedStatement selectBanStatement = connection.prepareStatement("SELECT * FROM bans WHERE id = ? LIMIT 1")) { selectBanStatement.setInt(1, set.getInt(1)); @@ -434,6 +436,10 @@ public class ModToolManager { Habbo target = Emulator.getGameEnvironment().getHabboManager().getHabbo(targetUserId); HabboInfo offlineInfo = target != null ? target.getHabboInfo() : HabboManager.getOfflineHabboInfo(targetUserId); + if (offlineInfo == null) { + return bans; + } + if (moderator.getHabboInfo().getRank().getId() < offlineInfo.getRank().getId()) { return bans; } @@ -454,7 +460,7 @@ public class ModToolManager { bans.add(ban); if (target != null) { - Emulator.getGameServer().getGameClientManager().disposeClient(target.getClient()); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(target.getClient()); } if ((type == ModToolBanType.IP || type == ModToolBanType.SUPER) && target != null && !ban.ip.equals("offline")) { @@ -465,7 +471,7 @@ public class ModToolManager { Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban)); Emulator.getThreading().run(ban); bans.add(ban); - Emulator.getGameServer().getGameClientManager().disposeClient(h.getClient()); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(h.getClient()); } } @@ -477,7 +483,7 @@ public class ModToolManager { Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban)); Emulator.getThreading().run(ban); bans.add(ban); - Emulator.getGameServer().getGameClientManager().disposeClient(h.getClient()); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(h.getClient()); } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java index fa31b151..1a69f83f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java @@ -158,10 +158,12 @@ public class Room implements Comparable, ISerialize, Runnable { private String tags; private boolean publicRoom; private boolean staffPromotedRoom; - private boolean allowPets; - private boolean allowPetsEat; + // Read every room cycle (processBots/processPets) but written from settings/ + // admin packet handlers on another thread — volatile for cross-thread visibility. + private volatile boolean allowPets; + private volatile boolean allowPetsEat; private boolean allowWalkthrough; - private boolean allowBotsWalk; + private volatile boolean allowBotsWalk; private boolean allowEffects; private boolean hideWall; private int chatMode; @@ -1026,6 +1028,10 @@ public class Room implements Comparable, ISerialize, Runnable { com.eu.habbo.habbohotel.wired.core.WiredManager.getEngine().clearRoomDiagnostics(this.id); } + // Drop this room's shared wired-variable assignment caches (otherwise + // they accrue per (room, item, user) for the JVM lifetime). + com.eu.habbo.habbohotel.items.interactions.wired.extra.WiredVariableReferenceSupport.invalidateRoom(this.id); + this.itemManager.clear(); this.unitManager.clearQueue(); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java index f79ca2b9..da3ccc3a 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomCycleManager.java @@ -300,15 +300,20 @@ public class RoomCycleManager { return; } - TIntObjectIterator botIterator = currentBots.iterator(); - for (int i = currentBots.size(); i-- > 0; ) { + // Snapshot under the map monitor (currentBots is a synchronizedMap whose + // iterator isn't concurrency-safe), then cycle OFF-lock. Holding the + // monitor across the whole tick would block bot place/pickup and room + // dispose for the tick duration AND invert the lock order vs + // roomUnitLock -> currentBots taken by RoomUnitManager.addBot/clear. + final ArrayList bots; + synchronized (currentBots) { + bots = new ArrayList<>(currentBots.valueCollection()); + } + + for (Bot bot : bots) { try { - final Bot bot; - try { - botIterator.advance(); - bot = botIterator.value(); - } catch (Exception e) { - break; + if (bot == null || bot.getRoomUnit() == null) { + continue; } if (!this.room.isAllowBotsWalk() && bot.getRoomUnit().isWalking()) { @@ -322,10 +327,8 @@ public class RoomCycleManager { if (this.cycleRoomUnit(bot.getRoomUnit(), RoomUnitType.BOT)) { updatedUnit.add(bot.getRoomUnit()); } - - } catch (NoSuchElementException e) { + } catch (Exception e) { LOGGER.error("Caught exception", e); - break; } } } @@ -339,31 +342,37 @@ public class RoomCycleManager { return; } - TIntObjectIterator petIterator = currentPets.iterator(); - for (int i = currentPets.size(); i-- > 0; ) { + // Snapshot under the monitor, then cycle off-lock (see processBots): avoids + // holding currentPets for the whole tick and the roomUnitLock inversion. + final ArrayList pets; + synchronized (currentPets) { + pets = new ArrayList<>(currentPets.valueCollection()); + } + + for (Pet pet : pets) { try { - petIterator.advance(); - } catch (NoSuchElementException e) { + if (pet == null || pet.getRoomUnit() == null) { + continue; + } + + if (this.cycleRoomUnit(pet.getRoomUnit(), RoomUnitType.PET)) { + updatedUnit.add(pet.getRoomUnit()); + } + + pet.cycle(); + + if (pet.packetUpdate) { + updatedUnit.add(pet.getRoomUnit()); + pet.packetUpdate = false; + } + + if (pet.getRoomUnit().isWalking() && pet.getRoomUnit().getPath().size() == 1 + && pet.getRoomUnit().hasStatus(RoomUnitStatus.GESTURE)) { + pet.getRoomUnit().removeStatus(RoomUnitStatus.GESTURE); + updatedUnit.add(pet.getRoomUnit()); + } + } catch (Exception e) { LOGGER.error("Caught exception", e); - break; - } - - Pet pet = petIterator.value(); - if (this.cycleRoomUnit(pet.getRoomUnit(), RoomUnitType.PET)) { - updatedUnit.add(pet.getRoomUnit()); - } - - pet.cycle(); - - if (pet.packetUpdate) { - updatedUnit.add(pet.getRoomUnit()); - pet.packetUpdate = false; - } - - if (pet.getRoomUnit().isWalking() && pet.getRoomUnit().getPath().size() == 1 - && pet.getRoomUnit().hasStatus(RoomUnitStatus.GESTURE)) { - pet.getRoomUnit().removeStatus(RoomUnitStatus.GESTURE); - updatedUnit.add(pet.getRoomUnit()); } } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomItemManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomItemManager.java index d823d8ea..b307d989 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomItemManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomItemManager.java @@ -167,17 +167,22 @@ public class RoomItemManager { */ public THashSet getFloorItems() { THashSet items = new THashSet<>(); - TIntObjectIterator iterator = this.roomItems.iterator(); + // roomItems is a TCollections.synchronizedMap; its iterator is not safe + // against concurrent put/remove (item place/pickup), so hold the map + // monitor for the whole traversal, matching the mutation sites. + synchronized (this.roomItems) { + TIntObjectIterator iterator = this.roomItems.iterator(); - for (int i = this.roomItems.size(); i-- > 0; ) { - try { - iterator.advance(); - } catch (Exception e) { - break; - } + for (int i = this.roomItems.size(); i-- > 0; ) { + try { + iterator.advance(); + } catch (Exception e) { + break; + } - if (iterator.value().getBaseItem().getType() == FurnitureType.FLOOR) { - items.add(iterator.value()); + if (iterator.value().getBaseItem().getType() == FurnitureType.FLOOR) { + items.add(iterator.value()); + } } } @@ -189,17 +194,19 @@ public class RoomItemManager { */ public THashSet getWallItems() { THashSet items = new THashSet<>(); - TIntObjectIterator iterator = this.roomItems.iterator(); + synchronized (this.roomItems) { + TIntObjectIterator iterator = this.roomItems.iterator(); - for (int i = this.roomItems.size(); i-- > 0; ) { - try { - iterator.advance(); - } catch (Exception e) { - break; - } + for (int i = this.roomItems.size(); i-- > 0; ) { + try { + iterator.advance(); + } catch (Exception e) { + break; + } - if (iterator.value().getBaseItem().getType() == FurnitureType.WALL) { - items.add(iterator.value()); + if (iterator.value().getBaseItem().getType() == FurnitureType.WALL) { + items.add(iterator.value()); + } } } @@ -211,18 +218,20 @@ public class RoomItemManager { */ public THashSet getPostItNotes() { THashSet items = new THashSet<>(); - TIntObjectIterator iterator = this.roomItems.iterator(); + synchronized (this.roomItems) { + TIntObjectIterator iterator = this.roomItems.iterator(); - for (int i = this.roomItems.size(); i-- > 0; ) { - try { - iterator.advance(); - } catch (Exception e) { - break; - } + for (int i = this.roomItems.size(); i-- > 0; ) { + try { + iterator.advance(); + } catch (Exception e) { + break; + } - if (iterator.value().getBaseItem().getInteractionType().getType() - == InteractionPostIt.class) { - items.add(iterator.value()); + if (iterator.value().getBaseItem().getInteractionType().getType() + == InteractionPostIt.class) { + items.add(iterator.value()); + } } } @@ -276,44 +285,49 @@ public class RoomItemManager { } } - TIntObjectIterator iterator = this.roomItems.iterator(); + // Cache miss: iterate roomItems under its monitor so a concurrent + // place/pickup can't rehash the map mid-traversal (which the per-advance + // try/catch would otherwise silently swallow into an incomplete result). + synchronized (this.roomItems) { + TIntObjectIterator iterator = this.roomItems.iterator(); - for (int i = this.roomItems.size(); i-- > 0; ) { - HabboItem item; - try { - iterator.advance(); - item = iterator.value(); - } catch (Exception e) { - break; - } + for (int i = this.roomItems.size(); i-- > 0; ) { + HabboItem item; + try { + iterator.advance(); + item = iterator.value(); + } catch (Exception e) { + break; + } - if (item == null) { - continue; - } + if (item == null) { + continue; + } - if (item.getBaseItem().getType() != FurnitureType.FLOOR) { - continue; - } + if (item.getBaseItem().getType() != FurnitureType.FLOOR) { + continue; + } - int width, length; + int width, length; - if (item.getRotation() != 2 && item.getRotation() != 6) { - width = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1; - length = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1; - } else { - width = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1; - length = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1; - } + if (item.getRotation() != 2 && item.getRotation() != 6) { + width = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1; + length = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1; + } else { + width = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1; + length = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1; + } - if (!(tile.x >= item.getX() && tile.x <= item.getX() + width - 1 && tile.y >= item.getY() - && tile.y <= item.getY() + length - 1)) { - continue; - } + if (!(tile.x >= item.getX() && tile.x <= item.getX() + width - 1 && tile.y >= item.getY() + && tile.y <= item.getY() + length - 1)) { + continue; + } - items.add(item); + items.add(item); - if (returnOnFirst) { - return items; + if (returnOnFirst) { + return items; + } } } @@ -956,9 +970,11 @@ public class RoomItemManager { public int getUserUniqueFurniCount(int userId) { THashSet items = new THashSet<>(); - for (HabboItem item : this.roomItems.valueCollection()) { - if (!items.contains(item.getBaseItem()) && item.getUserId() == userId) { - items.add(item.getBaseItem()); + synchronized (this.roomItems) { + for (HabboItem item : this.roomItems.valueCollection()) { + if (!items.contains(item.getBaseItem()) && item.getUserId() == userId) { + items.add(item.getBaseItem()); + } } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomLayout.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomLayout.java index 29156355..489c6345 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomLayout.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomLayout.java @@ -130,13 +130,16 @@ public class RoomLayout { this.roomTiles = new RoomTile[this.mapSizeX][this.mapSizeY]; for (short y = 0; y < this.mapSizeY; y++) { - if (modelTemp[y].isEmpty() || modelTemp[y].equalsIgnoreCase("\r")) { - continue; - } + // A row shorter/longer than the model width (or empty) cannot be parsed + // per-square. Previously such tiles were left null while tileExists() + // still reported them present, causing NPEs in the coordinate accessors. + // Fill them with INVALID tiles so every in-bounds coordinate is non-null. + boolean validRow = !modelTemp[y].isEmpty() && modelTemp[y].length() == this.mapSizeX; for (short x = 0; x < this.mapSizeX; x++) { - if (modelTemp[y].length() != this.mapSizeX) { - break; + if (!validRow) { + this.roomTiles[x][y] = new RoomTile(x, y, (short) 0, RoomTileState.INVALID, true); + continue; } String square = modelTemp[y].substring(x, x + 1).trim().toLowerCase(); @@ -159,7 +162,9 @@ public class RoomLayout { } } - this.doorTile = this.roomTiles[this.doorX][this.doorY]; + this.doorTile = (this.doorX >= 0 && this.doorX < this.mapSizeX && this.doorY >= 0 && this.doorY < this.mapSizeY) + ? this.roomTiles[this.doorX][this.doorY] + : null; if (this.doorTile != null) { this.doorTile.setAllowStack(false); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomRightsManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomRightsManager.java index c9eb266d..9166305b 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomRightsManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomRightsManager.java @@ -272,10 +272,16 @@ public class RoomRightsManager { } else if (this.isOwner(habbo)) { habbo.getClient().sendResponse(new RoomOwnerComposer()); flatCtrl = RoomRightLevels.MODERATOR; - } else if (this.hasRights(habbo) && !this.room.hasGuild()) { - flatCtrl = RoomRightLevels.RIGHTS; } else if (this.room.hasGuild()) { - flatCtrl = this.getGuildRightLevel(habbo); + // Explicit room rights must still be honoured in guild rooms (the old + // `&& !hasGuild()` guard stripped them for non-guild members) — take + // whichever of the two is stronger. + RoomRightLevels guildLevel = this.getGuildRightLevel(habbo); + flatCtrl = (this.hasRights(habbo) && RoomRightLevels.RIGHTS.isEqualOrGreaterThan(guildLevel)) + ? RoomRightLevels.RIGHTS + : guildLevel; + } else if (this.hasRights(habbo)) { + flatCtrl = RoomRightLevels.RIGHTS; } habbo.getClient().sendResponse(new RoomRightsComposer(flatCtrl)); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java index 4d22505b..9e775568 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomSpecialTypes.java @@ -152,15 +152,23 @@ public class RoomSpecialTypes { public InteractionNest getNest(int itemId) { - return this.nests.get(itemId); + synchronized (this.nests) { + return this.nests.get(itemId); + } } public void addNest(InteractionNest item) { - this.nests.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); + synchronized (this.nests) { + this.nests.put(item.getId(), item); + } + this.specialItemsById.put(item.getId(), item); } public void removeNest(InteractionNest item) { - this.nests.remove(item.getId()); this.specialItemsById.remove(item.getId()); + synchronized (this.nests) { + this.nests.remove(item.getId()); + } + this.specialItemsById.remove(item.getId()); } public THashSet getNests() { @@ -174,15 +182,23 @@ public class RoomSpecialTypes { public InteractionPetDrink getPetDrink(int itemId) { - return this.petDrinks.get(itemId); + synchronized (this.petDrinks) { + return this.petDrinks.get(itemId); + } } public void addPetDrink(InteractionPetDrink item) { - this.petDrinks.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); + synchronized (this.petDrinks) { + this.petDrinks.put(item.getId(), item); + } + this.specialItemsById.put(item.getId(), item); } public void removePetDrink(InteractionPetDrink item) { - this.petDrinks.remove(item.getId()); this.specialItemsById.remove(item.getId()); + synchronized (this.petDrinks) { + this.petDrinks.remove(item.getId()); + } + this.specialItemsById.remove(item.getId()); } public THashSet getPetDrinks() { @@ -196,15 +212,23 @@ public class RoomSpecialTypes { public InteractionPetFood getPetFood(int itemId) { - return this.petFoods.get(itemId); + synchronized (this.petFoods) { + return this.petFoods.get(itemId); + } } public void addPetFood(InteractionPetFood item) { - this.petFoods.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); + synchronized (this.petFoods) { + this.petFoods.put(item.getId(), item); + } + this.specialItemsById.put(item.getId(), item); } public void removePetFood(InteractionPetFood petFood) { - this.petFoods.remove(petFood.getId()); this.specialItemsById.remove(petFood.getId()); + synchronized (this.petFoods) { + this.petFoods.remove(petFood.getId()); + } + this.specialItemsById.remove(petFood.getId()); } public THashSet getPetFoods() { @@ -218,15 +242,23 @@ public class RoomSpecialTypes { public InteractionPetToy getPetToy(int itemId) { - return this.petToys.get(itemId); + synchronized (this.petToys) { + return this.petToys.get(itemId); + } } public void addPetToy(InteractionPetToy item) { - this.petToys.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); + synchronized (this.petToys) { + this.petToys.put(item.getId(), item); + } + this.specialItemsById.put(item.getId(), item); } public void removePetToy(InteractionPetToy petToy) { - this.petToys.remove(petToy.getId()); this.specialItemsById.remove(petToy.getId()); + synchronized (this.petToys) { + this.petToys.remove(petToy.getId()); + } + this.specialItemsById.remove(petToy.getId()); } public THashSet getPetToys() { @@ -240,15 +272,23 @@ public class RoomSpecialTypes { public InteractionPetTree getPetTree(int itemId) { - return this.petTrees.get(itemId); + synchronized (this.petTrees) { + return this.petTrees.get(itemId); + } } public void addPetTree(InteractionPetTree item) { - this.petTrees.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); + synchronized (this.petTrees) { + this.petTrees.put(item.getId(), item); + } + this.specialItemsById.put(item.getId(), item); } public void removePetTree(InteractionPetTree petTree) { - this.petTrees.remove(petTree.getId()); this.specialItemsById.remove(petTree.getId()); + synchronized (this.petTrees) { + this.petTrees.remove(petTree.getId()); + } + this.specialItemsById.remove(petTree.getId()); } public THashSet getPetTrees() { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java index 46138a3c..38d2b66f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTrade.java @@ -26,6 +26,7 @@ public class RoomTrade { private final List users; private final Room room; + private boolean completed = false; public RoomTrade(Habbo userOne, Habbo userTwo, Room room) { this.users = new ArrayList<>(); @@ -54,7 +55,7 @@ public class RoomTrade { this.sendMessageToUsers(new TradeStartComposer(this)); } - public void offerItem(Habbo habbo, HabboItem item) { + public synchronized void offerItem(Habbo habbo, HabboItem item) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); if (user.getItems().contains(item)) @@ -67,7 +68,7 @@ public class RoomTrade { this.updateWindow(); } - public void offerMultipleItems(Habbo habbo, THashSet items) { + public synchronized void offerMultipleItems(Habbo habbo, THashSet items) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); for (HabboItem item : items) { @@ -81,7 +82,7 @@ public class RoomTrade { this.updateWindow(); } - public void removeItem(Habbo habbo, HabboItem item) { + public synchronized void removeItem(Habbo habbo, HabboItem item) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); if (!user.getItems().contains(item)) @@ -94,7 +95,7 @@ public class RoomTrade { this.updateWindow(); } - public void accept(Habbo habbo, boolean value) { + public synchronized void accept(Habbo habbo, boolean value) { RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); user.setAccepted(value); @@ -110,7 +111,13 @@ public class RoomTrade { } } - public void confirm(Habbo habbo) { + public synchronized void confirm(Habbo habbo) { + // Re-entry guard: both participants confirm on their own EventLoop + // threads. Without this (and the method-level lock) two concurrent + // confirms could each observe "all confirmed" and run tradeItems() + // twice → item/credit duplication. + if (this.completed) return; + RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); user.confirm(); @@ -122,6 +129,8 @@ public class RoomTrade { accepted = false; } if (accepted) { + this.completed = true; + if (this.tradeItems()) { this.closeWindow(); this.sendMessageToUsers(new TradeCompleteComposer()); @@ -264,6 +273,10 @@ public class RoomTrade { protected void clearAccepted() { for (RoomTradeUser user : this.users) { user.setAccepted(false); + // Any change to the offered items invalidates a prior confirmation; + // without this a stale confirmed=true lets a user strip their side + // and still complete the trade once the partner re-confirms. + user.setConfirmed(false); } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeUser.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeUser.java index 6b222e32..9ac826c0 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeUser.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomTradeUser.java @@ -51,6 +51,10 @@ public class RoomTradeUser { this.confirmed = true; } + public void setConfirmed(boolean value) { + this.confirmed = value; + } + public void addItem(HabboItem item) { this.items.add(item); } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomUnit.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomUnit.java index 7b9326e0..af4987f9 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomUnit.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomUnit.java @@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ScheduledFuture; import java.util.stream.Collectors; @@ -71,7 +72,10 @@ public class RoomUnit { private RoomUserRotation headRotation = RoomUserRotation.NORTH; private DanceType danceType; private RoomUnitType roomUnitType; - private Deque path = new LinkedList<>(); + // Concurrent + volatile: the room cycle thread polls/clears this path while a + // walk packet thread rebuilds it via findPath/setPath. A plain LinkedList would + // corrupt under the concurrent structural modification. + private volatile Deque path = new ConcurrentLinkedDeque<>(); private int handItem; private long handItemTimestamp; private long lastRollerTime; @@ -587,7 +591,7 @@ public class RoomUnit { Deque newPath = this.room.getLayout().getPathfinder() .findPath(this.currentLocation, this.goalLocation, this.goalLocation, this); if (newPath != null && !newPath.isEmpty()) { - this.path = newPath; + this.path = new ConcurrentLinkedDeque<>(newPath); } } @@ -765,7 +769,7 @@ public class RoomUnit { } public void setPath(Deque path) { - this.path = path; + this.path = (path == null) ? new ConcurrentLinkedDeque<>() : new ConcurrentLinkedDeque<>(path); } public RoomRightLevels getRightsLevel() { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/pathfinding/impl/PathfinderImpl.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/pathfinding/impl/PathfinderImpl.java index b6bd7df5..c3105f54 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/pathfinding/impl/PathfinderImpl.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/pathfinding/impl/PathfinderImpl.java @@ -24,8 +24,11 @@ public class PathfinderImpl implements Pathfinder { private static final int CACHED_TIMEOUT_MS = Emulator.getConfig() .getInt(CONFIG_EXECUTION_TIME, 25); + // Default ON: bound A* to CACHED_TIMEOUT_MS (25ms) so a pathological search + // can't run unbounded and stall the thread. On timeout findPath returns an + // empty path (the unit simply doesn't move there) — graceful degradation. private static final boolean CACHED_TIMEOUT_ENABLED = Emulator.getConfig() - .getBoolean(CONFIG_TIMEOUT_ENABLED, false); + .getBoolean(CONFIG_TIMEOUT_ENABLED, true); private static final long CACHED_TIMEOUT_NANOS = CACHED_TIMEOUT_MS * 1_000_000L; private final Room room; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/Habbo.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/Habbo.java index 61f8075e..9983c49c 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/Habbo.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/Habbo.java @@ -146,31 +146,23 @@ public class Habbo implements Runnable { this.habboInfo.setIpLogin(ip); } - if (this.client.getMachineId() == null || this.client.getMachineId().length() == 0) { - return false; - } + // The Nitro client sends the UniqueID (machine fingerprint) packet right + // AFTER the SSO ticket, so client.getMachineId() may still be null here. + // Do NOT reject the login for a missing machineId — MachineIDEvent sets it + // and enforces the MAC ban as soon as the UniqueID packet arrives. Only + // MAC-ban check here when the fingerprint is already available. + String machineId = this.client.getMachineId(); + if (machineId != null && !machineId.isEmpty()) { + this.habboInfo.setMachineID(machineId); - this.habboInfo.setMachineID(this.client.getMachineId()); - - if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) { - return false; + if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) { + return false; + } } if (Emulator.getGameEnvironment().getModToolManager().hasIPBan(this.habboInfo.getIpLogin())) { return false; } - - this.habboInfo.setMachineID(this.client.getMachineId()); - - if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) { - return false; - } - - if (Emulator.getGameEnvironment().getModToolManager().hasIPBan(this.habboInfo.getIpLogin())) { - return false; - } - - this.habboInfo.setMachineID(this.client.getMachineId()); this.isOnline(true); this.messenger.connectionChanged(this, true, false); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java index 373642be..06bff0a0 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java @@ -55,6 +55,11 @@ public class HabboInfo implements Runnable { private RideablePet riding; private Class currentGame; private TIntIntHashMap currencies; + // Serializes credits + currencies read-modify-write and the saveCurrencies + // snapshot so the credit-roller thread and purchase/trade handler threads + // can't lose updates or rehash the Trove map mid-iteration. Never held + // across run()'s DB I/O. + private final Object currencyLock = new Object(); private GamePlayer gamePlayer; private int photoRoomId; private int photoTimestamp; @@ -123,11 +128,16 @@ public class HabboInfo implements Runnable { } private void saveCurrencies() { - List entries = new ArrayList<>(this.currencies.size()); - this.currencies.forEachEntry((type, amount) -> { - entries.add(new int[]{type, amount}); - return true; - }); + // Snapshot under the lock so a concurrent adjustOrPutValue/put can't + // rehash the Trove map while we iterate; do the DB batch off-lock. + List entries; + synchronized (this.currencyLock) { + entries = new ArrayList<>(this.currencies.size()); + this.currencies.forEachEntry((type, amount) -> { + entries.add(new int[]{type, amount}); + return true; + }); + } try { SqlQueries.batchUpdate( @@ -238,20 +248,30 @@ public class HabboInfo implements Runnable { } public int getCurrencyAmount(int type) { - return this.currencies.get(type); + synchronized (this.currencyLock) { + return this.currencies.get(type); + } } public TIntIntHashMap getCurrencies() { - return this.currencies; + // Return a snapshot under the lock: callers iterate this map, which would + // otherwise corrupt during a concurrent adjustOrPutValue rehash. + synchronized (this.currencyLock) { + return new TIntIntHashMap(this.currencies); + } } public void addCurrencyAmount(int type, int amount) { - this.currencies.adjustOrPutValue(type, amount, amount); + synchronized (this.currencyLock) { + this.currencies.adjustOrPutValue(type, amount, amount); + } this.run(); } public void setCurrencyAmount(int type, int amount) { - this.currencies.put(type, amount); + synchronized (this.currencyLock) { + this.currencies.put(type, amount); + } this.run(); } @@ -380,20 +400,26 @@ public class HabboInfo implements Runnable { } public boolean canBuy(CatalogItem item) { - return this.credits >= item.getCredits() && this.getCurrencies().get(item.getPointsType()) >= item.getPoints(); + return this.getCredits() >= item.getCredits() && this.getCurrencyAmount(item.getPointsType()) >= item.getPoints(); } public int getCredits() { - return this.credits; + synchronized (this.currencyLock) { + return this.credits; + } } public void setCredits(int credits) { - this.credits = credits; + synchronized (this.currencyLock) { + this.credits = credits; + } this.run(); } public void addCredits(int credits) { - this.credits += credits; + synchronized (this.currencyLock) { + this.credits += credits; + } this.run(); } @@ -600,6 +626,13 @@ public class HabboInfo implements Runnable { public void run() { this.saveCurrencies(); + // Read credits under the lock so the persisted value is consistent with + // concurrent addCredits/setCredits (matches the currencyLock invariant). + final int creditsForSave; + synchronized (this.currencyLock) { + creditsForSave = this.credits; + } + try { SqlQueries.update( "UPDATE users SET motto = ?, online = ?, look = ?, gender = ?, credits = ?, last_login = ?, last_online = ?, home_room = ?, ip_current = ?, `rank` = ?, machine_id = ?, username = ?, background_id = ?, background_stand_id = ?, background_overlay_id = ?, background_card_id = ?, background_border_id = ? WHERE id = ?", @@ -607,7 +640,7 @@ public class HabboInfo implements Runnable { this.online ? "1" : "0", this.look, this.gender.name(), - this.credits, + creditsForSave, Emulator.getIntUnixTimestamp(), this.lastOnline, this.homeRoom, diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java index 48261f66..ef602679 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java @@ -111,7 +111,7 @@ public class HabboManager { habbo = this.cloneCheck(userId); if (habbo != null) { habbo.alert(Emulator.getTexts().getValue("loggedin.elsewhere")); - Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient()); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(habbo.getClient()); habbo = null; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java index 3c7e820d..cbd84b82 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboStats.java @@ -448,14 +448,26 @@ public class HabboStats implements Runnable { return 0; } - if (this.achievementProgress.containsKey(achievement)) - return this.achievementProgress.get(achievement); - - return -1; + synchronized (this.achievementProgress) { + Integer progress = this.achievementProgress.get(achievement); + return progress != null ? progress : -1; + } } public void setProgress(Achievement achievement, int progress) { - this.achievementProgress.put(achievement, progress); + synchronized (this.achievementProgress) { + this.achievementProgress.put(achievement, progress); + } + } + + /** Atomic read-add-write so concurrent progress sources don't lose updates. Returns the new total. */ + public int incrementProgress(Achievement achievement, int amount) { + synchronized (this.achievementProgress) { + Integer current = this.achievementProgress.get(achievement); + int next = (current != null ? current : 0) + amount; + this.achievementProgress.put(achievement, next); + return next; + } } public int getRentedTimeEnd() { diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/WiredHandler.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/WiredHandler.java index 9ee3631a..119e1745 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/WiredHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/WiredHandler.java @@ -178,6 +178,15 @@ public class WiredHandler { private static boolean handle(InteractionWiredTrigger trigger, final RoomUnit roomUnit, final Room room, final Object[] stuff, final LegacyExecutionPlan executionPlan) { long millis = System.currentTimeMillis(); int roomUnitId = roomUnit != null ? roomUnit.getId() : -1; + + // Only one thread may process a given trigger box at a time, so the + // cooldown check (below) and setCooldown (further down) act as one + // atomic claim — preventing a concurrent packet/cycle double-fire. + if (!trigger.tryBeginProcessing()) { + return false; + } + + try { if (Emulator.isReady && ((Emulator.getConfig().getBoolean("wired.custom.enabled", false) && (trigger.canExecute(millis) || roomUnitId > -1) && trigger.userCanExecute(roomUnitId, millis)) || (!Emulator.getConfig().getBoolean("wired.custom.enabled", false) && trigger.canExecute(millis))) && trigger.execute(roomUnit, room, stuff)) { THashSet conditions = room.getRoomSpecialTypes().getConditions(trigger.getX(), trigger.getY()); THashSet effects = room.getRoomSpecialTypes().getEffects(trigger.getX(), trigger.getY()); @@ -272,6 +281,9 @@ public class WiredHandler { } return false; + } finally { + trigger.endProcessing(); + } } private static boolean evaluateConditions(THashSet conditions, RoomUnit roomUnit, Room room, Object[] stuff, int evaluationMode, int evaluationValue) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java b/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java index 55fdae44..5817a758 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/ClientMessage.java @@ -4,6 +4,8 @@ import com.eu.habbo.util.PacketUtils; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; +import java.nio.charset.StandardCharsets; + public class ClientMessage { private final int header; private final ByteBuf buffer; @@ -61,10 +63,17 @@ public class ClientMessage { public String readString() { try { - int length = this.readShort(); + // Length is an unsigned short in the protocol; mask to avoid a + // negative array size, and clamp to what's actually buffered so a + // bogus length can't throw mid-read and desync the remaining fields. + int length = this.readShort() & 0xFFFF; + int available = this.buffer.readableBytes(); + if (length > available) { + length = available; + } byte[] data = new byte[length]; this.buffer.readBytes(data); - return new String(data); + return new String(data, StandardCharsets.UTF_8); } catch (Exception e) { return ""; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/ServerMessage.java b/Emulator/src/main/java/com/eu/habbo/messages/ServerMessage.java index 7b03768e..73660a55 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/ServerMessage.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/ServerMessage.java @@ -7,6 +7,7 @@ import io.netty.buffer.ByteBufOutputStream; import io.netty.buffer.Unpooled; import java.io.IOException; +import java.nio.charset.StandardCharsets; public class ServerMessage { @@ -61,7 +62,7 @@ public class ServerMessage { } try { - byte[] data = obj.getBytes(); + byte[] data = obj.getBytes(StandardCharsets.UTF_8); this.stream.writeShort(data.length); this.stream.write(data); } catch (IOException e) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java index a80067b7..fdf052bd 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemEvent.java @@ -53,6 +53,15 @@ public class CatalogBuyItemEvent extends MessageHandler { String extraData = this.packet.readString(); int count = this.packet.readInt(); + // Clamp the client-supplied quantity. Without this the club-offer + // branch accumulates cost in plain ints and a huge count overflows + // to a negative total, bypassing the affordability checks and + // CREDITING the buyer (free currency/subscription exploit). + if (count < 1 || count > 100) { + this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR).compose()); + return; + } + try { if (this.client.getHabbo().getInventory().getItemsComponent().itemCount() > HabboInventory.MAXIMUM_ITEMS) { this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR).compose()); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/OpenRecycleBoxEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/OpenRecycleBoxEvent.java index 523d5d0d..bd8cd616 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/OpenRecycleBoxEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/OpenRecycleBoxEvent.java @@ -29,6 +29,11 @@ public class OpenRecycleBoxEvent extends MessageHandler { if (item.getUserId() != this.client.getHabbo().getHabboInfo().getId()) return; if (item instanceof InteractionGift) { + // The actual unwrap (OpenGift) runs async/delayed and only then + // removes the wrapper, so a second packet would otherwise pass + // the room/owner checks and double-process the gift. Claim it once. + if (!((InteractionGift) item).tryStartOpening()) return; + if (item.getBaseItem().getName().contains("present_wrap")) { ((InteractionGift) item).explode = true; room.updateItem(item); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/RecycleEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/RecycleEvent.java index 884b6f6c..f1b6d8dd 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/RecycleEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/recycler/RecycleEvent.java @@ -40,23 +40,26 @@ public class RecycleEvent extends MessageHandler { } } - if (items.size() == count) { - for (HabboItem item : items) { - this.client.getHabbo().getInventory().getItemsComponent().removeHabboItem(item); - this.client.sendResponse(new RemoveHabboItemComposer(item.getGiftAdjustedId())); - Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId())); - } - } else { + if (items.size() != count) { this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR)); return; } + // Compute the reward BEFORE consuming the inputs. Previously the + // inputs were deleted first, so a null reward (misconfiguration) + // permanently destroyed the 8 furni with nothing in return. HabboItem reward = Emulator.getGameEnvironment().getItemManager().handleRecycle(this.client.getHabbo(), Emulator.getGameEnvironment().getCatalogManager().getRandomRecyclerPrize().getId() + ""); if (reward == null) { this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR)); return; } + for (HabboItem item : items) { + this.client.getHabbo().getInventory().getItemsComponent().removeHabboItem(item); + this.client.sendResponse(new RemoveHabboItemComposer(item.getGiftAdjustedId())); + Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId())); + } + this.client.sendResponse(new AddHabboItemComposer(reward)); this.client.getHabbo().getInventory().getItemsComponent().addItem(reward); this.client.sendResponse(new RecyclerCompleteComposer(RecyclerCompleteComposer.RECYCLING_COMPLETE)); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/crafting/CraftingCraftItemEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/crafting/CraftingCraftItemEvent.java index fe0c4993..6bd4f2e4 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/crafting/CraftingCraftItemEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/crafting/CraftingCraftItemEvent.java @@ -37,6 +37,8 @@ public class CraftingCraftItemEvent extends MessageHandler { HabboItem habboItem = this.client.getHabbo().getInventory().getItemsComponent().getAndRemoveHabboItem(set.getKey()); if (habboItem == null) { + // Not enough ingredients — give back whatever we already pulled. + this.restoreItems(toRemove); return; } @@ -70,8 +72,23 @@ public class CraftingCraftItemEvent extends MessageHandler { return; } + // Reward creation failed after we already pulled the ingredients — + // restore them so the craft isn't a silent item sink. + this.restoreItems(toRemove); } this.client.sendResponse(new CraftingResultComposer(null)); } + + private void restoreItems(TIntObjectHashMap items) { + if (items.isEmpty()) { + return; + } + items.forEachValue(item -> { + this.client.getHabbo().getInventory().getItemsComponent().addItem(item); + this.client.sendResponse(new AddHabboItemComposer(item)); + return true; + }); + this.client.sendResponse(new InventoryRefreshComposer()); + } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java index 274a5dbd..bcbd5626 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java @@ -49,15 +49,17 @@ public class FurniEditorSearchEvent extends MessageHandler { try { int numericQuery = Integer.parseInt(query); isNumeric = true; + String likeQuery = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%"; whereClause.append(" AND (id = ? OR sprite_id = ? OR item_name LIKE ? OR public_name LIKE ?)"); params.add(numericQuery); params.add(numericQuery); - params.add("%" + query + "%"); - params.add("%" + query + "%"); + params.add(likeQuery); + params.add(likeQuery); } catch (NumberFormatException e) { + String likeQuery = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%"; whereClause.append(" AND (item_name LIKE ? OR public_name LIKE ?)"); - params.add("%" + query + "%"); - params.add("%" + query + "%"); + params.add(likeQuery); + params.add(likeQuery); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java index 64696bf7..b9c7fd71 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/MachineIDEvent.java @@ -32,8 +32,15 @@ public class MachineIDEvent extends MessageHandler { if (!storedMachineId.isEmpty() && this.client.getHabbo() != null && this.client.getHabbo().getHabboInfo() != null) { this.client.getHabbo().getHabboInfo().setMachineID(storedMachineId); Emulator.getThreading().run(this.client.getHabbo()); + + // The fingerprint can arrive AFTER login (UniqueID is sent right after the + // SSO ticket), so Habbo.connect() may have skipped the MAC-ban check for + // lack of a machineId. Enforce it now that the fingerprint is known. + if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) { + Emulator.getGameServer().getGameClientManager().forceDisposeClient(this.client); + } } LOGGER.debug("Setting client MachineId to {}", storedMachineId); } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java index cdebe27d..f9704bec 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java @@ -306,7 +306,7 @@ public class SecureLoginEvent extends MessageHandler { Emulator.getPluginManager().fireEvent(userLoginEvent); if(userLoginEvent.isCancelled()) { - Emulator.getGameServer().getGameClientManager().disposeClient(this.client); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(this.client); return; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCreditsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCreditsEvent.java index e0d16e2d..c47bad24 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCreditsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCreditsEvent.java @@ -12,6 +12,7 @@ import java.sql.SQLException; public class HousekeepingGiveCreditsEvent extends MessageHandler { private static final String ACTION_KEY = "user.give_credits"; + private static final int MAX_GRANT = 1_000_000_000; @Override public int getRatelimit() { @@ -27,7 +28,7 @@ public class HousekeepingGiveCreditsEvent extends MessageHandler { int userId = this.packet.readInt(); int amount = this.packet.readInt(); - if (userId <= 0 || amount == 0) { + if (userId <= 0 || amount == 0 || amount < -MAX_GRANT || amount > MAX_GRANT) { this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input")); return; } @@ -38,6 +39,7 @@ public class HousekeepingGiveCreditsEvent extends MessageHandler { // giveCredits already pushes UserCreditsComposer and persists via the // standard HabboInfo write path; nothing extra needed for the online branch. online.giveCredits(amount); + this.audit(userId, amount); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); return; } @@ -57,6 +59,15 @@ public class HousekeepingGiveCreditsEvent extends MessageHandler { return; } + this.audit(userId, amount); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); } + + private void audit(int userId, int amount) { + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "amount=" + amount, + this.client.getHabbo().getHabboInfo().getIpLogin()); + } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCurrencyEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCurrencyEvent.java index ba347b93..5e5053a1 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCurrencyEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingGiveCurrencyEvent.java @@ -18,6 +18,7 @@ import java.sql.SQLException; */ public class HousekeepingGiveCurrencyEvent extends MessageHandler { private static final int CURRENCY_DUCKETS = 0; + private static final int MAX_GRANT = 1_000_000_000; @Override public int getRatelimit() { @@ -36,7 +37,7 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler { String actionKey = "user.give_currency_" + currencyType; - if (userId <= 0 || amount == 0) { + if (userId <= 0 || amount == 0 || amount < -MAX_GRANT || amount > MAX_GRANT) { this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.invalid_input")); return; } @@ -52,6 +53,7 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler { online.givePoints(currencyType, amount); } + this.audit(actionKey, userId, currencyType, amount); this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, true, userId, "")); return; } @@ -69,6 +71,15 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler { return; } + this.audit(actionKey, userId, currencyType, amount); this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, true, userId, "")); } + + private void audit(String actionKey, int userId, int currencyType, int amount) { + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + actionKey, userId, "type=" + currencyType + " amount=" + amount, + this.client.getHabbo().getHabboInfo().getIpLogin()); + } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSearchRoomsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSearchRoomsEvent.java index 985250c9..bccf6588 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSearchRoomsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSearchRoomsEvent.java @@ -56,7 +56,7 @@ public class HousekeepingSearchRoomsEvent extends MessageHandler { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, exactMatch ? query : query + "%"); + statement.setString(1, exactMatch ? query : com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%"); statement.setInt(2, limit); try (ResultSet set = statement.executeQuery()) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetUserRankEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetUserRankEvent.java index 67f35d8a..db100eb8 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetUserRankEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/housekeeping/HousekeepingSetUserRankEvent.java @@ -11,6 +11,7 @@ import com.eu.habbo.messages.outgoing.users.UserPermissionsComposer; import java.sql.Connection; import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.sql.SQLException; public class HousekeepingSetUserRankEvent extends MessageHandler { @@ -44,6 +45,43 @@ public class HousekeepingSetUserRankEvent extends MessageHandler { Rank rank = permissions.getRank(rankId); + // Rank-ceiling guard: an operator must never be able to grant a rank + // above their own, nor modify a user who already outranks them. This + // mirrors GiveRankCommand and prevents privilege escalation through + // the housekeeping path (including self-promotion). + int operatorRankId = this.client.getHabbo().getHabboInfo().getRank().getId(); + + if (rank.getId() > operatorRankId) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + + Habbo online = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); + + int targetRankId; + if (online != null) { + targetRankId = online.getHabboInfo().getRank().getId(); + } else { + targetRankId = 0; + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT rank FROM users WHERE id = ? LIMIT 1")) { + statement.setInt(1, userId); + try (ResultSet set = statement.executeQuery()) { + if (set.next()) { + targetRankId = set.getInt("rank"); + } + } + } catch (SQLException e) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.db_failed")); + return; + } + } + + if (targetRankId > operatorRankId) { + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high")); + return; + } + // Persist for the offline path. Online users get their in-memory // HabboInfo.rank rebound below so server-side hasPermission() // checks land on the new permission set without a relogin. @@ -57,8 +95,6 @@ public class HousekeepingSetUserRankEvent extends MessageHandler { return; } - Habbo online = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId); - if (online != null) { online.getHabboInfo().setRank(rank); // Ship the refreshed permissions snapshot — same payload the @@ -66,6 +102,12 @@ public class HousekeepingSetUserRankEvent extends MessageHandler { online.getClient().sendResponse(new UserPermissionsComposer(online)); } + com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log( + this.client.getHabbo().getHabboInfo().getId(), + this.client.getHabbo().getHabboInfo().getUsername(), + ACTION_KEY, userId, "rankId=" + rankId, + this.client.getHabbo().getHabboInfo().getIpLogin()); + this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java index ad55465e..a175cdd0 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolRequestUserChatlogEvent.java @@ -3,6 +3,7 @@ package com.eu.habbo.messages.incoming.modtool; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.modtool.ScripterManager; import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.habbohotel.users.HabboInfo; import com.eu.habbo.habbohotel.users.HabboManager; import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.outgoing.modtool.ModToolUserChatlogComposer; @@ -12,7 +13,11 @@ public class ModToolRequestUserChatlogEvent extends MessageHandler { public void handle() throws Exception { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { int userId = this.packet.readInt(); - String username = HabboManager.getOfflineHabboInfo(userId).getUsername(); + HabboInfo habboInfo = HabboManager.getOfflineHabboInfo(userId); + if (habboInfo == null) { + return; + } + String username = habboInfo.getUsername(); this.client.sendResponse(new ModToolUserChatlogComposer(Emulator.getGameEnvironment().getModToolManager().getUserRoomVisitsAndChatlogs(userId), userId, username)); } else { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RoomRequestBannedUsersEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RoomRequestBannedUsersEvent.java index 321bdb42..e103bd0e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RoomRequestBannedUsersEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RoomRequestBannedUsersEvent.java @@ -14,7 +14,7 @@ public class RoomRequestBannedUsersEvent extends MessageHandler { Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId); if (room == null) return; - if (!room.hasRights(this.client.getHabbo()) || !this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) return; + if (!room.hasRights(this.client.getHabbo()) && !this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) return; this.client.sendResponse(new RoomBannedUsersComposer(room)); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/ConfirmPetBreedingEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/ConfirmPetBreedingEvent.java index 13a1e200..17692282 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/ConfirmPetBreedingEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/ConfirmPetBreedingEvent.java @@ -1,6 +1,7 @@ package com.eu.habbo.messages.incoming.rooms.pets; import com.eu.habbo.habbohotel.items.interactions.pets.InteractionPetBreedingNest; +import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.habbohotel.users.HabboItem; import com.eu.habbo.messages.incoming.MessageHandler; @@ -13,7 +14,10 @@ public class ConfirmPetBreedingEvent extends MessageHandler { int petOneId = this.packet.readInt(); int petTwoId = this.packet.readInt(); - HabboItem item = this.client.getHabbo().getHabboInfo().getCurrentRoom().getHabboItem(itemId); + Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); + if (room == null) return; + + HabboItem item = room.getHabboItem(itemId); if (item instanceof InteractionPetBreedingNest) { ((InteractionPetBreedingNest) item).breed(this.client.getHabbo(), name, petOneId, petTwoId); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/PetPickupEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/PetPickupEvent.java index 3cce0a34..7da0c5f4 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/PetPickupEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/pets/PetPickupEvent.java @@ -23,7 +23,7 @@ public class PetPickupEvent extends MessageHandler { Pet pet = room.getPet(petId); if (pet != null) { - if (this.client.getHabbo().getHabboInfo().getId() == pet.getId() || room.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) { + if (this.client.getHabbo().getHabboInfo().getId() == pet.getUserId() || room.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) { if (!this.client.getHabbo().hasPermission(Permission.ACC_UNLIMITED_PETS) && this.client.getHabbo().getInventory().getPetsComponent().getPets().size() >= PetManager.MAXIMUM_PET_INVENTORY_SIZE) { this.client.getHabbo().alert(Emulator.getTexts().getValue("error.pets.max.inventory").replace("%amount%", PetManager.MAXIMUM_PET_INVENTORY_SIZE + "")); return; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserWalkEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserWalkEvent.java index 51a38650..63e905af 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserWalkEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/users/RoomUserWalkEvent.java @@ -163,7 +163,13 @@ public class RoomUserWalkEvent extends MessageHandler { } if (roomUnit.getMoveBlockingTask() != null) { - roomUnit.getMoveBlockingTask().get(); + try { + // Bound the wait so a stuck/delayed move-blocking task can't park + // the Netty event loop (and thus every client on it) indefinitely. + roomUnit.getMoveBlockingTask().get(2, java.util.concurrent.TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException | java.util.concurrent.ExecutionException | InterruptedException e) { + // proceed with the walk regardless + } } boolean needsLocationResync = diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolSanctionInfoComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolSanctionInfoComposer.java index b281fe53..875e1597 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolSanctionInfoComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolSanctionInfoComposer.java @@ -9,8 +9,8 @@ import com.eu.habbo.messages.ServerMessage; import com.eu.habbo.messages.outgoing.MessageComposer; import com.eu.habbo.messages.outgoing.Outgoing; import gnu.trove.map.hash.THashMap; -import org.joda.time.DateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Date; @@ -47,7 +47,10 @@ public class ModToolSanctionInfoComposer extends MessageComposer { if (item.probationTimestamp > 0) { probationEndTime = new Date((long) item.probationTimestamp * 1000); - probationStartTime = new DateTime(probationEndTime).minusDays(modToolSanctions.getProbationDays(modToolSanctionLevelItem)).toDate(); + probationStartTime = Date.from(probationEndTime.toInstant() + .atZone(ZoneId.systemDefault()) + .minusDays(modToolSanctions.getProbationDays(modToolSanctionLevelItem)) + .toInstant()); Date tradeLockedUntil = null; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java index 51e00c4c..64bf2d1f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java @@ -99,7 +99,7 @@ public class RoomUsersComposer extends MessageComposer { this.response.appendInt(1); this.response.appendString(habbo.getHabboInfo().getGender().name().toUpperCase()); this.response.appendInt(habbo.getHabboStats().guild != 0 ? habbo.getHabboStats().guild : -1); - this.response.appendInt(habbo.getHabboStats().guild != 0 ? habbo.getHabboStats().guild : -1); + this.response.appendInt(habbo.getHabboStats().guild != 0 ? 1 : -1); String name = ""; if (habbo.getHabboStats().guild != 0) { Guild g = Emulator.getGameEnvironment().getGuildManager().getGuild(habbo.getHabboStats().guild); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/DisconnectUser.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/DisconnectUser.java index 8b768ac0..8f8a51ed 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/DisconnectUser.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/DisconnectUser.java @@ -29,7 +29,7 @@ public class DisconnectUser extends RCONMessage { private static final int MAX_FRAME_SIZE = 500000; + // Runs the game packet handler OFF the Netty I/O event loop, so a blocking + // handler (login/friends/catalog/guild JDBC, A* pathfinding, etc.) can no + // longer stall socket I/O for every other client sharing that I/O thread. + // A DefaultEventExecutorGroup pins each channel to one executor, so a single + // client's packets stay strictly ordered (no new intra-client races); the + // cross-client concurrency degree is the same the multi-threaded I/O group + // already had. Daemon threads so they don't block JVM shutdown. + private static final EventExecutorGroup PACKET_HANDLER_GROUP = new DefaultEventExecutorGroup( + packetHandlerThreads(), + new DefaultThreadFactory("GamePacketHandler", true)); + + // Size of the packet-handler pool. Defaults to max(16, 2x CPU cores); set + // the optional `io.packet.handler.threads` config key to override. + private static int packetHandlerThreads() { + int fallback = Math.max(16, Runtime.getRuntime().availableProcessors() * 2); + if (Emulator.getConfig() == null) { + return fallback; + } + int configured = Emulator.getConfig().getInt("io.packet.handler.threads", fallback); + return configured > 0 ? configured : fallback; + } + private final SslContext sslContext; private final boolean sslEnabled; private final WebSocketServerProtocolConfig wsConfig; @@ -82,7 +107,7 @@ public class WebSocketChannelInitializer extends ChannelInitializer(512), + new java.util.concurrent.ThreadFactory() { + private final AtomicInteger counter = new AtomicInteger(1); + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "auth-http-worker-" + counter.getAndIncrement()); + t.setDaemon(true); + return t; + } + }); + + // Max threads for the auth pool. Defaults to 16; set the optional + // `auth.http.pool.size` config key to override. + private static int authPoolMax() { + int fallback = 16; + if (com.eu.habbo.Emulator.getConfig() == null) { + return fallback; + } + int configured = com.eu.habbo.Emulator.getConfig().getInt("auth.http.pool.size", fallback); + return configured > 0 ? configured : fallback; + } + static final String LOGIN_PATH = "/api/auth/login"; static final String REGISTER_PATH = "/api/auth/register"; static final String FORGOT_PATH = "/api/auth/forgot-password"; @@ -52,10 +90,30 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { return; } + // Offload the (potentially blocking) auth work off the event loop. Netty + // writes are thread-safe, so the endpoints' sendJson/writeAndFlush calls + // are fine from the worker; the request is released once the work ends. try { - handle(ctx, req, path); - } finally { - ReferenceCountUtil.release(req); + AUTH_EXECUTOR.execute(() -> { + try { + handle(ctx, req, path); + } catch (Throwable t) { + LOGGER.error("Auth handler failed for {}", path, t); + try { + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Internal error.")); + } catch (Throwable ignored) { + // response may already be partially written — nothing else to do + } + } finally { + ReferenceCountUtil.release(req); + } + }); + } catch (RejectedExecutionException rejected) { + try { + sendJson(ctx, req, HttpResponseStatus.SERVICE_UNAVAILABLE, errorPayload("Server busy, try again shortly.")); + } finally { + ReferenceCountUtil.release(req); + } } } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java index 0d949ec8..644e9e1b 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java @@ -28,7 +28,7 @@ import java.sql.SQLException; import java.util.Base64; import java.util.regex.Pattern; -final class AuthHttpUtil { +public final class AuthHttpUtil { private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpUtil.class); @@ -132,7 +132,10 @@ final class AuthHttpUtil { String ipHeader = Emulator.getConfig() != null ? Emulator.getConfig().getValue("ws.ip.header", "") : ""; - if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) { + // Only trust a client-supplied forwarded-IP header when the DIRECT peer + // is a trusted reverse proxy; otherwise an attacker hitting the port + // directly could spoof it to evade per-IP rate limiting and IP bans. + if (!ipHeader.isEmpty() && req.headers().contains(ipHeader) && isTrustedProxy(ctx)) { String hv = req.headers().get(ipHeader); if (hv != null && !hv.isEmpty()) { int comma = hv.indexOf(','); @@ -148,6 +151,37 @@ final class AuthHttpUtil { return ""; } + /** + * Whether the channel's direct peer may set a forwarded-IP header. Loopback + * is always trusted; additional proxies can be allow-listed (exact IP or + * string prefix, comma-separated) via the {@code ws.ip.header.trusted} + * config key. Default-deny so the header can't be spoofed from the open net. + */ + public static boolean isTrustedProxy(ChannelHandlerContext ctx) { + String peerIp = (ctx.channel().remoteAddress() instanceof InetSocketAddress a) + ? a.getAddress().getHostAddress() : null; + if (peerIp == null || peerIp.isEmpty()) return false; + if (peerIp.equals("127.0.0.1") || peerIp.equals("::1") || peerIp.equals("0:0:0:0:0:0:0:1")) { + return true; + } + String trusted = Emulator.getConfig() != null + ? Emulator.getConfig().getValue("ws.ip.header.trusted", "") + : ""; + if (trusted.isEmpty()) return false; + for (String entry : trusted.split(",")) { + String t = entry.trim(); + if (t.isEmpty()) continue; + // Exact IP match, or a dotted/colon prefix range (e.g. "10.0.0." or + // "2001:db8:") — never a bare-IP prefix, so "10.0.0.1" can't also + // trust "10.0.0.12". + boolean isRange = t.endsWith(".") || t.endsWith(":"); + if (peerIp.equals(t) || (isRange && peerIp.startsWith(t))) { + return true; + } + } + return false; + } + static boolean checkPassword(String plain, String stored) { String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored; try { diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthRateLimiter.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthRateLimiter.java index 67cabf17..1d0186e3 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthRateLimiter.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthRateLimiter.java @@ -11,8 +11,34 @@ public final class AuthRateLimiter { private static final Map> STATE = new ConcurrentHashMap<>(); private static final Map> PROBE_STATE = new ConcurrentHashMap<>(); + // Both maps are keyed by client IP and reachable by unauthenticated traffic. + // recordSuccess removes STATE on login, but failed-only and probe-only IPs + // never get removed otherwise — unbounded growth over the JVM lifetime. + // Opportunistically evict window-expired entries once the maps get large. + private static final int SWEEP_THRESHOLD = 10_000; + private static final long SWEEP_MIN_INTERVAL_MS = 60_000L; + private static volatile long lastSweepMillis = 0L; + private AuthRateLimiter() {} + private static void maybeSweep(long now) { + if (STATE.size() < SWEEP_THRESHOLD && PROBE_STATE.size() < SWEEP_THRESHOLD) return; + if (now - lastSweepMillis < SWEEP_MIN_INTERVAL_MS) return; + lastSweepMillis = now; + + long stateWindowMs = configInt("login.ratelimit.window_sec", 60) * 1000L; + STATE.entrySet().removeIf(e -> { + State s = e.getValue().get(); + return s == null || (s.lockedUntilMillis <= now && (now - s.windowStartMillis) > stateWindowMs); + }); + + long probeWindowMs = configInt("login.probe.window_sec", 60) * 1000L; + PROBE_STATE.entrySet().removeIf(e -> { + ProbeState p = e.getValue().get(); + return p == null || (now - p.windowStartMillis) > probeWindowMs; + }); + } + public static boolean isLocked(String ip) { if (!isEnabled() || ip == null || ip.isEmpty()) return false; @@ -38,6 +64,7 @@ public final class AuthRateLimiter { if (!isEnabled() || ip == null || ip.isEmpty()) return; long now = System.currentTimeMillis(); + maybeSweep(now); long windowMs = configInt("login.ratelimit.window_sec", 60) * 1000L; int maxAttempts = configInt("login.ratelimit.max_attempts", 5); long lockoutMs = configInt("login.ratelimit.lockout_sec", 120) * 1000L; @@ -64,6 +91,7 @@ public final class AuthRateLimiter { if (isLocked(ip)) return false; long now = System.currentTimeMillis(); + maybeSweep(now); long windowMs = configInt("login.probe.window_sec", 60) * 1000L; int maxAttempts = configInt("login.probe.max_attempts", 20); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SessionEndpoints.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SessionEndpoints.java index 32f355be..74967c50 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SessionEndpoints.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SessionEndpoints.java @@ -69,7 +69,7 @@ final class SessionEndpoints { com.eu.habbo.habbohotel.users.Habbo habbo = Emulator.getGameServer().getGameClientManager().getHabbo(userId); if (habbo != null && habbo.getClient() != null) { - Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient()); + Emulator.getGameServer().getGameClientManager().forceDisposeClient(habbo.getClient()); } } } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecryption.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecryption.java index 126648ed..6845206e 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecryption.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameByteDecryption.java @@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.decoders; import com.eu.habbo.networking.gameserver.GameServerAttributes; import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.ByteToMessageDecoder; @@ -15,14 +16,17 @@ public class GameByteDecryption extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { - // Read all available bytes. - ByteBuf data = in.readBytes(in.readableBytes()); + // Copy the readable region into a plain array (offset-safe, so this is + // correct for pooled buffers too — buf.array() would have read the wrong + // region for a pooled/sliced buffer). + byte[] bytes = new byte[in.readableBytes()]; + in.readBytes(bytes); - // Decrypt. - ctx.channel().attr(GameServerAttributes.CRYPTO_CLIENT).get().parse(data.array()); + // Decrypt in place. + ctx.channel().attr(GameServerAttributes.CRYPTO_CLIENT).get().parse(bytes); // Continue in the pipeline. - out.add(data); + out.add(Unpooled.wrappedBuffer(bytes)); } } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageHandler.java index a80be5a5..2ae92c58 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageHandler.java @@ -56,14 +56,13 @@ public class GameMessageHandler extends ChannelInboundHandlerAdapter { ClientMessage message = (ClientMessage) msg; try { - ChannelReadHandler handler = new ChannelReadHandler(ctx, message); - - if (PacketManager.MULTI_THREADED_PACKET_HANDLING) { - Emulator.getThreading().run(handler); - return; - } - - handler.run(); + // This handler is registered on a dedicated EventExecutorGroup + // (see WebSocketChannelInitializer), so channelRead already runs OFF + // the Netty I/O event loop, serialized per channel. Running the + // handler inline here keeps that per-channel ordering — submitting to + // the shared game pool instead would break ordering, so we no longer + // branch on MULTI_THREADED_PACKET_HANDLING. + new ChannelReadHandler(ctx, message).run(); } catch (Exception e) { LOGGER.error("Caught exception", e); } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageRateLimit.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageRateLimit.java index 1b83a3ee..85967273 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageRateLimit.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/decoders/GameMessageRateLimit.java @@ -23,7 +23,12 @@ public class GameMessageRateLimit extends MessageToMessageDecoder protected void decode(ChannelHandlerContext ctx, ClientMessage message, List out) throws Exception { GameClient client = ctx.channel().attr(GameServerAttributes.CLIENT).get(); + // ClientMessage is not ReferenceCounted, so MessageToMessageDecoder's + // auto-release is a no-op for it; on every drop path we must release the + // wrapped ByteBuf ourselves or it leaks (it is only released downstream + // in ChannelReadHandler on the success path). if (client == null) { + message.release(); return; } @@ -42,6 +47,7 @@ public class GameMessageRateLimit extends MessageToMessageDecoder } if (count > MAX_COUNTER) { + message.release(); return; } @@ -53,6 +59,7 @@ public class GameMessageRateLimit extends MessageToMessageDecoder LOGGER.warn("Global packet rate limit exceeded for {} ({} packets/sec) — dropping excess packets", username, globalCount); } + message.release(); return; } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/encoders/GameByteEncryption.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/encoders/GameByteEncryption.java index d2930c52..e7a9a09b 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/encoders/GameByteEncryption.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/encoders/GameByteEncryption.java @@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.encoders; import com.eu.habbo.networking.gameserver.GameServerAttributes; import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelOutboundHandlerAdapter; import io.netty.channel.ChannelPromise; @@ -14,16 +15,19 @@ public class GameByteEncryption extends ChannelOutboundHandlerAdapter { // convert to Bytebuf ByteBuf in = (ByteBuf) msg; - // read available bytes - ByteBuf data = (in).readBytes(in.readableBytes()); + // Copy the readable region into a plain array (respects readerIndex / + // arrayOffset, so this is correct for pooled buffers too — buf.array() + // would have returned the wrong region for a pooled/sliced buffer). + byte[] bytes = new byte[in.readableBytes()]; + in.readBytes(bytes); //release old object ReferenceCountUtil.release(in); - // Encrypt. - ctx.channel().attr(GameServerAttributes.CRYPTO_SERVER).get().parse(data.array()); + // Encrypt in place. + ctx.channel().attr(GameServerAttributes.CRYPTO_SERVER).get().parse(bytes); // Continue in the pipeline. - ctx.write(data, promise); + ctx.write(Unpooled.wrappedBuffer(bytes), promise); } } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java index 572e9488..0ed1eb60 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java @@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.handlers; import com.eu.habbo.Emulator; import com.eu.habbo.networking.gameserver.GameServerAttributes; +import com.eu.habbo.networking.gameserver.auth.AuthHttpUtil; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; @@ -53,7 +54,7 @@ public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter { FullHttpResponse response = new DefaultFullHttpResponse( HttpVersion.HTTP_1_1, HttpResponseStatus.FORBIDDEN, - Unpooled.wrappedBuffer("Origin forbidden".getBytes()) + Unpooled.wrappedBuffer("Origin forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8)) ); response.headers().set("Vary", "Origin"); ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); @@ -65,9 +66,14 @@ public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter { private static void captureForwardedIp(ChannelHandlerContext ctx, HttpMessage req) { String ipHeader = Emulator.getConfig().getValue("ws.ip.header", ""); - if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) { + // Only honour the forwarded-IP header from a trusted reverse proxy, + // otherwise the game-session IP (used for bans/rate-limits) is spoofable. + if (!ipHeader.isEmpty() && req.headers().contains(ipHeader) && AuthHttpUtil.isTrustedProxy(ctx)) { String ip = req.headers().get(ipHeader); - ctx.channel().attr(GameServerAttributes.WS_IP).set(ip); + if (ip != null && !ip.isEmpty()) { + int comma = ip.indexOf(','); + ctx.channel().attr(GameServerAttributes.WS_IP).set((comma > 0 ? ip.substring(0, comma) : ip).trim()); + } } } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java index 0563fe0e..26226413 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/rconserver/RCONServerHandler.java @@ -16,6 +16,10 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter { private static final Logger LOGGER = LoggerFactory.getLogger(RCONServerHandler.class); + // Gson is thread-safe and immutable once built — share one instance instead + // of allocating a parser per RCON request. + private static final Gson GSON = new Gson(); + @Override public void channelRegistered(ChannelHandlerContext ctx) throws Exception { String adress = ctx.channel().remoteAddress().toString().split(":")[0].replace("/", ""); @@ -37,8 +41,8 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter { byte[] d = new byte[data.readableBytes()]; data.getBytes(0, d); - String message = new String(d); - Gson gson = new Gson(); + String message = new String(d, java.nio.charset.StandardCharsets.UTF_8); + Gson gson = GSON; String response = "ERROR"; String key = ""; try { @@ -52,7 +56,7 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter { e.printStackTrace(); } - ChannelFuture f = ctx.channel().write(Unpooled.copiedBuffer(response.getBytes()), ctx.channel().voidPromise()); + ChannelFuture f = ctx.channel().write(Unpooled.copiedBuffer(response.getBytes(java.nio.charset.StandardCharsets.UTF_8)), ctx.channel().voidPromise()); ctx.channel().flush(); ctx.flush(); f.channel().close(); diff --git a/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java b/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java index a8fe1e44..767d9976 100644 --- a/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java +++ b/Emulator/src/main/java/com/eu/habbo/plugin/PluginManager.java @@ -67,6 +67,10 @@ public class PluginManager { private static final Logger LOGGER = LoggerFactory.getLogger(PluginManager.class); + // Gson is thread-safe and immutable once built — reuse one instance instead + // of building a parser per plugin-config load. + private static final Gson PLUGIN_GSON = new GsonBuilder().create(); + private final THashSet plugins = new THashSet<>(); private final THashSet methods = new THashSet<>(); @@ -273,10 +277,9 @@ public class PluginManager { byte[] content = new byte[stream.available()]; if (stream.read(content) > 0) { - String body = new String(content); + String body = new String(content, java.nio.charset.StandardCharsets.UTF_8); - Gson gson = new GsonBuilder().create(); - HabboPluginConfiguration pluginConfigurtion = gson.fromJson(body, HabboPluginConfiguration.class); + HabboPluginConfiguration pluginConfigurtion = PLUGIN_GSON.fromJson(body, HabboPluginConfiguration.class); try { Class clazz = urlClassLoader.loadClass(pluginConfigurtion.main); diff --git a/Emulator/src/main/java/com/eu/habbo/threading/runnables/RebugKickBallAction.java b/Emulator/src/main/java/com/eu/habbo/threading/runnables/RebugKickBallAction.java index 7bc761e9..20637c35 100644 --- a/Emulator/src/main/java/com/eu/habbo/threading/runnables/RebugKickBallAction.java +++ b/Emulator/src/main/java/com/eu/habbo/threading/runnables/RebugKickBallAction.java @@ -165,12 +165,10 @@ public class RebugKickBallAction implements Runnable { this.dead = true; } - THashSet oldItems = this.room.getItemsAt(oldTile); - if (oldItems != null && !oldItems.isEmpty()) { - oldItems.remove(this.ball); - } - this.room.getItemsAt(nextTile).add(this.ball); - + // updateTile() below removes both tiles from the item cache (rebuilt + // lazily from the ball's already-updated position), so mutating the + // shared cached THashSets here is both redundant and a data race + // against the room-cycle/IO threads iterating those same sets. this.room.updateTile(oldTile); this.room.updateTile(nextTile); diff --git a/Emulator/src/main/java/com/eu/habbo/util/SqlLikeEscaper.java b/Emulator/src/main/java/com/eu/habbo/util/SqlLikeEscaper.java new file mode 100644 index 00000000..e8784547 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/util/SqlLikeEscaper.java @@ -0,0 +1,24 @@ +package com.eu.habbo.util; + +/** + * Escapes the LIKE wildcards {@code %} and {@code _} (and the escape char itself) + * in user-supplied search input, so they are matched literally instead of acting + * as wildcards. Prevents wildcard-driven over-broad matches and the expensive + * full-scans an attacker could trigger with a query like {@code "%"}. Uses + * MariaDB's default escape character {@code \}. + */ +public final class SqlLikeEscaper { + + private SqlLikeEscaper() { + } + + public static String escape(String input) { + if (input == null) { + return ""; + } + return input + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOfferContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOfferContractTest.java new file mode 100644 index 00000000..518f6ee7 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/catalog/marketplace/MarketPlaceOfferContractTest.java @@ -0,0 +1,13 @@ +package com.eu.habbo.habbohotel.catalog.marketplace; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class MarketPlaceOfferContractTest { + + @Test + void exposesPersistenceState() { + assertDoesNotThrow(() -> MarketPlaceOffer.class.getDeclaredMethod("isPersisted")); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/gameclients/GameClientManagerContractTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/gameclients/GameClientManagerContractTest.java new file mode 100644 index 00000000..4f35e668 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/gameclients/GameClientManagerContractTest.java @@ -0,0 +1,22 @@ +package com.eu.habbo.habbohotel.gameclients; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class GameClientManagerContractTest { + + @Test + void exposesExplicitForcedDisposePath() { + assertDoesNotThrow(() -> GameClient.class.getDeclaredMethod("dispose", boolean.class)); + assertDoesNotThrow(() -> GameClientManager.class.getDeclaredMethod("forceDisposeClient", GameClient.class)); + } + + @Test + void disposeMethodsIgnoreNullClient() { + GameClientManager manager = new GameClientManager(); + + assertDoesNotThrow(() -> manager.disposeClient(null)); + assertDoesNotThrow(() -> manager.forceDisposeClient(null)); + } +} diff --git a/Latest_Compiled_Version/config.ini.example b/Latest_Compiled_Version/config.ini.example index 547083ea..835e7f92 100644 --- a/Latest_Compiled_Version/config.ini.example +++ b/Latest_Compiled_Version/config.ini.example @@ -68,3 +68,9 @@ login.news.limit=5 ### ws.port=2096 ### ws.whitelist=localhost #Comma-separated whitelist of allowed origins. Supports wildcards: *.example.com, * (allow all) ### ws.ip.header=X-Forwarded-For #Header name for real client IP when behind a proxy (e.g., X-Forwarded-For, CF-Connecting-IP). Leave empty if not using a proxy. +### ws.ip.header.trusted= #Comma-separated trusted reverse-proxy IPs/prefixes (entries ending in '.' or ':' are prefix ranges, e.g. 10.0.0.) allowed to set ws.ip.header. Loopback (127.0.0.1/::1) is ALWAYS trusted; default-deny otherwise so the forwarded header can't be spoofed from the open net. + +#Performance / concurrency (optional — sensible defaults apply if unset; adjust in the Database). +### io.packet.handler.threads=24 #Game packet-handler pool size; runs game handlers OFF the Netty I/O loop. Default max(16, 2 x CPU cores). +### auth.http.pool.size=16 #Dedicated worker pool for the /api/auth/* HTTP endpoints (BCrypt/JDBC/Turnstile/SMTP run off the event loop). Default 16. +### io.netty.allocator.pooled=false #Set true to opt into Netty's pooled ByteBuf allocator. Default false (unpooled-heap).