diff --git a/Database Updates/004_fix_acc_supporttool_rank.sql b/Database Updates/004_fix_acc_supporttool_rank.sql new file mode 100644 index 00000000..f0dfd371 --- /dev/null +++ b/Database Updates/004_fix_acc_supporttool_rank.sql @@ -0,0 +1,31 @@ +-- ============================================================ +-- Fix: acc_supporttool wrongly granted to VIP / wrongly denied to Super Mod +-- ============================================================ +-- The default permission_definitions seed shipped acc_supporttool +-- with rank pattern (0, 1, 1, 1, 1, 0, 1) — i.e. rank_2 (VIP) and +-- rank_3 (X, junior helper) had ALLOWED, while rank_6 (Super Mod) +-- did NOT. That's two bugs: +-- +-- * VIP users see the ModTools button on the toolbar and can +-- open Room/User info windows. The actual sanction endpoints +-- still gate on ACC_SUPPORTTOOL server-side so they can't +-- actually moderate, but the UI exposure is wrong and lets a +-- VIP request user info / room info / chatlogs they have no +-- business reading. +-- * Super Mod is denied the tool entirely, which is obviously +-- unintended given the rank name. +-- +-- Intended pattern: only Support (4) and up — (0, 0, 0, 1, 1, 1, 1). +-- +-- Run on existing deployments to align with the corrected default +-- seed in `Default Database/FullDatabase.sql`. Idempotent. + +UPDATE `permission_definitions` + SET `rank_1` = 0, + `rank_2` = 0, + `rank_3` = 0, + `rank_4` = 1, + `rank_5` = 1, + `rank_6` = 1, + `rank_7` = 1 + WHERE `permission_key` = 'acc_supporttool'; diff --git a/Default Database/FullDatabase.sql b/Default Database/FullDatabase.sql index 75a3b75b..c7e85d40 100644 --- a/Default Database/FullDatabase.sql +++ b/Default Database/FullDatabase.sql @@ -28598,7 +28598,7 @@ INSERT INTO `permission_definitions` (`permission_key`, `max_value`, `comment`, ('acc_staff_chat', 1, 'Grants access to the in-game Staff Chat group buddy: receives broadcasts from other staff and can broadcast to anyone holding this permission.', 0, 0, 0, 0, 0, 0, 1), ('acc_staff_pick', 1, 'Allows using staff item pick-up actions that bypass normal room ownership restrictions.', 0, 0, 0, 0, 0, 0, 1), ('acc_superwired', 1, 'Allows saving advanced wired data without the normal wordfilter and reward payload restrictions applied to regular users.', 0, 0, 0, 0, 0, 0, 1), - ('acc_supporttool', 1, 'Allows opening and using the support/moderation tool interface.', 0, 1, 1, 1, 1, 0, 1), + ('acc_supporttool', 1, 'Allows opening and using the support/moderation tool interface.', 0, 0, 0, 1, 1, 1, 1), ('acc_trade_anywhere', 1, 'Allows starting trades outside the normal trade-enabled areas.', 0, 0, 0, 0, 0, 0, 1), ('acc_unignorable', 1, 'Prevents the account from being ignored by other users through the ignore system.', 0, 0, 0, 0, 0, 0, 0), ('acc_unkickable', 1, 'Prevents the user from being kicked by normal moderation or room commands.', 0, 0, 0, 0, 0, 0, 1), 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 1984ad28..03546504 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 @@ -651,6 +651,10 @@ public class ModToolManager { sender.getClient().sendResponse(new ModToolIssueHandledComposer(ModToolIssueHandledComposer.ABUSIVE)); } + // Reporter (the user who opened the CFH) gets their abusive + // counter bumped — the legacy stat shown in the User Info table. + bumpUserSettingCounter(issue.senderId, "cfh_abusive"); + this.updateTicketToMods(issue); this.removeTicket(issue); @@ -737,4 +741,38 @@ public class ModToolManager { return issues; } + + /** + * Increments a single integer counter on `users_settings` for the + * given user. Used by the moderation sanction handlers to bump the + * legacy counters that `ModToolUserInfoComposer` surfaces (cfh_warnings, + * cfh_bans, cfh_abusive, tradelock_amount) — historically these were + * only ever incremented by the CFH submission path, so a user could + * accumulate any number of bans/mutes without the User Info table + * reflecting it. + * + * Restricted to a whitelisted column name to keep the dynamic SQL + * safe; the caller passes a Permission-style constant. + */ + public static void bumpUserSettingCounter(int userId, String column) { + switch (column) { + case "cfh_warnings": + case "cfh_bans": + case "cfh_abusive": + case "tradelock_amount": + break; + default: + LOGGER.warn("Refusing to bump unrecognized user_settings column: {}", column); + return; + } + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "UPDATE users_settings SET " + column + " = " + column + " + 1 WHERE user_id = ?")) { + statement.setInt(1, userId); + statement.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("Caught SQL exception bumping {} for user {}", column, userId, e); + } + } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java index 4c901c46..21bd1393 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionAlertEvent.java @@ -1,6 +1,7 @@ package com.eu.habbo.messages.incoming.modtool; import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.modtool.ModToolManager; import com.eu.habbo.habbohotel.modtool.ModToolSanctionItem; import com.eu.habbo.habbohotel.modtool.ModToolSanctions; import com.eu.habbo.habbohotel.permissions.Permission; @@ -47,6 +48,8 @@ public class ModToolSanctionAlertEvent extends MessageHandler { } else { habbo.alert(message); } + + ModToolManager.bumpUserSettingCounter(userId, "cfh_warnings"); } else { this.client.sendResponse(new ModToolIssueHandledComposer(Emulator.getTexts().getValue("generic.user.not_found").replace("%user%", Emulator.getConfig().getValue("hotel.player.name")))); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java index 1814041b..5a2c8afa 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionBanEvent.java @@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.modtool; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.modtool.ModToolBanType; +import com.eu.habbo.habbohotel.modtool.ModToolManager; import com.eu.habbo.habbohotel.modtool.ModToolSanctionItem; import com.eu.habbo.habbohotel.modtool.ModToolSanctions; import com.eu.habbo.habbohotel.modtool.ScripterManager; @@ -73,6 +74,7 @@ public class ModToolSanctionBanEvent extends MessageHandler { Emulator.getGameEnvironment().getModToolManager().ban(userId, this.client.getHabbo(), message, duration, ModToolBanType.ACCOUNT, cfhTopic); } + ModToolManager.bumpUserSettingCounter(userId, "cfh_bans"); } else { ScripterManager.scripterDetected(this.client, Emulator.getTexts().getValue("scripter.warning.modtools.ban").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername())); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java index e9356fdf..3be70cf6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionMuteEvent.java @@ -1,6 +1,7 @@ package com.eu.habbo.messages.incoming.modtool; import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.modtool.ModToolManager; import com.eu.habbo.habbohotel.modtool.ModToolSanctionItem; import com.eu.habbo.habbohotel.modtool.ModToolSanctionLevelItem; import com.eu.habbo.habbohotel.modtool.ModToolSanctions; @@ -59,6 +60,8 @@ public class ModToolSanctionMuteEvent extends MessageHandler { habbo.alert(message); this.client.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_mute.muted").replace("%user%", habbo.getHabboInfo().getUsername())); } + + ModToolManager.bumpUserSettingCounter(userId, "cfh_warnings"); } else { this.client.sendResponse(new ModToolIssueHandledComposer(Emulator.getTexts().getValue("generic.user.not_found").replace("%user%", Emulator.getConfig().getValue("hotel.player.name")))); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java index 0cece3ce..d501eba5 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/modtool/ModToolSanctionTradeLockEvent.java @@ -1,6 +1,7 @@ package com.eu.habbo.messages.incoming.modtool; import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.modtool.ModToolManager; import com.eu.habbo.habbohotel.modtool.ModToolSanctionItem; import com.eu.habbo.habbohotel.modtool.ModToolSanctions; import com.eu.habbo.habbohotel.permissions.Permission; @@ -49,6 +50,8 @@ public class ModToolSanctionTradeLockEvent extends MessageHandler { habbo.getHabboStats().setAllowTrade(false); habbo.alert(message); } + + ModToolManager.bumpUserSettingCounter(userId, "tradelock_amount"); } else { this.client.sendResponse(new ModToolIssueHandledComposer(Emulator.getTexts().getValue("generic.user.not_found").replace("%user%", Emulator.getConfig().getValue("hotel.player.name")))); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolUserInfoComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolUserInfoComposer.java index 4772d455..3e2b938f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolUserInfoComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/modtool/ModToolUserInfoComposer.java @@ -12,10 +12,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.sql.*; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; public class ModToolUserInfoComposer extends MessageComposer { private static final Logger LOGGER = LoggerFactory.getLogger(ModToolUserInfoComposer.class); + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm"); private final ResultSet set; private final boolean hideMail; @@ -29,37 +32,30 @@ public class ModToolUserInfoComposer extends MessageComposer { protected ServerMessage composeInternal() { this.response.init(Outgoing.ModToolUserInfoComposer); try { - int totalBans = 0; + int userId = this.set.getInt("user_id"); + String machineId = this.set.getString("machine_id"); + int now = Emulator.getIntUnixTimestamp(); - try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); - PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) AS amount FROM bans WHERE user_id = ?")) { - statement.setInt(1, this.set.getInt("user_id")); - try (ResultSet set = statement.executeQuery()) { - if (set.next()) { - totalBans = set.getInt("amount"); - } - } catch (SQLException e) { - LOGGER.error("Caught SQL exception", e); - } - } catch (SQLException e) { - LOGGER.error("Caught SQL exception", e); - } + int totalBans = countBansForUser(userId); + int lastPurchaseTimestamp = fetchLastPurchaseTimestamp(userId); + int tradeLockExpiryTimestamp = fetchActiveTradeLockExpiry(userId, now); + int identityRelatedBanCount = countIdentityRelatedBans(userId, machineId); - this.response.appendInt(this.set.getInt("user_id")); + this.response.appendInt(userId); this.response.appendString(this.set.getString("username")); this.response.appendString(this.set.getString("look")); - this.response.appendInt((Emulator.getIntUnixTimestamp() - this.set.getInt("account_created")) / 60); - this.response.appendInt((this.set.getInt("online") == 1 ? 0 : Emulator.getIntUnixTimestamp() - this.set.getInt("last_online")) / 60); + this.response.appendInt((now - this.set.getInt("account_created")) / 60); + this.response.appendInt((this.set.getInt("online") == 1 ? 0 : now - this.set.getInt("last_online")) / 60); this.response.appendBoolean(this.set.getInt("online") == 1); this.response.appendInt(this.set.getInt("cfh_send")); this.response.appendInt(this.set.getInt("cfh_abusive")); this.response.appendInt(this.set.getInt("cfh_warnings")); this.response.appendInt(totalBans); // Number of bans this.response.appendInt(this.set.getInt("tradelock_amount")); - this.response.appendString(""); //Trading lock expiry timestamp - this.response.appendString(""); //Last Purchase Timestamp - this.response.appendInt(this.set.getInt("user_id")); //Personal Identification # - this.response.appendInt(0); // Number of account bans + this.response.appendString(formatUnixTimestamp(tradeLockExpiryTimestamp)); // Trading lock expiry timestamp + this.response.appendString(formatUnixTimestamp(lastPurchaseTimestamp)); // Last Purchase Timestamp + this.response.appendInt(userId); //Personal Identification # + this.response.appendInt(identityRelatedBanCount); // Number of account bans on the same machine_id this.response.appendString(this.hideMail ? "" : this.set.getString("mail")); this.response.appendString("Rank (" + this.set.getInt("rank_id") + "): " + this.set.getString("rank_name")); //user_class_txt @@ -90,4 +86,87 @@ public class ModToolUserInfoComposer extends MessageComposer { public ResultSet getSet() { return set; } + + private static int countBansForUser(int userId) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) AS amount FROM bans WHERE user_id = ?")) { + statement.setInt(1, userId); + try (ResultSet set = statement.executeQuery()) { + if (set.next()) return set.getInt("amount"); + } + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + } + return 0; + } + + /** + * Most recent purchase timestamp from logs_shop_purchases for this + * user. Returns 0 when the user has never bought anything (in which + * case the wire field stays empty and the client shows the empty + * placeholder). + */ + private static int fetchLastPurchaseTimestamp(int userId) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT MAX(`timestamp`) AS ts FROM logs_shop_purchases WHERE user_id = ?")) { + statement.setInt(1, userId); + try (ResultSet set = statement.executeQuery()) { + if (set.next()) return set.getInt("ts"); + } + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + } + return 0; + } + + /** + * Latest active trade-lock expiry from the sanctions table. Only + * locks expiring in the future are considered — past entries don't + * count. Returns 0 when no active lock exists. + */ + private static int fetchActiveTradeLockExpiry(int userId, int now) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT MAX(trade_locked_until) AS expiry FROM sanctions WHERE habbo_id = ? AND trade_locked_until > ?")) { + statement.setInt(1, userId); + statement.setInt(2, now); + try (ResultSet set = statement.executeQuery()) { + if (set.next()) return set.getInt("expiry"); + } + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + } + return 0; + } + + /** + * Count of OTHER user accounts that have been banned from the same + * machine_id as this user. An empty machine_id (default '') is + * ignored — never matches anything by definition. Self is excluded + * because the user's own bans are already counted under banCount. + */ + private static int countIdentityRelatedBans(int userId, String machineId) { + if (machineId == null || machineId.isEmpty()) return 0; + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT COUNT(DISTINCT user_id) AS amount FROM bans WHERE machine_id = ? AND user_id != ?")) { + statement.setString(1, machineId); + statement.setInt(2, userId); + try (ResultSet set = statement.executeQuery()) { + if (set.next()) return set.getInt("amount"); + } + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + } + return 0; + } + + /** + * Wire format for date fields is `yyyy-MM-dd HH:mm`. A 0 timestamp + * is rendered as an empty string so the client falls back to its + * empty-state placeholder. + */ + private static String formatUnixTimestamp(int timestamp) { + if (timestamp <= 0) return ""; + return DATE_FORMAT.format(new Date(timestamp * 1000L)); + } }