Compare commits

...

37 Commits

Author SHA1 Message Date
github-actions[bot] bb4b9fb7f4 🆙 Bump version to 4.2.38 [skip ci] 2026-06-07 06:56:00 +00:00
DuckieTM 84d7968b76 Merge pull request #158 from duckietm/dev
Dev
2026-06-07 08:55:03 +02:00
DuckieTM f5bf4baa79 🆙 move SQL 2026-06-07 08:54:43 +02:00
DuckieTM 4a02d22061 Merge pull request #157 from simoleo89/fix/messenger-offline-friend-look
fix(messenger): send friend look for offline friends in friend list
2026-06-07 08:23:17 +02:00
DuckieTM 14854efaeb Merge pull request #156 from simoleo89/feat/furni-editor
feat(furni): server-authoritative furni names + in-client Furni Editor (edit/search/sort/import)
2026-06-07 08:23:00 +02:00
simoleo89 564c8d647e fix(messenger): send friend look for offline friends in friend list
FriendsComposer only serialized a buddy's look when online, sending an
empty string for offline friends. The look is already loaded from the DB
for every friend in Messenger.loadFriends (SELECT users.look), so the
gate just discarded valid data: offline friends rendered with the
anonymous/standard avatar in the friend list and messenger, while their
profile (fetched separately) showed the real figure.

