Merge pull request #156 from simoleo89/feat/furni-editor

feat(furni): server-authoritative furni names + in-client Furni Editor (edit/search/sort/import)
This commit is contained in:
DuckieTM
2026-06-07 08:23:00 +02:00
committed by GitHub
31 changed files with 1851 additions and 9 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,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');
+15 -1
View File
@@ -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;
@@ -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));
}
}