diff --git a/Database Updates/Own_Database_RunFirst/020_furnidata_edit_log.sql b/Database Updates/Own_Database_RunFirst/020_furnidata_edit_log.sql new file mode 100644 index 00000000..d6a6a991 --- /dev/null +++ b/Database Updates/Own_Database_RunFirst/020_furnidata_edit_log.sql @@ -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'); diff --git a/Database Updates/Own_Database_RunFirst/021_furnidata_config.sql b/Database Updates/Own_Database_RunFirst/021_furnidata_config.sql new file mode 100644 index 00000000..9a41bae4 --- /dev/null +++ b/Database Updates/Own_Database_RunFirst/021_furnidata_config.sql @@ -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) +-- → /furnidata (split-tier) or /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'); diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 3d2eec4c..9cdbb427 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -62,6 +62,12 @@ public + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + @@ -172,12 +178,20 @@ 0.4 - org.eclipse.angus jakarta.mail 2.0.3 + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java index 8d45809c..3874a2df 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java @@ -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; } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java index f0dbccec..3261a132 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java @@ -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; } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntry.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntry.java new file mode 100644 index 00000000..bd273872 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataEntry.java @@ -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) { +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataLock.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataLock.java new file mode 100644 index 00000000..5c0224ec --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataLock.java @@ -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() {} +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java new file mode 100644 index 00000000..1af3dfc7 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java @@ -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 DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal"); + private static final List MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json"); + private static final List 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 read() { + List 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 out) { + List 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 readManifestList(Path dir, String key, List 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 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 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"); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java new file mode 100644 index 00000000..0b3aa615 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWatcher.java @@ -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 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 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); + } + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java new file mode 100644 index 00000000..fd4f701a --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataWriter.java @@ -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 DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal"); + + /** Manifest filenames tried in order (json5 first, plain json second). */ + private static final List 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": "". 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> items = new ArrayList<>(); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java new file mode 100644 index 00000000..fcdea56c --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorUpdateFurnidataEvent.java @@ -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 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 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); + } + } + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurnidataAuditLog.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurnidataAuditLog.java new file mode 100644 index 00000000..105d6eb4 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurnidataAuditLog.java @@ -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); + } + } +} 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 d842aff7..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 @@ -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; 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; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furniture/FurnitureDataReloadComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furniture/FurnitureDataReloadComposer.java new file mode 100644 index 00000000..78d05f8d --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furniture/FurnitureDataReloadComposer.java @@ -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 entries; + + public FurnitureDataReloadComposer(int mode, List 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; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/unknown/WatchAndEarnRewardComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/unknown/WatchAndEarnRewardComposer.java index 1fb1e74a..70af9496 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/unknown/WatchAndEarnRewardComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/unknown/WatchAndEarnRewardComposer.java @@ -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; } diff --git a/Emulator/src/test/java/com/eu/habbo/SmokeTest.java b/Emulator/src/test/java/com/eu/habbo/SmokeTest.java new file mode 100644 index 00000000..15b142ee --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/SmokeTest.java @@ -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); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java new file mode 100644 index 00000000..acb1b3d2 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataReaderTest.java @@ -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 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 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 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 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"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java new file mode 100644 index 00000000..bef12c82 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnidataWriterTest.java @@ -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"); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderDeltaTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderDeltaTest.java new file mode 100644 index 00000000..356a5498 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderDeltaTest.java @@ -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 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 first = List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit")); + p.reindex(first); + List 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 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()); + } +} diff --git a/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java new file mode 100644 index 00000000..4c8fd2a9 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/habbohotel/items/FurnitureTextProviderTest.java @@ -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)); + } +}