From f9644d83b75d5edfa36fd8c26e0c437606004f4e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 4 Jun 2026 20:45:10 +0200 Subject: [PATCH 01/31] 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/31] 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/31] 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/31] 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/31] =?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/31] 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/31] 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/31] 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/31] =?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/31] 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/31] 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/31] 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/31] 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/31] =?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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] =?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/31] =?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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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');