From 53b7dba185a3bc47c34b605d364e8c3702486a02 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Mon, 18 May 2026 22:00:16 +0200 Subject: [PATCH 1/2] feat(furnieditor): split-aware FurniDataManager + JSON5 tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Emulator/pom.xml | 2 +- .../furnieditor/FurniDataManager.java | 332 ++++++++++++++---- 2 files changed, 272 insertions(+), 62 deletions(-) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 5bd22892..b3729627 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -6,7 +6,7 @@ com.eu.habbo Habbo - 4.1.16 + 4.2.6 UTF-8 diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManager.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManager.java index 8035bb9b..eebdfc0f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManager.java @@ -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: + * + * / + * 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 DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal"); + private static final List MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json"); + private static final List 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 tiers = readTiersManifest(baseDir); + String found = null; + + for (String tier : tiers) { + Path tierDir = baseDir.resolve(tier); + if (!Files.isDirectory(tierDir)) continue; + + List 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 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 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 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 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 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; + } } From e334a3e0ac11678bfaa3672542853b8b4839490b Mon Sep 17 00:00:00 2001 From: medievalshell Date: Tue, 19 May 2026 00:46:58 +0200 Subject: [PATCH 2/2] feat(auth): backward-compatible TTL check on SSO auth_ticket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../020_auth_ticket_ttl.sql | 36 +++++++++++++++++++ Default Database/FullDatabase.sql | 1 + Emulator/pom.xml | 2 +- .../habbo/habbohotel/users/HabboManager.java | 4 +-- .../incoming/handshake/SecureLoginEvent.java | 2 +- .../gameserver/auth/SessionEndpoints.java | 4 +-- 6 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 Database Updates/Own_Database_RunFirst/020_auth_ticket_ttl.sql diff --git a/Database Updates/Own_Database_RunFirst/020_auth_ticket_ttl.sql b/Database Updates/Own_Database_RunFirst/020_auth_ticket_ttl.sql new file mode 100644 index 00000000..ac4d1fd8 --- /dev/null +++ b/Database Updates/Own_Database_RunFirst/020_auth_ticket_ttl.sql @@ -0,0 +1,36 @@ +-- ============================================================================ +-- 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; diff --git a/Default Database/FullDatabase.sql b/Default Database/FullDatabase.sql index 2351adde..75a3b75b 100644 --- a/Default Database/FullDatabase.sql +++ b/Default Database/FullDatabase.sql @@ -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, diff --git a/Emulator/pom.xml b/Emulator/pom.xml index b3729627..c17786aa 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -6,7 +6,7 @@ com.eu.habbo Habbo - 4.2.6 + 4.2.7 UTF-8 diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java index 14fd99bf..4ca99732 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/HabboManager.java @@ -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()) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java index 13d47c28..076a6795 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java @@ -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()) { diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SessionEndpoints.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SessionEndpoints.java index 931b5b89..32f355be 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SessionEndpoints.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/SessionEndpoints.java @@ -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()) {