diff --git a/Database Updates/002_forum_groups.sql b/Database Updates/002_forum_groups.sql new file mode 100644 index 00000000..fe077955 --- /dev/null +++ b/Database Updates/002_forum_groups.sql @@ -0,0 +1 @@ +ALTER TABLE `guild_forum_views` ADD UNIQUE KEY `user_guild` (`user_id`, `guild_id`); \ No newline at end of file diff --git a/Database Updates/003_furni_editor.sql b/Database Updates/003_furni_editor.sql new file mode 100644 index 00000000..37bfc80c --- /dev/null +++ b/Database Updates/003_furni_editor.sql @@ -0,0 +1,6 @@ +INSERT IGNORE INTO `emulator_settings` (`key`, `value`) VALUES + ('furni.editor.renderer.config.path', '/var/www/Gamedata/config/renderer-config.json'), + ('furni.editor.asset.base.path', '/var/www/Gamedata/furniture/nitro-assets/'); + +ALTER TABLE permissions +ADD COLUMN `acc_catalogfurni` ENUM('0','1') NOT NULL DEFAULT '0' AFTER `acc_catalog_ids`; \ No newline at end of file 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 a82a2d01..0bc0c265 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 @@ -135,16 +135,10 @@ public class ForumThread implements Runnable, ISerialize { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT A.*, B.`id` AS `last_comment_id` " + "FROM guilds_forums_threads A " + - "JOIN (" + - "SELECT * " + + "LEFT JOIN (" + + "SELECT `thread_id`, MAX(`id`) AS `id`, MAX(`created_at`) AS `created_at` " + "FROM `guilds_forums_comments` " + - "WHERE `id` IN (" + - "SELECT MAX(id) " + - "FROM `guilds_forums_comments` B " + - "GROUP BY `thread_id` AND B.`id` " + - "ORDER BY B.`id` " + - ") " + - "ORDER BY `id` DESC " + + "GROUP BY `thread_id`" + ") B ON A.`id` = B.`thread_id` " + "WHERE A.`guild_id` = ? " + "ORDER BY A.`pinned` DESC, B.`created_at` DESC " @@ -176,16 +170,10 @@ public class ForumThread implements Runnable, ISerialize { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( "SELECT A.*, B.`id` AS `last_comment_id` " + "FROM guilds_forums_threads A " + - "JOIN (" + - "SELECT * " + + "LEFT JOIN (" + + "SELECT `thread_id`, MAX(`id`) AS `id`, MAX(`created_at`) AS `created_at` " + "FROM `guilds_forums_comments` " + - "WHERE `id` IN (" + - "SELECT MAX(id) " + - "FROM `guilds_forums_comments` B " + - "GROUP BY `thread_id` AND b.`id`" + - "ORDER BY B.`id` " + - ") " + - "ORDER BY `id` DESC " + + "GROUP BY `thread_id`" + ") B ON A.`id` = B.`thread_id` " + "WHERE A.`id` = ? " + "ORDER BY A.`pinned` DESC, B.`created_at` DESC " + @@ -222,6 +210,19 @@ public class ForumThread implements Runnable, ISerialize { guildThreads.add(thread); } + public static void clearCacheForGuild(int guildId) { + synchronized (guildThreadsCache) { + THashSet threads = guildThreadsCache.remove(guildId); + if (threads != null) { + synchronized (forumThreadsCache) { + for (ForumThread thread : threads) { + forumThreadsCache.remove(thread.threadId); + } + } + } + } + } + public static void clearCache() { for (THashSet threads : guildThreadsCache.values()) { for (ForumThread thread : threads) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java index 885ec904..18163a51 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java @@ -11,6 +11,7 @@ import com.eu.habbo.messages.incoming.ambassadors.AmbassadorVisitCommandEvent; import com.eu.habbo.messages.incoming.camera.*; import com.eu.habbo.messages.incoming.catalog.*; import com.eu.habbo.messages.incoming.catalog.catalogadmin.*; +import com.eu.habbo.messages.incoming.furnieditor.*; import com.eu.habbo.messages.incoming.catalog.marketplace.*; import com.eu.habbo.messages.incoming.catalog.recycler.OpenRecycleBoxEvent; import com.eu.habbo.messages.incoming.catalog.recycler.RecycleEvent; @@ -260,6 +261,14 @@ public class PacketManager { this.registerHandler(Incoming.CatalogRequestClubDiscountEvent, CatalogRequestClubDiscountEvent.class); this.registerHandler(Incoming.CatalogBuyClubDiscountEvent, CatalogBuyClubDiscountEvent.class); + // Furni Editor + this.registerHandler(Incoming.FurniEditorSearchEvent, FurniEditorSearchEvent.class); + this.registerHandler(Incoming.FurniEditorDetailEvent, FurniEditorDetailEvent.class); + this.registerHandler(Incoming.FurniEditorBySpriteEvent, FurniEditorBySpriteEvent.class); + this.registerHandler(Incoming.FurniEditorInteractionsEvent, FurniEditorInteractionsEvent.class); + this.registerHandler(Incoming.FurniEditorUpdateEvent, FurniEditorUpdateEvent.class); + this.registerHandler(Incoming.FurniEditorDeleteEvent, FurniEditorDeleteEvent.class); + // Catalog Admin this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class); this.registerHandler(Incoming.CatalogAdminCreatePageEvent, CatalogAdminCreatePageEvent.class); @@ -572,6 +581,7 @@ public class PacketManager { this.registerHandler(Incoming.GuildForumModerateMessageEvent, GuildForumModerateMessageEvent.class); this.registerHandler(Incoming.GuildForumModerateThreadEvent, GuildForumModerateThreadEvent.class); this.registerHandler(Incoming.GuildForumThreadUpdateEvent, GuildForumThreadUpdateEvent.class); + this.registerHandler(Incoming.GuildForumMarkAsReadEvent, GuildForumMarkAsReadEvent.class); this.registerHandler(Incoming.GetHabboGuildBadgesMessageEvent, GetHabboGuildBadgesMessageEvent.class); // this.registerHandler(Incoming.GuildForumDataEvent, GuildForumModerateMessageEvent.class); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java index 20c7e469..9b99e7ab 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java @@ -412,6 +412,14 @@ public class Incoming { public static final int RequestInventoryPetDelete = 10030; public static final int RequestInventoryBadgeDelete = 10031; + // Furni Editor + public static final int FurniEditorSearchEvent = 10040; + public static final int FurniEditorDetailEvent = 10041; + public static final int FurniEditorBySpriteEvent = 10042; + public static final int FurniEditorInteractionsEvent = 10043; + public static final int FurniEditorUpdateEvent = 10044; + public static final int FurniEditorDeleteEvent = 10045; + // Catalog Admin public static final int CatalogAdminSavePageEvent = 10050; public static final int CatalogAdminCreatePageEvent = 10051; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManager.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManager.java new file mode 100644 index 00000000..8035bb9b --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManager.java @@ -0,0 +1,119 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Manages reading and writing of FurnitureData.json entries. + * Resolves the file path from emulator config keys. + */ +public class FurniDataManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(FurniDataManager.class); + + /** + * Get the JSON string for a specific item from FurnitureData.json. + * Returns "{}" if not found or on error. + */ + public static String getItemJson(int itemId) { + try { + Path furniDataPath = resolveFurniDataPath(); + if (furniDataPath == null || !Files.exists(furniDataPath)) { + return "{}"; + } + + String content = Files.readString(furniDataPath, StandardCharsets.UTF_8); + JsonObject root = JsonParser.parseString(content).getAsJsonObject(); + + // Search in both "roomitemtypes" and "wallitemtypes" + for (String section : new String[]{"roomitemtypes", "wallitemtypes"}) { + if (!root.has(section)) continue; + JsonObject sectionObj = root.getAsJsonObject(section); + if (!sectionObj.has("furnitype")) continue; + JsonArray types = sectionObj.getAsJsonArray("furnitype"); + + for (JsonElement el : types) { + JsonObject obj = el.getAsJsonObject(); + if (obj.has("id") && obj.get("id").getAsInt() == itemId) { + return obj.toString(); + } + } + } + } catch (Exception e) { + LOGGER.warn("Failed to read FurnitureData.json for item " + itemId, e); + } + + return "{}"; + } + + /** + * Resolve the path to FurnitureData.json from emulator config. + */ + private static Path resolveFurniDataPath() { + try { + String configPath = Emulator.getConfig().getValue("furni.editor.renderer.config.path", ""); + + if (configPath.isEmpty()) { + // Fallback: try common locations + String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", ""); + if (!basePath.isEmpty()) { + Path candidate = Paths.get(basePath, "FurnitureData.json"); + if (Files.exists(candidate)) return candidate; + } + return null; + } + + // Read the renderer config to find the furnidata URL/path + Path rendererConfig = Paths.get(configPath); + if (!Files.exists(rendererConfig)) return null; + + String rendererContent = Files.readString(rendererConfig, StandardCharsets.UTF_8); + JsonObject rendererObj = JsonParser.parseString(rendererContent).getAsJsonObject(); + + if (rendererObj.has("furnidata.url")) { + String furniUrl = rendererObj.get("furnidata.url").getAsString(); + + // Skip unresolved placeholders like ${gamedata.url} + if (furniUrl.contains("${")) { + String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", ""); + if (!basePath.isEmpty()) { + Path candidate = Paths.get(basePath, "FurnitureData.json"); + if (Files.exists(candidate)) return candidate; + } + return null; + } + + // Strip query string (?v=1 etc.) + String cleanUrl = furniUrl.contains("?") ? furniUrl.substring(0, furniUrl.indexOf('?')) : furniUrl; + + // If it's a local file path (not http), use it directly + if (!cleanUrl.startsWith("http")) { + return Paths.get(cleanUrl); + } + + // For http URLs, try to derive local path from base path + String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", ""); + if (!basePath.isEmpty()) { + // Extract filename from URL (without query string) + String filename = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1); + return Paths.get(basePath, filename); + } + } + } catch (Exception e) { + LOGGER.warn("Failed to resolve FurnitureData.json path", e); + } + + return null; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorBySpriteEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorBySpriteEvent.java new file mode 100644 index 00000000..e7f45362 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorBySpriteEvent.java @@ -0,0 +1,49 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +public class FurniEditorBySpriteEvent extends MessageHandler { + + @Override + public void handle() throws Exception { + if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No permission")); + return; + } + + int spriteId = this.packet.readInt(); + + if (spriteId <= 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid sprite ID")); + return; + } + + // Look up the item ID by sprite_id + int itemId = -1; + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement stmt = connection.prepareStatement("SELECT id FROM items_base WHERE sprite_id = ? LIMIT 1")) { + stmt.setInt(1, spriteId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + itemId = rs.getInt("id"); + } + } + } + + if (itemId <= 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No item found with sprite_id: " + spriteId)); + return; + } + + // Delegate to the detail response builder + FurniEditorDetailEvent.sendDetailResponse(this.client, itemId); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDeleteEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDeleteEvent.java new file mode 100644 index 00000000..b31194dd --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDeleteEvent.java @@ -0,0 +1,87 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +public class FurniEditorDeleteEvent extends MessageHandler { + + @Override + public void handle() throws Exception { + if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No permission")); + return; + } + + int id = this.packet.readInt(); + + if (id <= 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID")); + return; + } + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { + // Check if item exists + try (PreparedStatement stmt = connection.prepareStatement("SELECT id FROM items_base WHERE id = ?")) { + stmt.setInt(1, id); + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found: " + id)); + return; + } + } + } + + // Check usage count - items placed in rooms + int usageCount = 0; + try (PreparedStatement stmt = connection.prepareStatement("SELECT COUNT(*) FROM items WHERE item_id = ?")) { + stmt.setInt(1, id); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + usageCount = rs.getInt(1); + } + } + } + + if (usageCount > 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, + "Cannot delete: " + usageCount + " instances exist in the game")); + return; + } + + // Check catalog_items references + int catalogCount = 0; + try (PreparedStatement stmt = connection.prepareStatement( + "SELECT COUNT(*) FROM catalog_items WHERE item_ids LIKE ?")) { + stmt.setString(1, "%" + id + "%"); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + catalogCount = rs.getInt(1); + } + } + } + + if (catalogCount > 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, + "Cannot delete: item is referenced by " + catalogCount + " catalog entries")); + return; + } + + // Safe to delete + try (PreparedStatement stmt = connection.prepareStatement("DELETE FROM items_base WHERE id = ?")) { + stmt.setInt(1, id); + stmt.executeUpdate(); + } + } + + // Reload emulator item definitions + Emulator.getGameEnvironment().getItemManager().loadItems(); + + this.client.sendResponse(new FurniEditorResultComposer(true, "Item deleted")); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java new file mode 100644 index 00000000..b904fb55 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java @@ -0,0 +1,96 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorDetailComposer; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class FurniEditorDetailEvent extends MessageHandler { + + @Override + public void handle() throws Exception { + if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No permission")); + return; + } + + int id = this.packet.readInt(); + + if (id <= 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID")); + return; + } + + sendDetailResponse(this.client, id); + } + + /** + * Shared method to build and send a detail response for a given item ID. + * Used by both FurniEditorDetailEvent and FurniEditorBySpriteEvent. + */ + public static void sendDetailResponse(com.eu.habbo.habbohotel.gameclients.GameClient client, int itemId) throws Exception { + Map item = null; + int usageCount = 0; + List> catalogItems = new ArrayList<>(); + String furniDataJson = "{}"; + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { + // Load full item data + try (PreparedStatement stmt = connection.prepareStatement("SELECT * FROM items_base WHERE id = ?")) { + stmt.setInt(1, itemId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + item = FurniEditorHelper.readFullItem(rs); + } + } + } + + if (item == null) { + client.sendResponse(new FurniEditorResultComposer(false, "Item not found: " + itemId)); + return; + } + + // Count placed instances + try (PreparedStatement stmt = connection.prepareStatement("SELECT COUNT(*) FROM items WHERE item_id = ?")) { + stmt.setInt(1, itemId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + usageCount = rs.getInt(1); + } + } + } + + // Load catalog references (join catalog_items with catalog_pages) + try (PreparedStatement stmt = connection.prepareStatement( + "SELECT ci.id AS ci_id, ci.catalog_name, ci.cost_credits, ci.cost_points, ci.points_type, " + + "ci.page_id AS ci_page_id, COALESCE(cp.caption, '') AS page_caption " + + "FROM catalog_items ci " + + "LEFT JOIN catalog_pages cp ON ci.page_id = cp.id " + + "WHERE ci.item_ids LIKE ?")) { + stmt.setString(1, "%" + itemId + "%"); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + catalogItems.add(FurniEditorHelper.readCatalogRef(rs)); + } + } + } + } + + // Try to read furnidata.json entry + try { + furniDataJson = FurniDataManager.getItemJson(itemId); + } catch (Exception e) { + furniDataJson = "{}"; + } + + client.sendResponse(new FurniEditorDetailComposer(item, usageCount, catalogItems, furniDataJson)); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java new file mode 100644 index 00000000..0de0e1b6 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java @@ -0,0 +1,123 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; + +/** + * Shared utility for building item data maps from ResultSet rows. + * Used by FurniEditorDetailEvent, FurniEditorBySpriteEvent, and + * FurniEditorSearchEvent to ensure consistent field reading. + */ +public class FurniEditorHelper { + + /** + * Read the 14 base fields from items_base into a Map. + */ + public static Map readBaseItem(ResultSet set) throws SQLException { + Map item = new HashMap<>(); + item.put("id", set.getInt("id")); + item.put("sprite_id", set.getInt("sprite_id")); + item.put("item_name", set.getString("item_name")); + item.put("public_name", set.getString("public_name")); + item.put("type", set.getString("type")); + item.put("width", set.getInt("width")); + item.put("length", set.getInt("length")); + item.put("stack_height", set.getDouble("stack_height")); + item.put("allow_stack", set.getString("allow_stack")); + item.put("allow_walk", set.getString("allow_walk")); + item.put("allow_sit", set.getString("allow_sit")); + item.put("allow_lay", set.getString("allow_lay")); + item.put("interaction_type", set.getString("interaction_type")); + item.put("interaction_modes_count", set.getInt("interaction_modes_count")); + return item; + } + + /** + * Read all fields (14 base + 13 extended) from items_base into a Map. + */ + public static Map readFullItem(ResultSet set) throws SQLException { + Map item = readBaseItem(set); + item.put("allow_gift", set.getString("allow_gift")); + item.put("allow_trade", set.getString("allow_trade")); + item.put("allow_recycle", set.getString("allow_recycle")); + item.put("allow_marketplace_sell", set.getString("allow_marketplace_sell")); + item.put("allow_inventory_stack", set.getString("allow_inventory_stack")); + item.put("vending_ids", set.getString("vending_ids")); + item.put("customparams", set.getString("customparams")); + item.put("effect_id_male", set.getInt("effect_id_male")); + item.put("effect_id_female", set.getInt("effect_id_female")); + item.put("clothing_on_walk", set.getString("clothing_on_walk")); + item.put("multiheight", set.getString("multiheight")); + + // description may not exist in all schemas, handle gracefully + try { + item.put("description", set.getString("description")); + } catch (SQLException e) { + item.put("description", ""); + } + + return item; + } + + /** + * Read a catalog item reference from a result set that joined + * catalog_items with catalog_pages. + */ + public static Map readCatalogRef(ResultSet set) throws SQLException { + Map ref = new HashMap<>(); + ref.put("id", set.getInt("ci_id")); + ref.put("catalog_name", set.getString("catalog_name")); + ref.put("cost_credits", set.getInt("cost_credits")); + ref.put("cost_points", set.getInt("cost_points")); + ref.put("points_type", set.getInt("points_type")); + ref.put("page_id", set.getInt("ci_page_id")); + ref.put("page_caption", set.getString("page_caption")); + return ref; + } + + /** + * Whitelist of allowed field names for update operations. + * Prevents SQL injection via arbitrary column names. + */ + public static final java.util.Set ALLOWED_UPDATE_FIELDS = java.util.Set.of( + "item_name", "public_name", "sprite_id", "type", "width", "length", + "stack_height", "allow_stack", "allow_walk", "allow_sit", "allow_lay", + "allow_gift", "allow_trade", "allow_recycle", "allow_marketplace_sell", + "allow_inventory_stack", "interaction_type", "interaction_modes_count", + "vending_ids", "customparams", "effect_id_male", "effect_id_female", + "clothing_on_walk", "multiheight", "description" + ); + + /** + * Map camelCase JS field names to DB column names. + */ + public static final Map FIELD_MAP = Map.ofEntries( + Map.entry("itemName", "item_name"), + Map.entry("publicName", "public_name"), + Map.entry("spriteId", "sprite_id"), + Map.entry("type", "type"), + Map.entry("width", "width"), + Map.entry("length", "length"), + Map.entry("stackHeight", "stack_height"), + Map.entry("allowStack", "allow_stack"), + Map.entry("allowWalk", "allow_walk"), + Map.entry("allowSit", "allow_sit"), + Map.entry("allowLay", "allow_lay"), + Map.entry("allowGift", "allow_gift"), + Map.entry("allowTrade", "allow_trade"), + Map.entry("allowRecycle", "allow_recycle"), + Map.entry("allowMarketplaceSell", "allow_marketplace_sell"), + Map.entry("allowInventoryStack", "allow_inventory_stack"), + Map.entry("interactionType", "interaction_type"), + Map.entry("interactionModesCount", "interaction_modes_count"), + Map.entry("vendingIds", "vending_ids"), + Map.entry("customparams", "customparams"), + Map.entry("effectIdMale", "effect_id_male"), + Map.entry("effectIdFemale", "effect_id_female"), + Map.entry("clothingOnWalk", "clothing_on_walk"), + Map.entry("multiheight", "multiheight"), + Map.entry("description", "description") + ); +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorInteractionsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorInteractionsEvent.java new file mode 100644 index 00000000..78dbb0d9 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorInteractionsEvent.java @@ -0,0 +1,45 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorInteractionsComposer; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class FurniEditorInteractionsEvent extends MessageHandler { + + private static List cachedInteractions = null; + + @Override + public void handle() throws Exception { + if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No permission")); + return; + } + + if (cachedInteractions == null) { + synchronized (FurniEditorInteractionsEvent.class) { + if (cachedInteractions == null) { + List list = new ArrayList<>(); + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + Statement stmt = connection.createStatement(); + ResultSet set = stmt.executeQuery("SELECT DISTINCT interaction_type FROM items_base WHERE interaction_type != '' ORDER BY interaction_type ASC")) { + while (set.next()) { + list.add(set.getString("interaction_type")); + } + } + cachedInteractions = Collections.unmodifiableList(list); + } + } + } + + this.client.sendResponse(new FurniEditorInteractionsComposer(cachedInteractions)); + } +} 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 new file mode 100644 index 00000000..bfdc229c --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java @@ -0,0 +1,115 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorSearchComposer; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class FurniEditorSearchEvent extends MessageHandler { + + private static final int PAGE_SIZE = 20; + + @Override + public void handle() throws Exception { + if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No permission")); + return; + } + + String query = this.packet.readString(); + String type = this.packet.readString(); + int page = this.packet.readInt(); + + // Input validation + if (query.length() > 100) { + query = query.substring(0, 100); + } + + if (page < 1) page = 1; + + int offset = (page - 1) * PAGE_SIZE; + + // Build WHERE clause + StringBuilder whereClause = new StringBuilder("WHERE 1=1"); + List params = new ArrayList<>(); + + if (!query.isEmpty()) { + // Try numeric match first (id or sprite_id) + boolean isNumeric = false; + try { + int numericQuery = Integer.parseInt(query); + isNumeric = true; + 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 + "%"); + } catch (NumberFormatException e) { + whereClause.append(" AND (item_name LIKE ? OR public_name LIKE ?)"); + params.add("%" + query + "%"); + params.add("%" + query + "%"); + } + } + + if (type != null && !type.isEmpty()) { + whereClause.append(" AND type = ?"); + params.add(type); + } + + // Count total + int total = 0; + String countSql = "SELECT COUNT(*) FROM items_base " + whereClause; + String dataSql = "SELECT * FROM items_base " + whereClause + " ORDER BY id ASC LIMIT ? OFFSET ?"; + + List> items = new ArrayList<>(); + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { + // Get total count + try (PreparedStatement stmt = connection.prepareStatement(countSql)) { + int idx = 1; + for (Object param : params) { + if (param instanceof Integer) { + stmt.setInt(idx++, (Integer) param); + } else { + stmt.setString(idx++, (String) param); + } + } + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + total = rs.getInt(1); + } + } + } + + // Get items page + try (PreparedStatement stmt = connection.prepareStatement(dataSql)) { + int idx = 1; + for (Object param : params) { + if (param instanceof Integer) { + stmt.setInt(idx++, (Integer) param); + } else { + stmt.setString(idx++, (String) param); + } + } + stmt.setInt(idx++, PAGE_SIZE); + stmt.setInt(idx, offset); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + items.add(FurniEditorHelper.readBaseItem(rs)); + } + } + } + } + + this.client.sendResponse(new FurniEditorSearchComposer(items, total, page)); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateEvent.java new file mode 100644 index 00000000..845395f5 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateEvent.java @@ -0,0 +1,110 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class FurniEditorUpdateEvent extends MessageHandler { + + @Override + public void handle() throws Exception { + if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No permission")); + return; + } + + int id = this.packet.readInt(); + String jsonFieldsStr = this.packet.readString(); + + if (id <= 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID")); + return; + } + + JsonObject json; + try { + json = JsonParser.parseString(jsonFieldsStr).getAsJsonObject(); + } catch (Exception e) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid JSON data")); + return; + } + + if (json.size() == 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No fields to update")); + return; + } + + // Build dynamic UPDATE with whitelisted fields + StringBuilder setClauses = new StringBuilder(); + List values = new ArrayList<>(); + + for (Map.Entry entry : json.entrySet()) { + String jsKey = entry.getKey(); + String dbColumn = FurniEditorHelper.FIELD_MAP.get(jsKey); + + if (dbColumn == null || !FurniEditorHelper.ALLOWED_UPDATE_FIELDS.contains(dbColumn)) { + continue; // Skip unknown or disallowed fields + } + + if (setClauses.length() > 0) setClauses.append(", "); + setClauses.append("`").append(dbColumn).append("` = ?"); + + JsonElement val = entry.getValue(); + if (val.isJsonPrimitive()) { + if (val.getAsJsonPrimitive().isBoolean()) { + values.add(val.getAsBoolean() ? "1" : "0"); + } else if (val.getAsJsonPrimitive().isNumber()) { + // Check if it's a decimal number + String numStr = val.getAsString(); + if (numStr.contains(".")) { + values.add(val.getAsDouble()); + } else { + values.add(val.getAsInt()); + } + } else { + values.add(val.getAsString()); + } + } else { + values.add(val.toString()); + } + } + + if (setClauses.length() == 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No valid fields to update")); + return; + } + + String sql = "UPDATE items_base SET " + setClauses + " WHERE id = ?"; + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement stmt = connection.prepareStatement(sql)) { + int idx = 1; + for (Object value : values) { + if (value instanceof Integer) { + stmt.setInt(idx++, (Integer) value); + } else if (value instanceof Double) { + stmt.setDouble(idx++, (Double) value); + } else { + stmt.setString(idx++, String.valueOf(value)); + } + } + stmt.setInt(idx, id); + stmt.executeUpdate(); + } + + // Reload emulator item definitions + Emulator.getGameEnvironment().getItemManager().loadItems(); + + this.client.sendResponse(new FurniEditorResultComposer(true, "Item updated", id)); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/GuildChangeSettingsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/GuildChangeSettingsEvent.java index bf99d98c..5ad35f21 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/GuildChangeSettingsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/GuildChangeSettingsEvent.java @@ -2,16 +2,34 @@ package com.eu.habbo.messages.incoming.guilds; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.guilds.Guild; +import com.eu.habbo.habbohotel.guilds.GuildMember; import com.eu.habbo.habbohotel.guilds.GuildState; import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.habbohotel.rooms.Room; import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.habbohotel.guilds.forums.ForumThread; +import com.eu.habbo.messages.incoming.guilds.forums.GuildForumListEvent; +import com.eu.habbo.messages.outgoing.guilds.GuildInfoComposer; +import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumDataComposer; import com.eu.habbo.plugin.events.guilds.GuildChangedSettingsEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.concurrent.ConcurrentHashMap; public class GuildChangeSettingsEvent extends MessageHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(GuildChangeSettingsEvent.class); + + // Cooldown for forum toggle per guild: guildId -> last toggle timestamp + private static final ConcurrentHashMap forumToggleCooldown = new ConcurrentHashMap<>(); + private static final long FORUM_TOGGLE_COOLDOWN_MS = 30_000; // 30 seconds + @Override public int getRatelimit() { - return 500; + return 2000; // 2 seconds between settings saves } @Override @@ -31,6 +49,34 @@ public class GuildChangeSettingsEvent extends MessageHandler { guild.setState(GuildState.valueOf(settingsEvent.state)); guild.setRights(settingsEvent.rights); + // Read forum toggle + boolean forumEnabled = this.packet.readBoolean(); + boolean wasForumEnabled = guild.hasForum(); + + if (forumEnabled != wasForumEnabled) { + // Enforce cooldown on forum toggle to prevent rapid enable/disable spam + Long lastToggle = forumToggleCooldown.get(guildId); + long now = System.currentTimeMillis(); + + if (lastToggle != null && (now - lastToggle) < FORUM_TOGGLE_COOLDOWN_MS) { + LOGGER.warn("Forum toggle cooldown for guild {} by user {}", guildId, this.client.getHabbo().getHabboInfo().getUsername()); + } else { + forumToggleCooldown.put(guildId, now); + guild.setForum(forumEnabled); + + if (!forumEnabled) { + // Delete all threads and comments for this guild + ForumThread.clearCacheForGuild(guildId); + deleteForumData(guildId); + } + + // Invalidate caches + GuildForumDataComposer.invalidateUnreadCache(guildId); + GuildForumListEvent.invalidateActiveForumsCache(); + GuildForumListEvent.invalidateMyForumsCache(this.client.getHabbo().getHabboInfo().getId()); + } + } + Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(guild.getRoomId()); if(room != null) { room.refreshGuild(guild); @@ -39,7 +85,38 @@ public class GuildChangeSettingsEvent extends MessageHandler { guild.needsUpdate = true; Emulator.getThreading().run(guild); + + // Send updated group info back to client so hasForum flag refreshes immediately + GuildMember member = Emulator.getGameEnvironment().getGuildManager().getGuildMember(guild, this.client.getHabbo()); + this.client.sendResponse(new GuildInfoComposer(guild, this.client, false, member)); } } } + + private void deleteForumData(int guildId) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { + // Delete comments for all threads in this guild + try (PreparedStatement statement = connection.prepareStatement( + "DELETE FROM `guilds_forums_comments` WHERE `thread_id` IN (SELECT `id` FROM `guilds_forums_threads` WHERE `guild_id` = ?)")) { + statement.setInt(1, guildId); + statement.executeUpdate(); + } + + // Delete all threads for this guild + try (PreparedStatement statement = connection.prepareStatement( + "DELETE FROM `guilds_forums_threads` WHERE `guild_id` = ?")) { + statement.setInt(1, guildId); + statement.executeUpdate(); + } + + // Delete forum view records for this guild + try (PreparedStatement statement = connection.prepareStatement( + "DELETE FROM `guild_forum_views` WHERE `guild_id` = ?")) { + statement.setInt(1, guildId); + statement.executeUpdate(); + } + } catch (SQLException e) { + LOGGER.error("Failed to delete forum data for guild " + guildId, e); + } + } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumListEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumListEvent.java index 83e7b484..aa76d1c8 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumListEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumListEvent.java @@ -14,6 +14,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; public class GuildForumListEvent extends MessageHandler { @Override @@ -23,6 +24,26 @@ public class GuildForumListEvent extends MessageHandler { private static final Logger LOGGER = LoggerFactory.getLogger(GuildForumListEvent.class); + // Cache for active forums list (shared across all users) + private static volatile THashSet activeForumsCache = null; + private static volatile long activeForumsCachedAt = 0; + private static final long ACTIVE_FORUMS_TTL = 30 * 60 * 1000; // 30 minutes + + // Cache for user's forum list + private static final ConcurrentHashMap myForumsCache = new ConcurrentHashMap<>(); // userId -> {cachedAt} + private static final ConcurrentHashMap> myForumsData = new ConcurrentHashMap<>(); + private static final long MY_FORUMS_TTL = 10 * 60 * 1000; // 10 minutes + + public static void invalidateActiveForumsCache() { + activeForumsCache = null; + activeForumsCachedAt = 0; + } + + public static void invalidateMyForumsCache(int userId) { + myForumsCache.remove(userId); + myForumsData.remove(userId); + } + @Override public void handle() throws Exception { int mode = this.packet.readInt(); @@ -50,12 +71,18 @@ public class GuildForumListEvent extends MessageHandler { } private THashSet getActiveForums() { + long now = System.currentTimeMillis(); + + if (activeForumsCache != null && (now - activeForumsCachedAt) < ACTIVE_FORUMS_TTL) { + return activeForumsCache; + } + THashSet guilds = new THashSet(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT `guilds`.`id`, SUM(`guilds_forums_threads`.`posts_count`) AS `post_count` " + "FROM `guilds_forums_threads` " + "LEFT JOIN `guilds` ON `guilds`.`id` = `guilds_forums_threads`.`guild_id` " + - "WHERE `guilds`.`read_forum` = 'EVERYONE' AND `guilds_forums_threads`.`created_at` > ? " + + "WHERE `guilds`.`forum` = '1' AND `guilds_forums_threads`.`created_at` > ? " + "GROUP BY `guilds`.`id` " + "ORDER BY `post_count` DESC LIMIT 100")) { statement.setInt(1, Emulator.getIntUnixTimestamp() - 7 * 24 * 60 * 60); @@ -73,10 +100,21 @@ public class GuildForumListEvent extends MessageHandler { this.client.sendResponse(new ConnectionErrorComposer(500)); } + activeForumsCache = guilds; + activeForumsCachedAt = now; + return guilds; } private THashSet getMyForums(int userId) { + long now = System.currentTimeMillis(); + + long[] cached = myForumsCache.get(userId); + if (cached != null && (now - cached[0]) < MY_FORUMS_TTL) { + THashSet data = myForumsData.get(userId); + if (data != null) return data; + } + THashSet guilds = new THashSet(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT `guilds`.`id` FROM `guilds_members` " + @@ -97,6 +135,9 @@ public class GuildForumListEvent extends MessageHandler { this.client.sendResponse(new ConnectionErrorComposer(500)); } + myForumsCache.put(userId, new long[]{now}); + myForumsData.put(userId, guilds); + return guilds; } -} \ No newline at end of file +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumMarkAsReadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumMarkAsReadEvent.java new file mode 100644 index 00000000..ace83232 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumMarkAsReadEvent.java @@ -0,0 +1,50 @@ +package com.eu.habbo.messages.incoming.guilds.forums; + +import com.eu.habbo.Emulator; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumDataComposer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public class GuildForumMarkAsReadEvent extends MessageHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(GuildForumMarkAsReadEvent.class); + + @Override + public int getRatelimit() { + return 2000; + } + + @Override + public void handle() throws Exception { + int count = this.packet.readInt(); + int userId = this.client.getHabbo().getHabboInfo().getId(); + int timestamp = Emulator.getIntUnixTimestamp(); + + for (int i = 0; i < count; i++) { + int guildId = this.packet.readInt(); + this.packet.readInt(); // messageId (not used, we track by timestamp) + this.packet.readBoolean(); // isRead + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( + "INSERT INTO `guild_forum_views` (`user_id`, `guild_id`, `timestamp`) VALUES (?, ?, ?) " + + "ON DUPLICATE KEY UPDATE `timestamp` = ?" + )) { + statement.setInt(1, userId); + statement.setInt(2, guildId); + statement.setInt(3, timestamp); + statement.setInt(4, timestamp); + statement.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + } + + // Invalidate caches so next request gets fresh data + GuildForumDataComposer.invalidateLastSeenCache(userId, guildId); + GuildForumDataComposer.invalidateUnreadCache(guildId); + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java index bb4d87eb..51b9ec0b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateMessageEvent.java @@ -18,7 +18,7 @@ import com.eu.habbo.messages.outgoing.handshake.ConnectionErrorComposer; public class GuildForumModerateMessageEvent extends MessageHandler { @Override public int getRatelimit() { - return 500; + return 2000; } @Override @@ -36,6 +36,11 @@ public class GuildForumModerateMessageEvent extends MessageHandler { return; } + if (thread.getGuildId() != guildId) { + this.client.sendResponse(new ConnectionErrorComposer(403)); + return; + } + ForumThreadComment comment = thread.getCommentById(messageId); if (comment == null) { this.client.sendResponse(new ConnectionErrorComposer(404)); @@ -45,19 +50,20 @@ public class GuildForumModerateMessageEvent extends MessageHandler { boolean hasStaffPermissions = this.client.getHabbo().hasPermission(Permission.ACC_MODTOOL_TICKET_Q); GuildMember member = Emulator.getGameEnvironment().getGuildManager().getGuildMember(guildId, this.client.getHabbo().getHabboInfo().getId()); - if (member == null) { + if (member == null && !hasStaffPermissions) { this.client.sendResponse(new ConnectionErrorComposer(401)); return; } - boolean isGuildAdministrator = (guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || member.getRank().equals(GuildRank.ADMIN)); + boolean isGuildAdministrator = (guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || (member != null && member.getRank().equals(GuildRank.ADMIN))); if (!isGuildAdministrator && !hasStaffPermissions) { this.client.sendResponse(new ConnectionErrorComposer(403)); return; } - if (state == ForumThreadState.HIDDEN_BY_GUILD_ADMIN.getStateId() && !hasStaffPermissions) { + // Restrict state 20 (staff hidden) to staff only + if (state == 20 && !hasStaffPermissions) { this.client.sendResponse(new ConnectionErrorComposer(403)); return; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java index 95a972fa..3fa8905b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumModerateThreadEvent.java @@ -10,15 +10,24 @@ import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer; import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertKeys; +import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumDataComposer; import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumThreadMessagesComposer; import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumThreadsComposer; import com.eu.habbo.messages.outgoing.handshake.ConnectionErrorComposer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; public class GuildForumModerateThreadEvent extends MessageHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(GuildForumModerateThreadEvent.class); + @Override public int getRatelimit() { - return 500; + return 2000; } @Override @@ -26,8 +35,6 @@ public class GuildForumModerateThreadEvent extends MessageHandler { int guildId = packet.readInt(); int threadId = packet.readInt(); int state = packet.readInt(); - // STATE 20 - HIDDEN_BY_GUILD_ADMIN = HIDDEN BY GUILD ADMINS/ HOTEL MODERATORS - // STATE 1 = VISIBLE THREAD Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); ForumThread thread = ForumThread.getById(threadId); @@ -37,6 +44,11 @@ public class GuildForumModerateThreadEvent extends MessageHandler { return; } + if (thread.getGuildId() != guildId) { + this.client.sendResponse(new ConnectionErrorComposer(403)); + return; + } + GuildMember member = Emulator.getGameEnvironment().getGuildManager().getGuildMember(guildId, this.client.getHabbo().getHabboInfo().getId()); boolean hasStaffPerms = this.client.getHabbo().hasPermission(Permission.ACC_MODTOOL_TICKET_Q); @@ -52,12 +64,22 @@ public class GuildForumModerateThreadEvent extends MessageHandler { return; } - thread.setState(ForumThreadState.fromValue(state)); // sets state as defined in the packet + // State 20 = permanent delete (thread + comments removed from DB) + if (state == 20) { + deleteThread(threadId); + ForumThread.clearCacheForGuild(guildId); + GuildForumDataComposer.invalidateUnreadCache(guildId); + + this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FORUMS_THREAD_HIDDEN.key).compose()); + this.client.sendResponse(new GuildForumThreadsComposer(guild, 0)); + return; + } + + thread.setState(ForumThreadState.fromValue(state)); thread.run(); switch (state) { case 10: - case 20: this.client.sendResponse(new BubbleAlertComposer(BubbleAlertKeys.FORUMS_THREAD_HIDDEN.key).compose()); break; case 1: @@ -68,4 +90,22 @@ public class GuildForumModerateThreadEvent extends MessageHandler { this.client.sendResponse(new GuildForumThreadMessagesComposer(thread)); this.client.sendResponse(new GuildForumThreadsComposer(guild, 0)); } + + private void deleteThread(int threadId) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { + try (PreparedStatement statement = connection.prepareStatement( + "DELETE FROM `guilds_forums_comments` WHERE `thread_id` = ?")) { + statement.setInt(1, threadId); + statement.executeUpdate(); + } + + try (PreparedStatement statement = connection.prepareStatement( + "DELETE FROM `guilds_forums_threads` WHERE `id` = ?")) { + statement.setInt(1, threadId); + statement.executeUpdate(); + } + } catch (SQLException e) { + LOGGER.error("Failed to delete thread " + threadId, e); + } + } } \ No newline at end of file diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java index a1730c3b..ae021b5c 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumPostThreadEvent.java @@ -9,6 +9,7 @@ import com.eu.habbo.habbohotel.guilds.forums.ForumThreadComment; import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumAddCommentComposer; +import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumDataComposer; import com.eu.habbo.messages.outgoing.guilds.forums.GuildForumThreadMessagesComposer; import com.eu.habbo.messages.outgoing.handshake.ConnectionErrorComposer; @@ -17,7 +18,7 @@ public class GuildForumPostThreadEvent extends MessageHandler { @Override public int getRatelimit() { - return 1000; + return 2000; } @Override @@ -65,6 +66,7 @@ public class GuildForumPostThreadEvent extends MessageHandler { this.client.getHabbo().getHabboStats().forumPostsCount += 1; thread.setPostsCount(thread.getPostsCount() + 1); + GuildForumDataComposer.invalidateUnreadCache(guildId); this.client.sendResponse(new GuildForumThreadMessagesComposer(thread)); return; } @@ -74,6 +76,15 @@ public class GuildForumPostThreadEvent extends MessageHandler { return; } + if (thread.getGuildId() != guildId) { + this.client.sendResponse(new ConnectionErrorComposer(403)); + return; + } + + if (thread.isLocked() && !isStaff) { + this.client.sendResponse(new ConnectionErrorComposer(403)); + return; + } if (!((guild.canPostMessages().state == 0) || (guild.canPostMessages().state == 1 && member != null) @@ -91,6 +102,7 @@ public class GuildForumPostThreadEvent extends MessageHandler { thread.setUpdatedAt(Emulator.getIntUnixTimestamp()); this.client.getHabbo().getHabboStats().forumPostsCount += 1; thread.setPostsCount(thread.getPostsCount() + 1); + GuildForumDataComposer.invalidateUnreadCache(guildId); this.client.sendResponse(new GuildForumAddCommentComposer(comment)); } else { this.client.sendResponse(new ConnectionErrorComposer(500)); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java index 908ff2a0..741a818f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadUpdateEvent.java @@ -17,7 +17,7 @@ import com.eu.habbo.messages.outgoing.handshake.ConnectionErrorComposer; public class GuildForumThreadUpdateEvent extends MessageHandler { @Override public int getRatelimit() { - return 500; + return 2000; } @Override diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java index 009a2cee..3672a62a 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsEvent.java @@ -20,7 +20,7 @@ public class GuildForumThreadsEvent extends MessageHandler { Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); - if (guild == null || !guild.hasForum()) { + if (guild == null) { this.client.sendResponse(new ConnectionErrorComposer(404)); return; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java index 3fd6a50c..3680677f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumThreadsMessagesEvent.java @@ -37,6 +37,13 @@ public class GuildForumThreadsMessagesEvent extends MessageHandler { this.client.sendResponse(new ConnectionErrorComposer(404)); return; } + + // Verify thread belongs to the requested guild + if (thread.getGuildId() != guildId) { + this.client.sendResponse(new ConnectionErrorComposer(403)); + return; + } + GuildMember member = Emulator.getGameEnvironment().getGuildManager().getGuildMember(guildId, this.client.getHabbo().getHabboInfo().getId()); boolean isGuildAdministrator = (guild.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || (member != null && member.getRank().equals(GuildRank.ADMIN))); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java index 91a46b09..11916a7b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/guilds/forums/GuildForumUpdateSettingsEvent.java @@ -12,7 +12,7 @@ import com.eu.habbo.messages.outgoing.handshake.ConnectionErrorComposer; public class GuildForumUpdateSettingsEvent extends MessageHandler { @Override public int getRatelimit() { - return 500; + return 2000; } @Override diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java index 3d28174e..f354973e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java @@ -556,6 +556,12 @@ public class Outgoing { public static final int SnowStormUserRematchedComposer = 5029; + // Furni Editor + public static final int FurniEditorSearchComposer = 10040; + public static final int FurniEditorDetailComposer = 10041; + public static final int FurniEditorInteractionsComposer = 10043; + public static final int FurniEditorResultComposer = 10044; + // Catalog Admin public static final int CatalogAdminResultComposer = 10059; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorDetailComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorDetailComposer.java new file mode 100644 index 00000000..7c351af8 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorDetailComposer.java @@ -0,0 +1,77 @@ +package com.eu.habbo.messages.outgoing.furnieditor; + +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; +import java.util.Map; + +public class FurniEditorDetailComposer extends MessageComposer { + private final Map item; + private final int usageCount; + private final List> catalogItems; + private final String furniDataJson; + + public FurniEditorDetailComposer(Map item, int usageCount, List> catalogItems, String furniDataJson) { + this.item = item; + this.usageCount = usageCount; + this.catalogItems = catalogItems; + this.furniDataJson = furniDataJson; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.FurniEditorDetailComposer); + + // 14 base fields + this.response.appendInt((int) item.get("id")); + this.response.appendInt((int) item.get("sprite_id")); + this.response.appendString((String) item.getOrDefault("item_name", "")); + this.response.appendString((String) item.getOrDefault("public_name", "")); + this.response.appendString((String) item.getOrDefault("type", "s")); + this.response.appendInt((int) item.getOrDefault("width", 1)); + this.response.appendInt((int) item.getOrDefault("length", 1)); + this.response.appendDouble((double) item.getOrDefault("stack_height", 0.0)); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_stack", "1")))); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_walk", "0")))); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_sit", "0")))); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_lay", "0")))); + this.response.appendString((String) item.getOrDefault("interaction_type", "")); + this.response.appendInt((int) item.getOrDefault("interaction_modes_count", 0)); + + // 13 extended fields + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_gift", "1")))); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_trade", "1")))); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_recycle", "1")))); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_marketplace_sell", "1")))); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_inventory_stack", "1")))); + this.response.appendString((String) item.getOrDefault("vending_ids", "")); + this.response.appendString((String) item.getOrDefault("customparams", "")); + this.response.appendInt((int) item.getOrDefault("effect_id_male", 0)); + this.response.appendInt((int) item.getOrDefault("effect_id_female", 0)); + this.response.appendString((String) item.getOrDefault("clothing_on_walk", "")); + this.response.appendString((String) item.getOrDefault("multiheight", "")); + this.response.appendString((String) item.getOrDefault("description", "")); + + // usage count + this.response.appendInt(this.usageCount); + + // catalog references + this.response.appendInt(this.catalogItems.size()); + for (Map ci : this.catalogItems) { + this.response.appendInt((int) ci.get("id")); + this.response.appendString((String) ci.getOrDefault("catalog_name", "")); + this.response.appendInt((int) ci.getOrDefault("cost_credits", 0)); + this.response.appendInt((int) ci.getOrDefault("cost_points", 0)); + this.response.appendInt((int) ci.getOrDefault("points_type", 0)); + this.response.appendInt((int) ci.getOrDefault("page_id", -1)); + this.response.appendString((String) ci.getOrDefault("page_caption", "")); + } + + // furnidata JSON string + this.response.appendString(this.furniDataJson != null ? this.furniDataJson : "{}"); + + return this.response; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorInteractionsComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorInteractionsComposer.java new file mode 100644 index 00000000..ea778e65 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorInteractionsComposer.java @@ -0,0 +1,27 @@ +package com.eu.habbo.messages.outgoing.furnieditor; + +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +public class FurniEditorInteractionsComposer extends MessageComposer { + private final List interactions; + + public FurniEditorInteractionsComposer(List interactions) { + this.interactions = interactions; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.FurniEditorInteractionsComposer); + this.response.appendInt(this.interactions.size()); + + for (String interaction : this.interactions) { + this.response.appendString(interaction); + } + + return this.response; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorResultComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorResultComposer.java new file mode 100644 index 00000000..07bc8e67 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorResultComposer.java @@ -0,0 +1,30 @@ +package com.eu.habbo.messages.outgoing.furnieditor; + +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +public class FurniEditorResultComposer extends MessageComposer { + private final boolean success; + private final String message; + private final int id; + + public FurniEditorResultComposer(boolean success, String message) { + this(success, message, -1); + } + + public FurniEditorResultComposer(boolean success, String message, int id) { + this.success = success; + this.message = message; + this.id = id; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.FurniEditorResultComposer); + this.response.appendBoolean(this.success); + this.response.appendString(this.message); + this.response.appendInt(this.id); + return this.response; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorSearchComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorSearchComposer.java new file mode 100644 index 00000000..c7ec1fcb --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorSearchComposer.java @@ -0,0 +1,50 @@ +package com.eu.habbo.messages.outgoing.furnieditor; + +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; + +public class FurniEditorSearchComposer extends MessageComposer { + private final List> items; + private final int total; + private final int page; + + public FurniEditorSearchComposer(List> items, int total, int page) { + this.items = items; + this.total = total; + this.page = page; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.FurniEditorSearchComposer); + this.response.appendInt(this.items.size()); + + for (Map item : this.items) { + this.response.appendInt((int) item.get("id")); + this.response.appendInt((int) item.get("sprite_id")); + this.response.appendString((String) item.getOrDefault("item_name", "")); + this.response.appendString((String) item.getOrDefault("public_name", "")); + this.response.appendString((String) item.getOrDefault("type", "s")); + this.response.appendInt((int) item.getOrDefault("width", 1)); + this.response.appendInt((int) item.getOrDefault("length", 1)); + this.response.appendDouble((double) item.getOrDefault("stack_height", 0.0)); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_stack", "1")))); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_walk", "0")))); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_sit", "0")))); + this.response.appendBoolean("1".equals(String.valueOf(item.getOrDefault("allow_lay", "0")))); + this.response.appendString((String) item.getOrDefault("interaction_type", "")); + this.response.appendInt((int) item.getOrDefault("interaction_modes_count", 0)); + } + + this.response.appendInt(this.total); + this.response.appendInt(this.page); + + return this.response; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/guilds/GuildManageComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/guilds/GuildManageComposer.java index 6c22192f..22619f34 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/guilds/GuildManageComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/guilds/GuildManageComposer.java @@ -62,6 +62,7 @@ public class GuildManageComposer extends MessageComposer { } this.response.appendString(this.guild.getBadge()); this.response.appendInt(this.guild.getMemberCount()); + this.response.appendBoolean(this.guild.hasForum()); return this.response; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/guilds/forums/GuildForumDataComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/guilds/forums/GuildForumDataComposer.java index d753f9a3..64abf8c3 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/guilds/forums/GuildForumDataComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/guilds/forums/GuildForumDataComposer.java @@ -20,11 +20,20 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.concurrent.ConcurrentHashMap; public class GuildForumDataComposer extends MessageComposer { private static final Logger LOGGER = LoggerFactory.getLogger(GuildForumDataComposer.class); + // Cache for user last-seen timestamps: key = "userId:guildId", value = {timestamp, cachedAt} + private static final ConcurrentHashMap lastSeenCache = new ConcurrentHashMap<>(); + private static final long LAST_SEEN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + // Cache for unread counts: key = "guildId:lastSeenAt", value = {count, cachedAt} + private static final ConcurrentHashMap unreadCache = new ConcurrentHashMap<>(); + private static final long UNREAD_CACHE_TTL = 2 * 60 * 1000; // 2 minutes + public final Guild guild; public Habbo habbo; @@ -33,13 +42,77 @@ public class GuildForumDataComposer extends MessageComposer { this.habbo = habbo; } + public static void invalidateLastSeenCache(int userId, int guildId) { + lastSeenCache.remove(userId + ":" + guildId); + } + + public static void invalidateUnreadCache(int guildId) { + unreadCache.entrySet().removeIf(entry -> entry.getKey().startsWith(guildId + ":")); + } + + private static int getLastSeenAt(int userId, int guildId) { + String key = userId + ":" + guildId; + long now = System.currentTimeMillis(); + + long[] cached = lastSeenCache.get(key); + if (cached != null && (now - cached[1]) < LAST_SEEN_CACHE_TTL) { + return (int) cached[0]; + } + + int lastSeenAt = 0; + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( + "SELECT `timestamp` FROM `guild_forum_views` WHERE `user_id` = ? AND `guild_id` = ? LIMIT 1" + )) { + statement.setInt(1, userId); + statement.setInt(2, guildId); + ResultSet set = statement.executeQuery(); + if (set.next()) { + lastSeenAt = set.getInt("timestamp"); + } + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + } + + lastSeenCache.put(key, new long[]{lastSeenAt, now}); + return lastSeenAt; + } + + private static int getUnreadCount(int guildId, int lastSeenAt) { + String key = guildId + ":" + lastSeenAt; + long now = System.currentTimeMillis(); + + long[] cached = unreadCache.get(key); + if (cached != null && (now - cached[1]) < UNREAD_CACHE_TTL) { + return (int) cached[0]; + } + + int newComments = 0; + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( + "SELECT COUNT(*) FROM `guilds_forums_comments` " + + "JOIN `guilds_forums_threads` ON `guilds_forums_threads`.`id` = `guilds_forums_comments`.`thread_id` " + + "WHERE `guilds_forums_threads`.`guild_id` = ? AND `guilds_forums_comments`.`created_at` > ?" + )) { + statement.setInt(1, guildId); + statement.setInt(2, lastSeenAt); + + ResultSet set = statement.executeQuery(); + if (set.next()) { + newComments = set.getInt(1); + } + } catch (SQLException e) { + LOGGER.error("Caught SQL exception", e); + } + + unreadCache.put(key, new long[]{newComments, now}); + return newComments; + } + public static void serializeForumData(ServerMessage response, Guild guild, Habbo habbo) { final THashSet forumThreads = ForumThread.getByGuildId(guild.getId()); - int lastSeenAt = 0; + int lastSeenAt = getLastSeenAt(habbo.getHabboInfo().getId(), guild.getId()); int totalComments = 0; - int newComments = 0; int totalThreads = 0; ForumThreadComment lastComment = null; @@ -55,31 +128,7 @@ public class GuildForumDataComposer extends MessageComposer { } } - try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement( - "SELECT COUNT(*) " + - "FROM guilds_forums_threads A " + - "JOIN ( " + - "SELECT * " + - "FROM `guilds_forums_comments` " + - "WHERE `id` IN ( " + - "SELECT id " + - "FROM `guilds_forums_comments` B " + - "ORDER BY B.`id` ASC " + - ") " + - "ORDER BY `id` DESC " + - ") B ON A.`id` = B.`thread_id` " + - "WHERE A.`guild_id` = ? AND B.`created_at` > ?" - )) { - statement.setInt(1, guild.getId()); - statement.setInt(2, lastSeenAt); - - ResultSet set = statement.executeQuery(); - while (set.next()) { - newComments = set.getInt(1); - } - } catch (SQLException e) { - LOGGER.error("Caught SQL exception", e); - } + int newComments = getUnreadCount(guild.getId(), lastSeenAt); response.appendInt(guild.getId()); diff --git a/Latest_Compiled_Version/Habbo-4.1.0-jar-with-dependencies.jar b/Latest_Compiled_Version/Habbo-4.1.0-jar-with-dependencies.jar index e68db65b..a220ed8c 100644 Binary files a/Latest_Compiled_Version/Habbo-4.1.0-jar-with-dependencies.jar and b/Latest_Compiled_Version/Habbo-4.1.0-jar-with-dependencies.jar differ