diff --git a/Database Updates/018_live_required_schema.sql b/Database Updates/018_live_required_schema.sql new file mode 100644 index 00000000..cd84766c --- /dev/null +++ b/Database Updates/018_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/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); + } +}