diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/mentions/MentionManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/mentions/MentionManager.java index b95c7be4..8d9ca85f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/mentions/MentionManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/mentions/MentionManager.java @@ -1,37 +1,60 @@ package com.eu.habbo.habbohotel.mentions; import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.messenger.MessengerBuddy; import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.habbohotel.rooms.RoomChatType; import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.users.HabboManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; +import java.sql.*; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; public class MentionManager { private static final Logger LOGGER = LoggerFactory.getLogger(MentionManager.class); + private static final int ROOM_NAME_MAX_LENGTH = 64; + private static final int MESSAGE_MAX_LENGTH = 255; + private final ConcurrentHashMap cooldowns = new ConcurrentHashMap<>(); + private final ConcurrentHashMap roomBroadcastCooldowns = new ConcurrentHashMap<>(); + + // Per-user request rate limits for the incoming packets that hit the DB. + private final ConcurrentHashMap requestListCooldowns = new ConcurrentHashMap<>(); + private final ConcurrentHashMap markReadCooldowns = new ConcurrentHashMap<>(); + private final ConcurrentHashMap markAllCooldowns = new ConcurrentHashMap<>(); + private final ConcurrentHashMap deleteCooldowns = new ConcurrentHashMap<>(); + + private volatile long lastPrune = System.currentTimeMillis(); + private static final long PRUNE_INTERVAL_MS = 5 * 60_000L; public boolean isEnabled() { return Emulator.getConfig().getInt("mentions.enabled", 1) == 1; } - private Set roomAliases() { + /** Broadcast category resolved from a mention alias. */ + public enum BroadcastScope { + NONE, + // @room / @stanza - reaches the people currently in the room. + ROOM, + // @friends / @amici - reaches the sender's online friends, requires acc_mention_friends. + FRIENDS, + // @all / @everyone / @tutti - reaches every online user, requires acc_mention_everyone. + EVERYONE + } + + /** Permission key (DB column) required to send an "everyone" broadcast. */ + public static final String PERMISSION_EVERYONE = "acc_mention_everyone"; + /** Permission key (DB column) required to send a "friends" broadcast. */ + public static final String PERMISSION_FRIENDS = "acc_mention_friends"; + + private Set parseAliases(String configKey, String defaultValue) { Set aliases = new HashSet<>(); - String raw = Emulator.getConfig().getValue("mentions.room.aliases", "amici,friends,all,everyone,tutti,room,stanza"); + String raw = Emulator.getConfig().getValue(configKey, defaultValue); for (String alias : raw.split(",")) { String trimmed = alias.trim().toLowerCase(); if (!trimmed.isEmpty()) { @@ -41,6 +64,37 @@ public class MentionManager { return aliases; } + private Set roomAliases() { + return parseAliases("mentions.room.aliases", "room,stanza"); + } + + private Set friendsAliases() { + return parseAliases("mentions.friends.aliases", "friends,amici"); + } + + private Set everyoneAliases() { + return parseAliases("mentions.everyone.aliases", "all,everyone,tutti"); + } + + /** + * Classify an alias candidate (lowercased, punctuation-trimmed) into a + * broadcast scope. {@link BroadcastScope#EVERYONE} wins over + * {@link BroadcastScope#FRIENDS} which wins over {@link BroadcastScope#ROOM} + * so an admin who's also configured the same word into two lists gets the + * most permissive scope (which is also the one requiring the strongest + * permission, so it can't be misused). + */ + private BroadcastScope classifyAlias(String alias, + Set everyone, + Set friends, + Set room) { + if (alias.isEmpty()) return BroadcastScope.NONE; + if (everyone.contains(alias)) return BroadcastScope.EVERYONE; + if (friends.contains(alias)) return BroadcastScope.FRIENDS; + if (room.contains(alias)) return BroadcastScope.ROOM; + return BroadcastScope.NONE; + } + public void process(Habbo sender, Room room, String message, RoomChatType type) { try { if (!this.isEnabled()) { @@ -63,8 +117,11 @@ public class MentionManager { return; } - Set aliases = this.roomAliases(); - boolean roomBroadcast = false; + Set roomAliases = this.roomAliases(); + Set friendsAliases = this.friendsAliases(); + Set everyoneAliases = this.everyoneAliases(); + + BroadcastScope broadcastScope = BroadcastScope.NONE; LinkedHashSet directTokens = new LinkedHashSet<>(); for (String token : message.split("\\s+")) { @@ -75,47 +132,80 @@ public class MentionManager { String raw = token.substring(1); String aliasCandidate = trimTrailingPunctuation(raw).toLowerCase(); - if (!aliasCandidate.isEmpty() && aliases.contains(aliasCandidate)) { - roomBroadcast = true; + BroadcastScope scope = this.classifyAlias(aliasCandidate, everyoneAliases, friendsAliases, roomAliases); + + if (scope != BroadcastScope.NONE) { + // Promote to the strongest detected scope so a message with + // both @room and @all routes through the @all permission. + if (scope.ordinal() > broadcastScope.ordinal()) { + broadcastScope = scope; + } } else if (!raw.isEmpty()) { directTokens.add(raw); } } - if (!roomBroadcast && directTokens.isEmpty()) { + // Gate the broadcast on the matching permission. If the sender does + // not have the right to use it, drop the broadcast entirely but + // keep processing any direct @nick tokens in the same message. + if (broadcastScope == BroadcastScope.EVERYONE && !sender.hasPermission(PERMISSION_EVERYONE)) { + broadcastScope = BroadcastScope.NONE; + } else if (broadcastScope == BroadcastScope.FRIENDS && !sender.hasPermission(PERMISSION_FRIENDS)) { + broadcastScope = BroadcastScope.NONE; + } + + if (broadcastScope == BroadcastScope.NONE && directTokens.isEmpty()) { return; } + // Stricter cooldown for broadcasts: one @all/@friends/@room expands to + // up to mentions.max.targets DB writes and packet sends, so rate-limit it + // separately from direct mentions. + if (broadcastScope != BroadcastScope.NONE) { + long roomCooldownMs = Emulator.getConfig().getInt("mentions.room.cooldown.ms", 15000); + Long lastRoom = this.roomBroadcastCooldowns.get(senderId); + if (lastRoom != null && (now - lastRoom) < roomCooldownMs) { + return; + } + } + int maxTargets = Emulator.getConfig().getInt("mentions.max.targets", 50); + if (maxTargets <= 0) maxTargets = 1; + + // Bound the number of direct tokens we even attempt to resolve so a + // crafted message can't push us through the room iteration N times. + int maxDirectTokens = Math.min(directTokens.size(), maxTargets); List targets = new ArrayList<>(); Set seen = new HashSet<>(); - if (roomBroadcast) { - for (Habbo habbo : room.getHabbos()) { - if (habbo == null || habbo.getHabboInfo().getId() == senderId) { - continue; + switch (broadcastScope) { + case EVERYONE: + this.collectEveryoneTargets(senderId, targets, seen, maxTargets); + break; + case FRIENDS: + this.collectFriendsTargets(sender, senderId, targets, seen, maxTargets); + break; + case ROOM: + this.collectRoomTargets(room, senderId, targets, seen, maxTargets); + break; + case NONE: + default: + int processed = 0; + for (String token : directTokens) { + if (processed++ >= maxDirectTokens) break; + Habbo habbo = this.resolveHabbo(room, token); + if (habbo == null || habbo.getHabboInfo().getId() == senderId) { + continue; + } + if (seen.add(habbo.getHabboInfo().getId())) { + targets.add(habbo); + } + if (targets.size() >= maxTargets) { + break; + } } - if (seen.add(habbo.getHabboInfo().getId())) { - targets.add(habbo); - } - if (targets.size() >= maxTargets) { - break; - } - } - } else { - for (String token : directTokens) { - Habbo habbo = this.resolveHabbo(room, token); - if (habbo == null || habbo.getHabboInfo().getId() == senderId) { - continue; - } - if (seen.add(habbo.getHabboInfo().getId())) { - targets.add(habbo); - } - if (targets.size() >= maxTargets) { - break; - } - } + break; } if (targets.isEmpty()) { @@ -123,15 +213,13 @@ public class MentionManager { } this.cooldowns.put(senderId, now); + if (broadcastScope != BroadcastScope.NONE) this.roomBroadcastCooldowns.put(senderId, now); + this.pruneCooldownsIfDue(now); - int mentionType = roomBroadcast ? HabboMention.TYPE_ROOM : HabboMention.TYPE_DIRECT; + int mentionType = (broadcastScope != BroadcastScope.NONE) ? HabboMention.TYPE_ROOM : HabboMention.TYPE_DIRECT; int timestamp = Emulator.getIntUnixTimestamp(); - String roomName = room.getName(); - - String storedMessage = message; - if (storedMessage.length() > 255) { - storedMessage = storedMessage.substring(0, 255); - } + String roomName = truncate(room.getName(), ROOM_NAME_MAX_LENGTH); + String storedMessage = truncate(message, MESSAGE_MAX_LENGTH); for (Habbo target : targets) { this.store(target, sender, room, storedMessage, mentionType, timestamp, roomName); @@ -141,6 +229,36 @@ public class MentionManager { } } + private void collectRoomTargets(Room room, int senderId, List targets, Set seen, int maxTargets) { + for (Habbo habbo : room.getHabbos()) { + if (habbo == null || habbo.getHabboInfo().getId() == senderId) continue; + if (seen.add(habbo.getHabboInfo().getId())) targets.add(habbo); + if (targets.size() >= maxTargets) break; + } + } + + private void collectFriendsTargets(Habbo sender, int senderId, List targets, Set seen, int maxTargets) { + if (sender.getMessenger() == null) return; + HabboManager habboManager = Emulator.getGameEnvironment().getHabboManager(); + for (MessengerBuddy buddy : sender.getMessenger().getFriends().values()) { + if (buddy == null) continue; + int buddyId = buddy.getId(); + if (buddyId == senderId) continue; + Habbo online = habboManager.getHabbo(buddyId); + if (online == null) continue; + if (seen.add(buddyId)) targets.add(online); + if (targets.size() >= maxTargets) break; + } + } + + private void collectEveryoneTargets(int senderId, List targets, Set seen, int maxTargets) { + for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) { + if (habbo == null || habbo.getHabboInfo().getId() == senderId) continue; + if (seen.add(habbo.getHabboInfo().getId())) targets.add(habbo); + if (targets.size() >= maxTargets) break; + } + } + private void store(Habbo target, Habbo sender, Room room, String message, int mentionType, int timestamp, String roomName) { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( @@ -163,6 +281,13 @@ public class MentionManager { } } + // Don't push a notification to the client when the INSERT did not + // return an id - the client dedup keys on id and a 0 would skip + // dedup entirely, opening a flood path on the next packet. + if (generatedId <= 0) { + return; + } + HabboMention mention = new HabboMention(target.getHabboInfo().getId(), generatedId, sender, room, roomName, message, mentionType, timestamp); if (target.getClient() != null) { @@ -175,6 +300,8 @@ public class MentionManager { public List getMentions(int userId, int limit) { List mentions = new ArrayList<>(); + if (limit <= 0) limit = 50; + if (limit > 200) limit = 200; try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( "SELECT * FROM habbo_mentions WHERE target_user_id = ? ORDER BY id DESC LIMIT ?")) { @@ -192,9 +319,13 @@ public class MentionManager { } public void markRead(int userId, int mode, int mentionId) { + // Caller has already validated mode and mentionId; this method is defensive only. + if (mode != 0 && mode != 1) return; + if (mode == 1 && mentionId <= 0) return; + String query = mode == 1 - ? "UPDATE habbo_mentions SET `read` = 1 WHERE target_user_id = ? AND id = ?" - : "UPDATE habbo_mentions SET `read` = 1 WHERE target_user_id = ?"; + ? "UPDATE habbo_mentions SET `read` = 1 WHERE target_user_id = ? AND id = ? AND `read` = 0" + : "UPDATE habbo_mentions SET `read` = 1 WHERE target_user_id = ? AND `read` = 0"; try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement(query)) { statement.setInt(1, userId); @@ -208,6 +339,7 @@ public class MentionManager { } public void delete(int userId, int mentionId) { + if (mentionId <= 0) return; try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( "DELETE FROM habbo_mentions WHERE target_user_id = ? AND id = ?")) { @@ -219,6 +351,87 @@ public class MentionManager { } } + /** + * Per-user rate limit for {@code RequestMentionsEvent}. Returns true when + * the caller should be served, false when it must be silently dropped. + */ + public boolean tryAcquireRequestList(int userId) { + long cooldownMs = Emulator.getConfig().getInt("mentions.request.cooldown.ms", 2000); + return tryAcquire(this.requestListCooldowns, userId, cooldownMs); + } + + /** + * Per-user rate limit for {@code MarkMentionsReadEvent}. The mark-single + * path (mode == 1) is cheap and gets a short window; the mark-all path + * (mode != 1) is a bulk UPDATE and gets a longer one. + */ + public boolean tryAcquireMarkRead(int userId, int mode) { + long cooldownMs; + ConcurrentHashMap bucket; + if (mode == 1) { + cooldownMs = Emulator.getConfig().getInt("mentions.markread.cooldown.ms", 500); + bucket = this.markReadCooldowns; + } else { + cooldownMs = Emulator.getConfig().getInt("mentions.markall.cooldown.ms", 5000); + bucket = this.markAllCooldowns; + } + return tryAcquire(bucket, userId, cooldownMs); + } + + /** + * Per-user rate limit for {@code DeleteMentionEvent}. + */ + public boolean tryAcquireDelete(int userId) { + long cooldownMs = Emulator.getConfig().getInt("mentions.delete.cooldown.ms", 500); + return tryAcquire(this.deleteCooldowns, userId, cooldownMs); + } + + private boolean tryAcquire(ConcurrentHashMap bucket, int userId, long cooldownMs) { + long now = System.currentTimeMillis(); + Long last = bucket.get(userId); + if (last != null && (now - last) < cooldownMs) { + return false; + } + bucket.put(userId, now); + this.pruneCooldownsIfDue(now); + return true; + } + + /** + * Periodically drop cooldown entries older than the largest window so the + * maps don't accumulate one entry per user-that-ever-played for the entire + * server lifetime. + */ + private void pruneCooldownsIfDue(long now) { + if (now - this.lastPrune < PRUNE_INTERVAL_MS) return; + this.lastPrune = now; + + long mentionWindow = Emulator.getConfig().getInt("mentions.cooldown.ms", 3000); + long roomWindow = Emulator.getConfig().getInt("mentions.room.cooldown.ms", 15000); + long requestWindow = Emulator.getConfig().getInt("mentions.request.cooldown.ms", 2000); + long markReadWindow = Emulator.getConfig().getInt("mentions.markread.cooldown.ms", 500); + long markAllWindow = Emulator.getConfig().getInt("mentions.markall.cooldown.ms", 5000); + long deleteWindow = Emulator.getConfig().getInt("mentions.delete.cooldown.ms", 500); + + prune(this.cooldowns, now, mentionWindow); + prune(this.roomBroadcastCooldowns, now, roomWindow); + prune(this.requestListCooldowns, now, requestWindow); + prune(this.markReadCooldowns, now, markReadWindow); + prune(this.markAllCooldowns, now, markAllWindow); + prune(this.deleteCooldowns, now, deleteWindow); + } + + private static void prune(ConcurrentHashMap bucket, long now, long windowMs) { + Iterator> it = bucket.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + Long value = entry.getValue(); + if (value == null || (now - value) >= windowMs) { + it.remove(); + } + } + } + private static final String TRAILING_PUNCTUATION = ".,!?;:)]}\"'"; private static String trimTrailingPunctuation(String value) { @@ -229,6 +442,12 @@ public class MentionManager { return value.substring(0, end); } + private static String truncate(String value, int max) { + if (value == null) return ""; + if (value.length() <= max) return value; + return value.substring(0, max); + } + /** * Resolve a present room occupant from a raw mention token. Tries the token * verbatim first (so usernames containing allowed punctuation such as '-', diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/DeleteMentionEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/DeleteMentionEvent.java index 6be3622b..88d495da 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/DeleteMentionEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/DeleteMentionEvent.java @@ -1,14 +1,27 @@ package com.eu.habbo.messages.incoming.mentions; import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.mentions.MentionManager; import com.eu.habbo.messages.incoming.MessageHandler; public class DeleteMentionEvent extends MessageHandler { @Override public void handle() throws Exception { + if (this.client == null || this.client.getHabbo() == null) return; + int userId = this.client.getHabbo().getHabboInfo().getId(); int mentionId = this.packet.readInt(); - Emulator.getGameEnvironment().getMentionManager().delete(userId, mentionId); + if (mentionId <= 0) { + return; + } + + MentionManager manager = Emulator.getGameEnvironment().getMentionManager(); + + if (!manager.tryAcquireDelete(userId)) { + return; + } + + manager.delete(userId, mentionId); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/MarkMentionsReadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/MarkMentionsReadEvent.java index 5abfddda..8785552e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/MarkMentionsReadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/MarkMentionsReadEvent.java @@ -1,15 +1,35 @@ package com.eu.habbo.messages.incoming.mentions; import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.mentions.MentionManager; import com.eu.habbo.messages.incoming.MessageHandler; public class MarkMentionsReadEvent extends MessageHandler { @Override public void handle() throws Exception { + if (this.client == null || this.client.getHabbo() == null) return; + int userId = this.client.getHabbo().getHabboInfo().getId(); int mode = this.packet.readInt(); int mentionId = this.packet.readInt(); - Emulator.getGameEnvironment().getMentionManager().markRead(userId, mode, mentionId); + // Only mode 0 (mark-all) and mode 1 (mark-single) are valid. Reject + // anything else so a crafted packet can't fall into the mark-all branch + // by accident. + if (mode != 0 && mode != 1) { + return; + } + + if (mode == 1 && mentionId <= 0) { + return; + } + + MentionManager manager = Emulator.getGameEnvironment().getMentionManager(); + + if (!manager.tryAcquireMarkRead(userId, mode)) { + return; + } + + manager.markRead(userId, mode, mentionId); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/RequestMentionsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/RequestMentionsEvent.java index 9878da92..29765ecd 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/RequestMentionsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/mentions/RequestMentionsEvent.java @@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.mentions; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.mentions.HabboMention; +import com.eu.habbo.habbohotel.mentions.MentionManager; import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.outgoing.mentions.MentionsListComposer; @@ -10,10 +11,19 @@ import java.util.List; public class RequestMentionsEvent extends MessageHandler { @Override public void handle() throws Exception { + if (this.client == null || this.client.getHabbo() == null) return; + int userId = this.client.getHabbo().getHabboInfo().getId(); + + MentionManager manager = Emulator.getGameEnvironment().getMentionManager(); + + if (!manager.tryAcquireRequestList(userId)) { + return; + } + int limit = Emulator.getConfig().getInt("mentions.store.limit", 50); - List mentions = Emulator.getGameEnvironment().getMentionManager().getMentions(userId, limit); + List mentions = manager.getMentions(userId, limit); this.client.sendResponse(new MentionsListComposer(mentions)); } }