From f9644d83b75d5edfa36fd8c26e0c437606004f4e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 20:45:10 +0200 Subject: [PATCH 01/33] test: add JUnit 5 + surefire harness --- Emulator/pom.xml | 16 +++++++++++++++- .../src/test/java/com/eu/habbo/SmokeTest.java | 11 +++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/SmokeTest.java diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 3d2eec4c..9cdbb427 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -62,6 +62,12 @@ public + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + @@ -172,12 +178,20 @@ 0.4 - org.eclipse.angus jakarta.mail 2.0.3 + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + diff --git a/Emulator/src/test/java/com/eu/habbo/SmokeTest.java b/Emulator/src/test/java/com/eu/habbo/SmokeTest.java new file mode 100644 index 00000000..15b142ee --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/SmokeTest.java @@ -0,0 +1,11 @@ +package com.eu.habbo; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SmokeTest { + @Test + void harnessRuns() { + assertTrue(true); + } +} From 964f3885947516136debe1b3a7eb49336e24ccbb Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 20:46:52 +0200 Subject: [PATCH 02/33] feat(items): FurnidataEntry record --- .../com/eu/habbo/habbohotel/items/FurnidataEntry.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntry.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntry.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntry.java new file mode 100644 index 00000000..bd273872 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntry.java @@ -0,0 +1,8 @@ +package com.eu.habbo.habbohotel.items; + +/** + * One parsed furnidata entry. {@code classname} is the raw furnidata classname + * (may carry a {@code *N} colour-variant suffix); the provider keys on the base. + */ +public record FurnidataEntry(int id, String classname, FurnitureType type, String name, String description) { +} From 86498b6b4c91ffe3b0a549288782e4f584fb43e1 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 20:50:22 +0200 Subject: [PATCH 03/33] feat(items): FurnidataReader (single + split JSON5, path-guard, size-cap, fail-safe) --- .../habbohotel/items/FurnidataReader.java | 164 ++++++++++++++++++ .../habbohotel/items/FurnidataReaderTest.java | 74 ++++++++ 2 files changed, 238 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java new file mode 100644 index 00000000..d3b753d8 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java @@ -0,0 +1,164 @@ +package com.eu.habbo.habbohotel.items; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Neutral furnidata reader. Supports a single JSON/JSON5 file or a split-tier + * directory ({@code core/custom/seasonal} with {@code manifest.json(5)}). + * Never throws: any IO/parse error yields an empty list (the caller decides the + * fallback). All resolved paths are guarded against escaping the base dir. + */ +public class FurnidataReader { + + private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataReader.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"); + + private final Path source; + private final long maxBytes; + + public FurnidataReader(Path source, long maxBytes) { + this.source = source; + this.maxBytes = maxBytes; + } + + public List read() { + List out = new ArrayList<>(); + try { + if (this.source == null || !Files.exists(this.source)) return out; + + if (Files.isDirectory(this.source)) { + readSplitDir(this.source, out); + } else { + String content = readJson5Capped(this.source); + if (content != null) { + parseRoot(JsonParser.parseString(content).getAsJsonObject(), out); + } + } + } catch (Exception e) { + LOGGER.warn("FurnidataReader failed to read {} — returning empty", this.source, e); + return new ArrayList<>(); + } + return out; + } + + private void readSplitDir(Path base, List out) { + List tiers = readManifestList(base, "tiers", DEFAULT_TIERS); + Path baseNorm = base.toAbsolutePath().normalize(); + + for (String tier : tiers) { + Path tierDir = base.resolve(tier); + if (!isInside(baseNorm, tierDir) || !Files.isDirectory(tierDir)) continue; + + for (String fileName : readManifestList(tierDir, "files", List.of())) { + Path file = tierDir.resolve(fileName); + if (!isInside(baseNorm, file)) { + LOGGER.warn("FurnidataReader: ignoring out-of-base file {}", file); + continue; + } + if (!Files.exists(file)) continue; + try { + String content = readJson5Capped(file); + if (content != null) parseRoot(JsonParser.parseString(content).getAsJsonObject(), out); + } catch (Exception e) { + LOGGER.warn("FurnidataReader: failed to parse {}", file, e); + } + } + } + } + + private List readManifestList(Path dir, String key, List fallback) { + for (String name : MANIFEST_NAMES) { + Path m = dir.resolve(name); + if (!Files.exists(m)) continue; + try { + JsonObject obj = JsonParser.parseString(readJson5Capped(m)).getAsJsonObject(); + if (obj.has(key) && obj.get(key).isJsonArray()) { + List list = new ArrayList<>(); + for (JsonElement el : obj.getAsJsonArray(key)) list.add(el.getAsString()); + if (!list.isEmpty()) return list; + } + } catch (Exception e) { + LOGGER.warn("FurnidataReader: bad manifest {}", m, e); + } + } + return fallback; + } + + private void parseRoot(JsonObject root, List out) { + for (String section : SECTIONS) { + if (!root.has(section)) continue; + JsonObject sectionObj = root.getAsJsonObject(section); + if (!sectionObj.has("furnitype")) continue; + FurnitureType type = section.equals("roomitemtypes") ? FurnitureType.FLOOR : FurnitureType.WALL; + JsonArray types = sectionObj.getAsJsonArray("furnitype"); + for (JsonElement el : types) { + JsonObject o = el.getAsJsonObject(); + if (!o.has("id") || !o.has("classname")) continue; + out.add(new FurnidataEntry( + o.get("id").getAsInt(), + o.get("classname").getAsString(), + type, + o.has("name") ? o.get("name").getAsString() : "", + o.has("description") ? o.get("description").getAsString() : "" + )); + } + } + } + + /** Returns the JSON5-stripped content, or null if the file exceeds the byte cap. */ + private String readJson5Capped(Path path) throws Exception { + long size = Files.size(path); + if (size > this.maxBytes) { + LOGGER.warn("FurnidataReader: {} is {} bytes, over cap {} — refusing", path, size, this.maxBytes); + return null; + } + return stripJson5(Files.readString(path, StandardCharsets.UTF_8)); + } + + private static boolean isInside(Path baseNorm, Path candidate) { + return candidate.toAbsolutePath().normalize().startsWith(baseNorm); + } + + /** Strip // and block comments and trailing commas so Gson can parse JSON5. */ + static String stripJson5(String content) { + if (content == null || content.isEmpty()) return content; + StringBuilder out = new StringBuilder(content.length()); + int i = 0, len = content.length(); + boolean inString = false, escape = false; + char stringChar = 0; + 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) break; i = eol; continue; } + if (next == '*') { int end = content.indexOf("*/", i + 2); if (end < 0) break; i = end + 2; continue; } + } + out.append(c); + i++; + } + return out.toString().replaceAll(",(\\s*[}\\]])", "$1"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java new file mode 100644 index 00000000..a15e2c19 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java @@ -0,0 +1,74 @@ +package com.eu.habbo.habbohotel.items; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class FurnidataReaderTest { + + private static final String SINGLE = """ + { + // a comment + "roomitemtypes": { "furnitype": [ + { "id": 10, "classname": "chair_norja", "name": "Chair", "description": "Sit", "xdim": 1, "ydim": 1 }, + ]}, + "wallitemtypes": { "furnitype": [ + { "id": 20, "classname": "poster_5", "name": "Poster", "description": "Wall" } + ]} + } + """; + + @Test + void parsesSingleFileFloorAndWall(@TempDir Path dir) throws Exception { + Path file = dir.resolve("FurnitureData.json"); + Files.writeString(file, SINGLE); + + List entries = new FurnidataReader(file, 64 * 1024 * 1024).read(); + + assertEquals(2, entries.size()); + FurnidataEntry floor = entries.stream().filter(e -> e.id() == 10).findFirst().orElseThrow(); + assertEquals("chair_norja", floor.classname()); + assertEquals(FurnitureType.FLOOR, floor.type()); + assertEquals("Chair", floor.name()); + FurnidataEntry wall = entries.stream().filter(e -> e.id() == 20).findFirst().orElseThrow(); + assertEquals(FurnitureType.WALL, wall.type()); + } + + @Test + void rejectsFileOverSizeCap(@TempDir Path dir) throws Exception { + Path file = dir.resolve("FurnitureData.json"); + Files.writeString(file, SINGLE); + List entries = new FurnidataReader(file, 8 /* bytes */).read(); + assertTrue(entries.isEmpty(), "oversized file must be refused, returning empty"); + } + + @Test + void missingSourceReturnsEmptyNeverThrows(@TempDir Path dir) { + Path missing = dir.resolve("does-not-exist.json"); + assertDoesNotThrow(() -> { + assertTrue(new FurnidataReader(missing, 64 * 1024 * 1024).read().isEmpty()); + }); + } + + @Test + void splitDirRejectsTraversalFiles(@TempDir Path dir) throws Exception { + Path secret = dir.resolve("secret.json"); + Files.writeString(secret, "{ \"roomitemtypes\": { \"furnitype\": [ { \"id\": 99, \"classname\": \"x\", \"name\": \"LEAK\", \"description\": \"\" } ] } }"); + + Path base = dir.resolve("furnidata"); + Path core = base.resolve("core"); + Files.createDirectories(core); + Files.writeString(base.resolve("manifest.json"), "{ \"tiers\": [ \"core\" ] }"); + Files.writeString(core.resolve("manifest.json"), "{ \"files\": [ \"../../secret.json\" ] }"); + + List entries = new FurnidataReader(base, 64 * 1024 * 1024).read(); + + assertTrue(entries.stream().noneMatch(e -> e.id() == 99), + "traversal file outside the base dir must be ignored"); + } +} From b162b3f4d806ca577fe104ef6f9cfefe44f10846 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 20:54:13 +0200 Subject: [PATCH 04/33] fix(items): guard oversized manifest NPE in FurnidataReader + document JSON5 trailing-comma limit --- .../eu/habbo/habbohotel/items/FurnidataReader.java | 12 ++++++++++-- .../habbo/habbohotel/items/FurnidataReaderTest.java | 11 +++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java index d3b753d8..1af3dfc7 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java @@ -85,7 +85,9 @@ public class FurnidataReader { Path m = dir.resolve(name); if (!Files.exists(m)) continue; try { - JsonObject obj = JsonParser.parseString(readJson5Capped(m)).getAsJsonObject(); + String raw = readJson5Capped(m); + if (raw == null) continue; + JsonObject obj = JsonParser.parseString(raw).getAsJsonObject(); if (obj.has(key) && obj.get(key).isJsonArray()) { List list = new ArrayList<>(); for (JsonElement el : obj.getAsJsonArray(key)) list.add(el.getAsString()); @@ -133,7 +135,13 @@ public class FurnidataReader { return candidate.toAbsolutePath().normalize().startsWith(baseNorm); } - /** Strip // and block comments and trailing commas so Gson can parse JSON5. */ + /** + * Strip // and block comments and trailing commas so Gson can parse JSON5. + * Known limitation: the trailing-comma pass is a regex over the whole output, + * so a string value literally containing ",[whitespace]}" or ",[whitespace]]" + * would be altered. Real Habbo furnidata names/descriptions do not contain + * that pattern; values are additionally sanitized downstream before use. + */ static String stripJson5(String content) { if (content == null || content.isEmpty()) return content; StringBuilder out = new StringBuilder(content.length()); diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java index a15e2c19..acb1b3d2 100644 --- a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java @@ -55,6 +55,17 @@ class FurnidataReaderTest { }); } + @Test + void oversizedManifestIsSkippedNeverThrows(@TempDir Path dir) throws Exception { + Path base = dir.resolve("furnidata"); + Path core = base.resolve("core"); + Files.createDirectories(core); + // A root manifest larger than the cap we pass in. + Files.writeString(base.resolve("manifest.json"), "{ \"tiers\": [ \"core\" ] } // padding ".repeat(50)); + List entries = new FurnidataReader(base, 8 /* bytes */).read(); + assertTrue(entries.isEmpty()); + } + @Test void splitDirRejectsTraversalFiles(@TempDir Path dir) throws Exception { Path secret = dir.resolve("secret.json"); From 5bf1d42cfbc5c0e9abdc915e87653f40d42b449f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 20:57:44 +0200 Subject: [PATCH 05/33] =?UTF-8?q?feat(items):=20FurnitureTextProvider=20?= =?UTF-8?q?=E2=80=94=20volatile=20index,=20sanitize,=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../items/FurnitureTextProvider.java | 68 +++++++++++++++++++ .../items/FurnitureTextProviderTest.java | 61 +++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java new file mode 100644 index 00000000..634b8be0 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -0,0 +1,68 @@ +package com.eu.habbo.habbohotel.items; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * In-memory index of furnidata display names, keyed by the lowercased base + * classname (the {@code *N} colour-variant suffix is stripped). Read lazily by + * {@link Item#getDisplayName()}. Names are sanitized at index time. + * + * Thread-safety: the index is held behind a {@code volatile} reference; readers + * never block; {@link #reindex(List)} builds a fresh map and swaps it atomically. + */ +public class FurnitureTextProvider { + + private static final int MAX_LEN = 256; + + private final boolean enabled; + private volatile Map index = Map.of(); + + public FurnitureTextProvider(boolean enabled) { + this.enabled = enabled; + } + + /** Build a fresh sanitized index from the given entries and swap it in atomically. */ + public void reindex(List entries) { + Map next = new HashMap<>(Math.max(16, entries.size() * 2)); + for (FurnidataEntry e : entries) { + String key = baseKey(e.classname()); + if (key == null) continue; + next.put(key, new FurniText(e.id(), e.type(), sanitize(e.name()), sanitize(e.description()))); + } + this.index = next; // atomic reference swap + } + + /** Returns the sanitized display name for a DB classname, or null if absent/disabled. */ + public String getName(String classname) { + if (!this.enabled) return null; + String key = baseKey(classname); + if (key == null) return null; + FurniText t = this.index.get(key); + return (t != null) ? t.name() : null; + } + + private static String baseKey(String classname) { + if (classname == null) return null; + int star = classname.indexOf('*'); + String base = (star >= 0) ? classname.substring(0, star) : classname; + base = base.trim().toLowerCase(); + return base.isEmpty() ? null : base; + } + + /** Cap length, strip control chars/newlines, neutralize % (placeholder-injection safe). */ + static String sanitize(String value) { + if (value == null) return ""; + StringBuilder sb = new StringBuilder(Math.min(value.length(), MAX_LEN)); + for (int i = 0; i < value.length() && sb.length() < MAX_LEN; i++) { + char c = value.charAt(i); + if (c == '%') { sb.append('%'); continue; } // fullwidth percent — not a placeholder token + if (c == '\n' || c == '\r' || Character.isISOControl(c)) continue; + sb.append(c); + } + return sb.toString(); + } + + private record FurniText(int id, FurnitureType type, String name, String description) {} +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java new file mode 100644 index 00000000..5e510071 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java @@ -0,0 +1,61 @@ +package com.eu.habbo.habbohotel.items; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class FurnitureTextProviderTest { + + private FurnitureTextProvider provider(boolean enabled, FurnidataEntry... entries) { + FurnitureTextProvider p = new FurnitureTextProvider(enabled); + p.reindex(List.of(entries)); + return p; + } + + @Test + void resolvesNameByClassname() { + FurnitureTextProvider p = provider(true, + new FurnidataEntry(1, "chair_norja", FurnitureType.FLOOR, "Norja Chair", "Sit")); + assertEquals("Norja Chair", p.getName("chair_norja")); + } + + @Test + void matchesBaseClassnameIgnoringColourVariantAndCase() { + FurnitureTextProvider p = provider(true, + new FurnidataEntry(1, "chair_norja*2", FurnitureType.FLOOR, "Norja Chair", "Sit")); + assertEquals("Norja Chair", p.getName("CHAIR_NORJA")); + } + + @Test + void returnsNullWhenClassnameMissing() { + FurnitureTextProvider p = provider(true); + assertNull(p.getName("unknown_thing")); + } + + @Test + void returnsNullWhenDisabled() { + FurnitureTextProvider p = provider(false, + new FurnidataEntry(1, "chair_norja", FurnitureType.FLOOR, "Norja Chair", "Sit")); + assertNull(p.getName("chair_norja")); + } + + @Test + void sanitizesNameCapStripControlAndNeutralizesPercent() { + String evil = "Bad\nName %limit% %user.name%".repeat(20); + FurnitureTextProvider p = provider(true, + new FurnidataEntry(1, "x", FurnitureType.FLOOR, evil, "")); + String name = p.getName("x"); + assertTrue(name.length() <= 256, "must be capped to 256"); + assertFalse(name.chars().anyMatch(Character::isISOControl), "no control chars remain after sanitize"); + assertFalse(name.contains("%"), "ASCII percent neutralized"); + } + + @Test + void nullProviderNameNeverThrows() { + FurnitureTextProvider p = provider(true); + assertDoesNotThrow(() -> p.getName(null)); + assertNull(p.getName(null)); + } +} From 28c3e939451e1550bfd0613bad4b4fe48a02693b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 21:01:16 +0200 Subject: [PATCH 06/33] fix(items): Locale.ROOT case-folding + document sanitize cap unit + tighten cap test --- .../habbo/habbohotel/items/FurnitureTextProvider.java | 10 ++++++++-- .../habbohotel/items/FurnitureTextProviderTest.java | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index 634b8be0..43966984 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -2,6 +2,7 @@ package com.eu.habbo.habbohotel.items; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -47,11 +48,16 @@ public class FurnitureTextProvider { if (classname == null) return null; int star = classname.indexOf('*'); String base = (star >= 0) ? classname.substring(0, star) : classname; - base = base.trim().toLowerCase(); + base = base.trim().toLowerCase(Locale.ROOT); return base.isEmpty() ? null : base; } - /** Cap length, strip control chars/newlines, neutralize % (placeholder-injection safe). */ + /** + * Cap length, strip control chars/newlines, neutralize % (placeholder-injection safe). + * The 256 cap is in Java {@code char} units (UTF-16 code units), which is acceptable for + * furni names (controlled, predominantly ASCII source). Lone/astral surrogates are not + * specially handled. + */ static String sanitize(String value) { if (value == null) return ""; StringBuilder sb = new StringBuilder(Math.min(value.length(), MAX_LEN)); diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java index 5e510071..4c8fd2a9 100644 --- a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java @@ -47,7 +47,7 @@ class FurnitureTextProviderTest { FurnitureTextProvider p = provider(true, new FurnidataEntry(1, "x", FurnitureType.FLOOR, evil, "")); String name = p.getName("x"); - assertTrue(name.length() <= 256, "must be capped to 256"); + assertEquals(256, name.length(), "input far exceeds the cap, so it must be exactly 256"); assertFalse(name.chars().anyMatch(Character::isISOControl), "no control chars remain after sanitize"); assertFalse(name.contains("%"), "ASCII percent neutralized"); } From e7e75a285b794564faa5cabcdb8a50d9754f3664 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 21:03:53 +0200 Subject: [PATCH 07/33] feat(items): config-driven furnidata source resolution + init --- .../items/FurnitureTextProvider.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index 43966984..6caa2075 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -1,5 +1,11 @@ package com.eu.habbo.habbohotel.items; +import com.eu.habbo.Emulator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -16,6 +22,8 @@ import java.util.Map; public class FurnitureTextProvider { private static final int MAX_LEN = 256; + private static final Logger LOGGER = LoggerFactory.getLogger(FurnitureTextProvider.class); + private static final long DEFAULT_MAX_BYTES = 64L * 1024 * 1024; private final boolean enabled; private volatile Map index = Map.of(); @@ -24,6 +32,41 @@ public class FurnitureTextProvider { this.enabled = enabled; } + /** Production constructor: reads the enable toggle from config. */ + public FurnitureTextProvider() { + this(Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.names.enabled", "true"))); + } + + /** Resolve the furnidata source from config and build the initial index. Never throws. */ + public void init() { + try { + Path source = resolveSource(); + if (source == null) { + LOGGER.warn("FurnitureTextProvider: no furnidata source resolved — names fall back to public_name"); + return; + } + reindex(new FurnidataReader(source, DEFAULT_MAX_BYTES).read()); + LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), source); + } catch (Exception e) { + LOGGER.warn("FurnitureTextProvider.init failed — names fall back to public_name", e); + } + } + + private static Path resolveSource() { + String override = Emulator.getConfig().getValue("items.furnidata.path", ""); + if (!override.isEmpty()) { + Path p = Paths.get(override); + return Files.exists(p) ? p : null; + } + String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", ""); + if (basePath.isEmpty()) return null; + Path dir = Paths.get(basePath); + Path split = dir.resolve("furnidata"); + if (Files.isDirectory(split)) return split; + Path legacy = dir.resolve("FurnitureData.json"); + return Files.exists(legacy) ? legacy : null; + } + /** Build a fresh sanitized index from the given entries and swap it in atomically. */ public void reindex(List entries) { Map next = new HashMap<>(Math.max(16, entries.size() * 2)); From efb88e5957cc251078c1e2a5bd1ebbfa167324a5 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 21:05:37 +0200 Subject: [PATCH 08/33] feat(items): construct FurnitureTextProvider after ItemManager load --- .../java/com/eu/habbo/habbohotel/GameEnvironment.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java index 8d45809c..3874a2df 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java @@ -14,6 +14,7 @@ import com.eu.habbo.habbohotel.crafting.CraftingManager; import com.eu.habbo.habbohotel.guides.GuideManager; import com.eu.habbo.habbohotel.guilds.GuildManager; import com.eu.habbo.habbohotel.hotelview.HotelViewManager; +import com.eu.habbo.habbohotel.items.FurnitureTextProvider; import com.eu.habbo.habbohotel.items.ItemManager; import com.eu.habbo.habbohotel.modtool.ModToolManager; import com.eu.habbo.habbohotel.modtool.ModToolSanctions; @@ -47,6 +48,7 @@ public class GameEnvironment { private NavigatorManager navigatorManager; private GuildManager guildManager; private ItemManager itemManager; + private FurnitureTextProvider furnitureTextProvider; private CatalogManager catalogManager; private HotelViewManager hotelViewManager; private RoomManager roomManager; @@ -79,6 +81,8 @@ public class GameEnvironment { this.hotelViewManager = new HotelViewManager(); this.itemManager = new ItemManager(); this.itemManager.load(); + this.furnitureTextProvider = new FurnitureTextProvider(); + this.furnitureTextProvider.init(); this.botManager = new BotManager(); this.petManager = new PetManager(); this.guildManager = new GuildManager(); @@ -161,6 +165,10 @@ public class GameEnvironment { return this.itemManager; } + public FurnitureTextProvider getFurnitureTextProvider() { + return this.furnitureTextProvider; + } + public CatalogManager getCatalogManager() { return this.catalogManager; } From d73573e7c5176285b2a750dc2a1637ff79c2ec95 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 21:06:56 +0200 Subject: [PATCH 09/33] =?UTF-8?q?feat(items):=20Item.getDisplayName()=20?= =?UTF-8?q?=E2=80=94=20furnidata=20name=20with=20public=5Fname=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/eu/habbo/habbohotel/items/Item.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java index 323f9758..4fcecb85 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java @@ -167,6 +167,17 @@ public class Item implements ISerialize { return this.fullName; } + /** + * Display name for user-facing/log output, sourced from furnidata (by classname). + * Falls back to the DB public_name when furnidata has no entry or names are disabled. + * Never returns null. + */ + public String getDisplayName() { + FurnitureTextProvider provider = Emulator.getGameEnvironment().getFurnitureTextProvider(); + String name = (provider != null) ? provider.getName(this.name) : null; + return (name != null && !name.isBlank()) ? name : this.fullName; + } + public FurnitureType getType() { return this.type; } From f2e0f6e2d50b1d79d8f3658913606a79280190b9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 21:09:08 +0200 Subject: [PATCH 10/33] feat(items): source server-pronounced furni names from furnidata (6 sites) --- .../java/com/eu/habbo/habbohotel/catalog/CatalogManager.java | 4 ++-- .../habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java | 2 +- .../messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java | 4 ++-- .../messages/outgoing/unknown/WatchAndEarnRewardComposer.java | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java index f0dbccec..3261a132 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java @@ -1054,13 +1054,13 @@ public class CatalogManager { if (Emulator.getConfig().getBoolean("hotel.catalog.ltd.limit.enabled")) { int ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.total"); if (habbo.getHabboStats().totalLtds() >= ltdLimit) { - habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total").replace("%itemname%", item.getBaseItems().iterator().next().getFullName()).replace("%limit%", ltdLimit + "")); + habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total").replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName()).replace("%limit%", ltdLimit + "")); return; } ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.item"); if (habbo.getHabboStats().totalLtds(item.id) >= ltdLimit) { - habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item").replace("%itemname%", item.getBaseItems().iterator().next().getFullName()).replace("%limit%", ltdLimit + "")); + habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item").replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName()).replace("%limit%", ltdLimit + "")); return; } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java index 4b901e29..32d85ab7 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java @@ -279,7 +279,7 @@ public final class WiredTextPlaceholderUtil { continue; } - String furniName = item.getBaseItem().getFullName(); + String furniName = item.getBaseItem().getDisplayName(); if (furniName == null || furniName.trim().isEmpty()) { furniName = item.getBaseItem().getName(); } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java index 992ef724..92d941aa 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java @@ -248,7 +248,7 @@ public class CatalogBuyItemAsGiftEvent extends MessageHandler { LOGGER.debug("sender reached daily total LTD limit"); this.client.getHabbo().alert( Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total") - .replace("%itemname%", item.getBaseItems().iterator().next().getFullName()) + .replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName()) .replace("%limit%", ltdLimit + "") ); return; @@ -259,7 +259,7 @@ public class CatalogBuyItemAsGiftEvent extends MessageHandler { LOGGER.debug("sender reached daily LTD item limit"); this.client.getHabbo().alert( Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item") - .replace("%itemname%", item.getBaseItems().iterator().next().getFullName()) + .replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName()) .replace("%limit%", ltdLimit + "") ); return; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/unknown/WatchAndEarnRewardComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/unknown/WatchAndEarnRewardComposer.java index 1fb1e74a..70af9496 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/unknown/WatchAndEarnRewardComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/unknown/WatchAndEarnRewardComposer.java @@ -18,7 +18,7 @@ public class WatchAndEarnRewardComposer extends MessageComposer { this.response.appendString(this.item.getType().code); this.response.appendInt(this.item.getId()); this.response.appendString(this.item.getName()); - this.response.appendString(this.item.getFullName()); + this.response.appendString(this.item.getDisplayName()); return this.response; } From 3a505cd55973a7c9000e00f0c4e1a5610d6b0bd9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 21:16:55 +0200 Subject: [PATCH 11/33] fix(items): null-safe getDisplayName + log missing items.furnidata.path --- .../eu/habbo/habbohotel/items/FurnitureTextProvider.java | 4 +++- .../src/main/java/com/eu/habbo/habbohotel/items/Item.java | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index 6caa2075..ae7e501b 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -56,7 +56,9 @@ public class FurnitureTextProvider { String override = Emulator.getConfig().getValue("items.furnidata.path", ""); if (!override.isEmpty()) { Path p = Paths.get(override); - return Files.exists(p) ? p : null; + if (Files.exists(p)) return p; + LOGGER.warn("FurnitureTextProvider: items.furnidata.path '{}' does not exist", override); + return null; } String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", ""); if (basePath.isEmpty()) return null; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java index 4fcecb85..f0ca1719 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java @@ -173,9 +173,12 @@ public class Item implements ISerialize { * Never returns null. */ public String getDisplayName() { - FurnitureTextProvider provider = Emulator.getGameEnvironment().getFurnitureTextProvider(); + FurnitureTextProvider provider = (Emulator.getGameEnvironment() != null) + ? Emulator.getGameEnvironment().getFurnitureTextProvider() + : null; String name = (provider != null) ? provider.getName(this.name) : null; - return (name != null && !name.isBlank()) ? name : this.fullName; + if (name != null && !name.isBlank()) return name; + return (this.fullName != null) ? this.fullName : ""; } public FurnitureType getType() { From 0cf46471f2bb7e90078330552147709836815396 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 21:38:52 +0200 Subject: [PATCH 12/33] feat(items): FurnitureDataReloadComposer (header 10047, delta + reload-hint) --- .../eu/habbo/messages/outgoing/Outgoing.java | 1 + .../FurnitureDataReloadComposer.java | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/furniture/FurnitureDataReloadComposer.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java index d842aff7..72d001d6 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java @@ -570,6 +570,7 @@ public class Outgoing { public static final int FurniEditorDetailComposer = 10041; public static final int FurniEditorInteractionsComposer = 10043; public static final int FurniEditorResultComposer = 10044; + public static final int FurnitureDataReloadComposer = 10047; // CUSTOM // Catalog Admin public static final int CatalogAdminResultComposer = 10059; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furniture/FurnitureDataReloadComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furniture/FurnitureDataReloadComposer.java new file mode 100644 index 00000000..78d05f8d --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furniture/FurnitureDataReloadComposer.java @@ -0,0 +1,42 @@ +package com.eu.habbo.messages.outgoing.furniture; + +import com.eu.habbo.habbohotel.items.FurnidataEntry; +import com.eu.habbo.habbohotel.items.FurnitureType; +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +import java.util.List; + +public class FurnitureDataReloadComposer extends MessageComposer { + + public static final int MODE_DELTA = 0; + public static final int MODE_RELOAD_HINT = 1; + + private final int mode; + private final List entries; + + public FurnitureDataReloadComposer(int mode, List entries) { + this.mode = mode; + this.entries = entries; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.FurnitureDataReloadComposer); + this.response.appendInt(this.mode); + + if (this.mode == MODE_DELTA) { + this.response.appendInt(this.entries.size()); + for (FurnidataEntry e : this.entries) { + this.response.appendString(e.type() == FurnitureType.FLOOR ? "S" : "I"); + this.response.appendInt(e.id()); + this.response.appendString(e.classname()); + this.response.appendString(e.name()); + this.response.appendString(e.description()); + } + } + + return this.response; + } +} From 7f4f7d6da9e564063692fef5eb010ac5a82524da Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 21:42:46 +0200 Subject: [PATCH 13/33] feat(items): reindex returns sanitized furnidata delta --- .../items/FurnitureTextProvider.java | 19 ++++++++- .../items/FurnitureTextProviderDeltaTest.java | 40 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderDeltaTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index ae7e501b..b72823af 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -69,15 +69,30 @@ public class FurnitureTextProvider { return Files.exists(legacy) ? legacy : null; } - /** Build a fresh sanitized index from the given entries and swap it in atomically. */ - public void reindex(List entries) { + /** + * Build a fresh sanitized index, swap it in atomically, and return the + * changed/added entries (sanitized) as the delta versus the previous index. + */ + public java.util.List reindex(java.util.List entries) { Map next = new HashMap<>(Math.max(16, entries.size() * 2)); for (FurnidataEntry e : entries) { String key = baseKey(e.classname()); if (key == null) continue; next.put(key, new FurniText(e.id(), e.type(), sanitize(e.name()), sanitize(e.description()))); } + + Map prev = this.index; + java.util.List delta = new java.util.ArrayList<>(); + for (Map.Entry en : next.entrySet()) { + FurniText cur = en.getValue(); + FurniText old = prev.get(en.getKey()); + if (old == null || !old.name().equals(cur.name()) || !old.description().equals(cur.description())) { + delta.add(new FurnidataEntry(cur.id(), en.getKey(), cur.type(), cur.name(), cur.description())); + } + } + this.index = next; // atomic reference swap + return delta; } /** Returns the sanitized display name for a DB classname, or null if absent/disabled. */ diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderDeltaTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderDeltaTest.java new file mode 100644 index 00000000..356a5498 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderDeltaTest.java @@ -0,0 +1,40 @@ +package com.eu.habbo.habbohotel.items; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class FurnitureTextProviderDeltaTest { + + @Test + void firstReindexReturnsAllAsDelta() { + FurnitureTextProvider p = new FurnitureTextProvider(true); + List delta = p.reindex(List.of( + new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit"))); + assertEquals(1, delta.size()); + assertEquals("Chair", delta.get(0).name()); + } + + @Test + void unchangedReindexReturnsEmptyDelta() { + FurnitureTextProvider p = new FurnitureTextProvider(true); + List first = List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit")); + p.reindex(first); + List delta = p.reindex(first); + assertTrue(delta.isEmpty(), "no change => empty delta"); + } + + @Test + void changedNameAppearsInDeltaWithSanitizedValue() { + FurnitureTextProvider p = new FurnitureTextProvider(true); + p.reindex(List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit"))); + List delta = p.reindex(List.of( + new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "New %x%", "Sit"))); + assertEquals(1, delta.size()); + assertFalse(delta.get(0).name().contains("%"), "delta carries the sanitized name"); + assertEquals(1, delta.get(0).id()); + assertEquals(FurnitureType.FLOOR, delta.get(0).type()); + } +} From 8fb117ae73561f81d02dc70d7ba4a3f7035b732a Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 21:46:44 +0200 Subject: [PATCH 14/33] =?UTF-8?q?feat(items):=20furnidata=20file=20watcher?= =?UTF-8?q?=20=E2=80=94=20debounce,=20throttle,=20delta=20cap=20to=20reloa?= =?UTF-8?q?d-hint,=20broadcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../habbohotel/items/FurnidataWatcher.java | 112 ++++++++++++++++++ .../items/FurnitureTextProvider.java | 19 ++- 2 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java new file mode 100644 index 00000000..1172c0d2 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java @@ -0,0 +1,112 @@ +package com.eu.habbo.habbohotel.items; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.List; + +/** + * Watches the furnidata source on a single daemon thread. On change (debounced), + * re-indexes via the provider and broadcasts only the delta — or a compact + * reload-hint when the delta exceeds the cap. A minimum interval throttles bursts. + * Never throws out of the loop. + */ +public class FurnidataWatcher { + + private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataWatcher.class); + + private final FurnitureTextProvider provider; + private final Path watchDir; + private final long maxBytes; + private final long debounceMs; + private final long minIntervalMs; + private final int deltaCap; + + private volatile boolean running = false; + private long lastBroadcast = 0L; + + public FurnidataWatcher(FurnitureTextProvider provider, Path source, long maxBytes) { + this.provider = provider; + this.watchDir = java.nio.file.Files.isDirectory(source) ? source : source.getParent(); + this.maxBytes = maxBytes; + this.debounceMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.debounce.ms", "750")); + this.minIntervalMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.min.interval.ms", "5000")); + this.deltaCap = Integer.parseInt(Emulator.getConfig().getValue("items.furnidata.delta.cap", "500")); + } + + public void start() { + if (this.running || this.watchDir == null) return; + this.running = true; + Thread t = new Thread(this::run, "FurnidataWatcher"); + t.setDaemon(true); + t.start(); + } + + public void stop() { + this.running = false; + } + + private void run() { + try (WatchService ws = FileSystems.getDefault().newWatchService()) { + this.watchDir.register(ws, StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE); + + while (this.running) { + WatchKey key = ws.take(); + key.pollEvents(); + Thread.sleep(this.debounceMs); + key.pollEvents(); + key.reset(); + + try { + onChange(); + } catch (Exception e) { + LOGGER.warn("FurnidataWatcher: onChange failed", e); + } + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + LOGGER.warn("FurnidataWatcher stopped", e); + } + } + + private void onChange() { + Path source = this.provider.getSource(); + if (source == null) return; + + List delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read()); + if (delta.isEmpty()) return; + + long now = System.currentTimeMillis(); + if (now - this.lastBroadcast < this.minIntervalMs) { + LOGGER.info("FurnidataWatcher: {} changes throttled (min interval)", delta.size()); + return; + } + this.lastBroadcast = now; + + FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap) + ? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of()) + : new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta); + + broadcast(composer); + LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)", + delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size()); + } + + private void broadcast(FurnitureDataReloadComposer composer) { + for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) { + if (habbo.getClient() != null) { + habbo.getClient().sendResponse(composer); + } + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index b72823af..622e01be 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -27,6 +27,8 @@ public class FurnitureTextProvider { private final boolean enabled; private volatile Map index = Map.of(); + private volatile Path source; + private FurnidataWatcher watcher; public FurnitureTextProvider(boolean enabled) { this.enabled = enabled; @@ -40,18 +42,27 @@ public class FurnitureTextProvider { /** Resolve the furnidata source from config and build the initial index. Never throws. */ public void init() { try { - Path source = resolveSource(); - if (source == null) { + this.source = resolveSource(); + if (this.source == null) { LOGGER.warn("FurnitureTextProvider: no furnidata source resolved — names fall back to public_name"); return; } - reindex(new FurnidataReader(source, DEFAULT_MAX_BYTES).read()); - LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), source); + reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read()); + LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), this.source); + + if (Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.watch.enabled", "true"))) { + this.watcher = new FurnidataWatcher(this, this.source, DEFAULT_MAX_BYTES); + this.watcher.start(); + } } catch (Exception e) { LOGGER.warn("FurnitureTextProvider.init failed — names fall back to public_name", e); } } + public Path getSource() { + return this.source; + } + private static Path resolveSource() { String override = Emulator.getConfig().getValue("items.furnidata.path", ""); if (!override.isEmpty()) { From 4944d41410e0f599af5fc518cdd55a6bdf8fa25f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 21:56:01 +0200 Subject: [PATCH 15/33] fix(items): watcher registers split-tier subdirs, real stop()/close, key.reset guard --- .../habbohotel/items/FurnidataWatcher.java | 56 +++++++++++++++---- .../items/FurnitureTextProvider.java | 1 + 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java index 1172c0d2..7a068f3d 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java @@ -6,7 +6,11 @@ import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.nio.file.ClosedWatchServiceException; +import java.nio.file.DirectoryStream; import java.nio.file.FileSystems; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchKey; @@ -17,7 +21,8 @@ import java.util.List; * Watches the furnidata source on a single daemon thread. On change (debounced), * re-indexes via the provider and broadcasts only the delta — or a compact * reload-hint when the delta exceeds the cap. A minimum interval throttles bursts. - * Never throws out of the loop. + * For the split-tier directory layout, the base dir AND its immediate + * subdirectories are registered. Never throws out of the loop. */ public class FurnidataWatcher { @@ -25,17 +30,20 @@ public class FurnidataWatcher { private final FurnitureTextProvider provider; private final Path watchDir; + private final boolean sourceIsDir; private final long maxBytes; private final long debounceMs; private final long minIntervalMs; private final int deltaCap; private volatile boolean running = false; + private volatile WatchService ws; private long lastBroadcast = 0L; public FurnidataWatcher(FurnitureTextProvider provider, Path source, long maxBytes) { this.provider = provider; - this.watchDir = java.nio.file.Files.isDirectory(source) ? source : source.getParent(); + this.sourceIsDir = Files.isDirectory(source); + this.watchDir = this.sourceIsDir ? source : source.getParent(); this.maxBytes = maxBytes; this.debounceMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.debounce.ms", "750")); this.minIntervalMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.min.interval.ms", "5000")); @@ -52,20 +60,30 @@ public class FurnidataWatcher { public void stop() { this.running = false; + WatchService local = this.ws; + if (local != null) { + try { local.close(); } catch (IOException ignored) { } + } } private void run() { - try (WatchService ws = FileSystems.getDefault().newWatchService()) { - this.watchDir.register(ws, StandardWatchEventKinds.ENTRY_MODIFY, - StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE); - + try { + this.ws = FileSystems.getDefault().newWatchService(); + } catch (IOException e) { + LOGGER.warn("FurnidataWatcher: could not create WatchService", e); + return; + } + try (WatchService service = this.ws) { + registerDirs(service); while (this.running) { - WatchKey key = ws.take(); + WatchKey key = service.take(); key.pollEvents(); Thread.sleep(this.debounceMs); key.pollEvents(); - key.reset(); - + if (!key.reset()) { + LOGGER.warn("FurnidataWatcher: watch key invalidated (directory removed?) — stopping"); + break; + } try { onChange(); } catch (Exception e) { @@ -74,11 +92,29 @@ public class FurnidataWatcher { } } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); + } catch (ClosedWatchServiceException ignored) { + // stop() closed the service — normal shutdown } catch (Exception e) { LOGGER.warn("FurnidataWatcher stopped", e); } } + /** Register the base dir, plus one level of subdirectories for the split-tier layout. */ + private void registerDirs(WatchService service) throws IOException { + this.watchDir.register(service, StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE); + if (this.sourceIsDir) { + try (DirectoryStream ds = Files.newDirectoryStream(this.watchDir)) { + for (Path child : ds) { + if (Files.isDirectory(child)) { + child.register(service, StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE); + } + } + } + } + } + private void onChange() { Path source = this.provider.getSource(); if (source == null) return; @@ -88,7 +124,7 @@ public class FurnidataWatcher { long now = System.currentTimeMillis(); if (now - this.lastBroadcast < this.minIntervalMs) { - LOGGER.info("FurnidataWatcher: {} changes throttled (min interval)", delta.size()); + LOGGER.info("FurnidataWatcher: {} changes indexed but broadcast skipped (min interval) — clients update on next change or reconnect", delta.size()); return; } this.lastBroadcast = now; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index 622e01be..9f4fccbd 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -51,6 +51,7 @@ public class FurnitureTextProvider { LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), this.source); if (Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.watch.enabled", "true"))) { + if (this.watcher != null) this.watcher.stop(); this.watcher = new FurnidataWatcher(this, this.source, DEFAULT_MAX_BYTES); this.watcher.start(); } From 258a95a269fbe4c0d2dda724db8f2b68b47eb863 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 01:55:54 +0200 Subject: [PATCH 16/33] feat(furnidata): add furnidata_edit_log audit table + editor write config keys --- .../020_furnidata_edit_log.sql | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Database Updates/Own_Database_RunFirst/020_furnidata_edit_log.sql diff --git a/Database Updates/Own_Database_RunFirst/020_furnidata_edit_log.sql b/Database Updates/Own_Database_RunFirst/020_furnidata_edit_log.sql new file mode 100644 index 00000000..99553985 --- /dev/null +++ b/Database Updates/Own_Database_RunFirst/020_furnidata_edit_log.sql @@ -0,0 +1,22 @@ +-- 020_furnidata_edit_log.sql +-- Audit trail for furnidata name/description edits made through the furni editor, +-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are +-- read via Boolean.parseBoolean (true/false), but these two are numeric. +CREATE TABLE IF NOT EXISTS `furnidata_edit_log` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `classname` varchar(255) NOT NULL, + `action` enum('edit','revert') NOT NULL DEFAULT 'edit', + `old_name` varchar(256) NOT NULL DEFAULT '', + `new_name` varchar(256) NOT NULL DEFAULT '', + `old_description` varchar(256) NOT NULL DEFAULT '', + `new_description` varchar(256) NOT NULL DEFAULT '', + `timestamp` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_classname` (`classname`), + INDEX `idx_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci; + +INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES +('items.furnidata.edit.backup.keep','10'), +('items.furnidata.edit.ratelimit.ms','2000'); From caf6ad35fa332839c809582a6aec55662176d487 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 01:57:48 +0200 Subject: [PATCH 17/33] feat(furnidata): shared lock serializing watcher reindex and editor writes --- .../habbo/habbohotel/items/FurnidataLock.java | 13 +++++++ .../habbohotel/items/FurnidataWatcher.java | 39 +++++++++++-------- 2 files changed, 35 insertions(+), 17 deletions(-) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataLock.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataLock.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataLock.java new file mode 100644 index 00000000..5c0224ec --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataLock.java @@ -0,0 +1,13 @@ +package com.eu.habbo.habbohotel.items; + +import java.util.concurrent.locks.ReentrantLock; + +/** + * One process-wide lock serializing every furnidata reindex and every editor-driven + * furnidata write, so an editor write never races the file watcher's reindex and the + * volatile index is never observed mid-swap by two writers. + */ +public final class FurnidataLock { + public static final ReentrantLock LOCK = new ReentrantLock(); + private FurnidataLock() {} +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java index 7a068f3d..0b3aa615 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java @@ -116,26 +116,31 @@ public class FurnidataWatcher { } private void onChange() { - Path source = this.provider.getSource(); - if (source == null) return; + FurnidataLock.LOCK.lock(); + try { + Path source = this.provider.getSource(); + if (source == null) return; - List delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read()); - if (delta.isEmpty()) return; + List delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read()); + if (delta.isEmpty()) return; - long now = System.currentTimeMillis(); - if (now - this.lastBroadcast < this.minIntervalMs) { - LOGGER.info("FurnidataWatcher: {} changes indexed but broadcast skipped (min interval) — clients update on next change or reconnect", delta.size()); - return; + long now = System.currentTimeMillis(); + if (now - this.lastBroadcast < this.minIntervalMs) { + LOGGER.info("FurnidataWatcher: {} changes indexed but broadcast skipped (min interval) — clients update on next change or reconnect", delta.size()); + return; + } + this.lastBroadcast = now; + + FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap) + ? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of()) + : new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta); + + broadcast(composer); + LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)", + delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size()); + } finally { + FurnidataLock.LOCK.unlock(); } - this.lastBroadcast = now; - - FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap) - ? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of()) - : new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta); - - broadcast(composer); - LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)", - delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size()); } private void broadcast(FurnitureDataReloadComposer composer) { From a815c1b99d93559c65a1830347219f4eae270ecf Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 02:02:59 +0200 Subject: [PATCH 18/33] feat(furnidata): FurnidataWriter single-file comment-preserving atomic write + backup --- .../habbohotel/items/FurnidataWriter.java | 197 ++++++++++++++++++ .../habbohotel/items/FurnidataWriterTest.java | 40 ++++ 2 files changed, 237 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java create mode 100644 Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java new file mode 100644 index 00000000..e525a14e --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java @@ -0,0 +1,197 @@ +package com.eu.habbo.habbohotel.items; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Comment-preserving, atomic, backed-up writer for furnidata name/description, keyed by + * classname. Supports single-file and split-tier (writes the tier that currently resolves + * the classname). Edit-only: refuses classnames absent from the furnidata. + */ +public class FurnidataWriter { + private final Path source; // file (single) or base dir (split-tier) + private final boolean directory; // true => split-tier + private final long maxBytes; + private final int backupKeep; + + public FurnidataWriter(Path source, boolean directory, long maxBytes, int backupKeep) { + this.source = source; + this.directory = directory; + this.maxBytes = maxBytes; + this.backupKeep = Math.max(1, backupKeep); + } + + /** @return true if an entry for classname was found and written. */ + public boolean write(String classname, String name, String description) throws IOException { + String cn = classname == null ? "" : classname.trim().toLowerCase(java.util.Locale.ROOT); + if (cn.isEmpty()) return false; + String safeName = FurnitureTextProvider.sanitize(name); + String safeDesc = FurnitureTextProvider.sanitize(description); + + Path target = locateFile(cn); + if (target == null) return false; + + String raw = Files.readString(target, StandardCharsets.UTF_8); + String edited = replaceEntryFields(raw, cn, safeName, safeDesc); + if (edited == null || edited.equals(raw)) { + // classname not present in this file, or no change + return edited != null && !edited.equals(raw); + } + backup(target); + atomicWrite(target, edited); + return true; + } + + /** For single-file just returns the file; for split-tier, the tier file that contains cn. */ + private Path locateFile(String cn) throws IOException { + if (!directory) { + // confirm existence via the reader (size-guarded, parses the same way) + return containsClassname(source, cn) ? source : null; + } + // split-tier: iterate tiers in OVERRIDE order (later tiers win); pick the last containing cn + Path winner = null; + for (Path tierFile : splitTierFilesInOrder()) { + if (containsClassname(tierFile, cn)) winner = tierFile; + } + return winner; + } + + private boolean containsClassname(Path file, String cn) { + for (FurnidataEntry e : new FurnidataReader(file, maxBytes).read()) { + if (e.classname() != null && e.classname().trim().toLowerCase(java.util.Locale.ROOT).equals(cn)) return true; + } + return false; + } + + /** + * Replace the "name" and "description" string values inside the JSON object that holds + * "classname": "". Preserves everything else (comments, ordering, formatting). + * Handles double- and single-quoted JSON5 keys/values. Returns null if cn not found. + */ + static String replaceEntryFields(String raw, String cn, String name, String description) { + // find the classname value occurrence (case-insensitive on the value) + Pattern classProp = Pattern.compile( + "([\"'])classname\\1\\s*:\\s*([\"'])((?:\\\\.|(?!\\2).)*)\\2", Pattern.CASE_INSENSITIVE); + Matcher m = classProp.matcher(raw); + int objStart = -1, objEnd = -1; + while (m.find()) { + String val = m.group(3).trim().toLowerCase(java.util.Locale.ROOT); + if (!val.equals(cn)) continue; + // expand to the enclosing { ... } + objStart = lastUnbalancedBrace(raw, m.start()); + objEnd = matchingClose(raw, objStart); + break; + } + if (objStart < 0 || objEnd < 0) return null; + String obj = raw.substring(objStart, objEnd + 1); + String newObj = replaceField(obj, "name", name); + newObj = replaceField(newObj, "description", description); + return raw.substring(0, objStart) + newObj + raw.substring(objEnd + 1); + } + + private static String replaceField(String obj, String field, String value) { + Pattern p = Pattern.compile( + "(([\"'])" + Pattern.quote(field) + "\\2\\s*:\\s*)([\"'])((?:\\\\.|(?!\\3).)*)\\3"); + Matcher m = p.matcher(obj); + if (!m.find()) return obj; // field absent → leave object as-is + String replacement = m.group(1) + '"' + jsonEscape(value) + '"'; + return obj.substring(0, m.start()) + replacement + obj.substring(m.end()); + } + + private static int lastUnbalancedBrace(String s, int from) { + int depth = 0; + for (int i = from; i >= 0; i--) { + char c = s.charAt(i); + if (c == '}') depth++; + else if (c == '{') { if (depth == 0) return i; depth--; } + } + return -1; + } + + private static int matchingClose(String s, int open) { + int depth = 0; boolean inStr = false; char q = 0; + for (int i = open; i < s.length(); i++) { + char c = s.charAt(i); + if (inStr) { if (c == '\\') { i++; } else if (c == q) inStr = false; continue; } + if (c == '"' || c == '\'') { inStr = true; q = c; } + else if (c == '{') depth++; + else if (c == '}') { depth--; if (depth == 0) return i; } + } + return -1; + } + + private static String jsonEscape(String v) { + StringBuilder b = new StringBuilder(v.length() + 8); + for (int i = 0; i < v.length(); i++) { + char c = v.charAt(i); + if (c == '"' || c == '\\') b.append('\\').append(c); + else b.append(c); + } + return b.toString(); + } + + private List splitTierFilesInOrder() throws IOException { + // Mirrors FurnidataReader split-tier resolution at a coarse level: the manifest order. + // For the plan we reuse the reader's defaults; the concrete enumeration is implemented + // in Task 4 alongside the split-tier test. Single-file path does not call this. + throw new UnsupportedOperationException("implemented in Task 4"); + } + + private void backup(Path target) throws IOException { + Path bak = target.resolveSibling(target.getFileName() + ".bak." + System.nanoTime()); + Files.copy(target, bak, StandardCopyOption.COPY_ATTRIBUTES); + pruneBackups(target); + } + + private void pruneBackups(Path target) throws IOException { + String prefix = target.getFileName() + ".bak."; + try (var stream = Files.list(target.getParent())) { + List baks = stream.filter(p -> p.getFileName().toString().startsWith(prefix)) + .sorted(Comparator.comparingLong(p -> backupStamp(p))).toList(); + for (int i = 0; i < baks.size() - backupKeep; i++) Files.deleteIfExists(baks.get(i)); + } + } + + private static long backupStamp(Path p) { + String s = p.getFileName().toString(); + try { return Long.parseLong(s.substring(s.lastIndexOf('.') + 1)); } catch (Exception e) { return 0L; } + } + + private void atomicWrite(Path target, String content) throws IOException { + Path tmp = target.resolveSibling(target.getFileName() + ".tmp." + System.nanoTime()); + Files.writeString(tmp, content, StandardCharsets.UTF_8); + try { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** Restore the most recent backup of the (single-file) target. @return true if restored. */ + public boolean revertLastBackup() throws IOException { + if (directory) return revertSplitTier(); + return revertFile(source); + } + + private boolean revertFile(Path target) throws IOException { + String prefix = target.getFileName() + ".bak."; + try (var stream = Files.list(target.getParent())) { + Path latest = stream.filter(p -> p.getFileName().toString().startsWith(prefix)) + .max(Comparator.comparingLong(FurnidataWriter::backupStamp)).orElse(null); + if (latest == null) return false; + atomicWrite(target, Files.readString(latest, StandardCharsets.UTF_8)); + return true; + } + } + + private boolean revertSplitTier() throws IOException { + boolean any = false; + for (Path f : splitTierFilesInOrder()) any |= revertFile(f); + return any; + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java new file mode 100644 index 00000000..16faeb76 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java @@ -0,0 +1,40 @@ +package com.eu.habbo.habbohotel.items; + +import org.junit.jupiter.api.Test; +import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; + +class FurnidataWriterTest { + + private static final String SINGLE = + "{ \"roomitemtypes\": { \"furnitype\": [\n" + + " { \"id\": 1, \"classname\": \"01_caterhead\", \"name\": \"old name\", \"description\": \"old desc\" }\n" + + "] }, \"wallitemtypes\": { \"furnitype\": [] } }"; + + @Test + void writesNameAndDescriptionByClassnameSingleFile() throws Exception { + Path dir = Files.createTempDirectory("fd"); + Path file = dir.resolve("FurnitureData.json"); + Files.writeString(file, SINGLE); + + FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10); + boolean ok = w.write("01_caterhead", "Cat Head", "A cat head"); + + assertTrue(ok); + String after = Files.readString(file); + assertTrue(after.contains("\"Cat Head\"")); + assertTrue(after.contains("\"A cat head\"")); + assertFalse(after.contains("old name")); + // backup created + assertTrue(Files.list(dir).anyMatch(p -> p.getFileName().toString().startsWith("FurnitureData.json.bak"))); + } + + @Test + void rejectsUnknownClassname() throws Exception { + Path dir = Files.createTempDirectory("fd"); + Path file = dir.resolve("FurnitureData.json"); + Files.writeString(file, SINGLE); + FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10); + assertFalse(w.write("does_not_exist", "x", "y")); + } +} From 43c2c2b0f179cc587d2ce7d8d061379d1f12c7d0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 02:09:23 +0200 Subject: [PATCH 19/33] feat(furnidata): split-tier write to winning tier with path-traversal guard --- .../habbohotel/items/FurnidataWriter.java | 83 ++++++++++++++++- .../habbohotel/items/FurnidataWriterTest.java | 90 +++++++++++++++++++ 2 files changed, 169 insertions(+), 4 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java index e525a14e..fd4f701a 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java @@ -3,6 +3,8 @@ package com.eu.habbo.habbohotel.items; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.*; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.regex.Matcher; @@ -14,6 +16,13 @@ import java.util.regex.Pattern; * the classname). Edit-only: refuses classnames absent from the furnidata. */ public class FurnidataWriter { + + /** Default tier names in override order (later = higher priority, wins on conflict). */ + private static final List DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal"); + + /** Manifest filenames tried in order (json5 first, plain json second). */ + private static final List MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json"); + private final Path source; // file (single) or base dir (split-tier) private final boolean directory; // true => split-tier private final long maxBytes; @@ -135,11 +144,77 @@ public class FurnidataWriter { return b.toString(); } + /** + * Enumerate every data file reachable from the split-tier base directory, in + * override order (core → custom → seasonal, or the order declared in the top-level + * {@code manifest.json(5)}). Within each tier the per-tier manifest's {@code files} + * array determines the file order. + * + *

All resolved paths are checked against the normalised base directory via + * {@link #safeResolve}: any entry that would escape the base is silently skipped. + * + * @return ordered list of existing, in-bounds data files (earliest tier first). + */ private List splitTierFilesInOrder() throws IOException { - // Mirrors FurnidataReader split-tier resolution at a coarse level: the manifest order. - // For the plan we reuse the reader's defaults; the concrete enumeration is implemented - // in Task 4 alongside the split-tier test. Single-file path does not call this. - throw new UnsupportedOperationException("implemented in Task 4"); + Path base = source.toAbsolutePath().normalize(); + List tiers = manifestList(base, "tiers", DEFAULT_TIERS); + List result = new ArrayList<>(); + + for (String tier : tiers) { + Path tierDir = safeResolve(base, tier); + if (tierDir == null || !Files.isDirectory(tierDir)) continue; + + for (String fileName : manifestList(tierDir, "files", List.of())) { + Path file = safeResolve(base, tierDir.resolve(fileName).toString()); + if (file == null || !Files.isRegularFile(file)) continue; + result.add(file); + } + } + return result; + } + + /** + * Resolve {@code entry} relative to {@code base} and verify the result stays + * inside {@code base} (path-traversal guard). + * + * @param base the normalised absolute base directory. + * @param entry a path string (may be relative or absolute, may contain {@code ..}). + * @return the normalised absolute path if it is inside {@code base}; {@code null} otherwise. + */ + private static Path safeResolve(Path base, String entry) { + try { + Path resolved = base.resolve(entry).toAbsolutePath().normalize(); + return resolved.startsWith(base) ? resolved : null; + } catch (Exception e) { + return null; + } + } + + /** + * Read the {@code key} string-array from the first manifest file found in {@code dir} + * ({@code manifest.json5} then {@code manifest.json}). Falls back to {@code fallback} + * if no manifest exists or the key is absent/empty. + */ + private List manifestList(Path dir, String key, List fallback) { + for (String name : MANIFEST_NAMES) { + Path m = dir.resolve(name); + if (!Files.exists(m)) continue; + try { + String stripped = FurnidataReader.stripJson5( + Files.readString(m, StandardCharsets.UTF_8)); + com.google.gson.JsonObject obj = + com.google.gson.JsonParser.parseString(stripped).getAsJsonObject(); + if (obj.has(key) && obj.get(key).isJsonArray()) { + List list = new ArrayList<>(); + for (com.google.gson.JsonElement el : obj.getAsJsonArray(key)) + list.add(el.getAsString()); + if (!list.isEmpty()) return list; + } + } catch (Exception ignored) { + // bad manifest → fall through to next candidate / fallback + } + } + return fallback; } private void backup(Path target) throws IOException { diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java index 16faeb76..bef12c82 100644 --- a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java @@ -11,6 +11,18 @@ class FurnidataWriterTest { " { \"id\": 1, \"classname\": \"01_caterhead\", \"name\": \"old name\", \"description\": \"old desc\" }\n" + "] }, \"wallitemtypes\": { \"furnitype\": [] } }"; + // Tier data: core has the entry with "core name"; custom ALSO has it with "custom old name". + // The writer must pick the custom (winning) tier and leave core untouched. + private static final String CORE_DATA = + "{ \"roomitemtypes\": { \"furnitype\": [\n" + + " { \"id\": 1, \"classname\": \"split_chair\", \"name\": \"core name\", \"description\": \"core desc\" }\n" + + "] }, \"wallitemtypes\": { \"furnitype\": [] } }"; + + private static final String CUSTOM_DATA = + "{ \"roomitemtypes\": { \"furnitype\": [\n" + + " { \"id\": 1, \"classname\": \"split_chair\", \"name\": \"custom old name\", \"description\": \"custom old desc\" }\n" + + "] }, \"wallitemtypes\": { \"furnitype\": [] } }"; + @Test void writesNameAndDescriptionByClassnameSingleFile() throws Exception { Path dir = Files.createTempDirectory("fd"); @@ -37,4 +49,82 @@ class FurnidataWriterTest { FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10); assertFalse(w.write("does_not_exist", "x", "y")); } + + /** + * Split-tier: classname present in both core and custom tiers. + * The writer must update the winning (later) tier — custom — and leave core untouched. + */ + @Test + void splitTierWritesWinningTierLeavesEarlierTierUntouched() throws Exception { + Path base = Files.createTempDirectory("fd-split"); + + // Tier subdirectories + Path coreDir = base.resolve("core"); + Path customDir = base.resolve("custom"); + Files.createDirectories(coreDir); + Files.createDirectories(customDir); + + // Top-level manifest: tiers in override order (core < custom) + Files.writeString(base.resolve("manifest.json"), + "{ \"tiers\": [ \"core\", \"custom\" ] }"); + + // Per-tier manifests listing the data file + Files.writeString(coreDir.resolve("manifest.json"), + "{ \"files\": [ \"furnidata.json\" ] }"); + Files.writeString(customDir.resolve("manifest.json"), + "{ \"files\": [ \"furnidata.json\" ] }"); + + // Data files + Path coreFile = coreDir.resolve("furnidata.json"); + Path customFile = customDir.resolve("furnidata.json"); + Files.writeString(coreFile, CORE_DATA); + Files.writeString(customFile, CUSTOM_DATA); + + FurnidataWriter w = new FurnidataWriter(base, true, 64L * 1024 * 1024, 10); + boolean ok = w.write("split_chair", "New Name", "New desc"); + + assertTrue(ok, "write must succeed for classname present in split-tier layout"); + + // custom (winning tier) must be updated + String customAfter = Files.readString(customFile); + assertTrue(customAfter.contains("\"New Name\""), "winning tier must contain new name"); + assertTrue(customAfter.contains("\"New desc\""), "winning tier must contain new desc"); + assertFalse(customAfter.contains("custom old name"), "old name must be gone from winning tier"); + + // core (earlier tier) must be UNTOUCHED + String coreAfter = Files.readString(coreFile); + assertTrue(coreAfter.contains("core name"), "earlier tier must be left untouched"); + } + + /** + * Split-tier path-traversal guard: a manifest that lists "../escape" as a tier + * must be rejected by safeResolve so the writer cannot reach files outside the base dir. + */ + @Test + void splitTierRejectsTraversalTierInManifest() throws Exception { + Path base = Files.createTempDirectory("fd-traversal"); + + // "Escape" directory sits OUTSIDE base + Path escapeDir = base.getParent().resolve("escape_secret"); + Files.createDirectories(escapeDir); + Files.writeString(escapeDir.resolve("manifest.json"), + "{ \"files\": [ \"secret.json\" ] }"); + Files.writeString(escapeDir.resolve("secret.json"), + "{ \"roomitemtypes\": { \"furnitype\": [\n" + + " { \"id\": 99, \"classname\": \"escape_chair\", \"name\": \"secret old\", \"description\": \"\" }\n" + + "] }, \"wallitemtypes\": { \"furnitype\": [] } }"); + + // Top-level manifest references the escape dir via traversal + Files.writeString(base.resolve("manifest.json"), + "{ \"tiers\": [ \"../escape_secret\" ] }"); + + FurnidataWriter w = new FurnidataWriter(base, true, 64L * 1024 * 1024, 10); + boolean ok = w.write("escape_chair", "Pwned", "desc"); + + assertFalse(ok, "classname reachable only via traversal path must not be found/written"); + + // The secret file must not have been touched + String secretAfter = Files.readString(escapeDir.resolve("secret.json")); + assertTrue(secretAfter.contains("secret old"), "traversal target must be untouched"); + } } From 3b85d5fa34f85563e134ce964d55bb6a7e196d21 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 02:11:26 +0200 Subject: [PATCH 20/33] feat(furnidata): expose source kind, maxBytes, reindexFromSource on the provider --- .../items/FurnitureTextProvider.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index 9f4fccbd..89297399 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -64,6 +64,31 @@ public class FurnitureTextProvider { return this.source; } + /** Returns {@code true} when the resolved source is a directory (split-tier layout). */ + public boolean isSourceDirectory() { + return this.source != null && Files.isDirectory(this.source); + } + + /** Returns the byte cap used when reading furnidata files. */ + public long getMaxBytes() { + return DEFAULT_MAX_BYTES; + } + + /** + * Re-reads the furnidata from the current source and reindexes atomically. + * Returns the delta list (new/changed entries) from {@link #reindex(List)}. + * Never throws — returns an empty list when the source is unavailable. + */ + public java.util.List reindexFromSource() { + try { + if (this.source == null) return java.util.List.of(); + return reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read()); + } catch (Exception e) { + LOGGER.warn("FurnitureTextProvider.reindexFromSource failed", e); + return java.util.List.of(); + } + } + private static Path resolveSource() { String override = Emulator.getConfig().getValue("items.furnidata.path", ""); if (!override.isEmpty()) { From 9dcd58d0278b45544c5bdb8bd93a3e9ab5346ba3 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 02:13:30 +0200 Subject: [PATCH 21/33] feat(furnidata): audit-log writer for editor furnidata edits --- .../furnieditor/FurnidataAuditLog.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurnidataAuditLog.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurnidataAuditLog.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurnidataAuditLog.java new file mode 100644 index 00000000..105d6eb4 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurnidataAuditLog.java @@ -0,0 +1,32 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.sql.Connection; +import java.sql.PreparedStatement; + +public final class FurnidataAuditLog { + private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataAuditLog.class); + private FurnidataAuditLog() {} + + public static void record(int userId, String classname, String action, + String oldName, String newName, String oldDesc, String newDesc) { + try (Connection c = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement st = c.prepareStatement( + "INSERT INTO furnidata_edit_log (user_id, classname, action, old_name, new_name, old_description, new_description, timestamp) " + + "VALUES (?,?,?,?,?,?,?,?)")) { + st.setInt(1, userId); + st.setString(2, classname); + st.setString(3, action); + st.setString(4, oldName == null ? "" : oldName); + st.setString(5, newName == null ? "" : newName); + st.setString(6, oldDesc == null ? "" : oldDesc); + st.setString(7, newDesc == null ? "" : newDesc); + st.setInt(8, Emulator.getIntUnixTimestamp()); + st.executeUpdate(); + } catch (Exception e) { + LOGGER.error("Failed to write furnidata_edit_log", e); + } + } +} From 392d24b9c52f00c4fa9a58b8cf65c066c9b9fa2c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 02:18:28 +0200 Subject: [PATCH 22/33] =?UTF-8?q?feat(furnieditor):=20FurniEditorUpdateFur?= =?UTF-8?q?nidataEvent=20=E2=80=94=20write=20furnidata=20+=20reindex=20+?= =?UTF-8?q?=20broadcast=2010047?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/eu/habbo/messages/PacketManager.java | 1 + .../eu/habbo/messages/incoming/Incoming.java | 2 + .../FurniEditorUpdateFurnidataEvent.java | 185 ++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java index 40ff2aa0..c9bbfc09 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java @@ -285,6 +285,7 @@ public class PacketManager { this.registerHandler(Incoming.FurniEditorInteractionsEvent, FurniEditorInteractionsEvent.class); this.registerHandler(Incoming.FurniEditorUpdateEvent, FurniEditorUpdateEvent.class); this.registerHandler(Incoming.FurniEditorDeleteEvent, FurniEditorDeleteEvent.class); + this.registerHandler(Incoming.FurniEditorUpdateFurnidataEvent, FurniEditorUpdateFurnidataEvent.class); // Catalog Admin this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java index bd8d1219..85259592 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java @@ -431,6 +431,8 @@ public class Incoming { public static final int FurniEditorInteractionsEvent = 10043; public static final int FurniEditorUpdateEvent = 10044; public static final int FurniEditorDeleteEvent = 10045; + public static final int FurniEditorUpdateFurnidataEvent = 10046; + public static final int FurniEditorRevertFurnidataEvent = 10048; // Catalog Admin public static final int CatalogAdminSavePageEvent = 10050; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java new file mode 100644 index 00000000..5a9b2ecf --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java @@ -0,0 +1,185 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.items.FurnidataEntry; +import com.eu.habbo.habbohotel.items.FurnidataLock; +import com.eu.habbo.habbohotel.items.FurnidataWriter; +import com.eu.habbo.habbohotel.items.FurnitureTextProvider; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Incoming handler 10046 — admin saves a furni name/description in the editor. + * + * Flow: permission check → rate-limit → resolve classname from item_id → + * under FurnidataLock: FurnidataWriter.write → FurnitureTextProvider.reindexFromSource → + * broadcast FurnitureDataReloadComposer (10047) → audit log → respond. + */ +public class FurniEditorUpdateFurnidataEvent extends MessageHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorUpdateFurnidataEvent.class); + + /** Rate-limit: min milliseconds between successive calls per admin user id. */ + private static final long RATE_LIMIT_MS = 1_000L; + + /** Per-admin last-call timestamp map. */ + private static final Map LAST_CALL = new ConcurrentHashMap<>(); + + @Override + public void handle() throws Exception { + Habbo habbo = this.client.getHabbo(); + + // 1. Permission check + if (!habbo.hasPermission(Permission.ACC_CATALOGFURNI)) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No permission")); + return; + } + + // 2. Rate-limit per admin + int adminId = habbo.getHabboInfo().getId(); + long now = System.currentTimeMillis(); + Long last = LAST_CALL.get(adminId); + if (last != null && (now - last) < RATE_LIMIT_MS) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Too many requests")); + return; + } + LAST_CALL.put(adminId, now); + + // 3. Read packet + int itemId = this.packet.readInt(); + JsonObject json; + try { + json = JsonParser.parseString(this.packet.readString()).getAsJsonObject(); + } catch (Exception e) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid JSON data")); + return; + } + + if (itemId <= 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID")); + return; + } + + String name = json.has("name") ? json.get("name").getAsString() : null; + String description = json.has("description") ? json.get("description").getAsString() : null; + + if (name == null && description == null) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No name or description provided")); + return; + } + + // 4. Resolve classname from item_id + String classname = classnameForItem(itemId); + if (classname == null) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found")); + return; + } + + // 5. Write + reindex + broadcast under the shared lock + FurnitureTextProvider provider = + Emulator.getGameEnvironment().getFurnitureTextProvider(); + + if (provider == null || provider.getSource() == null) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Furnidata source not configured")); + return; + } + + // Capture old values (before write) for the audit log + String oldName = provider.getName(classname); + // description is not indexed in the provider — treat as empty string for audit + String oldDesc = ""; + + // FurnidataWriter.write() calls FurnitureTextProvider.sanitize() internally; + // pass the raw values here and use them also for the audit log. + String safeName = (name != null) ? name : ""; + String safeDesc = (description != null) ? description : ""; + + boolean written; + List delta; + + FurnidataLock.LOCK.lock(); + try { + FurnidataWriter writer = new FurnidataWriter( + provider.getSource(), + provider.isSourceDirectory(), + provider.getMaxBytes(), + 3 /* backupKeep */ + ); + written = writer.write(classname, safeName, safeDesc); + if (!written) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Classname not found in furnidata")); + return; + } + + delta = provider.reindexFromSource(); + + if (!delta.isEmpty()) { + int deltaCap = Integer.parseInt( + Emulator.getConfig().getValue("items.furnidata.delta.cap", "500")); + FurnitureDataReloadComposer composer = (delta.size() > deltaCap) + ? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of()) + : new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta); + broadcastToAll(composer); + } + } finally { + FurnidataLock.LOCK.unlock(); + } + + // 6. Audit log (outside lock — DB write, not latency-sensitive) + FurnidataAuditLog.record( + adminId, + classname, + "UPDATE_FURNIDATA", + oldName != null ? oldName : "", + safeName, + oldDesc, + safeDesc + ); + + // 7. Respond success + this.client.sendResponse(new FurniEditorResultComposer(true, "Furnidata updated", itemId)); + LOGGER.info("FurniEditorUpdateFurnidataEvent: admin {} updated furnidata for classname '{}' (item {})", + adminId, classname, itemId); + } + + /** + * Resolves the item_name (classname) from items_base for a given item id. + * Kept static so FurniEditorRevertFurnidataEvent can reuse it. + * + * @return the classname string, or {@code null} if not found or on error. + */ + public static String classnameForItem(int itemId) { + try (Connection c = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement st = c.prepareStatement("SELECT item_name FROM items_base WHERE id = ?")) { + st.setInt(1, itemId); + try (ResultSet rs = st.executeQuery()) { + if (rs.next()) return rs.getString("item_name"); + } + } catch (Exception e) { + LOGGER.warn("classnameForItem: failed to query items_base for id {}", itemId, e); + } + return null; + } + + private static void broadcastToAll(FurnitureDataReloadComposer composer) { + for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) { + if (habbo.getClient() != null) { + habbo.getClient().sendResponse(composer); + } + } + } +} From 1416cd746483157aff940bda2f3ff57bedd688e9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 02:21:21 +0200 Subject: [PATCH 23/33] =?UTF-8?q?feat(furnieditor):=20FurniEditorRevertFur?= =?UTF-8?q?nidataEvent=20=E2=80=94=20restore=20last=20furnidata=20backup?= =?UTF-8?q?=20+=20rebroadcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/eu/habbo/messages/PacketManager.java | 1 + .../FurniEditorRevertFurnidataEvent.java | 118 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorRevertFurnidataEvent.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java index c9bbfc09..ac1455ce 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java @@ -286,6 +286,7 @@ public class PacketManager { this.registerHandler(Incoming.FurniEditorUpdateEvent, FurniEditorUpdateEvent.class); this.registerHandler(Incoming.FurniEditorDeleteEvent, FurniEditorDeleteEvent.class); this.registerHandler(Incoming.FurniEditorUpdateFurnidataEvent, FurniEditorUpdateFurnidataEvent.class); + this.registerHandler(Incoming.FurniEditorRevertFurnidataEvent, FurniEditorRevertFurnidataEvent.class); // Catalog Admin this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorRevertFurnidataEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorRevertFurnidataEvent.java new file mode 100644 index 00000000..02e535be --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorRevertFurnidataEvent.java @@ -0,0 +1,118 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.items.FurnidataEntry; +import com.eu.habbo.habbohotel.items.FurnidataLock; +import com.eu.habbo.habbohotel.items.FurnidataWriter; +import com.eu.habbo.habbohotel.items.FurnitureTextProvider; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * Incoming handler 10048 — admin reverts a furni's furnidata to the last rotating backup. + * + * Flow: permission check → read item_id → resolve classname → under FurnidataLock: + * FurnidataWriter.revertLastBackup → FurnitureTextProvider.reindexFromSource → + * broadcast FurnitureDataReloadComposer (10047) → audit log → respond. + */ +public class FurniEditorRevertFurnidataEvent extends MessageHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorRevertFurnidataEvent.class); + + @Override + public void handle() throws Exception { + Habbo habbo = this.client.getHabbo(); + + // 1. Permission check + if (!habbo.hasPermission(Permission.ACC_CATALOGFURNI)) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No permission")); + return; + } + + // 2. Read packet + int itemId = this.packet.readInt(); + + if (itemId <= 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID")); + return; + } + + // 3. Resolve classname from item_id (reuse static helper from update handler) + String classname = FurniEditorUpdateFurnidataEvent.classnameForItem(itemId); + String classnameForLog = (classname != null) ? classname : "?"; + + // 4. Verify provider is configured + FurnitureTextProvider provider = + Emulator.getGameEnvironment().getFurnitureTextProvider(); + + if (provider == null || provider.getSource() == null) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Furnidata source not configured")); + return; + } + + int adminId = habbo.getHabboInfo().getId(); + + // 5. Revert + reindex + broadcast under the shared lock + boolean reverted; + List delta; + + FurnidataLock.LOCK.lock(); + try { + FurnidataWriter writer = new FurnidataWriter( + provider.getSource(), + provider.isSourceDirectory(), + provider.getMaxBytes(), + 3 /* backupKeep */ + ); + reverted = writer.revertLastBackup(); + if (!reverted) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No backup found to revert")); + return; + } + + delta = provider.reindexFromSource(); + + if (!delta.isEmpty()) { + int deltaCap = Integer.parseInt( + Emulator.getConfig().getValue("items.furnidata.delta.cap", "500")); + FurnitureDataReloadComposer composer = (delta.size() > deltaCap) + ? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of()) + : new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta); + broadcastToAll(composer); + } + } finally { + FurnidataLock.LOCK.unlock(); + } + + // 6. Audit log (outside lock — DB write, not latency-sensitive) + FurnidataAuditLog.record( + adminId, + classnameForLog, + "REVERT_FURNIDATA", + "", // previous state unknown at this point + "", + "", + "" + ); + + // 7. Respond success + this.client.sendResponse(new FurniEditorResultComposer(true, "Furnidata reverted", itemId)); + LOGGER.info("FurniEditorRevertFurnidataEvent: admin {} reverted furnidata for classname '{}' (item {})", + adminId, classnameForLog, itemId); + } + + private static void broadcastToAll(FurnitureDataReloadComposer composer) { + for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) { + if (habbo.getClient() != null) { + habbo.getClient().sendResponse(composer); + } + } + } +} From f55b182d8eab602c3f982db1ff670d1c116be24b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 02:25:15 +0200 Subject: [PATCH 24/33] feat(furnieditor): make item_name immutable (remove from DB update whitelist) --- .../habbo/messages/incoming/furnieditor/FurniEditorHelper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java index 0de0e1b6..cd7f7a82 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java @@ -82,7 +82,7 @@ public class FurniEditorHelper { * Prevents SQL injection via arbitrary column names. */ public static final java.util.Set ALLOWED_UPDATE_FIELDS = java.util.Set.of( - "item_name", "public_name", "sprite_id", "type", "width", "length", + "public_name", "sprite_id", "type", "width", "length", "stack_height", "allow_stack", "allow_walk", "allow_sit", "allow_lay", "allow_gift", "allow_trade", "allow_recycle", "allow_marketplace_sell", "allow_inventory_stack", "interaction_type", "interaction_modes_count", From 50444003bb4c991acd29ce69e9124ca101763584 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 02:28:26 +0200 Subject: [PATCH 25/33] fix(furnidata): correct revert audit enum, sanitize audit values, config-driven maxBytes --- .../eu/habbo/habbohotel/items/FurnitureTextProvider.java | 4 ++-- .../furnieditor/FurniEditorRevertFurnidataEvent.java | 2 +- .../furnieditor/FurniEditorUpdateFurnidataEvent.java | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index 89297399..abe061c0 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -71,7 +71,7 @@ public class FurnitureTextProvider { /** Returns the byte cap used when reading furnidata files. */ public long getMaxBytes() { - return DEFAULT_MAX_BYTES; + return Long.parseLong(com.eu.habbo.Emulator.getConfig().getValue("items.furnidata.max.bytes", String.valueOf(DEFAULT_MAX_BYTES))); } /** @@ -155,7 +155,7 @@ public class FurnitureTextProvider { * furni names (controlled, predominantly ASCII source). Lone/astral surrogates are not * specially handled. */ - static String sanitize(String value) { + public static String sanitize(String value) { if (value == null) return ""; StringBuilder sb = new StringBuilder(Math.min(value.length(), MAX_LEN)); for (int i = 0; i < value.length() && sb.length() < MAX_LEN; i++) { diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorRevertFurnidataEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorRevertFurnidataEvent.java index 02e535be..1ec588ce 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorRevertFurnidataEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorRevertFurnidataEvent.java @@ -95,7 +95,7 @@ public class FurniEditorRevertFurnidataEvent extends MessageHandler { FurnidataAuditLog.record( adminId, classnameForLog, - "REVERT_FURNIDATA", + "revert", "", // previous state unknown at this point "", "", diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java index 5a9b2ecf..4607364e 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java @@ -143,11 +143,11 @@ public class FurniEditorUpdateFurnidataEvent extends MessageHandler { FurnidataAuditLog.record( adminId, classname, - "UPDATE_FURNIDATA", + "edit", oldName != null ? oldName : "", - safeName, + FurnitureTextProvider.sanitize(safeName), oldDesc, - safeDesc + FurnitureTextProvider.sanitize(safeDesc) ); // 7. Respond success From 17629c210cf1d2fc709063b6a59d6a031cbd3536 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 12:54:01 +0200 Subject: [PATCH 26/33] feat(furnieditor): search also matches furnidata display names --- .../items/FurnitureTextProvider.java | 21 ++++++++++++++ .../furnieditor/FurniEditorSearchEvent.java | 28 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index abe061c0..ab2ae51f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -167,5 +167,26 @@ public class FurnitureTextProvider { return sb.toString(); } + /** + * Returns all lowercased base classnames whose furnidata display name contains + * {@code query} (case-insensitive, substring). Results are capped at 200 to + * bound SQL IN-clause size. Returns an empty list when query is null/blank. + */ + public java.util.List findClassnamesByName(String query) { + java.util.List out = new java.util.ArrayList<>(); + if (query == null) return out; + String q = query.trim().toLowerCase(Locale.ROOT); + if (q.isEmpty()) return out; + Map idx = this.index; // local ref (volatile) + for (Map.Entry e : idx.entrySet()) { + FurniText t = e.getValue(); + if (t != null && t.name() != null && t.name().toLowerCase(Locale.ROOT).contains(q)) { + out.add(e.getKey()); // key is the lowercased base classname + if (out.size() >= 200) break; // bound IN-clause size + } + } + return out; + } + private record FurniText(int id, FurnitureType type, String name, String description) {} } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java index bfdc229c..769f41cd 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java @@ -64,6 +64,34 @@ public class FurniEditorSearchEvent extends MessageHandler { params.add(type); } + // Extend search with furnidata display-name matches (server-authoritative names in JSON). + // Appends: OR (LOWER(item_name) IN (?,?,...) [AND type=?]) + // Both branches carry their own type filter, so type scoping is preserved. + // Params: [existing LIKE params] [existing type?] [furniCns...] [type again?] + if (!query.isEmpty()) { + java.util.List furniCns = Emulator.getGameEnvironment() + .getFurnitureTextProvider() + .findClassnamesByName(query); + if (!furniCns.isEmpty()) { + // Build: OR (LOWER(item_name) IN (?,?,...) [AND type = ?]) + StringBuilder orBranch = new StringBuilder(" OR (LOWER(item_name) IN ("); + for (int i = 0; i < furniCns.size(); i++) { + if (i > 0) orBranch.append(", "); + orBranch.append('?'); + } + orBranch.append(')'); + if (type != null && !type.isEmpty()) { + orBranch.append(" AND type = ?"); + } + orBranch.append(')'); + whereClause.append(orBranch); + params.addAll(furniCns); + if (type != null && !type.isEmpty()) { + params.add(type); + } + } + } + // Count total int total = 0; String countSql = "SELECT COUNT(*) FROM items_base " + whereClause; From 57c36da79544a6371315f80a61a7084ccfa8fce5 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 14:03:58 +0200 Subject: [PATCH 27/33] feat(furni-editor): mirror furnidata display name into items_base.public_name On a successful furnidata name update (10046), after the JSON write + 10047 broadcast, also UPDATE items_base.public_name to the new (sanitized) name and refresh the in-memory Item cache via loadItems() so Item.getFullName() stays consistent without a restart. Guarded by name != null (description-only edits never blank the column), runs only on the success path, outside FurnidataLock, with a parameterized statement. --- .../FurniEditorUpdateFurnidataEvent.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java index 4607364e..fcdea56c 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java @@ -139,6 +139,24 @@ public class FurniEditorUpdateFurnidataEvent extends MessageHandler { FurnidataLock.LOCK.unlock(); } + // 5b. Auto-mirror the new display name into items_base.public_name (DB) so the + // server-side fallback (Item.getFullName) and the editor's read-only + // "Public Name" field stay in sync with the furnidata edit. Only when a + // name was actually supplied (description-only edits must not blank it). + // Kept outside FurnidataLock (independent DB write, like the audit log). + if (name != null) { + try (Connection c = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement st = c.prepareStatement("UPDATE items_base SET public_name = ? WHERE id = ?")) { + st.setString(1, FurnitureTextProvider.sanitize(safeName)); + st.setInt(2, itemId); + st.executeUpdate(); + // Refresh the in-memory Item cache (Item.fullName) in place — no restart needed. + Emulator.getGameEnvironment().getItemManager().loadItems(); + } catch (Exception e) { + LOGGER.warn("Failed to mirror furnidata name into items_base.public_name for item {}", itemId, e); + } + } + // 6. Audit log (outside lock — DB write, not latency-sensitive) FurnidataAuditLog.record( adminId, From 2b8ce3cd91af1d20a446b4eb2a09aa14b8cde0fb Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 15:09:10 +0200 Subject: [PATCH 28/33] feat(furni-editor): server-side sort for the editor search Read sortField/sortDir from the search packet and ORDER BY a whitelisted items_base column (id/sprite_id/item_name/public_name/type/interaction_type) with a stable id tie-break, so sorting orders the whole result set instead of just the page the client received. Column names come from a fixed whitelist (never raw input) so the dynamic ORDER BY stays injection-safe. --- .../furnieditor/FurniEditorSearchEvent.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java index 769f41cd..274a5dbd 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java @@ -27,6 +27,8 @@ public class FurniEditorSearchEvent extends MessageHandler { String query = this.packet.readString(); String type = this.packet.readString(); int page = this.packet.readInt(); + String sortField = this.packet.readString(); + String sortDir = this.packet.readString(); // Input validation if (query.length() > 100) { @@ -92,10 +94,25 @@ public class FurniEditorSearchEvent extends MessageHandler { } } + // Resolve a SAFE ORDER BY from the whitelisted sort field/direction + // (column names are never taken from raw user input — injection-proof). + String orderColumn; + switch (sortField == null ? "" : sortField) { + case "spriteId": orderColumn = "sprite_id"; break; + case "itemName": orderColumn = "item_name"; break; + case "publicName": orderColumn = "public_name"; break; + case "type": orderColumn = "type"; break; + case "interactionType": orderColumn = "interaction_type"; break; + case "id": + default: orderColumn = "id"; break; + } + String orderDir = "desc".equalsIgnoreCase(sortDir) ? "DESC" : "ASC"; + // Count total int total = 0; String countSql = "SELECT COUNT(*) FROM items_base " + whereClause; - String dataSql = "SELECT * FROM items_base " + whereClause + " ORDER BY id ASC LIMIT ? OFFSET ?"; + String dataSql = "SELECT * FROM items_base " + whereClause + + " ORDER BY " + orderColumn + " " + orderDir + ", id ASC LIMIT ? OFFSET ?"; List> items = new ArrayList<>(); From 4621ed62b780c3720ecaea727bc4f9509b6d34ff Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 15:26:56 +0200 Subject: [PATCH 29/33] feat(furni-editor): server-side Habbo furnidata import (packet 10049) FurniEditorImportTextEvent (incoming 10049, ACC_CATALOGFURNI): resolves the classname, fetches the admin-configured furnidata URL via HttpClient with a TTL cache (furni.editor.import.url / .cache.ms, default habbo.it), finds name/description by classname and returns them via FurniEditorImportTextResultComposer (outgoing 10049). URL is DB-configured only (no client-supplied URL -> no SSRF); serves stale cache on failure. --- .../com/eu/habbo/messages/PacketManager.java | 1 + .../eu/habbo/messages/incoming/Incoming.java | 1 + .../FurniEditorImportTextEvent.java | 139 ++++++++++++++++++ .../eu/habbo/messages/outgoing/Outgoing.java | 1 + .../FurniEditorImportTextResultComposer.java | 33 +++++ 5 files changed, 175 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorImportTextEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorImportTextResultComposer.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java index ac1455ce..9f372c5b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java @@ -287,6 +287,7 @@ public class PacketManager { this.registerHandler(Incoming.FurniEditorDeleteEvent, FurniEditorDeleteEvent.class); this.registerHandler(Incoming.FurniEditorUpdateFurnidataEvent, FurniEditorUpdateFurnidataEvent.class); this.registerHandler(Incoming.FurniEditorRevertFurnidataEvent, FurniEditorRevertFurnidataEvent.class); + this.registerHandler(Incoming.FurniEditorImportTextEvent, FurniEditorImportTextEvent.class); // Catalog Admin this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java index 85259592..43ab4d44 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java @@ -433,6 +433,7 @@ public class Incoming { public static final int FurniEditorDeleteEvent = 10045; public static final int FurniEditorUpdateFurnidataEvent = 10046; public static final int FurniEditorRevertFurnidataEvent = 10048; + public static final int FurniEditorImportTextEvent = 10049; // Catalog Admin public static final int CatalogAdminSavePageEvent = 10050; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorImportTextEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorImportTextEvent.java new file mode 100644 index 00000000..023fa6ba --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorImportTextEvent.java @@ -0,0 +1,139 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorImportTextResultComposer; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +/** + * Incoming 10049 — admin imports the official Habbo display name/description for a + * furni's classname from a configured furnidata URL (e.g. + * https://www.habbo.it/gamedata/furnidata_json/1). The fetched text only POPULATES + * the editor fields client-side; the admin reviews and Saves via the normal flow. + * + * Source URL is admin-configured in emulator_settings ({@code furni.editor.import.url}), + * never supplied by the client (no SSRF). The remote furnidata is cached with a TTL. + */ +public class FurniEditorImportTextEvent extends MessageHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorImportTextEvent.class); + private static final List SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes"); + + // Shared TTL cache (the remote furnidata is multi-MB — do not refetch per click). + private static volatile JsonObject CACHE; + private static volatile String CACHE_URL; + private static volatile long CACHE_TIME; + + @Override + public void handle() throws Exception { + if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No permission")); + return; + } + + int itemId = this.packet.readInt(); + if (itemId <= 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID")); + return; + } + + String classname = FurniEditorUpdateFurnidataEvent.classnameForItem(itemId); + if (classname == null) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found")); + return; + } + String cn = classname.trim().toLowerCase(Locale.ROOT); + + String url = Emulator.getConfig().getValue( + "furni.editor.import.url", "https://www.habbo.it/gamedata/furnidata_json/1"); + if (url == null || !(url.startsWith("http://") || url.startsWith("https://"))) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Import source not configured")); + return; + } + + JsonObject root = fetchCached(url); + if (root == null) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Could not fetch Habbo furnidata")); + return; + } + + String foundName = null, foundDesc = null; + outer: + for (String section : SECTIONS) { + if (!root.has(section) || !root.get(section).isJsonObject()) continue; + JsonObject sec = root.getAsJsonObject(section); + if (!sec.has("furnitype") || !sec.get("furnitype").isJsonArray()) continue; + for (JsonElement el : sec.getAsJsonArray("furnitype")) { + if (!el.isJsonObject()) continue; + JsonObject o = el.getAsJsonObject(); + if (!o.has("classname")) continue; + if (o.get("classname").getAsString().trim().toLowerCase(Locale.ROOT).equals(cn)) { + foundName = (o.has("name") && !o.get("name").isJsonNull()) ? o.get("name").getAsString() : ""; + foundDesc = (o.has("description") && !o.get("description").isJsonNull()) ? o.get("description").getAsString() : ""; + break outer; + } + } + } + + boolean found = (foundName != null); + this.client.sendResponse(new FurniEditorImportTextResultComposer( + found, found ? foundName : "", found ? foundDesc : "", classname)); + LOGGER.info("FurniEditorImportTextEvent: admin {} import for classname '{}' (item {}) -> found={}", + this.client.getHabbo().getHabboInfo().getId(), classname, itemId, found); + } + + /** Fetch the remote furnidata JSON with a TTL cache (serves stale on failure). */ + private static synchronized JsonObject fetchCached(String url) { + long ttlMs; + try { + ttlMs = Long.parseLong(Emulator.getConfig().getValue("furni.editor.import.cache.ms", "600000")); + } catch (Exception e) { + ttlMs = 600000L; + } + + long now = System.currentTimeMillis(); + if (CACHE != null && url.equals(CACHE_URL) && (now - CACHE_TIME) < ttlMs) { + return CACHE; + } + + try { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(20)) + .header("User-Agent", "Arcturus-FurniEditor") + .GET() + .build(); + HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) { + LOGGER.warn("FurniEditorImportTextEvent: fetch {} returned HTTP {}", url, resp.statusCode()); + return CACHE; // serve stale if available + } + JsonObject root = JsonParser.parseString(resp.body()).getAsJsonObject(); + CACHE = root; + CACHE_URL = url; + CACHE_TIME = now; + return root; + } catch (Exception e) { + LOGGER.warn("FurniEditorImportTextEvent: failed to fetch {}", url, e); + return CACHE; // serve stale if available + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java index 72d001d6..4db4519d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java @@ -571,6 +571,7 @@ public class Outgoing { public static final int FurniEditorInteractionsComposer = 10043; public static final int FurniEditorResultComposer = 10044; public static final int FurnitureDataReloadComposer = 10047; // CUSTOM + public static final int FurniEditorImportTextResultComposer = 10049; // CUSTOM // Catalog Admin public static final int CatalogAdminResultComposer = 10059; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorImportTextResultComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorImportTextResultComposer.java new file mode 100644 index 00000000..81a8ab25 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorImportTextResultComposer.java @@ -0,0 +1,33 @@ +package com.eu.habbo.messages.outgoing.furnieditor; + +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +/** + * Outgoing 10049 — result of an "import texts from Habbo" request. + * Carries the official furnidata name/description for a classname (or found=false). + */ +public class FurniEditorImportTextResultComposer extends MessageComposer { + private final boolean found; + private final String name; + private final String description; + private final String classname; + + public FurniEditorImportTextResultComposer(boolean found, String name, String description, String classname) { + this.found = found; + this.name = name == null ? "" : name; + this.description = description == null ? "" : description; + this.classname = classname == null ? "" : classname; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.FurniEditorImportTextResultComposer); + this.response.appendBoolean(this.found); + this.response.appendString(this.name); + this.response.appendString(this.description); + this.response.appendString(this.classname); + return this.response; + } +} From 76eb1ecd0515805b7d88f084e8a8f0fdf2700f01 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 15:48:19 +0200 Subject: [PATCH 30/33] fix(furnidata): furnidata_edit_log charset utf8mb3 -> utf8mb4 utf8mb3 is deprecated (removed in MySQL 9.0) and can lose data on emoji / 4-byte characters in audited furni names/descriptions. Use utf8mb4/utf8mb4_unicode_ci (live table converted via ALTER). --- .../Own_Database_RunFirst/020_furnidata_edit_log.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Database Updates/Own_Database_RunFirst/020_furnidata_edit_log.sql b/Database Updates/Own_Database_RunFirst/020_furnidata_edit_log.sql index 99553985..d6a6a991 100644 --- a/Database Updates/Own_Database_RunFirst/020_furnidata_edit_log.sql +++ b/Database Updates/Own_Database_RunFirst/020_furnidata_edit_log.sql @@ -15,7 +15,7 @@ CREATE TABLE IF NOT EXISTS `furnidata_edit_log` ( PRIMARY KEY (`id`), INDEX `idx_classname` (`classname`), INDEX `idx_user` (`user_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES ('items.furnidata.edit.backup.keep','10'), From 0e7138a721368d24a9ccd5ba2a76da4063a82f62 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 18:27:04 +0200 Subject: [PATCH 31/33] feat(furnidata): seed furnidata feature config keys (021 migration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The names-server + watch + import config keys read by FurnitureTextProvider / FurnidataWatcher / FurniEditorImportTextEvent were never seeded — a fresh install logged 'Config key not found' for each and they were not DB-editable. Seed portable defaults (items.furnidata.path empty → derives from furni.editor.asset.base.path; booleans true/false; import URL = habbo.it). --- .../021_furnidata_config.sql | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 Database Updates/Own_Database_RunFirst/021_furnidata_config.sql diff --git a/Database Updates/Own_Database_RunFirst/021_furnidata_config.sql b/Database Updates/Own_Database_RunFirst/021_furnidata_config.sql new file mode 100644 index 00000000..9a41bae4 --- /dev/null +++ b/Database Updates/Own_Database_RunFirst/021_furnidata_config.sql @@ -0,0 +1,27 @@ +-- 021_furnidata_config.sql +-- Seeds the furnidata feature config keys read at runtime by +-- FurnitureTextProvider / FurnidataReader / FurnidataWatcher and +-- FurniEditorImportTextEvent. Without these rows a fresh install logs +-- "Config key not found" for each (ConfigurationManager logs ERROR even +-- when a default is supplied) and the values are not editable from the DB. +-- +-- Notes: +-- * *.enabled keys are read via Boolean.parseBoolean → use true/false (NOT 1/0). +-- * items.furnidata.path is intentionally empty: when blank the source is +-- derived from furni.editor.asset.base.path (seeded by 004_furni_editor.sql) +-- → /furnidata (split-tier) or /FurnitureData.json (single file). +-- * Editor write-path keys (items.furnidata.edit.*) are seeded by 020. + +INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES +-- Server-authoritative furni names (source of truth = furnidata JSON) +('items.furnidata.names.enabled','true'), +('items.furnidata.path',''), +('items.furnidata.max.bytes','67108864'), +-- Live-reload watcher +('items.furnidata.watch.enabled','true'), +('items.furnidata.watch.debounce.ms','750'), +('items.furnidata.watch.min.interval.ms','5000'), +('items.furnidata.delta.cap','500'), +-- Furni editor: import official names/descriptions from Habbo +('furni.editor.import.url','https://www.habbo.it/gamedata/furnidata_json/1'), +('furni.editor.import.cache.ms','600000'); From 564c8d647e834fcceef67509d2b8b8b02db5ec0e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 7 Jun 2026 00:34:50 +0200 Subject: [PATCH 32/33] fix(messenger): send friend look for offline friends in friend list FriendsComposer only serialized a buddy's look when online, sending an empty string for offline friends. The look is already loaded from the DB for every friend in Messenger.loadFriends (SELECT users.look), so the gate just discarded valid data: offline friends rendered with the anonymous/standard avatar in the friend list and messenger, while their profile (fetched separately) showed the real figure. Always serialize row.getLook(). StaffChatBuddy keeps a non-null look ("ADM") so there is no NPE risk, and UpdateFriendComposer already sent the look unconditionally, so this only aligns the initial friend list. --- .../com/eu/habbo/messages/outgoing/friends/FriendsComposer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/FriendsComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/FriendsComposer.java index b84039a5..0891c091 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/FriendsComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/friends/FriendsComposer.java @@ -40,7 +40,7 @@ public class FriendsComposer extends MessageComposer { this.response.appendInt(row.getGender().equals(HabboGender.M) ? 0 : 1); this.response.appendBoolean(row.getOnline() == 1); this.response.appendBoolean(row.inRoom()); //IN ROOM - this.response.appendString(row.getOnline() == 1 ? row.getLook() : ""); + this.response.appendString(row.getLook()); // send look for offline friends too (loaded from DB) this.response.appendInt(row.getCategoryId()); //Friends category this.response.appendString(row.getMotto()); this.response.appendString(""); //Last seen as DATETIMESTRING From f5bf4baa79abcf26b4b182e0e2339c0387c9d53d Mon Sep 17 00:00:00 2001 From: DuckieTM Date: Sun, 7 Jun 2026 08:54:43 +0200 Subject: [PATCH 33/33] =?UTF-8?q?=F0=9F=86=99=20move=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Database Updates/010_furnidata_edit_log.sql | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Database Updates/010_furnidata_edit_log.sql diff --git a/Database Updates/010_furnidata_edit_log.sql b/Database Updates/010_furnidata_edit_log.sql new file mode 100644 index 00000000..d6a6a991 --- /dev/null +++ b/Database Updates/010_furnidata_edit_log.sql @@ -0,0 +1,22 @@ +-- 020_furnidata_edit_log.sql +-- Audit trail for furnidata name/description edits made through the furni editor, +-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are +-- read via Boolean.parseBoolean (true/false), but these two are numeric. +CREATE TABLE IF NOT EXISTS `furnidata_edit_log` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `classname` varchar(255) NOT NULL, + `action` enum('edit','revert') NOT NULL DEFAULT 'edit', + `old_name` varchar(256) NOT NULL DEFAULT '', + `new_name` varchar(256) NOT NULL DEFAULT '', + `old_description` varchar(256) NOT NULL DEFAULT '', + `new_description` varchar(256) NOT NULL DEFAULT '', + `timestamp` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_classname` (`classname`), + INDEX `idx_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES +('items.furnidata.edit.backup.keep','10'), +('items.furnidata.edit.ratelimit.ms','2000');