Compare commits

...

35 Commits

Author SHA1 Message Date
github-actions[bot] 969f177108 🆙 Bump version to 4.2.13 [skip ci] 2026-05-21 07:02:08 +00:00
DuckieTM e485c2747c Merge pull request #116 from duckietm/dev
Dev
2026-05-21 09:01:07 +02:00
DuckieTM d99a51899b Merge pull request #115 from simoleo89/fix/modtool-counter-bumps
fix(modtool): bump users_settings counters on every sanction
2026-05-21 07:40:49 +02:00
DuckieTM 29677a19be Merge pull request #114 from simoleo89/feat/modtool-user-info-real-data
feat(modtool): populate lastPurchase / tradeLockExpiry / identityBans
2026-05-21 07:40:34 +02:00
DuckieTM 21ee36e089 Merge pull request #113 from simoleo89/fix/acc-supporttool-rank-pattern
fix(permissions): acc_supporttool incorrectly granted to VIP, denied to Super Mod
2026-05-21 07:40:19 +02:00
simoleo89 4e47dbee16 fix(modtool): bump users_settings counters on every sanction
The User Info panel reads its CFH / Cautions / Bans / Trade locks
counters from `users_settings.cfh_send` / `cfh_warnings` / `cfh_bans`
(via totalBans) / `tradelock_amount`. Historically only `cfh_send`
was ever incremented (by `InsertModToolIssue` on CFH submit), so a
user could accumulate any number of Alert / Mute / Ban / TradeLock
sanctions without the stats reflecting it — every panel showed all
zeros even on accounts with a long sanction history visible in the
modern `sanctions` table.

The two systems aren't going away — `ModToolSanctions` (the modern
one) tracks individual sanction events with probation timestamps,
while the legacy `users_settings.cfh_*` columns are flat counters
the ModTool UI displays. Both need to stay in sync.

Wire them up:

`ModToolManager.bumpUserSettingCounter(userId, column)`
  Static helper, column-whitelisted (`cfh_warnings` / `cfh_bans` /
  `cfh_abusive` / `tradelock_amount`) to keep the dynamic SQL safe.
  Single UPDATE per call; SQL exceptions logged, never thrown.

`ModToolSanctionAlertEvent`, `ModToolSanctionMuteEvent` → bump
  `cfh_warnings`. Mute is a punitive but non-banning action; both it
  and Alert are recorded as a warning on the legacy counter, matching
  what the Cautions stat card represents in the new UI.

`ModToolSanctionBanEvent` → bump `cfh_bans`. The `totalBans` field
  the composer sends ALREADY counts entries in the `bans` table, so
  the wire field reflects reality immediately — this column bump is
  a defensive duplicate so any code that reads `users_settings.cfh_bans`
  directly (e.g. plugin scripts, CMS dashboards) stays in sync.

`ModToolSanctionTradeLockEvent` → bump `tradelock_amount`. Mirrors
  what `AllowTradingCommand` already does for the command-line path.

`ModToolManager.closeTicketAsAbusive` → bump `cfh_abusive` for the
  REPORTER (issue.senderId), not the reported user. The Abusive
  counter measures false reports filed by the user, so it belongs on
  whoever opened the CFH that got closed as abusive.

No client-side changes — counter columns are unchanged, only the
write paths are.
2026-05-20 21:54:07 +02:00
simoleo89 e7ba4d0926 feat(modtool): populate lastPurchase / tradeLockExpiry / identityBans
ModToolUserInfoComposer used to send three trailing fields hardcoded
to empty/zero — the client rendered placeholders for every user, on
every panel open:

  appendString("");  // Trading lock expiry timestamp
  appendString("");  // Last Purchase Timestamp
  appendInt(0);      // Number of account bans

These are useful moderation signals and the data already exists in
the live tables. Wire them up.

Last Purchase
  Query MAX(timestamp) FROM logs_shop_purchases WHERE user_id = ?.
  Returns the most recent purchase epoch. Rendered as yyyy-MM-dd HH:mm.
  Empty when the user has never bought anything (the query returns
  NULL → getInt returns 0 → formatUnixTimestamp emits "").

Trading lock expiry
  Query MAX(trade_locked_until) FROM sanctions WHERE habbo_id = ? AND
  trade_locked_until > <now>. Latest ACTIVE lock only — past entries
  don't count. Same yyyy-MM-dd HH:mm format. Empty when no active
  lock.

Identity related bans
  Count of DISTINCT other user accounts that have a ban entry against
  the same machine_id as the target. Self is excluded since the target's
  own bans already show up in banCount. An empty machine_id (default
  '') short-circuits to 0 so we never match accounts whose machine
  fingerprint was never recorded.

The existing totalBans counter is extracted into a helper alongside
the three new ones — cleaner than the inline try-catch tower it used
to live in, same behaviour.

Format choice yyyy-MM-dd HH:mm matches the timestamp shown elsewhere
in moderation UI; both string fields go through the same formatter so
the empty case stays consistent (empty string, not "1970-01-01...").

No client-side changes needed — ModeratorUserInfoData already parses
both strings and the int, and the React ModToolsUserView already
renders them. They were just always empty before.
2026-05-20 21:32:10 +02:00
simoleo89 67d2f52f64 fix(permissions): acc_supporttool incorrectly granted to VIP, denied to Super Mod
The default permission_definitions seed for acc_supporttool used the
pattern (0, 1, 1, 1, 1, 0, 1) across rank_1..rank_7 — apparently
shifted by two columns:

  * rank_2 (VIP) and rank_3 (X) had ALLOWED. With acc_supporttool=1
    the SecureLoginEvent path sends ModeratorInitMessageEvent on
    login, which makes the React client surface the ModTools toolbar
    button and let the user open room/user info windows. The actual
    sanction endpoints (ModToolSanctionBanEvent, ModToolWarnEvent,
    …) still gate on ACC_SUPPORTTOOL so a VIP cannot actually take
    moderator action — but they can request user info, room info
    and chatlogs they have no business reading.
  * rank_6 (Super Mod) was DISALLOWED, which is obviously not what
    the name says.

Corrected pattern: (0, 0, 0, 1, 1, 1, 1) — Support (4), Moderator
(5), Super Mod (6), Administrator (7). Matches the convention used
by the other staff-only acc_modtool_* keys.

Two changes:
  - Default Database/FullDatabase.sql: fix the seed for fresh
    installs.
  - Database Updates/004_fix_acc_supporttool_rank.sql: idempotent
    UPDATE to realign existing deployments.

