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/pom.xml b/Emulator/pom.xml
index d2b50b75..c770682a 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.2.8
+ 4.2.9
UTF-8
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);
+ }
+}