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 5bd22892..c17786aa 100644
--- a/Emulator/pom.xml
+++ b/Emulator/pom.xml
@@ -6,7 +6,7 @@
com.eu.habbo
Habbo
- 4.1.16
+ 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/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;
+ }
}
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()) {