Found by user report: a rank-2 (VIP) account on the live retro had
the ModTools button visible in the toolbar after login.
2026-05-20 20:34:37 +02:00
github-actions[bot] 69d770b65e 🆙 Bump version to 4.2.12 [skip ci] 2026-05-20 09:36:00 +00:00
DuckieTM 2492569e16 Merge pull request #112 from duckietm/dev
🆙 Added the missing pet package for the borderID
2026-05-20 11:34:57 +02:00
duckietm 9c215bea6b 🆙 Added the missing pet package for the borderID 2026-05-20 11:34:33 +02:00
github-actions[bot] 7dc3581f8f 🆙 Bump version to 4.2.11 [skip ci] 2026-05-20 06:25:19 +00:00
DuckieTM f38eb32eee Merge pull request #111 from duckietm/dev
Dev
2026-05-20 08:24:20 +02:00
duckietm 222e356ff0 Merge branch 'dev' of https://github.com/duckietm/Arcturus-Morningstar-Extended into dev 2026-05-20 08:23:31 +02:00
duckietm c8022ccc45 Small update 2026-05-20 08:23:22 +02:00
DuckieTM 9579833775 Merge branch 'main' into dev 2026-05-20 08:20:56 +02:00
DuckieTM 87ad289a54 Merge pull request #110 from simoleo89/pr/update-permissions-broadcast
feat(commands): :update_permissions broadcasts refreshed permissions to every online client
2026-05-20 08:15:19 +02:00
DuckieTM fd28af5f69 Merge pull request #109 from simoleo89/pr/user-permissions-composer-extension
feat(messages): extend UserPermissionsComposer with rank metadata + resolved permission map
2026-05-20 08:15:03 +02:00
DuckieTM 99c938b98f Merge pull request #108 from Lorenzune/merge-duckie-main-2026-05-06
Add badge leaderboard API endpoint
2026-05-20 08:00:20 +02:00
simoleo89 82d90418cd feat(commands): :update_permissions broadcasts refreshed UserPermissionsComposer to every online client
`PermissionsManager.reload()` rebuilds the rank table from
`permission_ranks` + `permission_definitions`, but every Habbo
currently online still holds a reference to the OLD `Rank` object
on `HabboInfo.rank`. Server-side `hasPermission()` therefore keeps
returning stale results, and any Nitro client that reads permission
state from the wire keeps gating UI on the map shipped at login
— until a relogin or `:give_rank` forces a per-user refresh.

Extend the existing `UpdatePermissionsCommand` so after `reload()`
it:

1. Iterates the online Habbos via `HabboManager.getOnlineHabbos()`.
2. Re-binds each one's `HabboInfo.rank` to the FRESH `Rank` object
   returned by `PermissionsManager.getRank(currentRankId)`. Falls
   back to rank 1 if the admin deleted the rank from
   `permission_ranks` between sessions, so the user is never left
   with a null `Rank` reference.
3. Sends a fresh `UserPermissionsComposer` to each client.

With the companion composer extension PR also merged, this
broadcasts the rank metadata + resolved permission map runtime —
the Nitro React-side `useHasPermission(key)` / `useUserRank()`
consumers re-render against the freshly-loaded tables without
requiring an F5.

The whisper feedback now reports how many connected users were
refreshed, useful for ops feedback after a large `permission_ranks`
edit.

Defensive null guards on habbo / habboInfo / client survive
transient state during the broadcast (e.g. a user disconnecting
mid-iteration).
2026-05-19 20:20:08 +02:00
simoleo89 8b51be4940 feat(messages): extend UserPermissionsComposer with rank metadata + resolved permission map
Backward-compatible wire extension of `UserPermissionsComposer`
(header 411) that lets Nitro clients display per-deployment rank
info and drive UI gates against the actual `permission_definitions`
table instead of hardcoded SecurityLevel constants.

Wire layout after this change (each trailing block is guarded by
`bytesAvailable` on the client side so older Nitro builds keep
parsing the prefix and stop):

    int     clubLevel
    int     rank.level                          // mapped to securityLevel on the client
    bool    isAmbassador                        // existing ACC_AMBASSADOR flag

    --- new: rank metadata ---
    int     rank.id
    string  rank.name                           // permission_ranks.rank_name
    string  rank.badge
    string  rank.prefix
    string  rank.prefixColor

    --- new: resolved permission map ---
    int     count
    loop:   string permission_key + int value   // 1 = ALLOWED, 2 = ROOM_OWNER

The permission map is the union of:

  * Rank entries whose `PermissionSetting != DISALLOWED` (value 1
    for ALLOWED, 2 for ROOM_OWNER).
  * For every rank-DISALLOWED key, each installed
    `HabboPlugin.hasPermission(habbo, key)` is consulted; if any
    plugin grants the permission, the key lands on the wire with
    value 1 (plugins do not have a ROOM_OWNER concept).

Iterating `rank.getPermissions().keySet()` covers every key in
`permission_definitions` because `PermissionsManager.loadPermissionsNormalized()`
calls `rank.setPermission(key, ...)` for every row of the table —
including DISALLOWED ones. Custom keys a plugin invents that are
not in `permission_definitions` stay invisible (there is no
enumeration API on `HabboPlugin` to discover them); this is a rare
case documented in the class-level Javadoc.

The result is a client-side permission map whose semantics match
exactly what `PermissionsManager.hasPermission(habbo, key)` would
return server-side — including plugin-granted permissions, which
were invisible to the client before.

Performance: at login the loop is O(N keys × P plugins), with
N ≈ 200 (size of permission_definitions) and P typically 1-5.
`HabboPlugin.hasPermission` is O(1) hashset lookups in
real-world implementations. Sub-millisecond at login, and the
composer is only sent at login + `HabboManager.setRank` +
`:update_permissions` broadcast.

Backward compatibility: all new fields are appended in tail
position with `bytesAvailable` guards on the parser side, so:
  * existing Nitro clients keep parsing only the prefix and ignore
    the trailing bytes (no error, no behavior change);
  * new Nitro clients with the matching parser extension expose the
    extra data via `IUserDataSnapshot` snapshot getters and the
    React-side `useUserRank()` / `useHasPermission(key)` /
    `useUserPermissions()` hooks (see companion PRs on
    `duckietm/Nitro_Render_V3` and `duckietm/Nitro-V3`).