Always serialize row.getLook(). StaffChatBuddy keeps a non-null look
("ADM") so there is no NPE risk, and UpdateFriendComposer already sent
the look unconditionally, so this only aligns the initial friend list.
2026-06-07 00:34:50 +02:00
simoleo89 0e7138a721 feat(furnidata): seed furnidata feature config keys (021 migration)
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).
2026-06-06 18:27:12 +02:00
simoleo89 76eb1ecd05 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).
2026-06-06 17:31:13 +02:00
simoleo89 4621ed62b7 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.
2026-06-06 17:31:13 +02:00
simoleo89 2b8ce3cd91 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.
2026-06-06 17:31:12 +02:00
simoleo89 57c36da795 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.
2026-06-06 17:31:12 +02:00
simoleo89 17629c210c feat(furnieditor): search also matches furnidata display names 2026-06-06 17:31:12 +02:00
simoleo89 50444003bb fix(furnidata): correct revert audit enum, sanitize audit values, config-driven maxBytes 2026-06-06 17:31:12 +02:00
simoleo89 f55b182d8e feat(furnieditor): make item_name immutable (remove from DB update whitelist) 2026-06-06 17:31:12 +02:00
simoleo89 1416cd7464 feat(furnieditor): FurniEditorRevertFurnidataEvent — restore last furnidata backup + rebroadcast 2026-06-06 17:31:12 +02:00
simoleo89 392d24b9c5 feat(furnieditor): FurniEditorUpdateFurnidataEvent — write furnidata + reindex + broadcast 10047 2026-06-06 17:31:12 +02:00
simoleo89 9dcd58d027 feat(furnidata): audit-log writer for editor furnidata edits 2026-06-06 17:31:12 +02:00
simoleo89 3b85d5fa34 feat(furnidata): expose source kind, maxBytes, reindexFromSource on the provider 2026-06-06 17:31:12 +02:00
simoleo89 43c2c2b0f1 feat(furnidata): split-tier write to winning tier with path-traversal guard 2026-06-06 17:31:12 +02:00
simoleo89 a815c1b99d feat(furnidata): FurnidataWriter single-file comment-preserving atomic write + backup 2026-06-06 17:31:12 +02:00
simoleo89 caf6ad35fa feat(furnidata): shared lock serializing watcher reindex and editor writes 2026-06-06 17:31:11 +02:00
simoleo89 258a95a269 feat(furnidata): add furnidata_edit_log audit table + editor write config keys 2026-06-06 17:31:11 +02:00
simoleo89 4944d41410 fix(items): watcher registers split-tier subdirs, real stop()/close, key.reset guard 2026-06-06 17:31:11 +02:00
simoleo89 8fb117ae73 feat(items): furnidata file watcher — debounce, throttle, delta cap to reload-hint, broadcast 2026-06-06 17:31:11 +02:00
simoleo89 7f4f7d6da9 feat(items): reindex returns sanitized furnidata delta 2026-06-06 17:31:11 +02:00
simoleo89 0cf46471f2 feat(items): FurnitureDataReloadComposer (header 10047, delta + reload-hint) 2026-06-06 17:31:11 +02:00
simoleo89 3a505cd559 fix(items): null-safe getDisplayName + log missing items.furnidata.path 2026-06-06 17:31:11 +02:00
simoleo89 f2e0f6e2d5 feat(items): source server-pronounced furni names from furnidata (6 sites) 2026-06-06 17:31:11 +02:00
simoleo89 d73573e7c5 feat(items): Item.getDisplayName() — furnidata name with public_name fallback 2026-06-06 17:31:11 +02:00
simoleo89 efb88e5957 feat(items): construct FurnitureTextProvider after ItemManager load 2026-06-06 17:31:11 +02:00
simoleo89 e7e75a285b feat(items): config-driven furnidata source resolution + init 2026-06-06 17:31:11 +02:00
simoleo89 28c3e93945 fix(items): Locale.ROOT case-folding + document sanitize cap unit + tighten cap test 2026-06-06 17:31:11 +02:00
simoleo89 5bf1d42cfb feat(items): FurnitureTextProvider — volatile index, sanitize, toggle 2026-06-06 17:31:10 +02:00
simoleo89 b162b3f4d8 fix(items): guard oversized manifest NPE in FurnidataReader + document JSON5 trailing-comma limit 2026-06-06 17:31:10 +02:00
simoleo89 86498b6b4c feat(items): FurnidataReader (single + split JSON5, path-guard, size-cap, fail-safe) 2026-06-06 17:31:10 +02:00
simoleo89 964f388594 feat(items): FurnidataEntry record 2026-06-06 17:31:10 +02:00
simoleo89 f9644d83b7 test: add JUnit 5 + surefire harness 2026-06-06 17:31:10 +02:00
33 changed files with 1875 additions and 11 deletions
@@ -0,0 +1,22 @@
-- 020_furnidata_edit_log.sql
-- Audit trail for furnidata name/description edits made through the furni editor,
-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are
-- read via Boolean.parseBoolean (true/false), but these two are numeric.
CREATE TABLE IF NOT EXISTS `furnidata_edit_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`classname` varchar(255) NOT NULL,
`action` enum('edit','revert') NOT NULL DEFAULT 'edit',
`old_name` varchar(256) NOT NULL DEFAULT '',
`new_name` varchar(256) NOT NULL DEFAULT '',
`old_description` varchar(256) NOT NULL DEFAULT '',
`new_description` varchar(256) NOT NULL DEFAULT '',
`timestamp` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_classname` (`classname`),
INDEX `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
('items.furnidata.edit.backup.keep','10'),
('items.furnidata.edit.ratelimit.ms','2000');
@@ -0,0 +1,22 @@
-- 020_furnidata_edit_log.sql
-- Audit trail for furnidata name/description edits made through the furni editor,
-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are
-- read via Boolean.parseBoolean (true/false), but these two are numeric.
CREATE TABLE IF NOT EXISTS `furnidata_edit_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`classname` varchar(255) NOT NULL,
`action` enum('edit','revert') NOT NULL DEFAULT 'edit',
`old_name` varchar(256) NOT NULL DEFAULT '',
`new_name` varchar(256) NOT NULL DEFAULT '',
`old_description` varchar(256) NOT NULL DEFAULT '',
`new_description` varchar(256) NOT NULL DEFAULT '',
`timestamp` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_classname` (`classname`),
INDEX `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
('items.furnidata.edit.backup.keep','10'),
('items.furnidata.edit.ratelimit.ms','2000');
@@ -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)
-- → <base>/furnidata (split-tier) or <base>/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');
+16 -2
View File
@@ -6,7 +6,7 @@
<groupId>com.eu.habbo</groupId>
<artifactId>Habbo</artifactId>
<version>4.2.37</version>
<version>4.2.38</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -62,6 +62,12 @@
<show>public</show>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
@@ -172,12 +178,20 @@
<version>0.4</version>
</dependency>
<!-- Jakarta Mail used by the built-in forgot-password endpoint
<!-- Jakarta Mail used by the built-in forgot-password endpoint
when smtp.* keys are configured in emulator_settings -->
<dependency>
<groupId>org.eclipse.angus</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.3</version>
</dependency>
<!-- JUnit Jupiter -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -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;
}
@@ -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;
}
}
@@ -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) {
}
@@ -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() {}
}
@@ -0,0 +1,172 @@
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<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
private static final List<String> SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes");
private final Path source;
private final long maxBytes;
public FurnidataReader(Path source, long maxBytes) {
this.source = source;
this.maxBytes = maxBytes;
}
public List<FurnidataEntry> read() {
List<FurnidataEntry> 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<FurnidataEntry> out) {
List<String> 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<String> readManifestList(Path dir, String key, List<String> fallback) {
for (String name : MANIFEST_NAMES) {
Path m = dir.resolve(name);
if (!Files.exists(m)) continue;
try {
String raw = readJson5Capped(m);
if (raw == null) continue;
JsonObject obj = JsonParser.parseString(raw).getAsJsonObject();
if (obj.has(key) && obj.get(key).isJsonArray()) {
List<String> 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<FurnidataEntry> 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.
* 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());
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");
}
}
@@ -0,0 +1,153 @@
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.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;
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.
* For the split-tier directory layout, the base dir AND its immediate
* subdirectories are registered. 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 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.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"));
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;
WatchService local = this.ws;
if (local != null) {
try { local.close(); } catch (IOException ignored) { }
}
}
private void run() {
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 = service.take();
key.pollEvents();
Thread.sleep(this.debounceMs);
key.pollEvents();
if (!key.reset()) {
LOGGER.warn("FurnidataWatcher: watch key invalidated (directory removed?) — stopping");
break;
}
try {
onChange();
} catch (Exception e) {
LOGGER.warn("FurnidataWatcher: onChange failed", e);
}
}
} 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<Path> 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() {
FurnidataLock.LOCK.lock();
try {
Path source = this.provider.getSource();
if (source == null) return;
List<FurnidataEntry> 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;
}
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();
}
}
private void broadcast(FurnitureDataReloadComposer composer) {
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
if (habbo.getClient() != null) {
habbo.getClient().sendResponse(composer);
}
}
}
}
@@ -0,0 +1,272 @@
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;
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 {
/** Default tier names in override order (later = higher priority, wins on conflict). */
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
/** Manifest filenames tried in order (json5 first, plain json second). */
private static final List<String> 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;
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": "<cn>". 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();
}
/**
* 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.
*
* <p>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<Path> splitTierFilesInOrder() throws IOException {
Path base = source.toAbsolutePath().normalize();
List<String> tiers = manifestList(base, "tiers", DEFAULT_TIERS);
List<Path> 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<String> manifestList(Path dir, String key, List<String> 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<String> 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 {
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<Path> 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;
}
}
@@ -0,0 +1,192 @@
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;
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 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<String, FurniText> index = Map.of();
private volatile Path source;
private FurnidataWatcher watcher;
public FurnitureTextProvider(boolean enabled) {
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 {
this.source = resolveSource();
if (this.source == null) {
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved — names fall back to public_name");
return;
}
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"))) {
if (this.watcher != null) this.watcher.stop();
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;
}
/** 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 Long.parseLong(com.eu.habbo.Emulator.getConfig().getValue("items.furnidata.max.bytes", String.valueOf(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<FurnidataEntry> 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()) {
Path p = Paths.get(override);
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;
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, swap it in atomically, and return the
* changed/added entries (sanitized) as the delta versus the previous index.
*/
public java.util.List<FurnidataEntry> reindex(java.util.List<FurnidataEntry> entries) {
Map<String, FurniText> 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<String, FurniText> prev = this.index;
java.util.List<FurnidataEntry> delta = new java.util.ArrayList<>();
for (Map.Entry<String, FurniText> 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. */
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(Locale.ROOT);
return base.isEmpty() ? null : base;
}
/**
* 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.
*/
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++) {
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();
}
/**
* 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<String> findClassnamesByName(String query) {
java.util.List<String> out = new java.util.ArrayList<>();
if (query == null) return out;
String q = query.trim().toLowerCase(Locale.ROOT);
if (q.isEmpty()) return out;
Map<String, FurniText> idx = this.index; // local ref (volatile)
for (Map.Entry<String, FurniText> 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) {}
}
@@ -167,6 +167,20 @@ 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() != null)
? Emulator.getGameEnvironment().getFurnitureTextProvider()
: null;
String name = (provider != null) ? provider.getName(this.name) : null;
if (name != null && !name.isBlank()) return name;
return (this.fullName != null) ? this.fullName : "";
}
public FurnitureType getType() {
return this.type;
}
@@ -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();
}
@@ -285,6 +285,9 @@ 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);
this.registerHandler(Incoming.FurniEditorRevertFurnidataEvent, FurniEditorRevertFurnidataEvent.class);
this.registerHandler(Incoming.FurniEditorImportTextEvent, FurniEditorImportTextEvent.class);
// Catalog Admin
this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class);
@@ -431,6 +431,9 @@ 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;
public static final int FurniEditorImportTextEvent = 10049;
// Catalog Admin
public static final int CatalogAdminSavePageEvent = 10050;
@@ -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;
@@ -82,7 +82,7 @@ public class FurniEditorHelper {
* Prevents SQL injection via arbitrary column names.
*/
public static final java.util.Set<String> 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",
@@ -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<String> 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<String> 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
}
}
}
@@ -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<FurnidataEntry> 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",
"", // 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);
}
}
}
}
@@ -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) {
@@ -64,10 +66,53 @@ 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<String> 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);
}
}
}
// 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<Map<String, Object>> items = new ArrayList<>();
@@ -0,0 +1,203 @@
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<Integer, Long> 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<FurnidataEntry> 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();
}
// 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,
classname,
"edit",
oldName != null ? oldName : "",
FurnitureTextProvider.sanitize(safeName),
oldDesc,
FurnitureTextProvider.sanitize(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);
}
}
}
}
@@ -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);
}
}
}
@@ -570,6 +570,8 @@ 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
public static final int FurniEditorImportTextResultComposer = 10049; // CUSTOM
// Catalog Admin
public static final int CatalogAdminResultComposer = 10059;
@@ -40,7 +40,7 @@ public class FriendsComposer extends MessageComposer {
this.response.appendInt(row.getGender().equals(HabboGender.M) ? 0 : 1);
this.response.appendBoolean(row.getOnline() == 1);
this.response.appendBoolean(row.inRoom()); //IN ROOM
this.response.appendString(row.getOnline() == 1 ? row.getLook() : "");
this.response.appendString(row.getLook()); // send look for offline friends too (loaded from DB)
this.response.appendInt(row.getCategoryId()); //Friends category
this.response.appendString(row.getMotto());
this.response.appendString(""); //Last seen as DATETIMESTRING
@@ -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;
}
}
@@ -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<FurnidataEntry> entries;
public FurnitureDataReloadComposer(int mode, List<FurnidataEntry> 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;
}
}
@@ -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;
}
@@ -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);
}
}
@@ -0,0 +1,85 @@
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<FurnidataEntry> 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<FurnidataEntry> 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 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<FurnidataEntry> entries = new FurnidataReader(base, 8 /* bytes */).read();
assertTrue(entries.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<FurnidataEntry> 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");
}
}
@@ -0,0 +1,130 @@
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\": [] } }";
// 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");
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"));
}
/**
* 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");
}
}
@@ -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<FurnidataEntry> 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<FurnidataEntry> first = List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit"));
p.reindex(first);
List<FurnidataEntry> 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<FurnidataEntry> 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());
}
}
@@ -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");
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");
}
@Test
void nullProviderNameNeverThrows() {
FurnitureTextProvider p = provider(true);
assertDoesNotThrow(() -> p.getName(null));
assertNull(p.getName(null));
}
}