From 4621ed62b780c3720ecaea727bc4f9509b6d34ff Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 15:26:56 +0200 Subject: [PATCH] feat(furni-editor): server-side Habbo furnidata import (packet 10049) FurniEditorImportTextEvent (incoming 10049, ACC_CATALOGFURNI): resolves the classname, fetches the admin-configured furnidata URL via HttpClient with a TTL cache (furni.editor.import.url / .cache.ms, default habbo.it), finds name/description by classname and returns them via FurniEditorImportTextResultComposer (outgoing 10049). URL is DB-configured only (no client-supplied URL -> no SSRF); serves stale cache on failure. --- .../com/eu/habbo/messages/PacketManager.java | 1 + .../eu/habbo/messages/incoming/Incoming.java | 1 + .../FurniEditorImportTextEvent.java | 139 ++++++++++++++++++ .../eu/habbo/messages/outgoing/Outgoing.java | 1 + .../FurniEditorImportTextResultComposer.java | 33 +++++ 5 files changed, 175 insertions(+) create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorImportTextEvent.java create mode 100644 Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorImportTextResultComposer.java diff --git a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java index ac1455ce..9f372c5b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/PacketManager.java @@ -287,6 +287,7 @@ public class PacketManager { this.registerHandler(Incoming.FurniEditorDeleteEvent, FurniEditorDeleteEvent.class); this.registerHandler(Incoming.FurniEditorUpdateFurnidataEvent, FurniEditorUpdateFurnidataEvent.class); this.registerHandler(Incoming.FurniEditorRevertFurnidataEvent, FurniEditorRevertFurnidataEvent.class); + this.registerHandler(Incoming.FurniEditorImportTextEvent, FurniEditorImportTextEvent.class); // Catalog Admin this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java index 85259592..43ab4d44 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/Incoming.java @@ -433,6 +433,7 @@ public class Incoming { public static final int FurniEditorDeleteEvent = 10045; public static final int FurniEditorUpdateFurnidataEvent = 10046; public static final int FurniEditorRevertFurnidataEvent = 10048; + public static final int FurniEditorImportTextEvent = 10049; // Catalog Admin public static final int CatalogAdminSavePageEvent = 10050; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorImportTextEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorImportTextEvent.java new file mode 100644 index 00000000..023fa6ba --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorImportTextEvent.java @@ -0,0 +1,139 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.permissions.Permission; +import com.eu.habbo.messages.incoming.MessageHandler; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorImportTextResultComposer; +import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +/** + * Incoming 10049 — admin imports the official Habbo display name/description for a + * furni's classname from a configured furnidata URL (e.g. + * https://www.habbo.it/gamedata/furnidata_json/1). The fetched text only POPULATES + * the editor fields client-side; the admin reviews and Saves via the normal flow. + * + * Source URL is admin-configured in emulator_settings ({@code furni.editor.import.url}), + * never supplied by the client (no SSRF). The remote furnidata is cached with a TTL. + */ +public class FurniEditorImportTextEvent extends MessageHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorImportTextEvent.class); + private static final List SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes"); + + // Shared TTL cache (the remote furnidata is multi-MB — do not refetch per click). + private static volatile JsonObject CACHE; + private static volatile String CACHE_URL; + private static volatile long CACHE_TIME; + + @Override + public void handle() throws Exception { + if (!this.client.getHabbo().hasPermission(Permission.ACC_CATALOGFURNI)) { + this.client.sendResponse(new FurniEditorResultComposer(false, "No permission")); + return; + } + + int itemId = this.packet.readInt(); + if (itemId <= 0) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID")); + return; + } + + String classname = FurniEditorUpdateFurnidataEvent.classnameForItem(itemId); + if (classname == null) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found")); + return; + } + String cn = classname.trim().toLowerCase(Locale.ROOT); + + String url = Emulator.getConfig().getValue( + "furni.editor.import.url", "https://www.habbo.it/gamedata/furnidata_json/1"); + if (url == null || !(url.startsWith("http://") || url.startsWith("https://"))) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Import source not configured")); + return; + } + + JsonObject root = fetchCached(url); + if (root == null) { + this.client.sendResponse(new FurniEditorResultComposer(false, "Could not fetch Habbo furnidata")); + return; + } + + String foundName = null, foundDesc = null; + outer: + for (String section : SECTIONS) { + if (!root.has(section) || !root.get(section).isJsonObject()) continue; + JsonObject sec = root.getAsJsonObject(section); + if (!sec.has("furnitype") || !sec.get("furnitype").isJsonArray()) continue; + for (JsonElement el : sec.getAsJsonArray("furnitype")) { + if (!el.isJsonObject()) continue; + JsonObject o = el.getAsJsonObject(); + if (!o.has("classname")) continue; + if (o.get("classname").getAsString().trim().toLowerCase(Locale.ROOT).equals(cn)) { + foundName = (o.has("name") && !o.get("name").isJsonNull()) ? o.get("name").getAsString() : ""; + foundDesc = (o.has("description") && !o.get("description").isJsonNull()) ? o.get("description").getAsString() : ""; + break outer; + } + } + } + + boolean found = (foundName != null); + this.client.sendResponse(new FurniEditorImportTextResultComposer( + found, found ? foundName : "", found ? foundDesc : "", classname)); + LOGGER.info("FurniEditorImportTextEvent: admin {} import for classname '{}' (item {}) -> found={}", + this.client.getHabbo().getHabboInfo().getId(), classname, itemId, found); + } + + /** Fetch the remote furnidata JSON with a TTL cache (serves stale on failure). */ + private static synchronized JsonObject fetchCached(String url) { + long ttlMs; + try { + ttlMs = Long.parseLong(Emulator.getConfig().getValue("furni.editor.import.cache.ms", "600000")); + } catch (Exception e) { + ttlMs = 600000L; + } + + long now = System.currentTimeMillis(); + if (CACHE != null && url.equals(CACHE_URL) && (now - CACHE_TIME) < ttlMs) { + return CACHE; + } + + try { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(20)) + .header("User-Agent", "Arcturus-FurniEditor") + .GET() + .build(); + HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) { + LOGGER.warn("FurniEditorImportTextEvent: fetch {} returned HTTP {}", url, resp.statusCode()); + return CACHE; // serve stale if available + } + JsonObject root = JsonParser.parseString(resp.body()).getAsJsonObject(); + CACHE = root; + CACHE_URL = url; + CACHE_TIME = now; + return root; + } catch (Exception e) { + LOGGER.warn("FurniEditorImportTextEvent: failed to fetch {}", url, e); + return CACHE; // serve stale if available + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java index 72d001d6..4db4519d 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/Outgoing.java @@ -571,6 +571,7 @@ public class Outgoing { public static final int FurniEditorInteractionsComposer = 10043; public static final int FurniEditorResultComposer = 10044; public static final int FurnitureDataReloadComposer = 10047; // CUSTOM + public static final int FurniEditorImportTextResultComposer = 10049; // CUSTOM // Catalog Admin public static final int CatalogAdminResultComposer = 10059; diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorImportTextResultComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorImportTextResultComposer.java new file mode 100644 index 00000000..81a8ab25 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorImportTextResultComposer.java @@ -0,0 +1,33 @@ +package com.eu.habbo.messages.outgoing.furnieditor; + +import com.eu.habbo.messages.ServerMessage; +import com.eu.habbo.messages.outgoing.MessageComposer; +import com.eu.habbo.messages.outgoing.Outgoing; + +/** + * Outgoing 10049 — result of an "import texts from Habbo" request. + * Carries the official furnidata name/description for a classname (or found=false). + */ +public class FurniEditorImportTextResultComposer extends MessageComposer { + private final boolean found; + private final String name; + private final String description; + private final String classname; + + public FurniEditorImportTextResultComposer(boolean found, String name, String description, String classname) { + this.found = found; + this.name = name == null ? "" : name; + this.description = description == null ? "" : description; + this.classname = classname == null ? "" : classname; + } + + @Override + protected ServerMessage composeInternal() { + this.response.init(Outgoing.FurniEditorImportTextResultComposer); + this.response.appendBoolean(this.found); + this.response.appendString(this.name); + this.response.appendString(this.description); + this.response.appendString(this.classname); + return this.response; + } +}