You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-19 15:06:19 +00:00
@@ -0,0 +1,22 @@
|
|||||||
|
-- 020_furnidata_edit_log.sql
|
||||||
|
-- Audit trail for furnidata name/description edits made through the furni editor,
|
||||||
|
-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are
|
||||||
|
-- read via Boolean.parseBoolean (true/false), but these two are numeric.
|
||||||
|
CREATE TABLE IF NOT EXISTS `furnidata_edit_log` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` int(11) NOT NULL,
|
||||||
|
`classname` varchar(255) NOT NULL,
|
||||||
|
`action` enum('edit','revert') NOT NULL DEFAULT 'edit',
|
||||||
|
`old_name` varchar(256) NOT NULL DEFAULT '',
|
||||||
|
`new_name` varchar(256) NOT NULL DEFAULT '',
|
||||||
|
`old_description` varchar(256) NOT NULL DEFAULT '',
|
||||||
|
`new_description` varchar(256) NOT NULL DEFAULT '',
|
||||||
|
`timestamp` int(11) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_classname` (`classname`),
|
||||||
|
INDEX `idx_user` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
|
||||||
|
('items.furnidata.edit.backup.keep','10'),
|
||||||
|
('items.furnidata.edit.ratelimit.ms','2000');
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- 020_furnidata_edit_log.sql
|
||||||
|
-- Audit trail for furnidata name/description edits made through the furni editor,
|
||||||
|
-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are
|
||||||
|
-- read via Boolean.parseBoolean (true/false), but these two are numeric.
|
||||||
|
CREATE TABLE IF NOT EXISTS `furnidata_edit_log` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` int(11) NOT NULL,
|
||||||
|
`classname` varchar(255) NOT NULL,
|
||||||
|
`action` enum('edit','revert') NOT NULL DEFAULT 'edit',
|
||||||
|
`old_name` varchar(256) NOT NULL DEFAULT '',
|
||||||
|
`new_name` varchar(256) NOT NULL DEFAULT '',
|
||||||
|
`old_description` varchar(256) NOT NULL DEFAULT '',
|
||||||
|
`new_description` varchar(256) NOT NULL DEFAULT '',
|
||||||
|
`timestamp` int(11) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_classname` (`classname`),
|
||||||
|
INDEX `idx_user` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
|
||||||
|
('items.furnidata.edit.backup.keep','10'),
|
||||||
|
('items.furnidata.edit.ratelimit.ms','2000');
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- 021_furnidata_config.sql
|
||||||
|
-- Seeds the furnidata feature config keys read at runtime by
|
||||||
|
-- FurnitureTextProvider / FurnidataReader / FurnidataWatcher and
|
||||||
|
-- FurniEditorImportTextEvent. Without these rows a fresh install logs
|
||||||
|
-- "Config key not found" for each (ConfigurationManager logs ERROR even
|
||||||
|
-- when a default is supplied) and the values are not editable from the DB.
|
||||||
|
--
|
||||||
|
-- Notes:
|
||||||
|
-- * *.enabled keys are read via Boolean.parseBoolean → use true/false (NOT 1/0).
|
||||||
|
-- * items.furnidata.path is intentionally empty: when blank the source is
|
||||||
|
-- derived from furni.editor.asset.base.path (seeded by 004_furni_editor.sql)
|
||||||
|
-- → <base>/furnidata (split-tier) or <base>/FurnitureData.json (single file).
|
||||||
|
-- * Editor write-path keys (items.furnidata.edit.*) are seeded by 020.
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
|
||||||
|
-- Server-authoritative furni names (source of truth = furnidata JSON)
|
||||||
|
('items.furnidata.names.enabled','true'),
|
||||||
|
('items.furnidata.path',''),
|
||||||
|
('items.furnidata.max.bytes','67108864'),
|
||||||
|
-- Live-reload watcher
|
||||||
|
('items.furnidata.watch.enabled','true'),
|
||||||
|
('items.furnidata.watch.debounce.ms','750'),
|
||||||
|
('items.furnidata.watch.min.interval.ms','5000'),
|
||||||
|
('items.furnidata.delta.cap','500'),
|
||||||
|
-- Furni editor: import official names/descriptions from Habbo
|
||||||
|
('furni.editor.import.url','https://www.habbo.it/gamedata/furnidata_json/1'),
|
||||||
|
('furni.editor.import.cache.ms','600000');
|
||||||
+15
-1
@@ -62,6 +62,12 @@
|
|||||||
<show>public</show>
|
<show>public</show>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
@@ -172,12 +178,20 @@
|
|||||||
<version>0.4</version>
|
<version>0.4</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Jakarta Mail � used by the built-in forgot-password endpoint
|
<!-- Jakarta Mail — used by the built-in forgot-password endpoint
|
||||||
when smtp.* keys are configured in emulator_settings -->
|
when smtp.* keys are configured in emulator_settings -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.eclipse.angus</groupId>
|
<groupId>org.eclipse.angus</groupId>
|
||||||
<artifactId>jakarta.mail</artifactId>
|
<artifactId>jakarta.mail</artifactId>
|
||||||
<version>2.0.3</version>
|
<version>2.0.3</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JUnit Jupiter -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.10.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.eu.habbo.habbohotel.crafting.CraftingManager;
|
|||||||
import com.eu.habbo.habbohotel.guides.GuideManager;
|
import com.eu.habbo.habbohotel.guides.GuideManager;
|
||||||
import com.eu.habbo.habbohotel.guilds.GuildManager;
|
import com.eu.habbo.habbohotel.guilds.GuildManager;
|
||||||
import com.eu.habbo.habbohotel.hotelview.HotelViewManager;
|
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.items.ItemManager;
|
||||||
import com.eu.habbo.habbohotel.modtool.ModToolManager;
|
import com.eu.habbo.habbohotel.modtool.ModToolManager;
|
||||||
import com.eu.habbo.habbohotel.modtool.ModToolSanctions;
|
import com.eu.habbo.habbohotel.modtool.ModToolSanctions;
|
||||||
@@ -47,6 +48,7 @@ public class GameEnvironment {
|
|||||||
private NavigatorManager navigatorManager;
|
private NavigatorManager navigatorManager;
|
||||||
private GuildManager guildManager;
|
private GuildManager guildManager;
|
||||||
private ItemManager itemManager;
|
private ItemManager itemManager;
|
||||||
|
private FurnitureTextProvider furnitureTextProvider;
|
||||||
private CatalogManager catalogManager;
|
private CatalogManager catalogManager;
|
||||||
private HotelViewManager hotelViewManager;
|
private HotelViewManager hotelViewManager;
|
||||||
private RoomManager roomManager;
|
private RoomManager roomManager;
|
||||||
@@ -79,6 +81,8 @@ public class GameEnvironment {
|
|||||||
this.hotelViewManager = new HotelViewManager();
|
this.hotelViewManager = new HotelViewManager();
|
||||||
this.itemManager = new ItemManager();
|
this.itemManager = new ItemManager();
|
||||||
this.itemManager.load();
|
this.itemManager.load();
|
||||||
|
this.furnitureTextProvider = new FurnitureTextProvider();
|
||||||
|
this.furnitureTextProvider.init();
|
||||||
this.botManager = new BotManager();
|
this.botManager = new BotManager();
|
||||||
this.petManager = new PetManager();
|
this.petManager = new PetManager();
|
||||||
this.guildManager = new GuildManager();
|
this.guildManager = new GuildManager();
|
||||||
@@ -161,6 +165,10 @@ public class GameEnvironment {
|
|||||||
return this.itemManager;
|
return this.itemManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public FurnitureTextProvider getFurnitureTextProvider() {
|
||||||
|
return this.furnitureTextProvider;
|
||||||
|
}
|
||||||
|
|
||||||
public CatalogManager getCatalogManager() {
|
public CatalogManager getCatalogManager() {
|
||||||
return this.catalogManager;
|
return this.catalogManager;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1054,13 +1054,13 @@ public class CatalogManager {
|
|||||||
if (Emulator.getConfig().getBoolean("hotel.catalog.ltd.limit.enabled")) {
|
if (Emulator.getConfig().getBoolean("hotel.catalog.ltd.limit.enabled")) {
|
||||||
int ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.total");
|
int ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.total");
|
||||||
if (habbo.getHabboStats().totalLtds() >= ltdLimit) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.item");
|
ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.item");
|
||||||
if (habbo.getHabboStats().totalLtds(item.id) >= ltdLimit) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.eu.habbo.habbohotel.items;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One parsed furnidata entry. {@code classname} is the raw furnidata classname
|
||||||
|
* (may carry a {@code *N} colour-variant suffix); the provider keys on the base.
|
||||||
|
*/
|
||||||
|
public record FurnidataEntry(int id, String classname, FurnitureType type, String name, String description) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.eu.habbo.habbohotel.items;
|
||||||
|
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One process-wide lock serializing every furnidata reindex and every editor-driven
|
||||||
|
* furnidata write, so an editor write never races the file watcher's reindex and the
|
||||||
|
* volatile index is never observed mid-swap by two writers.
|
||||||
|
*/
|
||||||
|
public final class FurnidataLock {
|
||||||
|
public static final ReentrantLock LOCK = new ReentrantLock();
|
||||||
|
private FurnidataLock() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package com.eu.habbo.habbohotel.items;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neutral furnidata reader. Supports a single JSON/JSON5 file or a split-tier
|
||||||
|
* directory ({@code core/custom/seasonal} with {@code manifest.json(5)}).
|
||||||
|
* Never throws: any IO/parse error yields an empty list (the caller decides the
|
||||||
|
* fallback). All resolved paths are guarded against escaping the base dir.
|
||||||
|
*/
|
||||||
|
public class FurnidataReader {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataReader.class);
|
||||||
|
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
|
||||||
|
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
|
||||||
|
private static final List<String> SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes");
|
||||||
|
|
||||||
|
private final Path source;
|
||||||
|
private final long maxBytes;
|
||||||
|
|
||||||
|
public FurnidataReader(Path source, long maxBytes) {
|
||||||
|
this.source = source;
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FurnidataEntry> read() {
|
||||||
|
List<FurnidataEntry> out = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
if (this.source == null || !Files.exists(this.source)) return out;
|
||||||
|
|
||||||
|
if (Files.isDirectory(this.source)) {
|
||||||
|
readSplitDir(this.source, out);
|
||||||
|
} else {
|
||||||
|
String content = readJson5Capped(this.source);
|
||||||
|
if (content != null) {
|
||||||
|
parseRoot(JsonParser.parseString(content).getAsJsonObject(), out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("FurnidataReader failed to read {} — returning empty", this.source, e);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readSplitDir(Path base, List<FurnidataEntry> out) {
|
||||||
|
List<String> tiers = readManifestList(base, "tiers", DEFAULT_TIERS);
|
||||||
|
Path baseNorm = base.toAbsolutePath().normalize();
|
||||||
|
|
||||||
|
for (String tier : tiers) {
|
||||||
|
Path tierDir = base.resolve(tier);
|
||||||
|
if (!isInside(baseNorm, tierDir) || !Files.isDirectory(tierDir)) continue;
|
||||||
|
|
||||||
|
for (String fileName : readManifestList(tierDir, "files", List.of())) {
|
||||||
|
Path file = tierDir.resolve(fileName);
|
||||||
|
if (!isInside(baseNorm, file)) {
|
||||||
|
LOGGER.warn("FurnidataReader: ignoring out-of-base file {}", file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Files.exists(file)) continue;
|
||||||
|
try {
|
||||||
|
String content = readJson5Capped(file);
|
||||||
|
if (content != null) parseRoot(JsonParser.parseString(content).getAsJsonObject(), out);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("FurnidataReader: failed to parse {}", file, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> readManifestList(Path dir, String key, List<String> fallback) {
|
||||||
|
for (String name : MANIFEST_NAMES) {
|
||||||
|
Path m = dir.resolve(name);
|
||||||
|
if (!Files.exists(m)) continue;
|
||||||
|
try {
|
||||||
|
String raw = readJson5Capped(m);
|
||||||
|
if (raw == null) continue;
|
||||||
|
JsonObject obj = JsonParser.parseString(raw).getAsJsonObject();
|
||||||
|
if (obj.has(key) && obj.get(key).isJsonArray()) {
|
||||||
|
List<String> list = new ArrayList<>();
|
||||||
|
for (JsonElement el : obj.getAsJsonArray(key)) list.add(el.getAsString());
|
||||||
|
if (!list.isEmpty()) return list;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("FurnidataReader: bad manifest {}", m, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void parseRoot(JsonObject root, List<FurnidataEntry> out) {
|
||||||
|
for (String section : SECTIONS) {
|
||||||
|
if (!root.has(section)) continue;
|
||||||
|
JsonObject sectionObj = root.getAsJsonObject(section);
|
||||||
|
if (!sectionObj.has("furnitype")) continue;
|
||||||
|
FurnitureType type = section.equals("roomitemtypes") ? FurnitureType.FLOOR : FurnitureType.WALL;
|
||||||
|
JsonArray types = sectionObj.getAsJsonArray("furnitype");
|
||||||
|
for (JsonElement el : types) {
|
||||||
|
JsonObject o = el.getAsJsonObject();
|
||||||
|
if (!o.has("id") || !o.has("classname")) continue;
|
||||||
|
out.add(new FurnidataEntry(
|
||||||
|
o.get("id").getAsInt(),
|
||||||
|
o.get("classname").getAsString(),
|
||||||
|
type,
|
||||||
|
o.has("name") ? o.get("name").getAsString() : "",
|
||||||
|
o.has("description") ? o.get("description").getAsString() : ""
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the JSON5-stripped content, or null if the file exceeds the byte cap. */
|
||||||
|
private String readJson5Capped(Path path) throws Exception {
|
||||||
|
long size = Files.size(path);
|
||||||
|
if (size > this.maxBytes) {
|
||||||
|
LOGGER.warn("FurnidataReader: {} is {} bytes, over cap {} — refusing", path, size, this.maxBytes);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return stripJson5(Files.readString(path, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isInside(Path baseNorm, Path candidate) {
|
||||||
|
return candidate.toAbsolutePath().normalize().startsWith(baseNorm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip // and block comments and trailing commas so Gson can parse JSON5.
|
||||||
|
* Known limitation: the trailing-comma pass is a regex over the whole output,
|
||||||
|
* so a string value literally containing ",[whitespace]}" or ",[whitespace]]"
|
||||||
|
* would be altered. Real Habbo furnidata names/descriptions do not contain
|
||||||
|
* that pattern; values are additionally sanitized downstream before use.
|
||||||
|
*/
|
||||||
|
static String stripJson5(String content) {
|
||||||
|
if (content == null || content.isEmpty()) return content;
|
||||||
|
StringBuilder out = new StringBuilder(content.length());
|
||||||
|
int i = 0, len = content.length();
|
||||||
|
boolean inString = false, escape = false;
|
||||||
|
char stringChar = 0;
|
||||||
|
while (i < len) {
|
||||||
|
char c = content.charAt(i);
|
||||||
|
if (inString) {
|
||||||
|
out.append(c);
|
||||||
|
if (escape) escape = false;
|
||||||
|
else if (c == '\\') escape = true;
|
||||||
|
else if (c == stringChar) inString = false;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '"' || c == '\'') { inString = true; stringChar = c; out.append(c); i++; continue; }
|
||||||
|
if (c == '/' && i + 1 < len) {
|
||||||
|
char next = content.charAt(i + 1);
|
||||||
|
if (next == '/') { int eol = content.indexOf('\n', i + 2); if (eol < 0) break; i = eol; continue; }
|
||||||
|
if (next == '*') { int end = content.indexOf("*/", i + 2); if (end < 0) break; i = end + 2; continue; }
|
||||||
|
}
|
||||||
|
out.append(c);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return out.toString().replaceAll(",(\\s*[}\\]])", "$1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.eu.habbo.habbohotel.items;
|
||||||
|
|
||||||
|
import com.eu.habbo.Emulator;
|
||||||
|
import com.eu.habbo.habbohotel.users.Habbo;
|
||||||
|
import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.ClosedWatchServiceException;
|
||||||
|
import java.nio.file.DirectoryStream;
|
||||||
|
import java.nio.file.FileSystems;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardWatchEventKinds;
|
||||||
|
import java.nio.file.WatchKey;
|
||||||
|
import java.nio.file.WatchService;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches the furnidata source on a single daemon thread. On change (debounced),
|
||||||
|
* re-indexes via the provider and broadcasts only the delta — or a compact
|
||||||
|
* reload-hint when the delta exceeds the cap. A minimum interval throttles bursts.
|
||||||
|
* For the split-tier directory layout, the base dir AND its immediate
|
||||||
|
* subdirectories are registered. Never throws out of the loop.
|
||||||
|
*/
|
||||||
|
public class FurnidataWatcher {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataWatcher.class);
|
||||||
|
|
||||||
|
private final FurnitureTextProvider provider;
|
||||||
|
private final Path watchDir;
|
||||||
|
private final boolean sourceIsDir;
|
||||||
|
private final long maxBytes;
|
||||||
|
private final long debounceMs;
|
||||||
|
private final long minIntervalMs;
|
||||||
|
private final int deltaCap;
|
||||||
|
|
||||||
|
private volatile boolean running = false;
|
||||||
|
private volatile WatchService ws;
|
||||||
|
private long lastBroadcast = 0L;
|
||||||
|
|
||||||
|
public FurnidataWatcher(FurnitureTextProvider provider, Path source, long maxBytes) {
|
||||||
|
this.provider = provider;
|
||||||
|
this.sourceIsDir = Files.isDirectory(source);
|
||||||
|
this.watchDir = this.sourceIsDir ? source : source.getParent();
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
this.debounceMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.debounce.ms", "750"));
|
||||||
|
this.minIntervalMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.min.interval.ms", "5000"));
|
||||||
|
this.deltaCap = Integer.parseInt(Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
if (this.running || this.watchDir == null) return;
|
||||||
|
this.running = true;
|
||||||
|
Thread t = new Thread(this::run, "FurnidataWatcher");
|
||||||
|
t.setDaemon(true);
|
||||||
|
t.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
this.running = false;
|
||||||
|
WatchService local = this.ws;
|
||||||
|
if (local != null) {
|
||||||
|
try { local.close(); } catch (IOException ignored) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void run() {
|
||||||
|
try {
|
||||||
|
this.ws = FileSystems.getDefault().newWatchService();
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOGGER.warn("FurnidataWatcher: could not create WatchService", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (WatchService service = this.ws) {
|
||||||
|
registerDirs(service);
|
||||||
|
while (this.running) {
|
||||||
|
WatchKey key = service.take();
|
||||||
|
key.pollEvents();
|
||||||
|
Thread.sleep(this.debounceMs);
|
||||||
|
key.pollEvents();
|
||||||
|
if (!key.reset()) {
|
||||||
|
LOGGER.warn("FurnidataWatcher: watch key invalidated (directory removed?) — stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
onChange();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("FurnidataWatcher: onChange failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (InterruptedException ignored) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} catch (ClosedWatchServiceException ignored) {
|
||||||
|
// stop() closed the service — normal shutdown
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("FurnidataWatcher stopped", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register the base dir, plus one level of subdirectories for the split-tier layout. */
|
||||||
|
private void registerDirs(WatchService service) throws IOException {
|
||||||
|
this.watchDir.register(service, StandardWatchEventKinds.ENTRY_MODIFY,
|
||||||
|
StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE);
|
||||||
|
if (this.sourceIsDir) {
|
||||||
|
try (DirectoryStream<Path> ds = Files.newDirectoryStream(this.watchDir)) {
|
||||||
|
for (Path child : ds) {
|
||||||
|
if (Files.isDirectory(child)) {
|
||||||
|
child.register(service, StandardWatchEventKinds.ENTRY_MODIFY,
|
||||||
|
StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onChange() {
|
||||||
|
FurnidataLock.LOCK.lock();
|
||||||
|
try {
|
||||||
|
Path source = this.provider.getSource();
|
||||||
|
if (source == null) return;
|
||||||
|
|
||||||
|
List<FurnidataEntry> delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read());
|
||||||
|
if (delta.isEmpty()) return;
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - this.lastBroadcast < this.minIntervalMs) {
|
||||||
|
LOGGER.info("FurnidataWatcher: {} changes indexed but broadcast skipped (min interval) — clients update on next change or reconnect", delta.size());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastBroadcast = now;
|
||||||
|
|
||||||
|
FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap)
|
||||||
|
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
|
||||||
|
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
|
||||||
|
|
||||||
|
broadcast(composer);
|
||||||
|
LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)",
|
||||||
|
delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size());
|
||||||
|
} finally {
|
||||||
|
FurnidataLock.LOCK.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void broadcast(FurnitureDataReloadComposer composer) {
|
||||||
|
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
|
||||||
|
if (habbo.getClient() != null) {
|
||||||
|
habbo.getClient().sendResponse(composer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package com.eu.habbo.habbohotel.items;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comment-preserving, atomic, backed-up writer for furnidata name/description, keyed by
|
||||||
|
* classname. Supports single-file and split-tier (writes the tier that currently resolves
|
||||||
|
* the classname). Edit-only: refuses classnames absent from the furnidata.
|
||||||
|
*/
|
||||||
|
public class FurnidataWriter {
|
||||||
|
|
||||||
|
/** Default tier names in override order (later = higher priority, wins on conflict). */
|
||||||
|
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
|
||||||
|
|
||||||
|
/** Manifest filenames tried in order (json5 first, plain json second). */
|
||||||
|
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
|
||||||
|
|
||||||
|
private final Path source; // file (single) or base dir (split-tier)
|
||||||
|
private final boolean directory; // true => split-tier
|
||||||
|
private final long maxBytes;
|
||||||
|
private final int backupKeep;
|
||||||
|
|
||||||
|
public FurnidataWriter(Path source, boolean directory, long maxBytes, int backupKeep) {
|
||||||
|
this.source = source;
|
||||||
|
this.directory = directory;
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
this.backupKeep = Math.max(1, backupKeep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return true if an entry for classname was found and written. */
|
||||||
|
public boolean write(String classname, String name, String description) throws IOException {
|
||||||
|
String cn = classname == null ? "" : classname.trim().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
if (cn.isEmpty()) return false;
|
||||||
|
String safeName = FurnitureTextProvider.sanitize(name);
|
||||||
|
String safeDesc = FurnitureTextProvider.sanitize(description);
|
||||||
|
|
||||||
|
Path target = locateFile(cn);
|
||||||
|
if (target == null) return false;
|
||||||
|
|
||||||
|
String raw = Files.readString(target, StandardCharsets.UTF_8);
|
||||||
|
String edited = replaceEntryFields(raw, cn, safeName, safeDesc);
|
||||||
|
if (edited == null || edited.equals(raw)) {
|
||||||
|
// classname not present in this file, or no change
|
||||||
|
return edited != null && !edited.equals(raw);
|
||||||
|
}
|
||||||
|
backup(target);
|
||||||
|
atomicWrite(target, edited);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For single-file just returns the file; for split-tier, the tier file that contains cn. */
|
||||||
|
private Path locateFile(String cn) throws IOException {
|
||||||
|
if (!directory) {
|
||||||
|
// confirm existence via the reader (size-guarded, parses the same way)
|
||||||
|
return containsClassname(source, cn) ? source : null;
|
||||||
|
}
|
||||||
|
// split-tier: iterate tiers in OVERRIDE order (later tiers win); pick the last containing cn
|
||||||
|
Path winner = null;
|
||||||
|
for (Path tierFile : splitTierFilesInOrder()) {
|
||||||
|
if (containsClassname(tierFile, cn)) winner = tierFile;
|
||||||
|
}
|
||||||
|
return winner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean containsClassname(Path file, String cn) {
|
||||||
|
for (FurnidataEntry e : new FurnidataReader(file, maxBytes).read()) {
|
||||||
|
if (e.classname() != null && e.classname().trim().toLowerCase(java.util.Locale.ROOT).equals(cn)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the "name" and "description" string values inside the JSON object that holds
|
||||||
|
* "classname": "<cn>". Preserves everything else (comments, ordering, formatting).
|
||||||
|
* Handles double- and single-quoted JSON5 keys/values. Returns null if cn not found.
|
||||||
|
*/
|
||||||
|
static String replaceEntryFields(String raw, String cn, String name, String description) {
|
||||||
|
// find the classname value occurrence (case-insensitive on the value)
|
||||||
|
Pattern classProp = Pattern.compile(
|
||||||
|
"([\"'])classname\\1\\s*:\\s*([\"'])((?:\\\\.|(?!\\2).)*)\\2", Pattern.CASE_INSENSITIVE);
|
||||||
|
Matcher m = classProp.matcher(raw);
|
||||||
|
int objStart = -1, objEnd = -1;
|
||||||
|
while (m.find()) {
|
||||||
|
String val = m.group(3).trim().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
if (!val.equals(cn)) continue;
|
||||||
|
// expand to the enclosing { ... }
|
||||||
|
objStart = lastUnbalancedBrace(raw, m.start());
|
||||||
|
objEnd = matchingClose(raw, objStart);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (objStart < 0 || objEnd < 0) return null;
|
||||||
|
String obj = raw.substring(objStart, objEnd + 1);
|
||||||
|
String newObj = replaceField(obj, "name", name);
|
||||||
|
newObj = replaceField(newObj, "description", description);
|
||||||
|
return raw.substring(0, objStart) + newObj + raw.substring(objEnd + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String replaceField(String obj, String field, String value) {
|
||||||
|
Pattern p = Pattern.compile(
|
||||||
|
"(([\"'])" + Pattern.quote(field) + "\\2\\s*:\\s*)([\"'])((?:\\\\.|(?!\\3).)*)\\3");
|
||||||
|
Matcher m = p.matcher(obj);
|
||||||
|
if (!m.find()) return obj; // field absent → leave object as-is
|
||||||
|
String replacement = m.group(1) + '"' + jsonEscape(value) + '"';
|
||||||
|
return obj.substring(0, m.start()) + replacement + obj.substring(m.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int lastUnbalancedBrace(String s, int from) {
|
||||||
|
int depth = 0;
|
||||||
|
for (int i = from; i >= 0; i--) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
if (c == '}') depth++;
|
||||||
|
else if (c == '{') { if (depth == 0) return i; depth--; }
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int matchingClose(String s, int open) {
|
||||||
|
int depth = 0; boolean inStr = false; char q = 0;
|
||||||
|
for (int i = open; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
if (inStr) { if (c == '\\') { i++; } else if (c == q) inStr = false; continue; }
|
||||||
|
if (c == '"' || c == '\'') { inStr = true; q = c; }
|
||||||
|
else if (c == '{') depth++;
|
||||||
|
else if (c == '}') { depth--; if (depth == 0) return i; }
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String jsonEscape(String v) {
|
||||||
|
StringBuilder b = new StringBuilder(v.length() + 8);
|
||||||
|
for (int i = 0; i < v.length(); i++) {
|
||||||
|
char c = v.charAt(i);
|
||||||
|
if (c == '"' || c == '\\') b.append('\\').append(c);
|
||||||
|
else b.append(c);
|
||||||
|
}
|
||||||
|
return b.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate every data file reachable from the split-tier base directory, in
|
||||||
|
* override order (core → custom → seasonal, or the order declared in the top-level
|
||||||
|
* {@code manifest.json(5)}). Within each tier the per-tier manifest's {@code files}
|
||||||
|
* array determines the file order.
|
||||||
|
*
|
||||||
|
* <p>All resolved paths are checked against the normalised base directory via
|
||||||
|
* {@link #safeResolve}: any entry that would escape the base is silently skipped.
|
||||||
|
*
|
||||||
|
* @return ordered list of existing, in-bounds data files (earliest tier first).
|
||||||
|
*/
|
||||||
|
private List<Path> splitTierFilesInOrder() throws IOException {
|
||||||
|
Path base = source.toAbsolutePath().normalize();
|
||||||
|
List<String> tiers = manifestList(base, "tiers", DEFAULT_TIERS);
|
||||||
|
List<Path> result = new ArrayList<>();
|
||||||
|
|
||||||
|
for (String tier : tiers) {
|
||||||
|
Path tierDir = safeResolve(base, tier);
|
||||||
|
if (tierDir == null || !Files.isDirectory(tierDir)) continue;
|
||||||
|
|
||||||
|
for (String fileName : manifestList(tierDir, "files", List.of())) {
|
||||||
|
Path file = safeResolve(base, tierDir.resolve(fileName).toString());
|
||||||
|
if (file == null || !Files.isRegularFile(file)) continue;
|
||||||
|
result.add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve {@code entry} relative to {@code base} and verify the result stays
|
||||||
|
* inside {@code base} (path-traversal guard).
|
||||||
|
*
|
||||||
|
* @param base the normalised absolute base directory.
|
||||||
|
* @param entry a path string (may be relative or absolute, may contain {@code ..}).
|
||||||
|
* @return the normalised absolute path if it is inside {@code base}; {@code null} otherwise.
|
||||||
|
*/
|
||||||
|
private static Path safeResolve(Path base, String entry) {
|
||||||
|
try {
|
||||||
|
Path resolved = base.resolve(entry).toAbsolutePath().normalize();
|
||||||
|
return resolved.startsWith(base) ? resolved : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the {@code key} string-array from the first manifest file found in {@code dir}
|
||||||
|
* ({@code manifest.json5} then {@code manifest.json}). Falls back to {@code fallback}
|
||||||
|
* if no manifest exists or the key is absent/empty.
|
||||||
|
*/
|
||||||
|
private List<String> manifestList(Path dir, String key, List<String> fallback) {
|
||||||
|
for (String name : MANIFEST_NAMES) {
|
||||||
|
Path m = dir.resolve(name);
|
||||||
|
if (!Files.exists(m)) continue;
|
||||||
|
try {
|
||||||
|
String stripped = FurnidataReader.stripJson5(
|
||||||
|
Files.readString(m, StandardCharsets.UTF_8));
|
||||||
|
com.google.gson.JsonObject obj =
|
||||||
|
com.google.gson.JsonParser.parseString(stripped).getAsJsonObject();
|
||||||
|
if (obj.has(key) && obj.get(key).isJsonArray()) {
|
||||||
|
List<String> list = new ArrayList<>();
|
||||||
|
for (com.google.gson.JsonElement el : obj.getAsJsonArray(key))
|
||||||
|
list.add(el.getAsString());
|
||||||
|
if (!list.isEmpty()) return list;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// bad manifest → fall through to next candidate / fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void backup(Path target) throws IOException {
|
||||||
|
Path bak = target.resolveSibling(target.getFileName() + ".bak." + System.nanoTime());
|
||||||
|
Files.copy(target, bak, StandardCopyOption.COPY_ATTRIBUTES);
|
||||||
|
pruneBackups(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pruneBackups(Path target) throws IOException {
|
||||||
|
String prefix = target.getFileName() + ".bak.";
|
||||||
|
try (var stream = Files.list(target.getParent())) {
|
||||||
|
List<Path> baks = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
|
||||||
|
.sorted(Comparator.comparingLong(p -> backupStamp(p))).toList();
|
||||||
|
for (int i = 0; i < baks.size() - backupKeep; i++) Files.deleteIfExists(baks.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long backupStamp(Path p) {
|
||||||
|
String s = p.getFileName().toString();
|
||||||
|
try { return Long.parseLong(s.substring(s.lastIndexOf('.') + 1)); } catch (Exception e) { return 0L; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void atomicWrite(Path target, String content) throws IOException {
|
||||||
|
Path tmp = target.resolveSibling(target.getFileName() + ".tmp." + System.nanoTime());
|
||||||
|
Files.writeString(tmp, content, StandardCharsets.UTF_8);
|
||||||
|
try {
|
||||||
|
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} catch (AtomicMoveNotSupportedException e) {
|
||||||
|
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore the most recent backup of the (single-file) target. @return true if restored. */
|
||||||
|
public boolean revertLastBackup() throws IOException {
|
||||||
|
if (directory) return revertSplitTier();
|
||||||
|
return revertFile(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean revertFile(Path target) throws IOException {
|
||||||
|
String prefix = target.getFileName() + ".bak.";
|
||||||
|
try (var stream = Files.list(target.getParent())) {
|
||||||
|
Path latest = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
|
||||||
|
.max(Comparator.comparingLong(FurnidataWriter::backupStamp)).orElse(null);
|
||||||
|
if (latest == null) return false;
|
||||||
|
atomicWrite(target, Files.readString(latest, StandardCharsets.UTF_8));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean revertSplitTier() throws IOException {
|
||||||
|
boolean any = false;
|
||||||
|
for (Path f : splitTierFilesInOrder()) any |= revertFile(f);
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package com.eu.habbo.habbohotel.items;
|
||||||
|
|
||||||
|
import com.eu.habbo.Emulator;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory index of furnidata display names, keyed by the lowercased base
|
||||||
|
* classname (the {@code *N} colour-variant suffix is stripped). Read lazily by
|
||||||
|
* {@link Item#getDisplayName()}. Names are sanitized at index time.
|
||||||
|
*
|
||||||
|
* Thread-safety: the index is held behind a {@code volatile} reference; readers
|
||||||
|
* never block; {@link #reindex(List)} builds a fresh map and swaps it atomically.
|
||||||
|
*/
|
||||||
|
public class FurnitureTextProvider {
|
||||||
|
|
||||||
|
private static final int MAX_LEN = 256;
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(FurnitureTextProvider.class);
|
||||||
|
private static final long DEFAULT_MAX_BYTES = 64L * 1024 * 1024;
|
||||||
|
|
||||||
|
private final boolean enabled;
|
||||||
|
private volatile Map<String, FurniText> index = Map.of();
|
||||||
|
private volatile Path source;
|
||||||
|
private FurnidataWatcher watcher;
|
||||||
|
|
||||||
|
public FurnitureTextProvider(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Production constructor: reads the enable toggle from config. */
|
||||||
|
public FurnitureTextProvider() {
|
||||||
|
this(Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.names.enabled", "true")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the furnidata source from config and build the initial index. Never throws. */
|
||||||
|
public void init() {
|
||||||
|
try {
|
||||||
|
this.source = resolveSource();
|
||||||
|
if (this.source == null) {
|
||||||
|
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved — names fall back to public_name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read());
|
||||||
|
LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), this.source);
|
||||||
|
|
||||||
|
if (Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.watch.enabled", "true"))) {
|
||||||
|
if (this.watcher != null) this.watcher.stop();
|
||||||
|
this.watcher = new FurnidataWatcher(this, this.source, DEFAULT_MAX_BYTES);
|
||||||
|
this.watcher.start();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("FurnitureTextProvider.init failed — names fall back to public_name", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path getSource() {
|
||||||
|
return this.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns {@code true} when the resolved source is a directory (split-tier layout). */
|
||||||
|
public boolean isSourceDirectory() {
|
||||||
|
return this.source != null && Files.isDirectory(this.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the byte cap used when reading furnidata files. */
|
||||||
|
public long getMaxBytes() {
|
||||||
|
return Long.parseLong(com.eu.habbo.Emulator.getConfig().getValue("items.furnidata.max.bytes", String.valueOf(DEFAULT_MAX_BYTES)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-reads the furnidata from the current source and reindexes atomically.
|
||||||
|
* Returns the delta list (new/changed entries) from {@link #reindex(List)}.
|
||||||
|
* Never throws — returns an empty list when the source is unavailable.
|
||||||
|
*/
|
||||||
|
public java.util.List<FurnidataEntry> reindexFromSource() {
|
||||||
|
try {
|
||||||
|
if (this.source == null) return java.util.List.of();
|
||||||
|
return reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read());
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("FurnitureTextProvider.reindexFromSource failed", e);
|
||||||
|
return java.util.List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path resolveSource() {
|
||||||
|
String override = Emulator.getConfig().getValue("items.furnidata.path", "");
|
||||||
|
if (!override.isEmpty()) {
|
||||||
|
Path p = Paths.get(override);
|
||||||
|
if (Files.exists(p)) return p;
|
||||||
|
LOGGER.warn("FurnitureTextProvider: items.furnidata.path '{}' does not exist", override);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
|
||||||
|
if (basePath.isEmpty()) return null;
|
||||||
|
Path dir = Paths.get(basePath);
|
||||||
|
Path split = dir.resolve("furnidata");
|
||||||
|
if (Files.isDirectory(split)) return split;
|
||||||
|
Path legacy = dir.resolve("FurnitureData.json");
|
||||||
|
return Files.exists(legacy) ? legacy : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fresh sanitized index, swap it in atomically, and return the
|
||||||
|
* changed/added entries (sanitized) as the delta versus the previous index.
|
||||||
|
*/
|
||||||
|
public java.util.List<FurnidataEntry> reindex(java.util.List<FurnidataEntry> entries) {
|
||||||
|
Map<String, FurniText> next = new HashMap<>(Math.max(16, entries.size() * 2));
|
||||||
|
for (FurnidataEntry e : entries) {
|
||||||
|
String key = baseKey(e.classname());
|
||||||
|
if (key == null) continue;
|
||||||
|
next.put(key, new FurniText(e.id(), e.type(), sanitize(e.name()), sanitize(e.description())));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, FurniText> prev = this.index;
|
||||||
|
java.util.List<FurnidataEntry> delta = new java.util.ArrayList<>();
|
||||||
|
for (Map.Entry<String, FurniText> en : next.entrySet()) {
|
||||||
|
FurniText cur = en.getValue();
|
||||||
|
FurniText old = prev.get(en.getKey());
|
||||||
|
if (old == null || !old.name().equals(cur.name()) || !old.description().equals(cur.description())) {
|
||||||
|
delta.add(new FurnidataEntry(cur.id(), en.getKey(), cur.type(), cur.name(), cur.description()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.index = next; // atomic reference swap
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the sanitized display name for a DB classname, or null if absent/disabled. */
|
||||||
|
public String getName(String classname) {
|
||||||
|
if (!this.enabled) return null;
|
||||||
|
String key = baseKey(classname);
|
||||||
|
if (key == null) return null;
|
||||||
|
FurniText t = this.index.get(key);
|
||||||
|
return (t != null) ? t.name() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String baseKey(String classname) {
|
||||||
|
if (classname == null) return null;
|
||||||
|
int star = classname.indexOf('*');
|
||||||
|
String base = (star >= 0) ? classname.substring(0, star) : classname;
|
||||||
|
base = base.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return base.isEmpty() ? null : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap length, strip control chars/newlines, neutralize % (placeholder-injection safe).
|
||||||
|
* The 256 cap is in Java {@code char} units (UTF-16 code units), which is acceptable for
|
||||||
|
* furni names (controlled, predominantly ASCII source). Lone/astral surrogates are not
|
||||||
|
* specially handled.
|
||||||
|
*/
|
||||||
|
public static String sanitize(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
StringBuilder sb = new StringBuilder(Math.min(value.length(), MAX_LEN));
|
||||||
|
for (int i = 0; i < value.length() && sb.length() < MAX_LEN; i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (c == '%') { sb.append('%'); continue; } // fullwidth percent — not a placeholder token
|
||||||
|
if (c == '\n' || c == '\r' || Character.isISOControl(c)) continue;
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all lowercased base classnames whose furnidata display name contains
|
||||||
|
* {@code query} (case-insensitive, substring). Results are capped at 200 to
|
||||||
|
* bound SQL IN-clause size. Returns an empty list when query is null/blank.
|
||||||
|
*/
|
||||||
|
public java.util.List<String> findClassnamesByName(String query) {
|
||||||
|
java.util.List<String> out = new java.util.ArrayList<>();
|
||||||
|
if (query == null) return out;
|
||||||
|
String q = query.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (q.isEmpty()) return out;
|
||||||
|
Map<String, FurniText> idx = this.index; // local ref (volatile)
|
||||||
|
for (Map.Entry<String, FurniText> e : idx.entrySet()) {
|
||||||
|
FurniText t = e.getValue();
|
||||||
|
if (t != null && t.name() != null && t.name().toLowerCase(Locale.ROOT).contains(q)) {
|
||||||
|
out.add(e.getKey()); // key is the lowercased base classname
|
||||||
|
if (out.size() >= 200) break; // bound IN-clause size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record FurniText(int id, FurnitureType type, String name, String description) {}
|
||||||
|
}
|
||||||
@@ -167,6 +167,20 @@ public class Item implements ISerialize {
|
|||||||
return this.fullName;
|
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() {
|
public FurnitureType getType() {
|
||||||
return this.type;
|
return this.type;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -279,7 +279,7 @@ public final class WiredTextPlaceholderUtil {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
String furniName = item.getBaseItem().getFullName();
|
String furniName = item.getBaseItem().getDisplayName();
|
||||||
if (furniName == null || furniName.trim().isEmpty()) {
|
if (furniName == null || furniName.trim().isEmpty()) {
|
||||||
furniName = item.getBaseItem().getName();
|
furniName = item.getBaseItem().getName();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,6 +285,9 @@ public class PacketManager {
|
|||||||
this.registerHandler(Incoming.FurniEditorInteractionsEvent, FurniEditorInteractionsEvent.class);
|
this.registerHandler(Incoming.FurniEditorInteractionsEvent, FurniEditorInteractionsEvent.class);
|
||||||
this.registerHandler(Incoming.FurniEditorUpdateEvent, FurniEditorUpdateEvent.class);
|
this.registerHandler(Incoming.FurniEditorUpdateEvent, FurniEditorUpdateEvent.class);
|
||||||
this.registerHandler(Incoming.FurniEditorDeleteEvent, FurniEditorDeleteEvent.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
|
// Catalog Admin
|
||||||
this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class);
|
this.registerHandler(Incoming.CatalogAdminSavePageEvent, CatalogAdminSavePageEvent.class);
|
||||||
|
|||||||
@@ -431,6 +431,9 @@ public class Incoming {
|
|||||||
public static final int FurniEditorInteractionsEvent = 10043;
|
public static final int FurniEditorInteractionsEvent = 10043;
|
||||||
public static final int FurniEditorUpdateEvent = 10044;
|
public static final int FurniEditorUpdateEvent = 10044;
|
||||||
public static final int FurniEditorDeleteEvent = 10045;
|
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
|
// Catalog Admin
|
||||||
public static final int CatalogAdminSavePageEvent = 10050;
|
public static final int CatalogAdminSavePageEvent = 10050;
|
||||||
|
|||||||
+2
-2
@@ -248,7 +248,7 @@ public class CatalogBuyItemAsGiftEvent extends MessageHandler {
|
|||||||
LOGGER.debug("sender reached daily total LTD limit");
|
LOGGER.debug("sender reached daily total LTD limit");
|
||||||
this.client.getHabbo().alert(
|
this.client.getHabbo().alert(
|
||||||
Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total")
|
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 + "")
|
.replace("%limit%", ltdLimit + "")
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -259,7 +259,7 @@ public class CatalogBuyItemAsGiftEvent extends MessageHandler {
|
|||||||
LOGGER.debug("sender reached daily LTD item limit");
|
LOGGER.debug("sender reached daily LTD item limit");
|
||||||
this.client.getHabbo().alert(
|
this.client.getHabbo().alert(
|
||||||
Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item")
|
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 + "")
|
.replace("%limit%", ltdLimit + "")
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ public class FurniEditorHelper {
|
|||||||
* Prevents SQL injection via arbitrary column names.
|
* Prevents SQL injection via arbitrary column names.
|
||||||
*/
|
*/
|
||||||
public static final java.util.Set<String> ALLOWED_UPDATE_FIELDS = java.util.Set.of(
|
public static final java.util.Set<String> ALLOWED_UPDATE_FIELDS = java.util.Set.of(
|
||||||
"item_name", "public_name", "sprite_id", "type", "width", "length",
|
"public_name", "sprite_id", "type", "width", "length",
|
||||||
"stack_height", "allow_stack", "allow_walk", "allow_sit", "allow_lay",
|
"stack_height", "allow_stack", "allow_walk", "allow_sit", "allow_lay",
|
||||||
"allow_gift", "allow_trade", "allow_recycle", "allow_marketplace_sell",
|
"allow_gift", "allow_trade", "allow_recycle", "allow_marketplace_sell",
|
||||||
"allow_inventory_stack", "interaction_type", "interaction_modes_count",
|
"allow_inventory_stack", "interaction_type", "interaction_modes_count",
|
||||||
|
|||||||
+139
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
package com.eu.habbo.messages.incoming.furnieditor;
|
||||||
|
|
||||||
|
import com.eu.habbo.Emulator;
|
||||||
|
import com.eu.habbo.habbohotel.items.FurnidataEntry;
|
||||||
|
import com.eu.habbo.habbohotel.items.FurnidataLock;
|
||||||
|
import com.eu.habbo.habbohotel.items.FurnidataWriter;
|
||||||
|
import com.eu.habbo.habbohotel.items.FurnitureTextProvider;
|
||||||
|
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||||
|
import com.eu.habbo.habbohotel.users.Habbo;
|
||||||
|
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||||
|
import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
|
||||||
|
import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incoming handler 10048 — admin reverts a furni's furnidata to the last rotating backup.
|
||||||
|
*
|
||||||
|
* Flow: permission check → read item_id → resolve classname → under FurnidataLock:
|
||||||
|
* FurnidataWriter.revertLastBackup → FurnitureTextProvider.reindexFromSource →
|
||||||
|
* broadcast FurnitureDataReloadComposer (10047) → audit log → respond.
|
||||||
|
*/
|
||||||
|
public class FurniEditorRevertFurnidataEvent extends MessageHandler {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorRevertFurnidataEvent.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle() throws Exception {
|
||||||
|
Habbo habbo = this.client.getHabbo();
|
||||||
|
|
||||||
|
// 1. Permission check
|
||||||
|
if (!habbo.hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "No permission"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Read packet
|
||||||
|
int itemId = this.packet.readInt();
|
||||||
|
|
||||||
|
if (itemId <= 0) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Resolve classname from item_id (reuse static helper from update handler)
|
||||||
|
String classname = FurniEditorUpdateFurnidataEvent.classnameForItem(itemId);
|
||||||
|
String classnameForLog = (classname != null) ? classname : "?";
|
||||||
|
|
||||||
|
// 4. Verify provider is configured
|
||||||
|
FurnitureTextProvider provider =
|
||||||
|
Emulator.getGameEnvironment().getFurnitureTextProvider();
|
||||||
|
|
||||||
|
if (provider == null || provider.getSource() == null) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "Furnidata source not configured"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int adminId = habbo.getHabboInfo().getId();
|
||||||
|
|
||||||
|
// 5. Revert + reindex + broadcast under the shared lock
|
||||||
|
boolean reverted;
|
||||||
|
List<FurnidataEntry> delta;
|
||||||
|
|
||||||
|
FurnidataLock.LOCK.lock();
|
||||||
|
try {
|
||||||
|
FurnidataWriter writer = new FurnidataWriter(
|
||||||
|
provider.getSource(),
|
||||||
|
provider.isSourceDirectory(),
|
||||||
|
provider.getMaxBytes(),
|
||||||
|
3 /* backupKeep */
|
||||||
|
);
|
||||||
|
reverted = writer.revertLastBackup();
|
||||||
|
if (!reverted) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "No backup found to revert"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delta = provider.reindexFromSource();
|
||||||
|
|
||||||
|
if (!delta.isEmpty()) {
|
||||||
|
int deltaCap = Integer.parseInt(
|
||||||
|
Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
|
||||||
|
FurnitureDataReloadComposer composer = (delta.size() > deltaCap)
|
||||||
|
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
|
||||||
|
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
|
||||||
|
broadcastToAll(composer);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
FurnidataLock.LOCK.unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Audit log (outside lock — DB write, not latency-sensitive)
|
||||||
|
FurnidataAuditLog.record(
|
||||||
|
adminId,
|
||||||
|
classnameForLog,
|
||||||
|
"revert",
|
||||||
|
"", // previous state unknown at this point
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Respond success
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(true, "Furnidata reverted", itemId));
|
||||||
|
LOGGER.info("FurniEditorRevertFurnidataEvent: admin {} reverted furnidata for classname '{}' (item {})",
|
||||||
|
adminId, classnameForLog, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void broadcastToAll(FurnitureDataReloadComposer composer) {
|
||||||
|
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
|
||||||
|
if (habbo.getClient() != null) {
|
||||||
|
habbo.getClient().sendResponse(composer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-1
@@ -27,6 +27,8 @@ public class FurniEditorSearchEvent extends MessageHandler {
|
|||||||
String query = this.packet.readString();
|
String query = this.packet.readString();
|
||||||
String type = this.packet.readString();
|
String type = this.packet.readString();
|
||||||
int page = this.packet.readInt();
|
int page = this.packet.readInt();
|
||||||
|
String sortField = this.packet.readString();
|
||||||
|
String sortDir = this.packet.readString();
|
||||||
|
|
||||||
// Input validation
|
// Input validation
|
||||||
if (query.length() > 100) {
|
if (query.length() > 100) {
|
||||||
@@ -64,10 +66,53 @@ public class FurniEditorSearchEvent extends MessageHandler {
|
|||||||
params.add(type);
|
params.add(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extend search with furnidata display-name matches (server-authoritative names in JSON).
|
||||||
|
// Appends: OR (LOWER(item_name) IN (?,?,...) [AND type=?])
|
||||||
|
// Both branches carry their own type filter, so type scoping is preserved.
|
||||||
|
// Params: [existing LIKE params] [existing type?] [furniCns...] [type again?]
|
||||||
|
if (!query.isEmpty()) {
|
||||||
|
java.util.List<String> furniCns = Emulator.getGameEnvironment()
|
||||||
|
.getFurnitureTextProvider()
|
||||||
|
.findClassnamesByName(query);
|
||||||
|
if (!furniCns.isEmpty()) {
|
||||||
|
// Build: OR (LOWER(item_name) IN (?,?,...) [AND type = ?])
|
||||||
|
StringBuilder orBranch = new StringBuilder(" OR (LOWER(item_name) IN (");
|
||||||
|
for (int i = 0; i < furniCns.size(); i++) {
|
||||||
|
if (i > 0) orBranch.append(", ");
|
||||||
|
orBranch.append('?');
|
||||||
|
}
|
||||||
|
orBranch.append(')');
|
||||||
|
if (type != null && !type.isEmpty()) {
|
||||||
|
orBranch.append(" AND type = ?");
|
||||||
|
}
|
||||||
|
orBranch.append(')');
|
||||||
|
whereClause.append(orBranch);
|
||||||
|
params.addAll(furniCns);
|
||||||
|
if (type != null && !type.isEmpty()) {
|
||||||
|
params.add(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a SAFE ORDER BY from the whitelisted sort field/direction
|
||||||
|
// (column names are never taken from raw user input — injection-proof).
|
||||||
|
String orderColumn;
|
||||||
|
switch (sortField == null ? "" : sortField) {
|
||||||
|
case "spriteId": orderColumn = "sprite_id"; break;
|
||||||
|
case "itemName": orderColumn = "item_name"; break;
|
||||||
|
case "publicName": orderColumn = "public_name"; break;
|
||||||
|
case "type": orderColumn = "type"; break;
|
||||||
|
case "interactionType": orderColumn = "interaction_type"; break;
|
||||||
|
case "id":
|
||||||
|
default: orderColumn = "id"; break;
|
||||||
|
}
|
||||||
|
String orderDir = "desc".equalsIgnoreCase(sortDir) ? "DESC" : "ASC";
|
||||||
|
|
||||||
// Count total
|
// Count total
|
||||||
int total = 0;
|
int total = 0;
|
||||||
String countSql = "SELECT COUNT(*) FROM items_base " + whereClause;
|
String countSql = "SELECT COUNT(*) FROM items_base " + whereClause;
|
||||||
String dataSql = "SELECT * FROM items_base " + whereClause + " ORDER BY id ASC LIMIT ? OFFSET ?";
|
String dataSql = "SELECT * FROM items_base " + whereClause
|
||||||
|
+ " ORDER BY " + orderColumn + " " + orderDir + ", id ASC LIMIT ? OFFSET ?";
|
||||||
|
|
||||||
List<Map<String, Object>> items = new ArrayList<>();
|
List<Map<String, Object>> items = new ArrayList<>();
|
||||||
|
|
||||||
|
|||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
package com.eu.habbo.messages.incoming.furnieditor;
|
||||||
|
|
||||||
|
import com.eu.habbo.Emulator;
|
||||||
|
import com.eu.habbo.habbohotel.items.FurnidataEntry;
|
||||||
|
import com.eu.habbo.habbohotel.items.FurnidataLock;
|
||||||
|
import com.eu.habbo.habbohotel.items.FurnidataWriter;
|
||||||
|
import com.eu.habbo.habbohotel.items.FurnitureTextProvider;
|
||||||
|
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||||
|
import com.eu.habbo.habbohotel.users.Habbo;
|
||||||
|
import com.eu.habbo.messages.incoming.MessageHandler;
|
||||||
|
import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
|
||||||
|
import com.eu.habbo.messages.outgoing.furnieditor.FurniEditorResultComposer;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incoming handler 10046 — admin saves a furni name/description in the editor.
|
||||||
|
*
|
||||||
|
* Flow: permission check → rate-limit → resolve classname from item_id →
|
||||||
|
* under FurnidataLock: FurnidataWriter.write → FurnitureTextProvider.reindexFromSource →
|
||||||
|
* broadcast FurnitureDataReloadComposer (10047) → audit log → respond.
|
||||||
|
*/
|
||||||
|
public class FurniEditorUpdateFurnidataEvent extends MessageHandler {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(FurniEditorUpdateFurnidataEvent.class);
|
||||||
|
|
||||||
|
/** Rate-limit: min milliseconds between successive calls per admin user id. */
|
||||||
|
private static final long RATE_LIMIT_MS = 1_000L;
|
||||||
|
|
||||||
|
/** Per-admin last-call timestamp map. */
|
||||||
|
private static final Map<Integer, Long> LAST_CALL = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle() throws Exception {
|
||||||
|
Habbo habbo = this.client.getHabbo();
|
||||||
|
|
||||||
|
// 1. Permission check
|
||||||
|
if (!habbo.hasPermission(Permission.ACC_CATALOGFURNI)) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "No permission"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Rate-limit per admin
|
||||||
|
int adminId = habbo.getHabboInfo().getId();
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Long last = LAST_CALL.get(adminId);
|
||||||
|
if (last != null && (now - last) < RATE_LIMIT_MS) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "Too many requests"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LAST_CALL.put(adminId, now);
|
||||||
|
|
||||||
|
// 3. Read packet
|
||||||
|
int itemId = this.packet.readInt();
|
||||||
|
JsonObject json;
|
||||||
|
try {
|
||||||
|
json = JsonParser.parseString(this.packet.readString()).getAsJsonObject();
|
||||||
|
} catch (Exception e) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid JSON data"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemId <= 0) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "Invalid item ID"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String name = json.has("name") ? json.get("name").getAsString() : null;
|
||||||
|
String description = json.has("description") ? json.get("description").getAsString() : null;
|
||||||
|
|
||||||
|
if (name == null && description == null) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "No name or description provided"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Resolve classname from item_id
|
||||||
|
String classname = classnameForItem(itemId);
|
||||||
|
if (classname == null) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "Item not found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Write + reindex + broadcast under the shared lock
|
||||||
|
FurnitureTextProvider provider =
|
||||||
|
Emulator.getGameEnvironment().getFurnitureTextProvider();
|
||||||
|
|
||||||
|
if (provider == null || provider.getSource() == null) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "Furnidata source not configured"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture old values (before write) for the audit log
|
||||||
|
String oldName = provider.getName(classname);
|
||||||
|
// description is not indexed in the provider — treat as empty string for audit
|
||||||
|
String oldDesc = "";
|
||||||
|
|
||||||
|
// FurnidataWriter.write() calls FurnitureTextProvider.sanitize() internally;
|
||||||
|
// pass the raw values here and use them also for the audit log.
|
||||||
|
String safeName = (name != null) ? name : "";
|
||||||
|
String safeDesc = (description != null) ? description : "";
|
||||||
|
|
||||||
|
boolean written;
|
||||||
|
List<FurnidataEntry> delta;
|
||||||
|
|
||||||
|
FurnidataLock.LOCK.lock();
|
||||||
|
try {
|
||||||
|
FurnidataWriter writer = new FurnidataWriter(
|
||||||
|
provider.getSource(),
|
||||||
|
provider.isSourceDirectory(),
|
||||||
|
provider.getMaxBytes(),
|
||||||
|
3 /* backupKeep */
|
||||||
|
);
|
||||||
|
written = writer.write(classname, safeName, safeDesc);
|
||||||
|
if (!written) {
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(false, "Classname not found in furnidata"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delta = provider.reindexFromSource();
|
||||||
|
|
||||||
|
if (!delta.isEmpty()) {
|
||||||
|
int deltaCap = Integer.parseInt(
|
||||||
|
Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
|
||||||
|
FurnitureDataReloadComposer composer = (delta.size() > deltaCap)
|
||||||
|
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
|
||||||
|
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
|
||||||
|
broadcastToAll(composer);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
FurnidataLock.LOCK.unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5b. Auto-mirror the new display name into items_base.public_name (DB) so the
|
||||||
|
// server-side fallback (Item.getFullName) and the editor's read-only
|
||||||
|
// "Public Name" field stay in sync with the furnidata edit. Only when a
|
||||||
|
// name was actually supplied (description-only edits must not blank it).
|
||||||
|
// Kept outside FurnidataLock (independent DB write, like the audit log).
|
||||||
|
if (name != null) {
|
||||||
|
try (Connection c = Emulator.getDatabase().getDataSource().getConnection();
|
||||||
|
PreparedStatement st = c.prepareStatement("UPDATE items_base SET public_name = ? WHERE id = ?")) {
|
||||||
|
st.setString(1, FurnitureTextProvider.sanitize(safeName));
|
||||||
|
st.setInt(2, itemId);
|
||||||
|
st.executeUpdate();
|
||||||
|
// Refresh the in-memory Item cache (Item.fullName) in place — no restart needed.
|
||||||
|
Emulator.getGameEnvironment().getItemManager().loadItems();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("Failed to mirror furnidata name into items_base.public_name for item {}", itemId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Audit log (outside lock — DB write, not latency-sensitive)
|
||||||
|
FurnidataAuditLog.record(
|
||||||
|
adminId,
|
||||||
|
classname,
|
||||||
|
"edit",
|
||||||
|
oldName != null ? oldName : "",
|
||||||
|
FurnitureTextProvider.sanitize(safeName),
|
||||||
|
oldDesc,
|
||||||
|
FurnitureTextProvider.sanitize(safeDesc)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Respond success
|
||||||
|
this.client.sendResponse(new FurniEditorResultComposer(true, "Furnidata updated", itemId));
|
||||||
|
LOGGER.info("FurniEditorUpdateFurnidataEvent: admin {} updated furnidata for classname '{}' (item {})",
|
||||||
|
adminId, classname, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the item_name (classname) from items_base for a given item id.
|
||||||
|
* Kept static so FurniEditorRevertFurnidataEvent can reuse it.
|
||||||
|
*
|
||||||
|
* @return the classname string, or {@code null} if not found or on error.
|
||||||
|
*/
|
||||||
|
public static String classnameForItem(int itemId) {
|
||||||
|
try (Connection c = Emulator.getDatabase().getDataSource().getConnection();
|
||||||
|
PreparedStatement st = c.prepareStatement("SELECT item_name FROM items_base WHERE id = ?")) {
|
||||||
|
st.setInt(1, itemId);
|
||||||
|
try (ResultSet rs = st.executeQuery()) {
|
||||||
|
if (rs.next()) return rs.getString("item_name");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("classnameForItem: failed to query items_base for id {}", itemId, e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void broadcastToAll(FurnitureDataReloadComposer composer) {
|
||||||
|
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
|
||||||
|
if (habbo.getClient() != null) {
|
||||||
|
habbo.getClient().sendResponse(composer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.eu.habbo.messages.incoming.furnieditor;
|
||||||
|
|
||||||
|
import com.eu.habbo.Emulator;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
|
||||||
|
public final class FurnidataAuditLog {
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataAuditLog.class);
|
||||||
|
private FurnidataAuditLog() {}
|
||||||
|
|
||||||
|
public static void record(int userId, String classname, String action,
|
||||||
|
String oldName, String newName, String oldDesc, String newDesc) {
|
||||||
|
try (Connection c = Emulator.getDatabase().getDataSource().getConnection();
|
||||||
|
PreparedStatement st = c.prepareStatement(
|
||||||
|
"INSERT INTO furnidata_edit_log (user_id, classname, action, old_name, new_name, old_description, new_description, timestamp) " +
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?)")) {
|
||||||
|
st.setInt(1, userId);
|
||||||
|
st.setString(2, classname);
|
||||||
|
st.setString(3, action);
|
||||||
|
st.setString(4, oldName == null ? "" : oldName);
|
||||||
|
st.setString(5, newName == null ? "" : newName);
|
||||||
|
st.setString(6, oldDesc == null ? "" : oldDesc);
|
||||||
|
st.setString(7, newDesc == null ? "" : newDesc);
|
||||||
|
st.setInt(8, Emulator.getIntUnixTimestamp());
|
||||||
|
st.executeUpdate();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("Failed to write furnidata_edit_log", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -570,6 +570,8 @@ public class Outgoing {
|
|||||||
public static final int FurniEditorDetailComposer = 10041;
|
public static final int FurniEditorDetailComposer = 10041;
|
||||||
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 FurniEditorImportTextResultComposer = 10049; // CUSTOM
|
||||||
|
|
||||||
// Catalog Admin
|
// Catalog Admin
|
||||||
public static final int CatalogAdminResultComposer = 10059;
|
public static final int CatalogAdminResultComposer = 10059;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public class FriendsComposer extends MessageComposer {
|
|||||||
this.response.appendInt(row.getGender().equals(HabboGender.M) ? 0 : 1);
|
this.response.appendInt(row.getGender().equals(HabboGender.M) ? 0 : 1);
|
||||||
this.response.appendBoolean(row.getOnline() == 1);
|
this.response.appendBoolean(row.getOnline() == 1);
|
||||||
this.response.appendBoolean(row.inRoom()); //IN ROOM
|
this.response.appendBoolean(row.inRoom()); //IN ROOM
|
||||||
this.response.appendString(row.getOnline() == 1 ? row.getLook() : "");
|
this.response.appendString(row.getLook()); // send look for offline friends too (loaded from DB)
|
||||||
this.response.appendInt(row.getCategoryId()); //Friends category
|
this.response.appendInt(row.getCategoryId()); //Friends category
|
||||||
this.response.appendString(row.getMotto());
|
this.response.appendString(row.getMotto());
|
||||||
this.response.appendString(""); //Last seen as DATETIMESTRING
|
this.response.appendString(""); //Last seen as DATETIMESTRING
|
||||||
|
|||||||
+33
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package com.eu.habbo.messages.outgoing.furniture;
|
||||||
|
|
||||||
|
import com.eu.habbo.habbohotel.items.FurnidataEntry;
|
||||||
|
import com.eu.habbo.habbohotel.items.FurnitureType;
|
||||||
|
import com.eu.habbo.messages.ServerMessage;
|
||||||
|
import com.eu.habbo.messages.outgoing.MessageComposer;
|
||||||
|
import com.eu.habbo.messages.outgoing.Outgoing;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class FurnitureDataReloadComposer extends MessageComposer {
|
||||||
|
|
||||||
|
public static final int MODE_DELTA = 0;
|
||||||
|
public static final int MODE_RELOAD_HINT = 1;
|
||||||
|
|
||||||
|
private final int mode;
|
||||||
|
private final List<FurnidataEntry> entries;
|
||||||
|
|
||||||
|
public FurnitureDataReloadComposer(int mode, List<FurnidataEntry> entries) {
|
||||||
|
this.mode = mode;
|
||||||
|
this.entries = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ServerMessage composeInternal() {
|
||||||
|
this.response.init(Outgoing.FurnitureDataReloadComposer);
|
||||||
|
this.response.appendInt(this.mode);
|
||||||
|
|
||||||
|
if (this.mode == MODE_DELTA) {
|
||||||
|
this.response.appendInt(this.entries.size());
|
||||||
|
for (FurnidataEntry e : this.entries) {
|
||||||
|
this.response.appendString(e.type() == FurnitureType.FLOOR ? "S" : "I");
|
||||||
|
this.response.appendInt(e.id());
|
||||||
|
this.response.appendString(e.classname());
|
||||||
|
this.response.appendString(e.name());
|
||||||
|
this.response.appendString(e.description());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.response;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -18,7 +18,7 @@ public class WatchAndEarnRewardComposer extends MessageComposer {
|
|||||||
this.response.appendString(this.item.getType().code);
|
this.response.appendString(this.item.getType().code);
|
||||||
this.response.appendInt(this.item.getId());
|
this.response.appendInt(this.item.getId());
|
||||||
this.response.appendString(this.item.getName());
|
this.response.appendString(this.item.getName());
|
||||||
this.response.appendString(this.item.getFullName());
|
this.response.appendString(this.item.getDisplayName());
|
||||||
return this.response;
|
return this.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.eu.habbo;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class SmokeTest {
|
||||||
|
@Test
|
||||||
|
void harnessRuns() {
|
||||||
|
assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.eu.habbo.habbohotel.items;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class FurnidataReaderTest {
|
||||||
|
|
||||||
|
private static final String SINGLE = """
|
||||||
|
{
|
||||||
|
// a comment
|
||||||
|
"roomitemtypes": { "furnitype": [
|
||||||
|
{ "id": 10, "classname": "chair_norja", "name": "Chair", "description": "Sit", "xdim": 1, "ydim": 1 },
|
||||||
|
]},
|
||||||
|
"wallitemtypes": { "furnitype": [
|
||||||
|
{ "id": 20, "classname": "poster_5", "name": "Poster", "description": "Wall" }
|
||||||
|
]}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesSingleFileFloorAndWall(@TempDir Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve("FurnitureData.json");
|
||||||
|
Files.writeString(file, SINGLE);
|
||||||
|
|
||||||
|
List<FurnidataEntry> entries = new FurnidataReader(file, 64 * 1024 * 1024).read();
|
||||||
|
|
||||||
|
assertEquals(2, entries.size());
|
||||||
|
FurnidataEntry floor = entries.stream().filter(e -> e.id() == 10).findFirst().orElseThrow();
|
||||||
|
assertEquals("chair_norja", floor.classname());
|
||||||
|
assertEquals(FurnitureType.FLOOR, floor.type());
|
||||||
|
assertEquals("Chair", floor.name());
|
||||||
|
FurnidataEntry wall = entries.stream().filter(e -> e.id() == 20).findFirst().orElseThrow();
|
||||||
|
assertEquals(FurnitureType.WALL, wall.type());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsFileOverSizeCap(@TempDir Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve("FurnitureData.json");
|
||||||
|
Files.writeString(file, SINGLE);
|
||||||
|
List<FurnidataEntry> entries = new FurnidataReader(file, 8 /* bytes */).read();
|
||||||
|
assertTrue(entries.isEmpty(), "oversized file must be refused, returning empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingSourceReturnsEmptyNeverThrows(@TempDir Path dir) {
|
||||||
|
Path missing = dir.resolve("does-not-exist.json");
|
||||||
|
assertDoesNotThrow(() -> {
|
||||||
|
assertTrue(new FurnidataReader(missing, 64 * 1024 * 1024).read().isEmpty());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oversizedManifestIsSkippedNeverThrows(@TempDir Path dir) throws Exception {
|
||||||
|
Path base = dir.resolve("furnidata");
|
||||||
|
Path core = base.resolve("core");
|
||||||
|
Files.createDirectories(core);
|
||||||
|
// A root manifest larger than the cap we pass in.
|
||||||
|
Files.writeString(base.resolve("manifest.json"), "{ \"tiers\": [ \"core\" ] } // padding ".repeat(50));
|
||||||
|
List<FurnidataEntry> entries = new FurnidataReader(base, 8 /* bytes */).read();
|
||||||
|
assertTrue(entries.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void splitDirRejectsTraversalFiles(@TempDir Path dir) throws Exception {
|
||||||
|
Path secret = dir.resolve("secret.json");
|
||||||
|
Files.writeString(secret, "{ \"roomitemtypes\": { \"furnitype\": [ { \"id\": 99, \"classname\": \"x\", \"name\": \"LEAK\", \"description\": \"\" } ] } }");
|
||||||
|
|
||||||
|
Path base = dir.resolve("furnidata");
|
||||||
|
Path core = base.resolve("core");
|
||||||
|
Files.createDirectories(core);
|
||||||
|
Files.writeString(base.resolve("manifest.json"), "{ \"tiers\": [ \"core\" ] }");
|
||||||
|
Files.writeString(core.resolve("manifest.json"), "{ \"files\": [ \"../../secret.json\" ] }");
|
||||||
|
|
||||||
|
List<FurnidataEntry> entries = new FurnidataReader(base, 64 * 1024 * 1024).read();
|
||||||
|
|
||||||
|
assertTrue(entries.stream().noneMatch(e -> e.id() == 99),
|
||||||
|
"traversal file outside the base dir must be ignored");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.eu.habbo.habbohotel.items;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class FurnidataWriterTest {
|
||||||
|
|
||||||
|
private static final String SINGLE =
|
||||||
|
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||||
|
" { \"id\": 1, \"classname\": \"01_caterhead\", \"name\": \"old name\", \"description\": \"old desc\" }\n" +
|
||||||
|
"] }, \"wallitemtypes\": { \"furnitype\": [] } }";
|
||||||
|
|
||||||
|
// Tier data: core has the entry with "core name"; custom ALSO has it with "custom old name".
|
||||||
|
// The writer must pick the custom (winning) tier and leave core untouched.
|
||||||
|
private static final String CORE_DATA =
|
||||||
|
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||||
|
" { \"id\": 1, \"classname\": \"split_chair\", \"name\": \"core name\", \"description\": \"core desc\" }\n" +
|
||||||
|
"] }, \"wallitemtypes\": { \"furnitype\": [] } }";
|
||||||
|
|
||||||
|
private static final String CUSTOM_DATA =
|
||||||
|
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||||
|
" { \"id\": 1, \"classname\": \"split_chair\", \"name\": \"custom old name\", \"description\": \"custom old desc\" }\n" +
|
||||||
|
"] }, \"wallitemtypes\": { \"furnitype\": [] } }";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writesNameAndDescriptionByClassnameSingleFile() throws Exception {
|
||||||
|
Path dir = Files.createTempDirectory("fd");
|
||||||
|
Path file = dir.resolve("FurnitureData.json");
|
||||||
|
Files.writeString(file, SINGLE);
|
||||||
|
|
||||||
|
FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10);
|
||||||
|
boolean ok = w.write("01_caterhead", "Cat Head", "A cat head");
|
||||||
|
|
||||||
|
assertTrue(ok);
|
||||||
|
String after = Files.readString(file);
|
||||||
|
assertTrue(after.contains("\"Cat Head\""));
|
||||||
|
assertTrue(after.contains("\"A cat head\""));
|
||||||
|
assertFalse(after.contains("old name"));
|
||||||
|
// backup created
|
||||||
|
assertTrue(Files.list(dir).anyMatch(p -> p.getFileName().toString().startsWith("FurnitureData.json.bak")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownClassname() throws Exception {
|
||||||
|
Path dir = Files.createTempDirectory("fd");
|
||||||
|
Path file = dir.resolve("FurnitureData.json");
|
||||||
|
Files.writeString(file, SINGLE);
|
||||||
|
FurnidataWriter w = new FurnidataWriter(file, false, 64L * 1024 * 1024, 10);
|
||||||
|
assertFalse(w.write("does_not_exist", "x", "y"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split-tier: classname present in both core and custom tiers.
|
||||||
|
* The writer must update the winning (later) tier — custom — and leave core untouched.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void splitTierWritesWinningTierLeavesEarlierTierUntouched() throws Exception {
|
||||||
|
Path base = Files.createTempDirectory("fd-split");
|
||||||
|
|
||||||
|
// Tier subdirectories
|
||||||
|
Path coreDir = base.resolve("core");
|
||||||
|
Path customDir = base.resolve("custom");
|
||||||
|
Files.createDirectories(coreDir);
|
||||||
|
Files.createDirectories(customDir);
|
||||||
|
|
||||||
|
// Top-level manifest: tiers in override order (core < custom)
|
||||||
|
Files.writeString(base.resolve("manifest.json"),
|
||||||
|
"{ \"tiers\": [ \"core\", \"custom\" ] }");
|
||||||
|
|
||||||
|
// Per-tier manifests listing the data file
|
||||||
|
Files.writeString(coreDir.resolve("manifest.json"),
|
||||||
|
"{ \"files\": [ \"furnidata.json\" ] }");
|
||||||
|
Files.writeString(customDir.resolve("manifest.json"),
|
||||||
|
"{ \"files\": [ \"furnidata.json\" ] }");
|
||||||
|
|
||||||
|
// Data files
|
||||||
|
Path coreFile = coreDir.resolve("furnidata.json");
|
||||||
|
Path customFile = customDir.resolve("furnidata.json");
|
||||||
|
Files.writeString(coreFile, CORE_DATA);
|
||||||
|
Files.writeString(customFile, CUSTOM_DATA);
|
||||||
|
|
||||||
|
FurnidataWriter w = new FurnidataWriter(base, true, 64L * 1024 * 1024, 10);
|
||||||
|
boolean ok = w.write("split_chair", "New Name", "New desc");
|
||||||
|
|
||||||
|
assertTrue(ok, "write must succeed for classname present in split-tier layout");
|
||||||
|
|
||||||
|
// custom (winning tier) must be updated
|
||||||
|
String customAfter = Files.readString(customFile);
|
||||||
|
assertTrue(customAfter.contains("\"New Name\""), "winning tier must contain new name");
|
||||||
|
assertTrue(customAfter.contains("\"New desc\""), "winning tier must contain new desc");
|
||||||
|
assertFalse(customAfter.contains("custom old name"), "old name must be gone from winning tier");
|
||||||
|
|
||||||
|
// core (earlier tier) must be UNTOUCHED
|
||||||
|
String coreAfter = Files.readString(coreFile);
|
||||||
|
assertTrue(coreAfter.contains("core name"), "earlier tier must be left untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split-tier path-traversal guard: a manifest that lists "../escape" as a tier
|
||||||
|
* must be rejected by safeResolve so the writer cannot reach files outside the base dir.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void splitTierRejectsTraversalTierInManifest() throws Exception {
|
||||||
|
Path base = Files.createTempDirectory("fd-traversal");
|
||||||
|
|
||||||
|
// "Escape" directory sits OUTSIDE base
|
||||||
|
Path escapeDir = base.getParent().resolve("escape_secret");
|
||||||
|
Files.createDirectories(escapeDir);
|
||||||
|
Files.writeString(escapeDir.resolve("manifest.json"),
|
||||||
|
"{ \"files\": [ \"secret.json\" ] }");
|
||||||
|
Files.writeString(escapeDir.resolve("secret.json"),
|
||||||
|
"{ \"roomitemtypes\": { \"furnitype\": [\n" +
|
||||||
|
" { \"id\": 99, \"classname\": \"escape_chair\", \"name\": \"secret old\", \"description\": \"\" }\n" +
|
||||||
|
"] }, \"wallitemtypes\": { \"furnitype\": [] } }");
|
||||||
|
|
||||||
|
// Top-level manifest references the escape dir via traversal
|
||||||
|
Files.writeString(base.resolve("manifest.json"),
|
||||||
|
"{ \"tiers\": [ \"../escape_secret\" ] }");
|
||||||
|
|
||||||
|
FurnidataWriter w = new FurnidataWriter(base, true, 64L * 1024 * 1024, 10);
|
||||||
|
boolean ok = w.write("escape_chair", "Pwned", "desc");
|
||||||
|
|
||||||
|
assertFalse(ok, "classname reachable only via traversal path must not be found/written");
|
||||||
|
|
||||||
|
// The secret file must not have been touched
|
||||||
|
String secretAfter = Files.readString(escapeDir.resolve("secret.json"));
|
||||||
|
assertTrue(secretAfter.contains("secret old"), "traversal target must be untouched");
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package com.eu.habbo.habbohotel.items;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class FurnitureTextProviderDeltaTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstReindexReturnsAllAsDelta() {
|
||||||
|
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||||
|
List<FurnidataEntry> delta = p.reindex(List.of(
|
||||||
|
new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit")));
|
||||||
|
assertEquals(1, delta.size());
|
||||||
|
assertEquals("Chair", delta.get(0).name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unchangedReindexReturnsEmptyDelta() {
|
||||||
|
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||||
|
List<FurnidataEntry> first = List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit"));
|
||||||
|
p.reindex(first);
|
||||||
|
List<FurnidataEntry> delta = p.reindex(first);
|
||||||
|
assertTrue(delta.isEmpty(), "no change => empty delta");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changedNameAppearsInDeltaWithSanitizedValue() {
|
||||||
|
FurnitureTextProvider p = new FurnitureTextProvider(true);
|
||||||
|
p.reindex(List.of(new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "Chair", "Sit")));
|
||||||
|
List<FurnidataEntry> delta = p.reindex(List.of(
|
||||||
|
new FurnidataEntry(1, "chair", FurnitureType.FLOOR, "New %x%", "Sit")));
|
||||||
|
assertEquals(1, delta.size());
|
||||||
|
assertFalse(delta.get(0).name().contains("%"), "delta carries the sanitized name");
|
||||||
|
assertEquals(1, delta.get(0).id());
|
||||||
|
assertEquals(FurnitureType.FLOOR, delta.get(0).type());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.eu.habbo.habbohotel.items;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class FurnitureTextProviderTest {
|
||||||
|
|
||||||
|
private FurnitureTextProvider provider(boolean enabled, FurnidataEntry... entries) {
|
||||||
|
FurnitureTextProvider p = new FurnitureTextProvider(enabled);
|
||||||
|
p.reindex(List.of(entries));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesNameByClassname() {
|
||||||
|
FurnitureTextProvider p = provider(true,
|
||||||
|
new FurnidataEntry(1, "chair_norja", FurnitureType.FLOOR, "Norja Chair", "Sit"));
|
||||||
|
assertEquals("Norja Chair", p.getName("chair_norja"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void matchesBaseClassnameIgnoringColourVariantAndCase() {
|
||||||
|
FurnitureTextProvider p = provider(true,
|
||||||
|
new FurnidataEntry(1, "chair_norja*2", FurnitureType.FLOOR, "Norja Chair", "Sit"));
|
||||||
|
assertEquals("Norja Chair", p.getName("CHAIR_NORJA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsNullWhenClassnameMissing() {
|
||||||
|
FurnitureTextProvider p = provider(true);
|
||||||
|
assertNull(p.getName("unknown_thing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsNullWhenDisabled() {
|
||||||
|
FurnitureTextProvider p = provider(false,
|
||||||
|
new FurnidataEntry(1, "chair_norja", FurnitureType.FLOOR, "Norja Chair", "Sit"));
|
||||||
|
assertNull(p.getName("chair_norja"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizesNameCapStripControlAndNeutralizesPercent() {
|
||||||
|
String evil = "Bad\nName %limit% %user.name%".repeat(20);
|
||||||
|
FurnitureTextProvider p = provider(true,
|
||||||
|
new FurnidataEntry(1, "x", FurnitureType.FLOOR, evil, ""));
|
||||||
|
String name = p.getName("x");
|
||||||
|
assertEquals(256, name.length(), "input far exceeds the cap, so it must be exactly 256");
|
||||||
|
assertFalse(name.chars().anyMatch(Character::isISOControl), "no control chars remain after sanitize");
|
||||||
|
assertFalse(name.contains("%"), "ASCII percent neutralized");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullProviderNameNeverThrows() {
|
||||||
|
FurnitureTextProvider p = provider(true);
|
||||||
|
assertDoesNotThrow(() -> p.getName(null));
|
||||||
|
assertNull(p.getName(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user