2026-05-19 20:18:31 +02:00
duckietm 54259f89bd 🆕 Infostand Borders 2026-05-19 16:57:34 +02:00
Lorenzune 272a9b9f42 Add badge leaderboard API and live schema update 2026-05-19 15:30:47 +02:00
duckietm 9c94402f78 🆙 Small update to the SQL 2026-05-19 11:48:33 +02:00
github-actions[bot] 7271506262 🆙 Bump version to 4.2.10 [skip ci] 2026-05-19 09:42:32 +00:00
DuckieTM 09710fc5d6 Merge pull request #107 from duckietm/dev
SMall fix for CORS
2026-05-19 11:41:32 +02:00
duckietm d958fbc0ab SMall fix for CORS 2026-05-19 11:41:17 +02:00
github-actions[bot] dca405ffb5 🆙 Bump version to 4.2.9 [skip ci] 2026-05-19 08:07:32 +00:00
DuckieTM 4190fa96d4 Merge pull request #106 from duckietm/dev
Dev
2026-05-19 10:06:40 +02:00
duckietm 033faaeab6 🆙 Update Database 2026-05-19 10:04:59 +02:00
DuckieTM 98326e11af Merge pull request #104 from duckietm/main
Main to DEV
2026-05-19 10:03:12 +02:00
github-actions[bot] 0f2666916f 🆙 Bump version to 4.2.8 [skip ci] 2026-05-19 07:58:26 +00:00
DuckieTM 46041eedfe Merge pull request #103 from medievalshell/Dev
feat(furnieditor): split-aware FurniDataManager + JSON5 tolerance
2026-05-19 09:57:34 +02:00
medievalshell e334a3e0ac feat(auth): backward-compatible TTL check on SSO auth_ticket
Pairs with the CMS-side change introducing auth_ticket_expires_at (60s
expiry written on every ticket issuance). Without an emulator-side
verification the column was advisory only — this commit gates every
SELECT that resolves a user by auth_ticket on

    auth_ticket = ?
    AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW())

The NULL branch preserves backward-compatibility: CMS deployments that
do not yet populate the column keep working exactly like before
(every ticket passes the WHERE clause as soon as auth_ticket matches),
and the TTL takes effect automatically the moment a CMS starts writing
the expiry value.

Five SELECTs touched:
- SessionEndpoints.java (cms-issued SSO + remember-token flow)
- HabboManager.loadHabbo (game client login by ticket)
- SecureLoginEvent (legacy handshake path)

DB schema delivered both ways:
- Database Updates/Own_Database_RunFirst/020_auth_ticket_ttl.sql:
  idempotent ALTER, skips if column already present (information_schema
  guard so re-running the bundle is safe).
- Default Database/FullDatabase.sql: column added to the `users` table
  definition for fresh installs.

Bumps the emulator version to 4.2.7.
2026-05-19 00:46:58 +02:00
medievalshell 53b7dba185 feat(furnieditor): split-aware FurniDataManager + JSON5 tolerance
Aligns the :furnidata in-game admin command with the split-aware gamedata
layout shipped by the Nitro V3 client. FurniDataManager now resolves the
furnidata source through three accepted shapes:

- legacy single-file path (filesystem or http URL ending in .json/.json5)
- split-mode directory (URL ending with '/') — walks core/custom/seasonal
  tiers via manifest.json5 files and merges by item id, with later tiers
  overriding earlier ones (same semantics as the client-side loader)
- fallback to furni.editor.asset.base.path when the renderer config is
  missing or contains an unresolved placeholder

Adds a small JSON5 sanitiser (stripJson5) that removes line and block
comments and trailing commas before handing the content to Gson, so both
the renderer config and the split-mode files can be JSON or JSON5
without pulling in a JSON5 dependency. String contents are preserved
verbatim — comment-looking substrings inside strings (e.g. URLs) are
not touched.

