Merge remote-tracking branch 'upstream/main' into feature/pr-20260327

This commit is contained in:
Lorenzune
2026-03-31 09:13:54 +02:00
31 changed files with 1304 additions and 61 deletions
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `guild_forum_views` ADD UNIQUE KEY `user_guild` (`user_id`, `guild_id`);
+6
View File
@@ -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`;
@@ -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<ForumThread> threads = guildThreadsCache.remove(guildId);
if (threads != null) {
synchronized (forumThreadsCache) {
for (ForumThread thread : threads) {
forumThreadsCache.remove(thread.threadId);
}
}
}
}
}
public static void clearCache() {
for (THashSet<ForumThread> threads : guildThreadsCache.values()) {
for (ForumThread thread : threads) {
@@ -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);
@@ -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;
@@ -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;
}
}
@@ -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);
}
}
@@ -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"));
}
}
@@ -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<String, Object> item = null;
int usageCount = 0;
List<Map<String, Object>> 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));
}
}
@@ -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<String, Object> readBaseItem(ResultSet set) throws SQLException {
Map<String, Object> 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<String, Object> readFullItem(ResultSet set) throws SQLException {
Map<String, Object> 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<String, Object> readCatalogRef(ResultSet set) throws SQLException {
Map<String, Object> 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<String> 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<String, String> 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")
);
}
@@ -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<String> 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<String> 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));
}
}
@@ -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<Object> 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<Map<String, Object>> 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));
}
}
@@ -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<Object> values = new ArrayList<>();
for (Map.Entry<String, JsonElement> 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));
}
}
@@ -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<Integer, Long> 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);
}
}
}
@@ -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<Guild> 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<Integer, long[]> myForumsCache = new ConcurrentHashMap<>(); // userId -> {cachedAt}
private static final ConcurrentHashMap<Integer, THashSet<Guild>> 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<Guild> getActiveForums() {
long now = System.currentTimeMillis();
if (activeForumsCache != null && (now - activeForumsCachedAt) < ACTIVE_FORUMS_TTL) {
return activeForumsCache;
}
THashSet<Guild> guilds = new THashSet<Guild>();
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<Guild> getMyForums(int userId) {
long now = System.currentTimeMillis();
long[] cached = myForumsCache.get(userId);
if (cached != null && (now - cached[0]) < MY_FORUMS_TTL) {
THashSet<Guild> data = myForumsData.get(userId);
if (data != null) return data;
}
THashSet<Guild> guilds = new THashSet<Guild>();
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;
}
}
@@ -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);
}
}
}
@@ -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;
}
@@ -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);
}
}
}
@@ -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));
@@ -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
@@ -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;
}
@@ -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)));
@@ -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
@@ -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;
@@ -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<String, Object> item;
private final int usageCount;
private final List<Map<String, Object>> catalogItems;
private final String furniDataJson;
public FurniEditorDetailComposer(Map<String, Object> item, int usageCount, List<Map<String, Object>> 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<String, Object> 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;
}
}
@@ -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<String> interactions;
public FurniEditorInteractionsComposer(List<String> 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;
}
}
@@ -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;
}
}
@@ -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<Map<String, Object>> items;
private final int total;
private final int page;
public FurniEditorSearchComposer(List<Map<String, Object>> 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<String, Object> 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;
}
}
@@ -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;
}
@@ -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<String, long[]> 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<String, long[]> 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<ForumThread> 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());