". 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.
+ *
+ * All resolved paths are checked against the normalised base directory via
+ * {@link #safeResolve}: any entry that would escape the base is silently skipped.
+ *
+ * @return ordered list of existing, in-bounds data files (earliest tier first).
+ */
+ private List splitTierFilesInOrder() throws IOException {
+ Path base = source.toAbsolutePath().normalize();
+ List tiers = manifestList(base, "tiers", DEFAULT_TIERS);
+ List result = new ArrayList<>();
+
+ for (String tier : tiers) {
+ Path tierDir = safeResolve(base, tier);
+ if (tierDir == null || !Files.isDirectory(tierDir)) continue;
+
+ for (String fileName : manifestList(tierDir, "files", List.of())) {
+ Path file = safeResolve(base, tierDir.resolve(fileName).toString());
+ if (file == null || !Files.isRegularFile(file)) continue;
+ result.add(file);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Resolve {@code entry} relative to {@code base} and verify the result stays
+ * inside {@code base} (path-traversal guard).
+ *
+ * @param base the normalised absolute base directory.
+ * @param entry a path string (may be relative or absolute, may contain {@code ..}).
+ * @return the normalised absolute path if it is inside {@code base}; {@code null} otherwise.
+ */
+ private static Path safeResolve(Path base, String entry) {
+ try {
+ Path resolved = base.resolve(entry).toAbsolutePath().normalize();
+ return resolved.startsWith(base) ? resolved : null;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * Read the {@code key} string-array from the first manifest file found in {@code dir}
+ * ({@code manifest.json5} then {@code manifest.json}). Falls back to {@code fallback}
+ * if no manifest exists or the key is absent/empty.
+ */
+ private List manifestList(Path dir, String key, List fallback) {
+ for (String name : MANIFEST_NAMES) {
+ Path m = dir.resolve(name);
+ if (!Files.exists(m)) continue;
+ try {
+ String stripped = FurnidataReader.stripJson5(
+ Files.readString(m, StandardCharsets.UTF_8));
+ com.google.gson.JsonObject obj =
+ com.google.gson.JsonParser.parseString(stripped).getAsJsonObject();
+ if (obj.has(key) && obj.get(key).isJsonArray()) {
+ List list = new ArrayList<>();
+ for (com.google.gson.JsonElement el : obj.getAsJsonArray(key))
+ list.add(el.getAsString());
+ if (!list.isEmpty()) return list;
+ }
+ } catch (Exception ignored) {
+ // bad manifest → fall through to next candidate / fallback
+ }
+ }
+ return fallback;
+ }
+
+ private void backup(Path target) throws IOException {
+ Path bak = target.resolveSibling(target.getFileName() + ".bak." + System.nanoTime());
+ Files.copy(target, bak, StandardCopyOption.COPY_ATTRIBUTES);
+ pruneBackups(target);
+ }
+
+ private void pruneBackups(Path target) throws IOException {
+ String prefix = target.getFileName() + ".bak.";
+ try (var stream = Files.list(target.getParent())) {
+ List baks = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
+ .sorted(Comparator.comparingLong(p -> backupStamp(p))).toList();
+ for (int i = 0; i < baks.size() - backupKeep; i++) Files.deleteIfExists(baks.get(i));
+ }
+ }
+
+ private static long backupStamp(Path p) {
+ String s = p.getFileName().toString();
+ try { return Long.parseLong(s.substring(s.lastIndexOf('.') + 1)); } catch (Exception e) { return 0L; }
+ }
+
+ private void atomicWrite(Path target, String content) throws IOException {
+ Path tmp = target.resolveSibling(target.getFileName() + ".tmp." + System.nanoTime());
+ Files.writeString(tmp, content, StandardCharsets.UTF_8);
+ try {
+ Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException e) {
+ Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ /** Restore the most recent backup of the (single-file) target. @return true if restored. */
+ public boolean revertLastBackup() throws IOException {
+ if (directory) return revertSplitTier();
+ return revertFile(source);
+ }
+
+ private boolean revertFile(Path target) throws IOException {
+ String prefix = target.getFileName() + ".bak.";
+ try (var stream = Files.list(target.getParent())) {
+ Path latest = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
+ .max(Comparator.comparingLong(FurnidataWriter::backupStamp)).orElse(null);
+ if (latest == null) return false;
+ atomicWrite(target, Files.readString(latest, StandardCharsets.UTF_8));
+ return true;
+ }
+ }
+
+ private boolean revertSplitTier() throws IOException {
+ boolean any = false;
+ for (Path f : splitTierFilesInOrder()) any |= revertFile(f);
+ return any;
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java
new file mode 100644
index 00000000..ab2ae51f
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java
@@ -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 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 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 reindex(java.util.List entries) {
+ Map next = new HashMap<>(Math.max(16, entries.size() * 2));
+ for (FurnidataEntry e : entries) {
+ String key = baseKey(e.classname());
+ if (key == null) continue;
+ next.put(key, new FurniText(e.id(), e.type(), sanitize(e.name()), sanitize(e.description())));
+ }
+
+ Map prev = this.index;
+ java.util.List delta = new java.util.ArrayList<>();
+ for (Map.Entry en : next.entrySet()) {
+ FurniText cur = en.getValue();
+ FurniText old = prev.get(en.getKey());
+ if (old == null || !old.name().equals(cur.name()) || !old.description().equals(cur.description())) {
+ delta.add(new FurnidataEntry(cur.id(), en.getKey(), cur.type(), cur.name(), cur.description()));
+ }
+ }
+
+ this.index = next; // atomic reference swap
+ return delta;
+ }
+
+ /** Returns the sanitized display name for a DB classname, or null if absent/disabled. */
+ 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 findClassnamesByName(String query) {
+ java.util.List out = new java.util.ArrayList<>();
+ if (query == null) return out;
+ String q = query.trim().toLowerCase(Locale.ROOT);
+ if (q.isEmpty()) return out;
+ Map idx = this.index; // local ref (volatile)
+ for (Map.Entry e : idx.entrySet()) {
+ FurniText t = e.getValue();
+ if (t != null && t.name() != null && t.name().toLowerCase(Locale.ROOT).contains(q)) {
+ out.add(e.getKey()); // key is the lowercased base classname
+ if (out.size() >= 200) break; // bound IN-clause size
+ }
+ }
+ return out;
+ }
+
+ private record FurniText(int id, FurnitureType type, String name, String description) {}
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java
index 323f9758..f0ca1719 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/Item.java
@@ -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;
}
diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java
index 4b901e29..32d85ab7 100644
--- a/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java
+++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/wired/core/WiredTextPlaceholderUtil.java
@@ -279,7 +279,7 @@ public final class WiredTextPlaceholderUtil {
continue;
}
- String furniName = item.getBaseItem().getFullName();
+ String furniName = item.getBaseItem().getDisplayName();
if (furniName == null || furniName.trim().isEmpty()) {
furniName = item.getBaseItem().getName();
}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java
index 40ff2aa0..9f372c5b 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java
@@ -285,6 +285,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);
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java
index bd8d1219..43ab4d44 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java
@@ -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;
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java
index 992ef724..92d941aa 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/catalog/CatalogBuyItemAsGiftEvent.java
@@ -248,7 +248,7 @@ public class CatalogBuyItemAsGiftEvent extends MessageHandler {
LOGGER.debug("sender reached daily total LTD limit");
this.client.getHabbo().alert(
Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total")
- .replace("%itemname%", item.getBaseItems().iterator().next().getFullName())
+ .replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName())
.replace("%limit%", ltdLimit + "")
);
return;
@@ -259,7 +259,7 @@ public class CatalogBuyItemAsGiftEvent extends MessageHandler {
LOGGER.debug("sender reached daily LTD item limit");
this.client.getHabbo().alert(
Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item")
- .replace("%itemname%", item.getBaseItems().iterator().next().getFullName())
+ .replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName())
.replace("%limit%", ltdLimit + "")
);
return;
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java
index 0de0e1b6..cd7f7a82 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorHelper.java
@@ -82,7 +82,7 @@ public class FurniEditorHelper {
* Prevents SQL injection via arbitrary column names.
*/
public static final java.util.Set ALLOWED_UPDATE_FIELDS = java.util.Set.of(
- "item_name", "public_name", "sprite_id", "type", "width", "length",
+ "public_name", "sprite_id", "type", "width", "length",
"stack_height", "allow_stack", "allow_walk", "allow_sit", "allow_lay",
"allow_gift", "allow_trade", "allow_recycle", "allow_marketplace_sell",
"allow_inventory_stack", "interaction_type", "interaction_modes_count",
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorImportTextEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorImportTextEvent.java
new file mode 100644
index 00000000..023fa6ba
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorImportTextEvent.java
@@ -0,0 +1,139 @@
+package com.eu.habbo.messages.incoming.furnieditor;
+
+import com.eu.habbo.Emulator;
+import com.eu.habbo.habbohotel.permissions.Permission;
+import com.eu.habbo.messages.incoming.MessageHandler;
+import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorImportTextResultComposer;
+import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Incoming 10049 — admin imports the official Habbo display name/description for a
+ * furni's classname from a configured furnidata URL (e.g.
+ * https://www.habbo.it/gamedata/furnidata_json/1). The fetched text only POPULATES
+ * the editor fields client-side; the admin reviews and Saves via the normal flow.
+ *
+ * Source URL is admin-configured in emulator_settings ({@code furni.editor.import.url}),
+ * never supplied by the client (no SSRF). The remote furnidata is cached with a TTL.
+ */
+public class FurniEditorImportTextEvent extends MessageHandler {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorImportTextEvent.class);
+ private static final List SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes");
+
+ // Shared TTL cache (the remote furnidata is multi-MB — do not refetch per click).
+ private static volatile JsonObject CACHE;
+ private static volatile String CACHE_URL;
+ private static volatile long CACHE_TIME;
+
+ @Override
+ public void handle() throws Exception {
+ if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) {
+ this.client.sendResponse(new FurniEditorResultComposer(false, "No permission"));
+ return;
+ }
+
+ int itemId = this.packet.readInt();
+ if (itemId <= 0) {
+ this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID"));
+ return;
+ }
+
+ String classname = FurniEditorUpdateFurnidataEvent.classnameForItem(itemId);
+ if (classname == null) {
+ this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found"));
+ return;
+ }
+ String cn = classname.trim().toLowerCase(Locale.ROOT);
+
+ String url = Emulator.getConfig().getValue(
+ "furni.editor.import.url", "https://www.habbo.it/gamedata/furnidata_json/1");
+ if (url == null || !(url.startsWith("http://") || url.startsWith("https://"))) {
+ this.client.sendResponse(new FurniEditorResultComposer(false, "Import source not configured"));
+ return;
+ }
+
+ JsonObject root = fetchCached(url);
+ if (root == null) {
+ this.client.sendResponse(new FurniEditorResultComposer(false, "Could not fetch Habbo furnidata"));
+ return;
+ }
+
+ String foundName = null, foundDesc = null;
+ outer:
+ for (String section : SECTIONS) {
+ if (!root.has(section) || !root.get(section).isJsonObject()) continue;
+ JsonObject sec = root.getAsJsonObject(section);
+ if (!sec.has("furnitype") || !sec.get("furnitype").isJsonArray()) continue;
+ for (JsonElement el : sec.getAsJsonArray("furnitype")) {
+ if (!el.isJsonObject()) continue;
+ JsonObject o = el.getAsJsonObject();
+ if (!o.has("classname")) continue;
+ if (o.get("classname").getAsString().trim().toLowerCase(Locale.ROOT).equals(cn)) {
+ foundName = (o.has("name") && !o.get("name").isJsonNull()) ? o.get("name").getAsString() : "";
+ foundDesc = (o.has("description") && !o.get("description").isJsonNull()) ? o.get("description").getAsString() : "";
+ break outer;
+ }
+ }
+ }
+
+ boolean found = (foundName != null);
+ this.client.sendResponse(new FurniEditorImportTextResultComposer(
+ found, found ? foundName : "", found ? foundDesc : "", classname));
+ LOGGER.info("FurniEditorImportTextEvent: admin {} import for classname '{}' (item {}) -> found={}",
+ this.client.getHabbo().getHabboInfo().getId(), classname, itemId, found);
+ }
+
+ /** Fetch the remote furnidata JSON with a TTL cache (serves stale on failure). */
+ private static synchronized JsonObject fetchCached(String url) {
+ long ttlMs;
+ try {
+ ttlMs = Long.parseLong(Emulator.getConfig().getValue("furni.editor.import.cache.ms", "600000"));
+ } catch (Exception e) {
+ ttlMs = 600000L;
+ }
+
+ long now = System.currentTimeMillis();
+ if (CACHE != null && url.equals(CACHE_URL) && (now - CACHE_TIME) < ttlMs) {
+ return CACHE;
+ }
+
+ try {
+ HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+ HttpRequest request = HttpRequest.newBuilder(URI.create(url))
+ .timeout(Duration.ofSeconds(20))
+ .header("User-Agent", "Arcturus-FurniEditor")
+ .GET()
+ .build();
+ HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if (resp.statusCode() != 200) {
+ LOGGER.warn("FurniEditorImportTextEvent: fetch {} returned HTTP {}", url, resp.statusCode());
+ return CACHE; // serve stale if available
+ }
+ JsonObject root = JsonParser.parseString(resp.body()).getAsJsonObject();
+ CACHE = root;
+ CACHE_URL = url;
+ CACHE_TIME = now;
+ return root;
+ } catch (Exception e) {
+ LOGGER.warn("FurniEditorImportTextEvent: failed to fetch {}", url, e);
+ return CACHE; // serve stale if available
+ }
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorRevertFurnidataEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorRevertFurnidataEvent.java
new file mode 100644
index 00000000..1ec588ce
--- /dev/null
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorRevertFurnidataEvent.java
@@ -0,0 +1,118 @@
+package com.eu.habbo.messages.incoming.furnieditor;
+
+import com.eu.habbo.Emulator;
+import com.eu.habbo.habbohotel.items.FurnidataEntry;
+import com.eu.habbo.habbohotel.items.FurnidataLock;
+import com.eu.habbo.habbohotel.items.FurnidataWriter;
+import com.eu.habbo.habbohotel.items.FurnitureTextProvider;
+import com.eu.habbo.habbohotel.permissions.Permission;
+import com.eu.habbo.habbohotel.users.Habbo;
+import com.eu.habbo.messages.incoming.MessageHandler;
+import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
+import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+/**
+ * Incoming handler 10048 — admin reverts a furni's furnidata to the last rotating backup.
+ *
+ * Flow: permission check → read item_id → resolve classname → under FurnidataLock:
+ * FurnidataWriter.revertLastBackup → FurnitureTextProvider.reindexFromSource →
+ * broadcast FurnitureDataReloadComposer (10047) → audit log → respond.
+ */
+public class FurniEditorRevertFurnidataEvent extends MessageHandler {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorRevertFurnidataEvent.class);
+
+ @Override
+ public void handle() throws Exception {
+ Habbo habbo = this.client.getHabbo();
+
+ // 1. Permission check
+ if (!habbo.hasPermission(Permission.ACC_CATALOGFURNI)) {
+ this.client.sendResponse(new FurniEditorResultComposer(false, "No permission"));
+ return;
+ }
+
+ // 2. Read packet
+ int itemId = this.packet.readInt();
+
+ if (itemId <= 0) {
+ this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID"));
+ return;
+ }
+
+ // 3. Resolve classname from item_id (reuse static helper from update handler)
+ String classname = FurniEditorUpdateFurnidataEvent.classnameForItem(itemId);
+ String classnameForLog = (classname != null) ? classname : "?";
+
+ // 4. Verify provider is configured
+ FurnitureTextProvider provider =
+ Emulator.getGameEnvironment().getFurnitureTextProvider();
+
+ if (provider == null || provider.getSource() == null) {
+ this.client.sendResponse(new FurniEditorResultComposer(false, "Furnidata source not configured"));
+ return;
+ }
+
+ int adminId = habbo.getHabboInfo().getId();
+
+ // 5. Revert + reindex + broadcast under the shared lock
+ boolean reverted;
+ List delta;
+
+ FurnidataLock.LOCK.lock();
+ try {
+ FurnidataWriter writer = new FurnidataWriter(
+ provider.getSource(),
+ provider.isSourceDirectory(),
+ provider.getMaxBytes(),
+ 3 /* backupKeep */
+ );
+ reverted = writer.revertLastBackup();
+ if (!reverted) {
+ this.client.sendResponse(new FurniEditorResultComposer(false, "No backup found to revert"));
+ return;
+ }
+
+ delta = provider.reindexFromSource();
+
+ if (!delta.isEmpty()) {
+ int deltaCap = Integer.parseInt(
+ Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
+ FurnitureDataReloadComposer composer = (delta.size() > deltaCap)
+ ? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
+ : new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
+ broadcastToAll(composer);
+ }
+ } finally {
+ FurnidataLock.LOCK.unlock();
+ }
+
+ // 6. Audit log (outside lock — DB write, not latency-sensitive)
+ FurnidataAuditLog.record(
+ adminId,
+ classnameForLog,
+ "revert",
+ "", // 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);
+ }
+ }
+ }
+}
diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java
index bfdc229c..274a5dbd 100644
--- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java
+++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorSearchEvent.java
@@ -27,6 +27,8 @@ public class FurniEditorSearchEvent extends MessageHandler {
String query = this.packet.readString();
String type = this.packet.readString();
int page = this.packet.readInt();
+ String sortField = this.packet.readString();
+ String sortDir = this.packet.readString();
// Input validation
if (query.length() > 100) {
@@ -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 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