Bumps the emulator version to 4.2.6.
2026-05-18 22:00:16 +02:00
29 changed files with 1699 additions and 112 deletions
+46
View File
@@ -0,0 +1,46 @@
-- ============================================================================
-- 020_auth_ticket_ttl.sql
--
-- Adds an explicit expiry timestamp to the SSO auth_ticket on `users`.
--
-- The CMS issuing the ticket is expected to populate auth_ticket_expires_at
-- (e.g. NOW() + INTERVAL 60 SECOND) on every login redirect. The emulator-
-- side SELECT queries that look up a user by auth_ticket have been changed to
--
-- WHERE auth_ticket = ?
-- AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW())
--
-- The NULL branch keeps backward-compatibility with CMS deployments that do
-- not populate the column yet: existing rows continue to authenticate the
-- same way they always did, and the TTL kicks in only once the CMS starts
-- writing the expiry value.
--
-- Idempotent: skips the ALTER if the column already exists.
-- ============================================================================
SET @col_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'auth_ticket_expires_at'
);
SET @ddl = IF(@col_exists = 0,
'ALTER TABLE `users` ADD COLUMN `auth_ticket_expires_at` TIMESTAMP NULL DEFAULT NULL AFTER `auth_ticket`',
'SELECT ''auth_ticket_expires_at already present, skipping'' AS info'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
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 IGNORE INTO emulator_settings (`key`, `value`)
VALUES ('ws.ip.header', 'X-Forwarded-For');
INSERT IGNORE INTO emulator_settings (`key`, `value`)
VALUES ('ws.enabled', 'true');
@@ -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);
@@ -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`;
@@ -0,0 +1,31 @@
-- ============================================================
-- Fix: acc_supporttool wrongly granted to VIP / wrongly denied to Super Mod
-- ============================================================
-- The default permission_definitions seed shipped acc_supporttool
-- with rank pattern (0, 1, 1, 1, 1, 0, 1) — i.e. rank_2 (VIP) and
-- rank_3 (X, junior helper) had ALLOWED, while rank_6 (Super Mod)
-- did NOT. That's two bugs:
--
-- * VIP users see the ModTools button on the toolbar and can
-- open Room/User info windows. The actual sanction endpoints
-- still gate on ACC_SUPPORTTOOL server-side so they can't
-- actually moderate, but the UI exposure is wrong and lets a
-- VIP request user info / room info / chatlogs they have no
-- business reading.
-- * Super Mod is denied the tool entirely, which is obviously
-- unintended given the rank name.
--
-- Intended pattern: only Support (4) and up — (0, 0, 0, 1, 1, 1, 1).
--
-- Run on existing deployments to align with the corrected default
-- seed in `Default Database/FullDatabase.sql`. Idempotent.
UPDATE `permission_definitions`
SET `rank_1` = 0,
`rank_2` = 0,
`rank_3` = 0,
`rank_4` = 1,
`rank_5` = 1,
`rank_6` = 1,
`rank_7` = 1
WHERE `permission_key` = 'acc_supporttool';
+2 -1
View File
@@ -28598,7 +28598,7 @@ INSERT INTO `permission_definitions` (`permission_key`, `max_value`, `comment`,
('acc_staff_chat', 1, 'Grants access to the in-game Staff Chat group buddy: receives broadcasts from other staff and can broadcast to anyone holding this permission.', 0, 0, 0, 0, 0, 0, 1),
('acc_staff_pick', 1, 'Allows using staff item pick-up actions that bypass normal room ownership restrictions.', 0, 0, 0, 0, 0, 0, 1),
('acc_superwired', 1, 'Allows saving advanced wired data without the normal wordfilter and reward payload restrictions applied to regular users.', 0, 0, 0, 0, 0, 0, 1),
('acc_supporttool', 1, 'Allows opening and using the support/moderation tool interface.', 0, 1, 1, 1, 1, 0, 1),
('acc_supporttool', 1, 'Allows opening and using the support/moderation tool interface.', 0, 0, 0, 1, 1, 1, 1),
('acc_trade_anywhere', 1, 'Allows starting trades outside the normal trade-enabled areas.', 0, 0, 0, 0, 0, 0, 1),
('acc_unignorable', 1, 'Prevents the account from being ignored by other users through the ignore system.', 0, 0, 0, 0, 0, 0, 0),
('acc_unkickable', 1, 'Prevents the user from being kicked by normal moderation or room commands.', 0, 0, 0, 0, 0, 0, 1),
@@ -30682,6 +30682,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`points` int(11) NOT NULL DEFAULT 10,
`online` enum('0','1','2') CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '0',
`auth_ticket` varchar(256) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT '',
`auth_ticket_expires_at` timestamp NULL DEFAULT NULL,
`remember_token_hash` varchar(64) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '',
`remember_token_expires_at` int(11) unsigned NOT NULL DEFAULT 0,
`ip_register` varchar(45) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
+1 -1
View File
@@ -6,7 +6,7 @@
<groupId>com.eu.habbo</groupId>
<artifactId>Habbo</artifactId>
<version>4.1.16</version>
<version>4.2.13</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -50,6 +50,7 @@ public class RoomUserPetComposer extends MessageComposer {
this.response.appendString("");
this.response.appendString("unknown");
this.response.appendInt(0);
this.response.appendInt(0);
return this.response;
}
@@ -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;
}
@@ -651,6 +651,10 @@ public class ModToolManager {
sender.getClient().sendResponse(new ModToolIssueHandledComposer(ModToolIssueHandledComposer.ABUSIVE));
}
// Reporter (the user who opened the CFH) gets their abusive
// counter bumped — the legacy stat shown in the User Info table.
bumpUserSettingCounter(issue.senderId, "cfh_abusive");
this.updateTicketToMods(issue);
this.removeTicket(issue);
@@ -737,4 +741,38 @@ public class ModToolManager {
return issues;
}
/**
* Increments a single integer counter on `users_settings` for the
* given user. Used by the moderation sanction handlers to bump the
* legacy counters that `ModToolUserInfoComposer` surfaces (cfh_warnings,
* cfh_bans, cfh_abusive, tradelock_amount) — historically these were
* only ever incremented by the CFH submission path, so a user could
* accumulate any number of bans/mutes without the User Info table
* reflecting it.
*
* Restricted to a whitelisted column name to keep the dynamic SQL
* safe; the caller passes a Permission-style constant.
*/
public static void bumpUserSettingCounter(int userId, String column) {
switch (column) {
case "cfh_warnings":
case "cfh_bans":
case "cfh_abusive":
case "tradelock_amount":
break;
default:
LOGGER.warn("Refusing to bump unrecognized user_settings column: {}", column);
return;
}
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"UPDATE users_settings SET " + column + " = " + column + " + 1 WHERE user_id = ?")) {
statement.setInt(1, userId);
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Caught SQL exception bumping {} for user {}", column, userId, e);
}
}
}
@@ -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);
@@ -95,7 +95,7 @@ public class HabboManager {
int userId = 0;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) {
PreparedStatement statement = connection.prepareStatement("SELECT id FROM users WHERE auth_ticket = ? AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW()) LIMIT 1")) {
statement.setString(1, sso);
try (ResultSet s = statement.executeQuery()) {
if (s.next()) {
@@ -121,7 +121,7 @@ public class HabboManager {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT * FROM users WHERE auth_ticket = ? LIMIT 1")) {
PreparedStatement statement = connection.prepareStatement("SELECT * FROM users WHERE auth_ticket = ? AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW()) LIMIT 1")) {
statement.setString(1, sso);
try (ResultSet set = statement.executeQuery()) {
if (set.next()) {
@@ -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).");
}
@@ -13,107 +13,317 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
/**
* Manages reading and writing of FurnitureData.json entries.
* Resolves the file path from emulator config keys.
* Manages reading and writing of FurnitureData entries.
*
* Accepts both legacy single-file layouts (FurnitureData.json) and the split
* directory layout introduced by the split-aware loader on the Nitro V3 side:
*
* <base>/
* manifest.json5 OPTIONAL { "tiers": ["core", "custom", "seasonal"] }
* core/manifest.json5 REQUIRED { "files": ["floor-001.json5", ...] }
* core/*.json5
* custom/manifest.json5 OPTIONAL
* seasonal/manifest.json5 OPTIONAL
*
* The path is resolved from the emulator config:
*
* furni.editor.renderer.config.path -> renderer-config.json (read for the
* furnidata.url value)
* furni.editor.asset.base.path -> filesystem base used to derive the
* local path from an http(s) URL
*/
public class FurniDataManager {
private static final Logger LOGGER = LoggerFactory.getLogger(FurniDataManager.class);
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
private static final List<String> SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes");
/**
* Get the JSON string for a specific item from FurnitureData.json.
* Get the JSON string for a specific item.
* Returns "{}" if not found or on error.
*/
public static String getItemJson(int itemId) {
try {
Path furniDataPath = resolveFurniDataPath();
if (furniDataPath == null || !Files.exists(furniDataPath)) {
return "{}";
ResolvedSource source = resolveSource();
if (source == null) return "{}";
if (source.directory) {
return findItemInSplitDir(source.path, itemId);
}
String content = Files.readString(furniDataPath, StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(content).getAsJsonObject();
if (!Files.exists(source.path)) return "{}";
// Search in both "roomitemtypes" and "wallitemtypes"
for (String section : new String[]{"roomitemtypes", "wallitemtypes"}) {
if (!root.has(section)) continue;
JsonObject sectionObj = root.getAsJsonObject(section);
if (!sectionObj.has("furnitype")) continue;
JsonArray types = sectionObj.getAsJsonArray("furnitype");
for (JsonElement el : types) {
JsonObject obj = el.getAsJsonObject();
if (obj.has("id") && obj.get("id").getAsInt() == itemId) {
return obj.toString();
}
}
}
String content = readJson5(source.path);
return findItemInRoot(JsonParser.parseString(content).getAsJsonObject(), itemId);
} catch (Exception e) {
LOGGER.warn("Failed to read FurnitureData.json for item " + itemId, e);
LOGGER.warn("Failed to read FurnitureData for item " + itemId, e);
}
return "{}";
}
private static String findItemInRoot(JsonObject root, int itemId) {
for (String section : SECTIONS) {
if (!root.has(section)) continue;
JsonObject sectionObj = root.getAsJsonObject(section);
if (!sectionObj.has("furnitype")) continue;
JsonArray types = sectionObj.getAsJsonArray("furnitype");
for (JsonElement el : types) {
JsonObject obj = el.getAsJsonObject();
if (obj.has("id") && obj.get("id").getAsInt() == itemId) {
return obj.toString();
}
}
}
return null;
}
/**
* Resolve the path to FurnitureData.json from emulator config.
* Walk the split directory layout looking for an item by id.
* Later tiers (custom, then seasonal) override earlier ones.
*/
private static Path resolveFurniDataPath() {
private static String findItemInSplitDir(Path baseDir, int itemId) {
if (!Files.isDirectory(baseDir)) return "{}";
List<String> tiers = readTiersManifest(baseDir);
String found = null;
for (String tier : tiers) {
Path tierDir = baseDir.resolve(tier);
if (!Files.isDirectory(tierDir)) continue;
List<String> files = readFilesManifest(tierDir);
for (String fileName : files) {
Path file = tierDir.resolve(fileName);
if (!Files.exists(file)) continue;
try {
String content = readJson5(file);
JsonObject obj = JsonParser.parseString(content).getAsJsonObject();
String match = findItemInRoot(obj, itemId);
if (match != null) found = match;
} catch (Exception e) {
LOGGER.warn("Failed to parse split gamedata file " + file, e);
}
}
}
return found != null ? found : "{}";
}
@SuppressWarnings("unchecked")
private static List<String> readTiersManifest(Path baseDir) {
Path manifest = firstExisting(baseDir, MANIFEST_NAMES);
if (manifest == null) return DEFAULT_TIERS;
try {
String content = readJson5(manifest);
JsonObject obj = JsonParser.parseString(content).getAsJsonObject();
if (obj.has("tiers") && obj.get("tiers").isJsonArray()) {
JsonArray arr = obj.getAsJsonArray("tiers");
List<String> out = new java.util.ArrayList<>();
for (JsonElement el : arr) out.add(el.getAsString());
if (!out.isEmpty()) return out;
}
} catch (Exception e) {
LOGGER.warn("Failed to read root manifest " + manifest + ", falling back to default tiers", e);
}
return DEFAULT_TIERS;
}
private static List<String> readFilesManifest(Path tierDir) {
Path manifest = firstExisting(tierDir, MANIFEST_NAMES);
if (manifest == null) return java.util.Collections.emptyList();
try {
String content = readJson5(manifest);
JsonObject obj = JsonParser.parseString(content).getAsJsonObject();
if (obj.has("files") && obj.get("files").isJsonArray()) {
JsonArray arr = obj.getAsJsonArray("files");
List<String> out = new java.util.ArrayList<>();
for (JsonElement el : arr) out.add(el.getAsString());
return out;
}
} catch (Exception e) {
LOGGER.warn("Failed to read tier manifest " + manifest, e);
}
return java.util.Collections.emptyList();
}
private static Path firstExisting(Path dir, List<String> names) {
for (String name : names) {
Path p = dir.resolve(name);
if (Files.exists(p)) return p;
}
return null;
}
/**
* Read a JSON or JSON5 file. Strips line and block comments and trailing
* commas so Gson can parse the result. String contents are preserved
* verbatim; comments embedded inside strings are not removed.
*/
private static String readJson5(Path path) throws IOException {
String raw = Files.readString(path, StandardCharsets.UTF_8);
return stripJson5(raw);
}
static String stripJson5(String content) {
if (content == null || content.isEmpty()) return content;
StringBuilder out = new StringBuilder(content.length());
int i = 0;
int len = content.length();
boolean inString = false;
char stringChar = 0;
boolean escape = false;
while (i < len) {
char c = content.charAt(i);
if (inString) {
out.append(c);
if (escape) {
escape = false;
} else if (c == '\\') {
escape = true;
} else if (c == stringChar) {
inString = false;
}
i++;
continue;
}
if (c == '"' || c == '\'') {
inString = true;
stringChar = c;
out.append(c);
i++;
continue;
}
if (c == '/' && i + 1 < len) {
char next = content.charAt(i + 1);
if (next == '/') {
int eol = content.indexOf('\n', i + 2);
if (eol < 0) { i = len; break; }
i = eol;
continue;
}
if (next == '*') {
int end = content.indexOf("*/", i + 2);
if (end < 0) { i = len; break; }
i = end + 2;
continue;
}
}
out.append(c);
i++;
}
String stripped = out.toString();
// Remove trailing commas before } or ]
stripped = stripped.replaceAll(",(\\s*[}\\]])", "$1");
return stripped;
}
/**
* Represents the resolved location of the furnidata source: either a single
* file or a directory in split-layout mode.
*/
private static class ResolvedSource {
final Path path;
final boolean directory;
ResolvedSource(Path path, boolean directory) {
this.path = path;
this.directory = directory;
}
}
/**
* Resolve the location of the furnidata source. Returns null if no
* candidate can be found.
*/
private static ResolvedSource resolveSource() {
try {
String configPath = Emulator.getConfig().getValue("furni.editor.renderer.config.path", "");
if (configPath.isEmpty()) {
// Fallback: try common locations
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (!basePath.isEmpty()) {
Path candidate = Paths.get(basePath, "FurnitureData.json");
if (Files.exists(candidate)) return candidate;
}
return null;
Path fallback = fallbackToBasePath();
return fallback != null ? new ResolvedSource(fallback, Files.isDirectory(fallback)) : null;
}
// Read the renderer config to find the furnidata URL/path
Path rendererConfig = Paths.get(configPath);
if (!Files.exists(rendererConfig)) return null;
String rendererContent = Files.readString(rendererConfig, StandardCharsets.UTF_8);
String rendererContent = readJson5(rendererConfig);
JsonObject rendererObj = JsonParser.parseString(rendererContent).getAsJsonObject();
if (rendererObj.has("furnidata.url")) {
String furniUrl = rendererObj.get("furnidata.url").getAsString();
if (!rendererObj.has("furnidata.url")) return null;
// Skip unresolved placeholders like ${gamedata.url}
if (furniUrl.contains("${")) {
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (!basePath.isEmpty()) {
Path candidate = Paths.get(basePath, "FurnitureData.json");
if (Files.exists(candidate)) return candidate;
}
return null;
}
String furniUrl = rendererObj.get("furnidata.url").getAsString();
// Strip query string (?v=1 etc.)
String cleanUrl = furniUrl.contains("?") ? furniUrl.substring(0, furniUrl.indexOf('?')) : furniUrl;
// If it's a local file path (not http), use it directly
if (!cleanUrl.startsWith("http")) {
return Paths.get(cleanUrl);
}
// For http URLs, try to derive local path from base path
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (!basePath.isEmpty()) {
// Extract filename from URL (without query string)
String filename = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1);
return Paths.get(basePath, filename);
}
if (furniUrl.contains("${")) {
Path fallback = fallbackToBasePath();
return fallback != null ? new ResolvedSource(fallback, Files.isDirectory(fallback)) : null;
}
// Strip query string and fragment (e.g. ?v=123 or #anchor)
String cleanUrl = furniUrl;
int q = cleanUrl.indexOf('?');
if (q >= 0) cleanUrl = cleanUrl.substring(0, q);
int h = cleanUrl.indexOf('#');
if (h >= 0) cleanUrl = cleanUrl.substring(0, h);
boolean splitMode = cleanUrl.endsWith("/");
// Local file path (not http) — return as-is, the caller will check
// whether it points at a file or a directory.
if (!cleanUrl.startsWith("http")) {
Path local = Paths.get(cleanUrl);
return new ResolvedSource(local, splitMode || Files.isDirectory(local));
}
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (basePath.isEmpty()) return null;
if (splitMode) {
// Derive the directory name from the URL: take the last non-empty
// segment before the trailing slash. e.g. https://x/y/furnidata/ -> "furnidata"
String trimmed = cleanUrl.endsWith("/") ? cleanUrl.substring(0, cleanUrl.length() - 1) : cleanUrl;
String dirName = trimmed.substring(trimmed.lastIndexOf('/') + 1);
Path candidate = Paths.get(basePath, dirName);
return new ResolvedSource(candidate, true);
}
String filename = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1);
Path candidate = Paths.get(basePath, filename);
return new ResolvedSource(candidate, false);
} catch (Exception e) {
LOGGER.warn("Failed to resolve FurnitureData.json path", e);
LOGGER.warn("Failed to resolve FurnitureData source", e);
}
return null;
}
private static Path fallbackToBasePath() {
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (basePath.isEmpty()) return null;
Path dir = Paths.get(basePath);
// Prefer the split layout if it exists, then the legacy file.
Path splitCandidate = dir.resolve("furnidata");
if (Files.isDirectory(splitCandidate)) return splitCandidate;
Path legacy = dir.resolve("FurnitureData.json");
if (Files.exists(legacy)) return legacy;
return null;
}
}
@@ -104,7 +104,7 @@ public class SecureLoginEvent extends MessageHandler {
// First, look up the user ID to check for ghost sessions
int lookupUserId = 0;
try (java.sql.Connection conn = Emulator.getDatabase().getDataSource().getConnection();
java.sql.PreparedStatement stmt = conn.prepareStatement("SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) {
java.sql.PreparedStatement stmt = conn.prepareStatement("SELECT id FROM users WHERE auth_ticket = ? AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW()) LIMIT 1")) {
stmt.setString(1, sso);
try (java.sql.ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
@@ -1,6 +1,7 @@
package com.eu.habbo.messages.incoming.modtool;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.modtool.ModToolManager;
import com.eu.habbo.habbohotel.modtool.ModToolSanctionItem;
import com.eu.habbo.habbohotel.modtool.ModToolSanctions;
import com.eu.habbo.habbohotel.permissions.Permission;
@@ -47,6 +48,8 @@ public class ModToolSanctionAlertEvent extends MessageHandler {
} else {
habbo.alert(message);
}
ModToolManager.bumpUserSettingCounter(userId, "cfh_warnings");
} else {
this.client.sendResponse(new ModToolIssueHandledComposer(Emulator.getTexts().getValue("generic.user.not_found").replace("%user%", Emulator.getConfig().getValue("hotel.player.name"))));
}
@@ -2,6 +2,7 @@ package com.eu.habbo.messages.incoming.modtool;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.modtool.ModToolBanType;
import com.eu.habbo.habbohotel.modtool.ModToolManager;
import com.eu.habbo.habbohotel.modtool.ModToolSanctionItem;
import com.eu.habbo.habbohotel.modtool.ModToolSanctions;
import com.eu.habbo.habbohotel.modtool.ScripterManager;
@@ -73,6 +74,7 @@ public class ModToolSanctionBanEvent extends MessageHandler {
Emulator.getGameEnvironment().getModToolManager().ban(userId, this.client.getHabbo(), message, duration, ModToolBanType.ACCOUNT, cfhTopic);
}
ModToolManager.bumpUserSettingCounter(userId, "cfh_bans");
} else {
ScripterManager.scripterDetected(this.client, Emulator.getTexts().getValue("scripter.warning.modtools.ban").replace("%username%", this.client.getHabbo().getHabboInfo().getUsername()));
}
@@ -1,6 +1,7 @@
package com.eu.habbo.messages.incoming.modtool;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.modtool.ModToolManager;
import com.eu.habbo.habbohotel.modtool.ModToolSanctionItem;
import com.eu.habbo.habbohotel.modtool.ModToolSanctionLevelItem;
import com.eu.habbo.habbohotel.modtool.ModToolSanctions;
@@ -59,6 +60,8 @@ public class ModToolSanctionMuteEvent extends MessageHandler {
habbo.alert(message);
this.client.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_mute.muted").replace("%user%", habbo.getHabboInfo().getUsername()));
}
ModToolManager.bumpUserSettingCounter(userId, "cfh_warnings");
} else {
this.client.sendResponse(new ModToolIssueHandledComposer(Emulator.getTexts().getValue("generic.user.not_found").replace("%user%", Emulator.getConfig().getValue("hotel.player.name"))));
}
@@ -1,6 +1,7 @@
package com.eu.habbo.messages.incoming.modtool;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.modtool.ModToolManager;
import com.eu.habbo.habbohotel.modtool.ModToolSanctionItem;
import com.eu.habbo.habbohotel.modtool.ModToolSanctions;
import com.eu.habbo.habbohotel.permissions.Permission;
@@ -49,6 +50,8 @@ public class ModToolSanctionTradeLockEvent extends MessageHandler {
habbo.getHabboStats().setAllowTrade(false);
habbo.alert(message);
}
ModToolManager.bumpUserSettingCounter(userId, "tradelock_amount");
} else {
this.client.sendResponse(new ModToolIssueHandledComposer(Emulator.getTexts().getValue("generic.user.not_found").replace("%user%", Emulator.getConfig().getValue("hotel.player.name"))));
}
@@ -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) {
@@ -12,10 +12,13 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.*;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
public class ModToolUserInfoComposer extends MessageComposer {
private static final Logger LOGGER = LoggerFactory.getLogger(ModToolUserInfoComposer.class);
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
private final ResultSet set;
private final boolean hideMail;
@@ -29,37 +32,30 @@ public class ModToolUserInfoComposer extends MessageComposer {
protected ServerMessage composeInternal() {
this.response.init(Outgoing.ModToolUserInfoComposer);
try {
int totalBans = 0;
int userId = this.set.getInt("user_id");
String machineId = this.set.getString("machine_id");
int now = Emulator.getIntUnixTimestamp();
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) AS amount FROM bans WHERE user_id = ?")) {
statement.setInt(1, this.set.getInt("user_id"));
try (ResultSet set = statement.executeQuery()) {
if (set.next()) {
totalBans = set.getInt("amount");
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
int totalBans = countBansForUser(userId);
int lastPurchaseTimestamp = fetchLastPurchaseTimestamp(userId);
int tradeLockExpiryTimestamp = fetchActiveTradeLockExpiry(userId, now);
int identityRelatedBanCount = countIdentityRelatedBans(userId, machineId);
this.response.appendInt(this.set.getInt("user_id"));
this.response.appendInt(userId);
this.response.appendString(this.set.getString("username"));
this.response.appendString(this.set.getString("look"));
this.response.appendInt((Emulator.getIntUnixTimestamp() - this.set.getInt("account_created")) / 60);
this.response.appendInt((this.set.getInt("online") == 1 ? 0 : Emulator.getIntUnixTimestamp() - this.set.getInt("last_online")) / 60);
this.response.appendInt((now - this.set.getInt("account_created")) / 60);
this.response.appendInt((this.set.getInt("online") == 1 ? 0 : now - this.set.getInt("last_online")) / 60);
this.response.appendBoolean(this.set.getInt("online") == 1);
this.response.appendInt(this.set.getInt("cfh_send"));
this.response.appendInt(this.set.getInt("cfh_abusive"));
this.response.appendInt(this.set.getInt("cfh_warnings"));
this.response.appendInt(totalBans); // Number of bans
this.response.appendInt(this.set.getInt("tradelock_amount"));
this.response.appendString(""); //Trading lock expiry timestamp
this.response.appendString(""); //Last Purchase Timestamp
this.response.appendInt(this.set.getInt("user_id")); //Personal Identification #
this.response.appendInt(0); // Number of account bans
this.response.appendString(formatUnixTimestamp(tradeLockExpiryTimestamp)); // Trading lock expiry timestamp
this.response.appendString(formatUnixTimestamp(lastPurchaseTimestamp)); // Last Purchase Timestamp
this.response.appendInt(userId); //Personal Identification #
this.response.appendInt(identityRelatedBanCount); // Number of account bans on the same machine_id
this.response.appendString(this.hideMail ? "" : this.set.getString("mail"));
this.response.appendString("Rank (" + this.set.getInt("rank_id") + "): " + this.set.getString("rank_name")); //user_class_txt
@@ -90,4 +86,87 @@ public class ModToolUserInfoComposer extends MessageComposer {
public ResultSet getSet() {
return set;
}
private static int countBansForUser(int userId) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) AS amount FROM bans WHERE user_id = ?")) {
statement.setInt(1, userId);
try (ResultSet set = statement.executeQuery()) {
if (set.next()) return set.getInt("amount");
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
return 0;
}
/**
* Most recent purchase timestamp from logs_shop_purchases for this
* user. Returns 0 when the user has never bought anything (in which
* case the wire field stays empty and the client shows the empty
* placeholder).
*/
private static int fetchLastPurchaseTimestamp(int userId) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT MAX(`timestamp`) AS ts FROM logs_shop_purchases WHERE user_id = ?")) {
statement.setInt(1, userId);
try (ResultSet set = statement.executeQuery()) {
if (set.next()) return set.getInt("ts");
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
return 0;
}
/**
* Latest active trade-lock expiry from the sanctions table. Only
* locks expiring in the future are considered past entries don't
* count. Returns 0 when no active lock exists.
*/
private static int fetchActiveTradeLockExpiry(int userId, int now) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT MAX(trade_locked_until) AS expiry FROM sanctions WHERE habbo_id = ? AND trade_locked_until > ?")) {
statement.setInt(1, userId);
statement.setInt(2, now);
try (ResultSet set = statement.executeQuery()) {
if (set.next()) return set.getInt("expiry");
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
return 0;
}
/**
* Count of OTHER user accounts that have been banned from the same
* machine_id as this user. An empty machine_id (default '') is
* ignored never matches anything by definition. Self is excluded
* because the user's own bans are already counted under banCount.
*/
private static int countIdentityRelatedBans(int userId, String machineId) {
if (machineId == null || machineId.isEmpty()) return 0;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT COUNT(DISTINCT user_id) AS amount FROM bans WHERE machine_id = ? AND user_id != ?")) {
statement.setString(1, machineId);
statement.setInt(2, userId);
try (ResultSet set = statement.executeQuery()) {
if (set.next()) return set.getInt("amount");
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
return 0;
}
/**
* Wire format for date fields is `yyyy-MM-dd HH:mm`. A 0 timestamp
* is rendered as an empty string so the client falls back to its
* empty-state placeholder.
*/
private static String formatUnixTimestamp(int timestamp) {
if (timestamp <= 0) return "";
return DATE_FORMAT.format(new Date(timestamp * 1000L));
}
}
@@ -62,6 +62,7 @@ public class RoomPetComposer extends MessageComposer implements TIntObjectProced
this.response.appendString("");
this.response.appendString("unknown");
this.response.appendInt(0);
this.response.appendInt(0);
return true;
}
@@ -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;
}
@@ -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;
@@ -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<String, Permission> rankPermissions = rank.getPermissions();
Map<String, Integer> resolved = new LinkedHashMap<>(rankPermissions.size());
for (Map.Entry<String, Permission> 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<String, Integer> 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;
}
@@ -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<SocketChanne
ch.pipeline().addLast("nitroSecureApiHandler", new NitroSecureApiHandler());
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
ch.pipeline().addLast("badgeHttpHandler", new BadgeHttpHandler());
ch.pipeline().addLast("badgeLeaderboardHttpHandler", new BadgeLeaderboardHttpHandler());
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
ch.pipeline().addLast("wsFrameAggregator", new WebSocketFrameAggregator(MAX_FRAME_SIZE));
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
@@ -50,7 +50,7 @@ final class SessionEndpoints {
if (ssoTicket != null && !ssoTicket.isEmpty()) {
try (PreparedStatement lookup = conn.prepareStatement(
"SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) {
"SELECT id FROM users WHERE auth_ticket = ? AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW()) LIMIT 1")) {
lookup.setString(1, ssoTicket);
try (ResultSet rs = lookup.executeQuery()) {
if (rs.next()) userId = rs.getInt("id");
@@ -134,7 +134,7 @@ final class SessionEndpoints {
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement lookup = conn.prepareStatement(
"SELECT id, username FROM users WHERE auth_ticket = ? LIMIT 1")) {
"SELECT id, username FROM users WHERE auth_ticket = ? AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW()) LIMIT 1")) {
lookup.setString(1, ssoTicket);
try (ResultSet rs = lookup.executeQuery()) {
if (!rs.next()) {
@@ -0,0 +1,458 @@
package com.eu.habbo.networking.gameserver.badges;
import com.eu.habbo.Emulator;
import com.eu.habbo.networking.gameserver.auth.AccessTokenService;
import com.eu.habbo.networking.gameserver.auth.CorsOriginGate;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.*;
import io.netty.util.ReferenceCountUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.*;
public class BadgeLeaderboardHttpHandler extends ChannelInboundHandlerAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(BadgeLeaderboardHttpHandler.class);
private static final String BASE_PATH = "/api/badges/leaderboard";
private static final long CACHE_TTL_MS = 15_000L;
private static final int MAX_BOARD_USERS = 100;
private static volatile Snapshot cache = null;
private static final class Snapshot {
final List<UserBadgeAggregate> badgeUsers;
final List<UserAchievementAggregate> achievementUsers;
final JsonArray badgeStats;
final long expiresAt;
Snapshot(List<UserBadgeAggregate> badgeUsers, List<UserAchievementAggregate> 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<Rarity, Integer> counts;
UserBadgeAggregate(int userId, String username, String figure, int totalBadges, EnumMap<Rarity, Integer> 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<UserBadgeAggregate> badgeUsers = new ArrayList<>();
List<UserAchievementAggregate> 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<UserBadgeAggregate> 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<Rarity, Integer> 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<UserAchievementAggregate> 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<UserBadgeAggregate> users, int viewerUserId, ViewerProfile viewerProfile, Rarity rarity) {
List<JsonObject> 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<UserAchievementAggregate> users, int viewerUserId, ViewerProfile viewerProfile) {
List<JsonObject> 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<JsonObject> 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);
}
}
@@ -8,10 +8,13 @@ import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.*;
import io.netty.util.ReferenceCountUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(WebSocketHttpHandler.class);
private static final String ORIGIN_HEADER = "Origin";
@Override
@@ -27,6 +30,12 @@ public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter {
}
private boolean handleHttpRequest(ChannelHandlerContext ctx, HttpMessage req) {
captureForwardedIp(ctx, req);
if (!isWebSocketUpgrade(req)) {
return true;
}
String origin = "error";
try {
@@ -38,27 +47,47 @@ public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter {
String whitelist = Emulator.getConfig().getValue("ws.whitelist", "localhost");
if (!isWhitelisted(origin, whitelist.split(","))) {
LOGGER.warn("WebSocket upgrade rejected — origin '{}' not in ws.whitelist='{}'",
req.headers().get(ORIGIN_HEADER), whitelist);
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.FORBIDDEN,
Unpooled.wrappedBuffer("Origin forbidden".getBytes())
);
response.headers().set("Vary", "Origin");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
return false;
}
String ipHeader = Emulator.getConfig().getValue("ws.ip.header", "");
if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) {
String ip = req.headers().get(ipHeader);
ctx.channel().attr(GameServerAttributes.WS_IP).set(ip);
}
return true;
}
private static void captureForwardedIp(ChannelHandlerContext ctx, HttpMessage req) {
String ipHeader = Emulator.getConfig().getValue("ws.ip.header", "");
if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) {
String ip = req.headers().get(ipHeader);
ctx.channel().attr(GameServerAttributes.WS_IP).set(ip);
}
}
private static boolean isWebSocketUpgrade(HttpMessage req) {
String upgrade = req.headers().get(HttpHeaderNames.UPGRADE);
if (upgrade == null || !"websocket".equalsIgnoreCase(upgrade)) return false;
String connection = req.headers().get(HttpHeaderNames.CONNECTION);
if (connection == null) return false;
for (String token : connection.split(",")) {
if ("upgrade".equalsIgnoreCase(token.trim())) return true;
}
return false;
}
private static String getDomainNameFromUrl(String url) throws Exception {
URI uri = new URI(url);
String domain = uri.getHost();
if (domain == null) return "error";
return domain.startsWith("www.") ? domain.substring(4) : domain;
}
+9 -10
View File
@@ -27,16 +27,6 @@ rcon.host=127.0.0.1
rcon.port=3001
rcon.allowed=127.0.0.1;127.0.0.2
#WebSocket Configuration (for Nitro)
#Set ws.enabled to true to enable WebSocket connections.
ws.enabled=false
ws.host=0.0.0.0
ws.port=2096
#Comma-separated whitelist of allowed origins. Supports wildcards: *.example.com, * (allow all)
ws.whitelist=localhost
#Header name for real client IP when behind a proxy (e.g., X-Forwarded-For, CF-Connecting-IP). Leave empty if not using a proxy.
ws.ip.header=
# Databse configuration
db.pool.connection_timeout_ms = 10000
db.pool.idle_timeout_ms = 600000
@@ -69,3 +59,12 @@ login.remember.jwt.secret=
# Login news API.
login.news.limit=5
#WebSocket Configuration (for Nitro)
#Please adjust this setting in the Database !!!!
### ws.enabled=false
### ws.host=0.0.0.0
### ws.port=2096
### ws.whitelist=localhost #Comma-separated whitelist of allowed origins. Supports wildcards: *.example.com, * (allow all)
### ws.ip.header=X-Forwarded-For #Header name for real client IP when behind a proxy (e.g., X-Forwarded-For, CF-Connecting-IP). Leave empty if not using a proxy.