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.
This commit is contained in:
simoleo89
2026-06-06 15:26:56 +02:00
parent 2b8ce3cd91
commit 4621ed62b7
5 changed files with 175 additions and 0 deletions
@@ -287,6 +287,7 @@ public class PacketManager {
this.registerHandler(Incoming.FurniEditorDeleteEvent, FurniEditorDeleteEvent.class); this.registerHandler(Incoming.FurniEditorDeleteEvent, FurniEditorDeleteEvent.class);
this.registerHandler(Incoming.FurniEditorUpdateFurnidataEvent, FurniEditorUpdateFurnidataEvent.class); this.registerHandler(Incoming.FurniEditorUpdateFurnidataEvent, FurniEditorUpdateFurnidataEvent.class);
this.registerHandler(Incoming.FurniEditorRevertFurnidataEvent, FurniEditorRevertFurnidataEvent.class); this.registerHandler(Incoming.FurniEditorRevertFurnidataEvent, FurniEditorRevertFurnidataEvent.class);
this.registerHandler(Incoming.FurniEditorImportTextEvent, FurniEditorImportTextEvent.class);
// Catalog Admin // Catalog Admin
this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class); this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class);
@@ -433,6 +433,7 @@ public class Incoming {
public static final int FurniEditorDeleteEvent = 10045; public static final int FurniEditorDeleteEvent = 10045;
public static final int FurniEditorUpdateFurnidataEvent = 10046; public static final int FurniEditorUpdateFurnidataEvent = 10046;
public static final int FurniEditorRevertFurnidataEvent = 10048; public static final int FurniEditorRevertFurnidataEvent = 10048;
public static final int FurniEditorImportTextEvent = 10049;
// Catalog Admin // Catalog Admin
public static final int CatalogAdminSavePageEvent = 10050; public static final int CatalogAdminSavePageEvent = 10050;
@@ -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
}
}
}
@@ -571,6 +571,7 @@ public class Outgoing {
public static final int FurniEditorInteractionsComposer = 10043; public static final int FurniEditorInteractionsComposer = 10043;
public static final int FurniEditorResultComposer = 10044; public static final int FurniEditorResultComposer = 10044;
public static final int FurnitureDataReloadComposer = 10047; // CUSTOM public static final int FurnitureDataReloadComposer = 10047; // CUSTOM
public static final int FurniEditorImportTextResultComposer = 10049; // CUSTOM
// Catalog Admin // Catalog Admin
public static final int CatalogAdminResultComposer = 10059; 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;
}
}