diff --git a/Database Updates/001_auth_ticket_ttl.sql b/Database Updates/001_auth_ticket_ttl.sql index ae8a669f..6d563bc1 100644 --- a/Database Updates/001_auth_ticket_ttl.sql +++ b/Database Updates/001_auth_ticket_ttl.sql @@ -39,5 +39,8 @@ DEALLOCATE PREPARE stmt; UPDATE emulator_settings SET `key`='ws.whitelist' WHERE `key`='websockets.whitelist'; UPDATE emulator_settings SET `key`='ws.host' WHERE `key`='ws.nitro.host'; UPDATE emulator_settings SET `key`='ws.port' WHERE `key`='ws.nitro.port'; -INSERT emulator_settings (`key`, `value`) VALUES ('ws.ip.header', 'X-Forwarded-For'); -INSERT emulator_settings (`key`, `value`) VALUES ('ws.enabled', 'true'); \ No newline at end of file +INSERT IGNORE INTO emulator_settings (`key`, `value`) +VALUES ('ws.ip.header', 'X-Forwarded-For'); + +INSERT IGNORE INTO emulator_settings (`key`, `value`) +VALUES ('ws.enabled', 'true'); diff --git a/Database Updates/002_backgounds_border.sql b/Database Updates/002_backgounds_border.sql new file mode 100644 index 00000000..3e542f30 --- /dev/null +++ b/Database Updates/002_backgounds_border.sql @@ -0,0 +1,33 @@ +ALTER TABLE users +ADD COLUMN `background_border_id` INT(11) NOT NULL DEFAULT 0 AFTER `background_id`; + +ALTER TABLE infostand_backgrounds +CHANGE COLUMN `category` `category` ENUM('background', 'stand', 'overlay', 'card', 'border') NOT NULL ; + + +INSERT IGNORE INTO `infostand_backgrounds` (`id`, `category`, `min_rank`, `is_hc_only`, `is_ambassador_only`) VALUES + (1, 'border', 1, 0, 0), + (2, 'border', 1, 0, 0), + (3, 'border', 1, 0, 0), + (4, 'border', 1, 0, 0), + (5, 'border', 1, 0, 0), + (6, 'border', 1, 0, 0), + (7, 'border', 1, 0, 0), + (8, 'border', 1, 0, 0), + (9, 'border', 1, 0, 0), + (10, 'border', 1, 0, 0), + (11, 'border', 1, 0, 0), + (12, 'border', 1, 0, 0), + (13, 'border', 1, 0, 0), + (14, 'border', 1, 0, 0), + (15, 'border', 1, 0, 0), + (16, 'border', 1, 0, 0), + (17, 'border', 1, 0, 0), + (18, 'border', 1, 0, 0), + (19, 'border', 1, 0, 0), + (20, 'border', 1, 0, 0), + (21, 'border', 1, 0, 0), + (22, 'border', 1, 0, 0), + (23, 'border', 1, 0, 0), + (24, 'border', 1, 0, 0), + (25, 'border', 1, 0, 0); \ No newline at end of file diff --git a/Database Updates/003_live_required_schema.sql b/Database Updates/003_live_required_schema.sql new file mode 100644 index 00000000..cd84766c --- /dev/null +++ b/Database Updates/003_live_required_schema.sql @@ -0,0 +1,477 @@ +-- ============================================================ +-- Live required schema +-- ============================================================ +-- Consolidated schema for the currently used Nitro/Arcturus live +-- additions. This file intentionally excludes old/unused migration +-- artifacts and dump-only data. +-- +-- Scope: +-- - tables/columns currently referenced by Java code +-- - runtime settings required by secure assets/API, login, wired, and UI +-- - safe CREATE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS statements +-- +-- Assumes the base Arcturus database already exists. +-- Tested for MariaDB-style syntax used by this project. +-- ============================================================ + +SET NAMES utf8mb4; + +-- ------------------------------------------------------------ +-- Core settings support +-- ------------------------------------------------------------ + +ALTER TABLE `emulator_settings` + ADD COLUMN IF NOT EXISTS `comment` TEXT NULL DEFAULT '' AFTER `value`; + +CREATE TABLE IF NOT EXISTS `wired_emulator_settings` ( + `key` VARCHAR(255) NOT NULL, + `value` TEXT NOT NULL, + `comment` TEXT NULL DEFAULT '', + PRIMARY KEY (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `emulator_settings` (`key`, `value`) VALUES + ('crypto.ws.enabled', '0'), + ('crypto.ws.signing.enabled', '0'), + ('crypto.ws.signing.public_key', ''), + ('crypto.ws.signing.private_key', ''), + ('login.access.jwt.secret', ''), + ('login.remember.duration.days', '30'), + ('login.remember.rotate.interval.minutes', '15'), + ('login.remember.jwt.secret', ''), + ('login.turnstile.enabled', '0'), + ('login.turnstile.sitekey', ''), + ('login.turnstile.secretkey', ''), + ('login.ratelimit.enabled', '1'), + ('login.ratelimit.max_attempts', '5'), + ('login.ratelimit.window_sec', '60'), + ('login.ratelimit.lockout_sec', '120'), + ('login.register.enabled', '1'), + ('register.max_per_ip', '5'), + ('register.default.look', 'hr-100-7.hd-180-1.ch-210-66.lg-270-82.sh-290-80'), + ('register.default.motto', 'I love Habbo!'), + ('password.reset.url', 'http://localhost/reset-password'), + ('smtp.provider', 'own'), + ('smtp.host', 'localhost'), + ('smtp.port', '587'), + ('smtp.username', ''), + ('smtp.password', ''), + ('smtp.from_address', 'no-reply@example.com'), + ('smtp.from_name', 'Habbo Hotel'), + ('smtp.use_tls', '1'), + ('smtp.use_ssl', '0'), + ('new_user_credits', '0'), + ('new_user_duckets', '0'), + ('new_user_diamonds', '0') +ON DUPLICATE KEY UPDATE `value` = `value`; + +INSERT INTO `wired_emulator_settings` (`key`, `value`, `comment`) VALUES + ('wired.engine.enabled', '1', 'Compatibility flag. The runtime uses the new wired engine.'), + ('wired.engine.exclusive', '1', 'Compatibility flag. The runtime uses exclusive wired engine execution.'), + ('wired.engine.maxStepsPerStack', '100', 'Maximum internal processing steps allowed for a single wired stack execution.'), + ('wired.engine.debug', '0', 'Enable verbose debug logging for the wired engine.'), + ('wired.custom.enabled', '0', 'Enable custom legacy wired compatibility behavior.'), + ('hotel.wired.furni.selection.count', '5', 'Maximum number of furni that a wired box can store or select.'), + ('hotel.wired.max_delay', '20', 'Maximum delay value accepted by wired effects that support delayed execution.'), + ('hotel.wired.message.max_length', '512', 'Maximum length of wired message text fields.'), + ('wired.effect.teleport.delay', '500', 'Delay in milliseconds used by wired teleport movement.'), + ('wired.place.under', '0', 'Allow placing wired furniture underneath other items when room rules permit it.'), + ('wired.tick.interval.ms', '50', 'Global wired tick interval in milliseconds.'), + ('wired.tick.resolution', '100', 'Legacy wired tick resolution value.'), + ('wired.tick.debug', '0', 'Enable verbose logging for the wired tick service.'), + ('wired.tick.thread.priority', '6', 'Java thread priority used by the wired tick service.'), + ('wired.highscores.displaycount', '25', 'Maximum number of wired highscore entries shown to users.'), + ('wired.abuse.max.recursion.depth', '10', 'Maximum recursive wired depth before execution is stopped.'), + ('wired.abuse.max.events.per.window', '100', 'Maximum identical wired events allowed inside the abuse rate-limit window.'), + ('wired.abuse.rate.limit.window.ms', '10000', 'Wired abuse rate-limit window in milliseconds.'), + ('wired.abuse.ban.duration.ms', '600000', 'Temporary wired ban duration after abuse detection.'), + ('wired.monitor.usage.window.ms', '1000', 'Rolling window size for wired usage monitoring.'), + ('wired.monitor.usage.limit', '1000', 'Maximum wired usage budget in one monitor window.'), + ('wired.monitor.delayed.events.limit', '100', 'Maximum delayed wired events queued in one room.'), + ('wired.monitor.overload.average.ms', '50', 'Average execution time threshold for overload tracking.'), + ('wired.monitor.overload.peak.ms', '150', 'Peak execution time threshold for overload tracking.'), + ('wired.monitor.overload.consecutive.windows', '2', 'Consecutive overloaded windows required before logging overload.'), + ('wired.monitor.heavy.usage.percent', '70', 'Usage percentage threshold for heavy-room tracking.'), + ('wired.monitor.heavy.consecutive.windows', '5', 'Consecutive windows above heavy usage threshold.'), + ('wired.monitor.heavy.delayed.percent', '60', 'Delayed queue percentage threshold for heavy-room tracking.') +ON DUPLICATE KEY UPDATE + `value` = VALUES(`value`), + `comment` = VALUES(`comment`); + +-- ------------------------------------------------------------ +-- Login API, room templates, remember-me, and news +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS `password_resets` ( + `user_id` INT(11) NOT NULL, + `token` VARCHAR(128) NOT NULL, + `expires_at` TIMESTAMP NOT NULL, + `created_ip` VARCHAR(64) NOT NULL DEFAULT '', + PRIMARY KEY (`user_id`), + UNIQUE KEY `idx_password_resets_token` (`token`), + CONSTRAINT `fk_password_resets_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `users_remember_families` ( + `family_id` CHAR(36) NOT NULL, + `user_id` INT(11) NOT NULL, + `current_version` INT(11) NOT NULL DEFAULT 1, + `created_at` INT(11) NOT NULL, + `expires_at` INT(11) NOT NULL, + `revoked` TINYINT(1) NOT NULL DEFAULT 0, + `last_ip` VARCHAR(45) NOT NULL DEFAULT '', + PRIMARY KEY (`family_id`), + KEY `idx_users_remember_families_user_id` (`user_id`), + KEY `idx_users_remember_families_expires_at` (`expires_at`), + CONSTRAINT `fk_users_remember_families_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE IF NOT EXISTS `room_templates` ( + `template_id` INT(11) NOT NULL AUTO_INCREMENT, + `title` VARCHAR(128) NOT NULL DEFAULT '', + `description` VARCHAR(256) NOT NULL DEFAULT '', + `thumbnail` VARCHAR(512) NOT NULL DEFAULT '', + `sort_order` INT(11) NOT NULL DEFAULT 0, + `enabled` ENUM('0','1') NOT NULL DEFAULT '1', + `name` VARCHAR(50) NOT NULL DEFAULT '', + `room_description` VARCHAR(250) NOT NULL DEFAULT '', + `model` VARCHAR(100) NOT NULL, + `password` VARCHAR(50) NOT NULL DEFAULT '', + `state` ENUM('open','locked','password','invisible') NOT NULL DEFAULT 'open', + `users_max` INT(11) NOT NULL DEFAULT 25, + `category` INT(11) NOT NULL DEFAULT 0, + `paper_floor` VARCHAR(50) NOT NULL DEFAULT '0.0', + `paper_wall` VARCHAR(50) NOT NULL DEFAULT '0.0', + `paper_landscape` VARCHAR(50) NOT NULL DEFAULT '0.0', + `thickness_wall` INT(11) NOT NULL DEFAULT 0, + `thickness_floor` INT(11) NOT NULL DEFAULT 0, + `moodlight_data` VARCHAR(2048) NOT NULL DEFAULT '', + `override_model` ENUM('0','1') NOT NULL DEFAULT '0', + `trade_mode` INT(2) NOT NULL DEFAULT 2, + `heightmap` MEDIUMTEXT NOT NULL, + `door_x` INT(11) NOT NULL DEFAULT 0, + `door_y` INT(11) NOT NULL DEFAULT 0, + `door_dir` INT(4) NOT NULL DEFAULT 2, + PRIMARY KEY (`template_id`), + KEY `idx_room_templates_enabled_sort` (`enabled`, `sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE IF NOT EXISTS `room_templates_items` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `template_id` INT(11) NOT NULL, + `item_id` INT(11) UNSIGNED NOT NULL, + `wall_pos` VARCHAR(20) NOT NULL DEFAULT '', + `x` INT(11) NOT NULL DEFAULT 0, + `y` INT(11) NOT NULL DEFAULT 0, + `z` DOUBLE(10,6) NOT NULL DEFAULT 0.000000, + `rot` INT(11) NOT NULL DEFAULT 0, + `extra_data` VARCHAR(2096) NOT NULL DEFAULT '', + `wired_data` VARCHAR(4096) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_room_templates_items_template_id` (`template_id`), + KEY `idx_room_templates_items_item_id` (`item_id`), + CONSTRAINT `fk_room_templates_items_template` + FOREIGN KEY (`template_id`) REFERENCES `room_templates` (`template_id`) ON DELETE CASCADE, + CONSTRAINT `fk_room_templates_items_item_base` + FOREIGN KEY (`item_id`) REFERENCES `items_base` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE IF NOT EXISTS `ui_news` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `title` VARCHAR(150) NOT NULL, + `body` TEXT NOT NULL, + `image` MEDIUMTEXT DEFAULT NULL, + `link_text` VARCHAR(80) NOT NULL DEFAULT '', + `link_url` VARCHAR(255) NOT NULL DEFAULT '', + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `sort_order` INT(11) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_ui_news_enabled_sort` (`enabled`, `sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +INSERT INTO `ui_news` (`title`, `body`, `image`, `link_text`, `link_url`, `enabled`, `sort_order`) +SELECT 'Welcome to the Hotel!', 'Catch up on the latest events, updates and competitions happening right now in the hotel.', '', '', '', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM `ui_news`); + +-- ------------------------------------------------------------ +-- Wired runtime data +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS `room_wired_settings` ( + `room_id` INT(11) NOT NULL, + `inspect_mask` INT(11) NOT NULL DEFAULT 0 COMMENT 'Bitmask for who can open and inspect Wired in the room. 1=everyone, 2=users with rights, 4=group members, 8=group admins.', + `modify_mask` INT(11) NOT NULL DEFAULT 0 COMMENT 'Bitmask for who can modify Wired in the room. 2=users with rights, 4=group members, 8=group admins.', + PRIMARY KEY (`room_id`), + CONSTRAINT `fk_room_wired_settings_room` + FOREIGN KEY (`room_id`) REFERENCES `rooms` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `room_wired_variables` ( + `room_id` INT(11) NOT NULL, + `variable_item_id` INT(11) NOT NULL, + `value` INT(11) DEFAULT NULL, + `created_at` INT(11) NOT NULL DEFAULT 0, + `updated_at` INT(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`room_id`, `variable_item_id`), + KEY `idx_room_wired_variables_room_item` (`room_id`, `variable_item_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `room_user_wired_variables` ( + `room_id` INT(11) NOT NULL, + `user_id` INT(11) NOT NULL, + `variable_item_id` INT(11) NOT NULL, + `value` INT(11) DEFAULT NULL, + `created_at` INT(11) NOT NULL DEFAULT 0, + `updated_at` INT(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`room_id`, `user_id`, `variable_item_id`), + KEY `idx_room_user_wired_variables_room_item` (`room_id`, `variable_item_id`), + KEY `idx_room_user_wired_variables_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `room_furni_wired_variables` ( + `room_id` INT(11) NOT NULL, + `furni_id` INT(11) NOT NULL, + `variable_item_id` INT(11) NOT NULL, + `value` INT(11) DEFAULT NULL, + `created_at` INT(11) NOT NULL DEFAULT 0, + `updated_at` INT(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`room_id`, `furni_id`, `variable_item_id`), + KEY `idx_room_furni_wired_variables_room_item` (`room_id`, `variable_item_id`), + KEY `idx_room_furni_wired_variables_furni` (`furni_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- User customization: prefixes, nick icons, profile backgrounds +-- ------------------------------------------------------------ + +ALTER TABLE `users` + ADD COLUMN IF NOT EXISTS `background_id` INT(11) NOT NULL DEFAULT 0 AFTER `machine_id`, + ADD COLUMN IF NOT EXISTS `background_stand_id` INT(11) NOT NULL DEFAULT 0 AFTER `background_id`, + ADD COLUMN IF NOT EXISTS `background_overlay_id` INT(11) NOT NULL DEFAULT 0 AFTER `background_stand_id`, + ADD COLUMN IF NOT EXISTS `background_card_id` INT(11) NOT NULL DEFAULT 0 AFTER `background_overlay_id`; + +CREATE TABLE IF NOT EXISTS `infostand_backgrounds` ( + `id` INT(11) NOT NULL, + `category` ENUM('background','stand','overlay','card') NOT NULL, + `min_rank` INT(11) NOT NULL DEFAULT 0, + `is_hc_only` TINYINT(1) NOT NULL DEFAULT 0, + `is_ambassador_only` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`, `category`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO `infostand_backgrounds` (`id`, `category`, `min_rank`, `is_hc_only`, `is_ambassador_only`) VALUES + (0, 'background', 0, 0, 0), + (0, 'stand', 0, 0, 0), + (0, 'overlay', 0, 0, 0), + (0, 'card', 0, 0, 0); + +CREATE TABLE IF NOT EXISTS `user_prefixes` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `user_id` INT(11) NOT NULL, + `text` VARCHAR(50) NOT NULL, + `color` VARCHAR(255) NOT NULL DEFAULT '#FFFFFF', + `icon` VARCHAR(50) NOT NULL DEFAULT '', + `effect` VARCHAR(50) NOT NULL DEFAULT '', + `font` VARCHAR(50) NOT NULL DEFAULT '', + `catalog_prefix_id` INT(11) NOT NULL DEFAULT 0, + `display_name` VARCHAR(100) NOT NULL DEFAULT '', + `points` INT(11) NOT NULL DEFAULT 0, + `points_type` INT(11) NOT NULL DEFAULT 0, + `is_custom` TINYINT(1) NOT NULL DEFAULT 1, + `active` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_user_prefixes_user_id` (`user_id`), + KEY `idx_user_prefixes_user_active` (`user_id`, `active`), + CONSTRAINT `fk_user_prefixes_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `custom_prefixes_catalog` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `display_name` VARCHAR(100) NOT NULL DEFAULT '', + `text` VARCHAR(50) NOT NULL, + `color` VARCHAR(255) NOT NULL DEFAULT '#FFFFFF', + `icon` VARCHAR(50) NOT NULL DEFAULT '', + `effect` VARCHAR(50) NOT NULL DEFAULT '', + `font` VARCHAR(50) NOT NULL DEFAULT '', + `points` INT(11) NOT NULL DEFAULT 0, + `points_type` INT(11) NOT NULL DEFAULT 0, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `sort_order` INT(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `user_visual_settings` ( + `user_id` INT(11) NOT NULL, + `display_order` VARCHAR(50) NOT NULL DEFAULT 'icon-prefix-name', + PRIMARY KEY (`user_id`), + CONSTRAINT `fk_user_visual_settings_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `custom_prefix_settings` ( + `key_name` VARCHAR(100) NOT NULL, + `value` VARCHAR(255) NOT NULL, + PRIMARY KEY (`key_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `custom_prefix_blacklist` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `word` VARCHAR(100) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_custom_prefix_blacklist_word` (`word`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO `custom_prefix_settings` (`key_name`, `value`) VALUES + ('max_length', '15'), + ('min_rank_to_buy', '1'), + ('price_credits', '5'), + ('price_points', '0'), + ('points_type', '0'), + ('font_price_credits', '10'), + ('font_price_points', '0'), + ('font_points_type', '0'); + +INSERT IGNORE INTO `custom_prefixes_catalog` + (`id`, `display_name`, `text`, `color`, `icon`, `effect`, `font`, `points`, `points_type`, `enabled`, `sort_order`) +VALUES + (1, 'VIP', 'VIP', '#FFD700', '', 'glow', '', 10, 0, 1, 1), + (2, 'Legend', 'Legend', '#8B5CF6', '', 'discord-neon', '', 15, 0, 1, 2), + (3, 'Staff Pick', 'Staff', '#3B82F6', '*', 'cartoon', '', 20, 0, 1, 3); + +CREATE TABLE IF NOT EXISTS `custom_nick_icons_catalog` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `icon_key` VARCHAR(50) NOT NULL, + `display_name` VARCHAR(100) NOT NULL DEFAULT '', + `points` INT(11) NOT NULL DEFAULT 0, + `points_type` INT(11) NOT NULL DEFAULT 0, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `sort_order` INT(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_custom_nick_icons_catalog_icon_key` (`icon_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `user_nick_icons` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `user_id` INT(11) NOT NULL, + `icon_key` VARCHAR(50) NOT NULL, + `active` TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_nick_icons_user_icon` (`user_id`, `icon_key`), + KEY `idx_user_nick_icons_user_id` (`user_id`), + KEY `idx_user_nick_icons_user_active` (`user_id`, `active`), + CONSTRAINT `fk_user_nick_icons_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO `custom_nick_icons_catalog` (`icon_key`, `display_name`, `points`, `points_type`, `enabled`, `sort_order`) VALUES + ('1', 'Icon 1', 10, 0, 1, 1), + ('2', 'Icon 2', 10, 0, 1, 2), + ('3', 'Icon 3', 10, 0, 1, 3), + ('4', 'Icon 4', 10, 0, 1, 4), + ('5', 'Icon 5', 10, 0, 1, 5), + ('6', 'Icon 6', 10, 0, 1, 6); + +-- ------------------------------------------------------------ +-- Custom badge maker +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS `users_custom_badge_settings` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `badge_path` VARCHAR(255) NOT NULL DEFAULT '/var/www/gamedata/c_images/album1584', + `badge_url` VARCHAR(255) NOT NULL DEFAULT '/gamedata/c_images/album1584', + `price_badge` INT(11) NOT NULL DEFAULT 0, + `currency_type` INT(11) NOT NULL DEFAULT -1, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +INSERT INTO `users_custom_badge_settings` (`id`, `badge_path`, `badge_url`, `price_badge`, `currency_type`) +SELECT 1, '/var/www/gamedata/c_images/album1584', '/gamedata/c_images/album1584', 50, 5 +WHERE NOT EXISTS (SELECT 1 FROM `users_custom_badge_settings` WHERE `id` = 1); + +CREATE TABLE IF NOT EXISTS `user_custom_badge` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `user_id` INT(11) NOT NULL, + `badge_id` VARCHAR(64) NOT NULL, + `badge_name` VARCHAR(64) NOT NULL DEFAULT '', + `badge_description` VARCHAR(255) NOT NULL DEFAULT '', + `date_created` INT(11) NOT NULL DEFAULT 0, + `date_edit` INT(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_custom_badge_badge_id` (`badge_id`), + KEY `idx_user_custom_badge_user_id` (`user_id`), + CONSTRAINT `fk_user_custom_badge_user` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- ------------------------------------------------------------ +-- UI/catalog compatibility values used by the current client +-- ------------------------------------------------------------ + +INSERT INTO `chat_bubbles` (`type`, `name`, `permission`, `overridable`, `triggers_talking_furniture`) VALUES + (200, 'SHOW_MESSAGE_RED', '', 1, 0), + (201, 'SHOW_MESSAGE_GREEN', '', 1, 0), + (202, 'SHOW_MESSAGE_BLUE', '', 1, 0), + (210, 'SHOW_MESSAGE_ALERT', '', 1, 0), + (211, 'SHOW_MESSAGE_INFO', '', 1, 0), + (212, 'SHOW_MESSAGE_WARNING', '', 1, 0), + (220, 'SHOW_MESSAGE_WRONG', '', 1, 0), + (221, 'SHOW_MESSAGE_WRONG_CIRCLED', '', 1, 0), + (222, 'SHOW_MESSAGE_CORRECT', '', 1, 0), + (223, 'SHOW_MESSAGE_CORRECT_CIRCLED', '', 1, 0), + (224, 'SHOW_MESSAGE_QUESTION', '', 1, 0), + (225, 'SHOW_MESSAGE_QUESTION_CIRCLED', '', 1, 0), + (226, 'SHOW_MESSAGE_ARROW_UP', '', 1, 0), + (227, 'SHOW_MESSAGE_ARROW_UP_CIRCLED', '', 1, 0), + (228, 'SHOW_MESSAGE_ARROW_DOWN', '', 1, 0), + (229, 'SHOW_MESSAGE_ARROW_DOWN_CIRCLED', '', 1, 0), + (250, 'SHOW_MESSAGE_SKULL', '', 1, 0), + (251, 'SHOW_MESSAGE_SKULL_ALT', '', 1, 0), + (252, 'SHOW_MESSAGE_MAGNIFIER', '', 1, 0) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `permission` = VALUES(`permission`), + `overridable` = VALUES(`overridable`), + `triggers_talking_furniture` = VALUES(`triggers_talking_furniture`); + +INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES + ('commands.keys.cmd_setroom_template', 'setroom_template;set_room_template'), + ('commands.succes.cmd_setroom_template.verify', 'Copy the current room "%roomname%" to room_templates? Type :setroom_template %generic.yes% to confirm.'), + ('commands.succes.cmd_setroom_template', 'Room saved as template id %id% with %items% items (%skipped% skipped - item_id not in items_base).'), + ('commands.error.cmd_setroom_template', 'Could not save room as template. Check the server log for details.'), + ('commands.error.cmd_setroom_template.no_room', 'You must be inside a room to use this command.'), + ('commands.keys.cmd_give_prefix', 'giveprefix'), + ('commands.keys.cmd_list_prefixes', 'listprefixes'), + ('commands.keys.cmd_remove_prefix', 'removeprefix'), + ('commands.keys.cmd_prefix_blacklist', 'prefixblacklist'), + ('wiredfurni.badgereceived.body', 'You have just received a new Badge! Check your Inventory!'), + ('wiredfurni.badgereceived.title', 'Badge received!'); + +-- Optional permission metadata for normalized permission schemas. +-- Actual rank values still belong in the permissions/permission_ranks setup. +CREATE TABLE IF NOT EXISTS `permission_definitions` ( + `permission_key` VARCHAR(64) NOT NULL, + `max_value` TINYINT(3) UNSIGNED NOT NULL DEFAULT 1, + `comment` TEXT NOT NULL, + PRIMARY KEY (`permission_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `permission_definitions` (`permission_key`, `max_value`, `comment`) VALUES + ('cmd_setroom_template', 1, 'Allows using :setroom_template to copy a room into the login room-template table.'), + ('cmd_give_prefix', 1, 'Allows granting custom prefixes to users.'), + ('cmd_list_prefixes', 1, 'Allows listing custom prefixes assigned to users.'), + ('cmd_remove_prefix', 1, 'Allows removing custom prefixes from users.'), + ('cmd_prefix_blacklist', 1, 'Allows managing the custom prefix blacklist.') +ON DUPLICATE KEY UPDATE + `max_value` = VALUES(`max_value`), + `comment` = VALUES(`comment`); + +-- ------------------------------------------------------------ +-- Explicitly obsolete table from older remember-me attempts. +-- The current Java uses users_remember_families only. +-- ------------------------------------------------------------ + +DROP TABLE IF EXISTS `users_remember_tokens`; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/UpdatePermissionsCommand.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/UpdatePermissionsCommand.java index 9f257bdd..b2efc3df 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/UpdatePermissionsCommand.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/commands/UpdatePermissionsCommand.java @@ -2,7 +2,12 @@ package com.eu.habbo.habbohotel.commands; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.gameclients.GameClient; +import com.eu.habbo.habbohotel.permissions.PermissionsManager; +import com.eu.habbo.habbohotel.permissions.Rank; import com.eu.habbo.habbohotel.rooms.RoomChatMessageBubbles; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.users.HabboManager; +import com.eu.habbo.messages.outgoing.users.UserPermissionsComposer; public class UpdatePermissionsCommand extends Command { public UpdatePermissionsCommand() { @@ -13,7 +18,41 @@ public class UpdatePermissionsCommand extends Command { public boolean handle(GameClient gameClient, String[] params) throws Exception { Emulator.getGameEnvironment().getPermissionsManager().reload(); - gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_update_permissions"), RoomChatMessageBubbles.ALERT); + // PermissionsManager.reload() rebuilt the rank table — each online + // Habbo's HabboInfo still references the OLD Rank object, so + // server-side hasPermission() / wire composers would keep + // reporting stale data until relogin. Re-bind every connected + // user to the freshly-loaded Rank by id, then ship the new + // UserPermissionsComposer (which carries clubLevel, + // securityLevel, isAmbassador, rank metadata and the resolved + // permission_definitions map) so Nitro clients' React-side + // useHasPermission(key) / useUserRank() / useUserPermissions() + // consumers re-render against the updated tables without an F5. + HabboManager habboManager = Emulator.getGameEnvironment().getHabboManager(); + PermissionsManager permissions = Emulator.getGameEnvironment().getPermissionsManager(); + + int refreshed = 0; + + for (Habbo habbo : habboManager.getOnlineHabbos().values()) { + if (habbo == null || habbo.getHabboInfo() == null || habbo.getClient() == null) continue; + + int currentRankId = habbo.getHabboInfo().getRank().getId(); + // Defensive fallback: if the admin deleted the rank from the + // permission_ranks table between sessions, fall back to rank 1 + // (Member) so the user isn't stranded with a null Rank. + Rank freshRank = permissions.rankExists(currentRankId) + ? permissions.getRank(currentRankId) + : permissions.getRank(1); + + habbo.getHabboInfo().setRank(freshRank); + habbo.getClient().sendResponse(new UserPermissionsComposer(habbo)); + refreshed++; + } + + gameClient.getHabbo().whisper( + Emulator.getTexts().getValue("commands.succes.cmd_update_permissions") + " (" + refreshed + " online refreshed)", + RoomChatMessageBubbles.ALERT + ); return true; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java index 0bcc8247..373642be 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboInfo.java @@ -46,6 +46,7 @@ public class HabboInfo implements Runnable { private int InfostandStand; private int InfostandOverlay; private int InfostandCardBg; + private int InfostandBorder; private int loadingRoom; private Room currentRoom; private String roomEntryMethod = "door"; @@ -93,6 +94,11 @@ public class HabboInfo implements Runnable { this.InfostandStand = set.getInt("background_stand_id"); this.InfostandOverlay = set.getInt("background_overlay_id"); this.InfostandCardBg = set.getInt("background_card_id"); + try { + this.InfostandBorder = set.getInt("background_border_id"); + } catch (SQLException ignored) { + this.InfostandBorder = 0; + } this.currentRoom = null; } catch (SQLException e) { LOGGER.error("Caught SQL exception", e); @@ -300,6 +306,15 @@ public class HabboInfo implements Runnable { public void setInfostandCardBg(int infostandCardBg) { InfostandCardBg = infostandCardBg; } + + public int getInfostandBorder() { + return InfostandBorder; + } + + public void setInfostandBorder(int infostandBorder) { + InfostandBorder = infostandBorder; + } + public Rank getRank() { return this.rank; } @@ -587,7 +602,7 @@ public class HabboInfo implements Runnable { try { SqlQueries.update( - "UPDATE users SET motto = ?, online = ?, look = ?, gender = ?, credits = ?, last_login = ?, last_online = ?, home_room = ?, ip_current = ?, `rank` = ?, machine_id = ?, username = ?, background_id = ?, background_stand_id = ?, background_overlay_id = ?, background_card_id = ? WHERE id = ?", + "UPDATE users SET motto = ?, online = ?, look = ?, gender = ?, credits = ?, last_login = ?, last_online = ?, home_room = ?, ip_current = ?, `rank` = ?, machine_id = ?, username = ?, background_id = ?, background_stand_id = ?, background_overlay_id = ?, background_card_id = ?, background_border_id = ? WHERE id = ?", this.motto, this.online ? "1" : "0", this.look, @@ -604,6 +619,7 @@ public class HabboInfo implements Runnable { this.InfostandStand, this.InfostandOverlay, this.InfostandCardBg, + this.InfostandBorder, this.id); } catch (SqlQueries.DataAccessException e) { LOGGER.error("Caught SQL exception", e); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java index 60ce0419..8217bbfe 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/infostand/InfostandBackgroundManager.java @@ -24,7 +24,8 @@ public class InfostandBackgroundManager { BACKGROUND("background"), STAND("stand"), OVERLAY("overlay"), - CARD("card"); + CARD("card"), + BORDER("border"); public final String dbValue; @@ -89,11 +90,12 @@ public class InfostandBackgroundManager { this.enforce = loaded > 0; if (this.enforce) { - LOGGER.info("InfostandBackgroundManager -> Loaded {} backgrounds, {} stands, {} overlays, {} cards from infostand_backgrounds.", + LOGGER.info("InfostandBackgroundManager -> Loaded {} backgrounds, {} stands, {} overlays, {} cards, {} borders from infostand_backgrounds.", this.entries.get(Category.BACKGROUND).size(), this.entries.get(Category.STAND).size(), this.entries.get(Category.OVERLAY).size(), - this.entries.get(Category.CARD).size()); + this.entries.get(Category.CARD).size(), + this.entries.get(Category.BORDER).size()); } else { LOGGER.info("InfostandBackgroundManager -> infostand_backgrounds is empty, server-side validation disabled (only range clamp will apply)."); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/users/ChangeInfostandBgEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/users/ChangeInfostandBgEvent.java index 74485096..8e9c3930 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/users/ChangeInfostandBgEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/users/ChangeInfostandBgEvent.java @@ -37,6 +37,7 @@ public class ChangeInfostandBgEvent extends MessageHandler { int requestedStand = sanitize(this.packet.readInt()); int requestedOverlay = sanitize(this.packet.readInt()); int requestedCard = this.packet.bytesAvailable() >= 4 ? sanitize(this.packet.readInt()) : 0; + int requestedBorder = this.packet.bytesAvailable() >= 4 ? sanitize(this.packet.readInt()) : 0; InfostandBackgroundManager manager = Emulator.getGameEnvironment() != null ? Emulator.getGameEnvironment().getInfostandBackgroundManager() : null; @@ -44,11 +45,13 @@ public class ChangeInfostandBgEvent extends MessageHandler { int backgroundStand = resolve(manager, habbo, Category.STAND, requestedStand, info.getInfostandStand()); int backgroundOverlay = resolve(manager, habbo, Category.OVERLAY, requestedOverlay, info.getInfostandOverlay()); int backgroundCard = resolve(manager, habbo, Category.CARD, requestedCard, info.getInfostandCardBg()); + int backgroundBorder = resolve(manager, habbo, Category.BORDER, requestedBorder, info.getInfostandBorder()); if (info.getInfostandBg() == backgroundImage && info.getInfostandStand() == backgroundStand && info.getInfostandOverlay() == backgroundOverlay - && info.getInfostandCardBg() == backgroundCard) { + && info.getInfostandCardBg() == backgroundCard + && info.getInfostandBorder() == backgroundBorder) { return; } @@ -56,6 +59,7 @@ public class ChangeInfostandBgEvent extends MessageHandler { info.setInfostandStand(backgroundStand); info.setInfostandOverlay(backgroundOverlay); info.setInfostandCardBg(backgroundCard); + info.setInfostandBorder(backgroundBorder); info.run(); if (info.getCurrentRoom() != null) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUserDataComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUserDataComposer.java index 7162dbf0..fd2c8a32 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUserDataComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUserDataComposer.java @@ -33,6 +33,7 @@ public class RoomUserDataComposer extends MessageComposer { this.response.appendString(customizationData.prefixEffect); this.response.appendString(customizationData.prefixFont); this.response.appendString(customizationData.displayOrder); + this.response.appendInt(this.habbo.getHabboInfo().getInfostandBorder()); return this.response; } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java index 796935c2..8b651b48 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/rooms/users/RoomUsersComposer.java @@ -78,6 +78,7 @@ public class RoomUsersComposer extends MessageComposer { this.response.appendString(customizationData.displayOrder); this.response.appendString(this.habbo.getHabboInfo().getRoomEntryMethod()); this.response.appendInt(this.habbo.getHabboInfo().getRoomEntryTeleportId()); + this.response.appendInt(this.habbo.getHabboInfo().getInfostandBorder()); } else if (this.habbos != null) { this.response.appendInt(this.habbos.size()); for (Habbo habbo : this.habbos) { @@ -120,6 +121,7 @@ public class RoomUsersComposer extends MessageComposer { this.response.appendString(customizationData.displayOrder); this.response.appendString(habbo.getHabboInfo().getRoomEntryMethod()); this.response.appendInt(habbo.getHabboInfo().getRoomEntryTeleportId()); + this.response.appendInt(habbo.getHabboInfo().getInfostandBorder()); } } } else if (this.bot != null) { @@ -154,6 +156,7 @@ public class RoomUsersComposer extends MessageComposer { this.response.appendShort(9); this.response.appendString("unknown"); this.response.appendInt(0); + this.response.appendInt(0); } else if (this.bots != null) { this.response.appendInt(this.bots.size()); for (Bot bot : this.bots) { @@ -187,6 +190,7 @@ public class RoomUsersComposer extends MessageComposer { this.response.appendShort(9); this.response.appendString("unknown"); this.response.appendInt(0); + this.response.appendInt(0); } } return this.response; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/users/UserPermissionsComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/users/UserPermissionsComposer.java index f67c2bfa..9e89af3d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/users/UserPermissionsComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/users/UserPermissionsComposer.java @@ -1,11 +1,57 @@ package com.eu.habbo.messages.outgoing.users; +import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.habbohotel.permissions.PermissionSetting; +import com.eu.habbo.habbohotel.permissions.Rank; import com.eu.habbo.habbohotel.users.Habbo; import com.eu.habbo.messages.ServerMessage; import com.eu.habbo.messages.outgoing.MessageComposer; import com.eu.habbo.messages.outgoing.Outgoing; +import com.eu.habbo.plugin.HabboPlugin; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Sends the full per-user permission state to the connected client. + * + * Wire layout (each trailing block is guarded by `bytesAvailable` on + * the client so older Nitro builds keep parsing the prefix and stop): + * + * int clubLevel + * int rank.level // mapped to securityLevel on the client + * bool isAmbassador // legacy ACC_AMBASSADOR flag + * --- rank metadata (Arcturus ≥ 4.2.10) --- + * int rank.id + * string rank.name // permission_ranks.rank_name + * string rank.badge + * string rank.prefix + * string rank.prefixColor + * --- resolved permission map (Arcturus ≥ 4.2.10) --- + * int count + * loop: string permission_key + int value // 1 = ALLOWED, 2 = ROOM_OWNER + * + * The map is the union of: + * • rank entries with `PermissionSetting != DISALLOWED` — same data + * `Rank.hasPermission(key, isRoomOwner)` reads server-side. + * • plugin grants — for each key the rank doesn't allow, every + * installed `HabboPlugin.hasPermission(habbo, key)` is consulted; + * if any plugin grants it, the key lands on the wire with value 1 + * (plugins don't have a ROOM_OWNER concept). + * + * The React-side `useHasPermission(key)` / `useUserPermissions()` + * consumers read the map directly so UI gates follow the same + * semantics as `PermissionsManager.hasPermission(habbo, key)` + * server-side — including plugin-granted permissions, which were + * invisible to the client before this commit. + * + * Two send points: + * 1. End of `SecureLoginEvent` — client receives the full state once. + * 2. Inside `HabboManager.setRank` — runtime promote/demote refresh. + * 3. Inside `UpdatePermissionsCommand` — broadcast after + * `:update_permissions` reloads the tables at runtime. + */ public class UserPermissionsComposer extends MessageComposer { private final int clubLevel; @@ -20,11 +66,70 @@ public class UserPermissionsComposer extends MessageComposer { protected ServerMessage composeInternal() { this.response.init(Outgoing.UserPermissionsComposer); this.response.appendInt(this.clubLevel); - this.response.appendInt(this.habbo.getHabboInfo().getRank().getLevel()); + + Rank rank = this.habbo.getHabboInfo().getRank(); + + this.response.appendInt(rank.getLevel()); this.response.appendBoolean(this.habbo.hasPermission(Permission.ACC_AMBASSADOR)); + + // Rank metadata + this.response.appendInt(rank.getId()); + this.response.appendString(rank.getName()); + this.response.appendString(rank.getBadge()); + this.response.appendString(rank.getPrefix()); + this.response.appendString(rank.getPrefixColor()); + + // Build the resolved permission map. Walk rank.getPermissions() + // (Rank.permissions has every row from permission_definitions + // because PermissionsManager.loadPermissionsNormalized() calls + // rank.setPermission(key, …) for every key, including DISALLOWED + // ones) and emit the final value per key: + // ALLOWED → 1 + // ROOM_OWNER → 2 + // DISALLOWED + plugin yes → 1 + // DISALLOWED + plugin no → omit + // + // LinkedHashMap preserves the alphabetical order that the rank + // table was populated with, which is helpful for snapshotting + // and grep'ing wire dumps. + Map rankPermissions = rank.getPermissions(); + Map resolved = new LinkedHashMap<>(rankPermissions.size()); + + for (Map.Entry entry : rankPermissions.entrySet()) { + String key = entry.getKey(); + Permission rankPerm = entry.getValue(); + + if (rankPerm.setting == PermissionSetting.ALLOWED) { + resolved.put(key, 1); + } else if (rankPerm.setting == PermissionSetting.ROOM_OWNER) { + resolved.put(key, 2); + } else if (this.anyPluginGrants(key)) { + resolved.put(key, 1); + } + } + + // Plugins may also grant CUSTOM keys that aren't in + // permission_definitions — rare but legal. There's no enumeration + // API on HabboPlugin to discover them, so they stay invisible + // here. Document the limitation rather than over-engineer. + + this.response.appendInt(resolved.size()); + + for (Map.Entry entry : resolved.entrySet()) { + this.response.appendString(entry.getKey()); + this.response.appendInt(entry.getValue()); + } + return this.response; } + private boolean anyPluginGrants(String key) { + for (HabboPlugin plugin : Emulator.getPluginManager().getPlugins()) { + if (plugin.hasPermission(this.habbo, key)) return true; + } + return false; + } + public int getClubLevel() { return clubLevel; } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java index d2f19b13..2ff7809e 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java @@ -5,6 +5,7 @@ import com.eu.habbo.messages.PacketManager; import com.eu.habbo.networking.gameserver.auth.AuthHttpHandler; import com.eu.habbo.networking.gameserver.auth.NitroSecureApiHandler; import com.eu.habbo.networking.gameserver.auth.NitroSecureAssetHandler; +import com.eu.habbo.networking.gameserver.badges.BadgeLeaderboardHttpHandler; import com.eu.habbo.networking.gameserver.badges.BadgeHttpHandler; import com.eu.habbo.networking.gameserver.codec.WebSocketCodec; import com.eu.habbo.networking.gameserver.crypto.WsHandshakeHandler; @@ -60,6 +61,7 @@ public class WebSocketChannelInitializer extends ChannelInitializer badgeUsers; + final List achievementUsers; + final JsonArray badgeStats; + final long expiresAt; + + Snapshot(List badgeUsers, List achievementUsers, JsonArray badgeStats, long expiresAt) { + this.badgeUsers = badgeUsers; + this.achievementUsers = achievementUsers; + this.badgeStats = badgeStats; + this.expiresAt = expiresAt; + } + } + + private static final class UserBadgeAggregate { + final int userId; + final String username; + final String figure; + final int totalBadges; + final EnumMap counts; + + UserBadgeAggregate(int userId, String username, String figure, int totalBadges, EnumMap counts) { + this.userId = userId; + this.username = username; + this.figure = figure; + this.totalBadges = totalBadges; + this.counts = counts; + } + } + + private static final class UserAchievementAggregate { + final int userId; + final String username; + final String figure; + final int achievementScore; + + UserAchievementAggregate(int userId, String username, String figure, int achievementScore) { + this.userId = userId; + this.username = username; + this.figure = figure; + this.achievementScore = achievementScore; + } + } + + private static final class ViewerProfile { + final int userId; + final String username; + final String figure; + + ViewerProfile(int userId, String username, String figure) { + this.userId = userId; + this.username = username; + this.figure = figure; + } + } + + private enum Rarity { + COMMON("common"), + RARE("rare"), + EPIC("epic"), + LEGENDARY("legendary"), + MYTHICAL("mythical"), + UNIQUE("unique"); + + final String key; + + Rarity(String key) { + this.key = key; + } + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + if (!(msg instanceof FullHttpRequest req)) { + super.channelRead(ctx, msg); + return; + } + + String path = new QueryStringDecoder(req.uri()).path(); + if (!path.equals(BASE_PATH) && !path.startsWith(BASE_PATH + "/")) { + super.channelRead(ctx, msg); + return; + } + + try { + handle(ctx, req); + } finally { + ReferenceCountUtil.release(req); + } + } + + private void handle(ChannelHandlerContext ctx, FullHttpRequest req) { + if (req.method() == HttpMethod.OPTIONS) { + sendCors(ctx, req); + return; + } + + if (req.method() != HttpMethod.GET && req.method() != HttpMethod.HEAD) { + sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, error("Use GET.")); + return; + } + + try { + Snapshot snapshot = loadSnapshot(); + int viewerUserId = authenticateOptional(req); + ViewerProfile viewerProfile = loadViewerProfile(viewerUserId); + + JsonObject payload = new JsonObject(); + payload.addProperty("viewerUserId", viewerUserId); + payload.add("badgeStats", cloneArray(snapshot.badgeStats)); + payload.add("thresholds", buildThresholdsPayload()); + + JsonObject boards = new JsonObject(); + boards.add("totalBadges", buildBadgeBoard(snapshot.badgeUsers, viewerUserId, viewerProfile, null)); + boards.add("achievementLevel", buildAchievementBoard(snapshot.achievementUsers, viewerUserId, viewerProfile)); + + JsonObject rarityBoards = new JsonObject(); + for (Rarity rarity : Rarity.values()) { + rarityBoards.add(rarity.key, buildBadgeBoard(snapshot.badgeUsers, viewerUserId, viewerProfile, rarity)); + } + + boards.add("rarity", rarityBoards); + payload.add("leaderboards", boards); + + sendJson(ctx, req, HttpResponseStatus.OK, payload); + } catch (Exception e) { + LOGGER.error("[badges/leaderboard] unexpected error", e); + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, error("Server error.")); + } + } + + private Snapshot loadSnapshot() throws Exception { + long now = System.currentTimeMillis(); + Snapshot current = cache; + if (current != null && current.expiresAt >= now) return current; + + synchronized (BadgeLeaderboardHttpHandler.class) { + current = cache; + if (current != null && current.expiresAt >= now) return current; + + JsonArray badgeStats = new JsonArray(); + List badgeUsers = new ArrayList<>(); + List achievementUsers = new ArrayList<>(); + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { + loadBadgeStats(connection, badgeStats); + loadBadgeUsers(connection, badgeUsers); + loadAchievementUsers(connection, achievementUsers); + } + + Snapshot built = new Snapshot(badgeUsers, achievementUsers, badgeStats, now + CACHE_TTL_MS); + cache = built; + return built; + } + } + + private void loadBadgeStats(Connection connection, JsonArray badgeStats) throws Exception { + try (PreparedStatement statement = connection.prepareStatement( + "SELECT badge_code, COUNT(DISTINCT user_id) AS owner_count " + + "FROM users_badges GROUP BY badge_code ORDER BY owner_count ASC, badge_code ASC")) { + try (ResultSet set = statement.executeQuery()) { + while (set.next()) { + String badgeCode = set.getString("badge_code"); + int ownerCount = set.getInt("owner_count"); + + JsonObject entry = new JsonObject(); + entry.addProperty("badgeCode", badgeCode); + entry.addProperty("ownerCount", ownerCount); + entry.addProperty("rarity", classify(ownerCount).key); + badgeStats.add(entry); + } + } + } + } + + private void loadBadgeUsers(Connection connection, List badgeUsers) throws Exception { + String sql = + "SELECT u.id AS user_id, u.username, u.look, " + + "COUNT(DISTINCT ub.badge_code) AS total_badges, " + + "COUNT(DISTINCT CASE WHEN counts.owner_count > 50 THEN ub.badge_code END) AS common_count, " + + "COUNT(DISTINCT CASE WHEN counts.owner_count > 10 AND counts.owner_count <= 50 THEN ub.badge_code END) AS rare_count, " + + "COUNT(DISTINCT CASE WHEN counts.owner_count > 6 AND counts.owner_count <= 10 THEN ub.badge_code END) AS epic_count, " + + "COUNT(DISTINCT CASE WHEN counts.owner_count > 3 AND counts.owner_count <= 6 THEN ub.badge_code END) AS legendary_count, " + + "COUNT(DISTINCT CASE WHEN counts.owner_count > 1 AND counts.owner_count <= 3 THEN ub.badge_code END) AS mythical_count, " + + "COUNT(DISTINCT CASE WHEN counts.owner_count = 1 THEN ub.badge_code END) AS unique_count " + + "FROM users_badges ub " + + "INNER JOIN users u ON u.id = ub.user_id " + + "INNER JOIN (SELECT badge_code, COUNT(DISTINCT user_id) AS owner_count FROM users_badges GROUP BY badge_code) counts ON counts.badge_code = ub.badge_code " + + "GROUP BY u.id, u.username, u.look"; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + try (ResultSet set = statement.executeQuery()) { + while (set.next()) { + EnumMap counts = new EnumMap<>(Rarity.class); + counts.put(Rarity.COMMON, set.getInt("common_count")); + counts.put(Rarity.RARE, set.getInt("rare_count")); + counts.put(Rarity.EPIC, set.getInt("epic_count")); + counts.put(Rarity.LEGENDARY, set.getInt("legendary_count")); + counts.put(Rarity.MYTHICAL, set.getInt("mythical_count")); + counts.put(Rarity.UNIQUE, set.getInt("unique_count")); + + badgeUsers.add(new UserBadgeAggregate( + set.getInt("user_id"), + safe(set.getString("username")), + safe(set.getString("look")), + set.getInt("total_badges"), + counts + )); + } + } + } + } + + private void loadAchievementUsers(Connection connection, List achievementUsers) throws Exception { + try (PreparedStatement statement = connection.prepareStatement( + "SELECT u.id AS user_id, u.username, u.look, COALESCE(us.achievement_score, 0) AS achievement_score " + + "FROM users u INNER JOIN users_settings us ON us.user_id = u.id " + + "WHERE COALESCE(us.achievement_score, 0) > 0")) { + try (ResultSet set = statement.executeQuery()) { + while (set.next()) { + achievementUsers.add(new UserAchievementAggregate( + set.getInt("user_id"), + safe(set.getString("username")), + safe(set.getString("look")), + set.getInt("achievement_score") + )); + } + } + } + } + + private ViewerProfile loadViewerProfile(int viewerUserId) throws Exception { + if (viewerUserId <= 0) return null; + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "SELECT id, username, look FROM users WHERE id = ? LIMIT 1")) { + statement.setInt(1, viewerUserId); + + try (ResultSet set = statement.executeQuery()) { + if (!set.next()) return null; + + return new ViewerProfile( + set.getInt("id"), + safe(set.getString("username")), + safe(set.getString("look")) + ); + } + } + } + + private JsonObject buildBadgeBoard(List users, int viewerUserId, ViewerProfile viewerProfile, Rarity rarity) { + List ranked = new ArrayList<>(); + + for (UserBadgeAggregate user : users) { + int score = (rarity == null) ? user.totalBadges : user.counts.getOrDefault(rarity, 0); + if (score <= 0) continue; + ranked.add(toEntry(user.userId, user.username, user.figure, score)); + } + + ranked.sort((a, b) -> { + int scoreCompare = Integer.compare(b.get("score").getAsInt(), a.get("score").getAsInt()); + if (scoreCompare != 0) return scoreCompare; + return Integer.compare(a.get("userId").getAsInt(), b.get("userId").getAsInt()); + }); + + return finalizeBoard(ranked, viewerUserId, viewerProfile); + } + + private JsonObject buildAchievementBoard(List users, int viewerUserId, ViewerProfile viewerProfile) { + List ranked = new ArrayList<>(); + + for (UserAchievementAggregate user : users) { + if (user.achievementScore <= 0) continue; + ranked.add(toEntry(user.userId, user.username, user.figure, user.achievementScore)); + } + + ranked.sort((a, b) -> { + int scoreCompare = Integer.compare(b.get("score").getAsInt(), a.get("score").getAsInt()); + if (scoreCompare != 0) return scoreCompare; + return Integer.compare(a.get("userId").getAsInt(), b.get("userId").getAsInt()); + }); + + return finalizeBoard(ranked, viewerUserId, viewerProfile); + } + + private JsonObject finalizeBoard(List ranked, int viewerUserId, ViewerProfile viewerProfile) { + JsonArray entries = new JsonArray(); + JsonObject viewerEntry = null; + + int cappedSize = Math.min(ranked.size(), MAX_BOARD_USERS); + + for (int index = 0; index < cappedSize; index++) { + JsonObject entry = ranked.get(index).deepCopy(); + int rank = index + 1; + entry.addProperty("rank", rank); + + entries.add(entry); + if (viewerUserId > 0 && entry.get("userId").getAsInt() == viewerUserId) viewerEntry = entry; + } + + if (viewerEntry == null && viewerUserId > 0) { + for (int index = 0; index < ranked.size(); index++) { + JsonObject entry = ranked.get(index); + + if (entry.get("userId").getAsInt() != viewerUserId) continue; + + viewerEntry = entry.deepCopy(); + viewerEntry.addProperty("rank", index + 1); + break; + } + } + + if (viewerEntry == null && viewerProfile != null) { + viewerEntry = toEntry(viewerProfile.userId, viewerProfile.username, viewerProfile.figure, 0); + viewerEntry.addProperty("rank", 0); + } + + JsonObject board = new JsonObject(); + board.add("entries", entries); + board.addProperty("totalPlayers", cappedSize); + board.add("viewerEntry", viewerEntry != null ? viewerEntry : new JsonObject()); + return board; + } + + private JsonObject toEntry(int userId, String username, String figure, int score) { + JsonObject entry = new JsonObject(); + entry.addProperty("userId", userId); + entry.addProperty("username", username); + entry.addProperty("figure", figure); + entry.addProperty("score", score); + return entry; + } + + private JsonObject buildThresholdsPayload() { + JsonObject thresholds = new JsonObject(); + thresholds.addProperty("commonMinOwners", 51); + thresholds.addProperty("rareMinOwners", 11); + thresholds.addProperty("epicMinOwners", 7); + thresholds.addProperty("legendaryMinOwners", 4); + thresholds.addProperty("mythicalMinOwners", 2); + thresholds.addProperty("uniqueOwners", 1); + return thresholds; + } + + private static Rarity classify(int ownerCount) { + if (ownerCount > 50) return Rarity.COMMON; + if (ownerCount > 10) return Rarity.RARE; + if (ownerCount > 6) return Rarity.EPIC; + if (ownerCount > 3) return Rarity.LEGENDARY; + if (ownerCount > 1) return Rarity.MYTHICAL; + if (ownerCount > 0) return Rarity.UNIQUE; + return Rarity.COMMON; + } + + private static int authenticateOptional(FullHttpRequest req) { + String header = req.headers().get(HttpHeaderNames.AUTHORIZATION); + if (header == null || header.isEmpty()) return 0; + + String token = header.startsWith("Bearer ") ? header.substring(7).trim() : header.trim(); + return AccessTokenService.verify(token); + } + + private static JsonArray cloneArray(JsonArray source) { + JsonArray copy = new JsonArray(); + source.forEach(element -> copy.add(element.deepCopy())); + return copy; + } + + private static String safe(String value) { + return value == null ? "" : value; + } + + private static JsonObject error(String message) { + JsonObject obj = new JsonObject(); + obj.addProperty("error", message); + return obj; + } + + private static void sendJson(ChannelHandlerContext ctx, FullHttpRequest req, HttpResponseStatus status, JsonObject body) { + byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8); + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.wrappedBuffer(bytes)); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8"); + response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length); + response.headers().set(HttpHeaderNames.CACHE_CONTROL, "no-store, no-cache, must-revalidate"); + applyCors(req, response); + boolean keepAlive = isKeepAlive(req); + if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE); + var future = ctx.writeAndFlush(response); + if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE); + } + + private static void sendCors(ChannelHandlerContext ctx, FullHttpRequest req) { + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT); + applyCors(req, response); + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + private static void applyCors(FullHttpRequest req, FullHttpResponse response) { + String origin = req.headers().get(HttpHeaderNames.ORIGIN); + + if (origin != null && !origin.isEmpty() && CorsOriginGate.isAllowed(req)) { + response.headers().set("Access-Control-Allow-Origin", origin); + response.headers().set("Access-Control-Allow-Credentials", "true"); + } + + response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); + + String requestedHeaders = req.headers().get("Access-Control-Request-Headers"); + if (requestedHeaders != null && !requestedHeaders.isEmpty()) { + response.headers().set("Access-Control-Allow-Headers", requestedHeaders); + } else { + response.headers().set("Access-Control-Allow-Headers", + "Authorization, Content-Type, X-Requested-With, X-Nitro-Key, X-Nitro-Api"); + } + + response.headers().set("Vary", "Origin, Access-Control-Request-Headers, Access-Control-Request-Method"); + response.headers().set("Access-Control-Max-Age", "600"); + response.headers().set("Access-Control-Expose-Headers", "X-Nitro-Sec, X-Nitro-Key-Fp, X-Nitro-Derive-Fp"); + } + + private static boolean isKeepAlive(FullHttpRequest req) { + String connection = req.headers().get(HttpHeaderNames.CONNECTION); + return connection == null || !"close".equalsIgnoreCase(connection); + } +}