diff --git a/Database Updates/014_Make_Custom_Badge.sql b/Database Updates/014_Make_Custom_Badge.sql new file mode 100644 index 00000000..3fc97157 --- /dev/null +++ b/Database Updates/014_Make_Custom_Badge.sql @@ -0,0 +1,28 @@ +-- Make sure that the emulator has write access to the badge_path folder !!!!! + +CREATE TABLE IF NOT EXISTS `users_custom_badge_settings` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `badge_path` varchar(255) NOT NULL DEFAULT '/var/www/gamedata/c_images/album1584', + `badge_url` varchar(255) NOT NULL DEFAULT '/gamedata/c_images/album1584', + `price_badge` int(11) NOT NULL DEFAULT 0, + `currency_type` int(11) NOT NULL DEFAULT -1, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +INSERT INTO `users_custom_badge_settings` (`id`, `badge_path`, `badge_url`, `price_badge`, `currency_type`) +SELECT 1, '/var/www/gamedata/c_images/album1584', '/gamedata/c_images/album1584', 50, 5 +WHERE NOT EXISTS (SELECT 1 FROM `users_custom_badge_settings` WHERE `id` = 1); + +CREATE TABLE IF NOT EXISTS `user_custom_badge` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `badge_id` varchar(64) NOT NULL, + `badge_name` varchar(64) NOT NULL DEFAULT '', + `badge_description` varchar(255) NOT NULL DEFAULT '', + `date_created` int(11) NOT NULL DEFAULT 0, + `date_edit` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `badge_id` (`badge_id`), + KEY `user_id` (`user_id`), + CONSTRAINT `fk_user_custom_badge_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; \ No newline at end of file diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java index 879e2d6b..ac401c1f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/GameEnvironment.java @@ -22,6 +22,7 @@ import com.eu.habbo.habbohotel.polls.PollManager; import com.eu.habbo.habbohotel.rooms.RoomChatBubbleManager; import com.eu.habbo.habbohotel.rooms.RoomManager; import com.eu.habbo.habbohotel.users.HabboManager; +import com.eu.habbo.habbohotel.users.custombadge.CustomBadgeManager; import com.eu.habbo.habbohotel.users.subscriptions.SubscriptionManager; import com.eu.habbo.habbohotel.users.subscriptions.SubscriptionScheduler; import org.slf4j.Logger; @@ -58,6 +59,7 @@ public class GameEnvironment { private SubscriptionManager subscriptionManager; private CalendarManager calendarManager; private RoomChatBubbleManager roomChatBubbleManager; + private CustomBadgeManager customBadgeManager; public void load() throws Exception { LOGGER.info("GameEnvironment -> Loading..."); @@ -84,6 +86,7 @@ public class GameEnvironment { this.pollManager = new PollManager(); this.calendarManager = new CalendarManager(); this.roomChatBubbleManager = new RoomChatBubbleManager(); + this.customBadgeManager = new CustomBadgeManager(); this.roomManager.loadPublicRooms(); this.navigatorManager.loadNavigator(); @@ -219,4 +222,8 @@ public class GameEnvironment { public RoomChatBubbleManager getRoomChatBubbleManager() { return roomChatBubbleManager; } + + public CustomBadgeManager getCustomBadgeManager() { + return this.customBadgeManager; + } } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadge.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadge.java new file mode 100644 index 00000000..c80c9be7 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadge.java @@ -0,0 +1,75 @@ +package com.eu.habbo.habbohotel.users.custombadge; + +import java.sql.ResultSet; +import java.sql.SQLException; + +public class CustomBadge { + + private final int id; + private final int userId; + private final String badgeId; + private String badgeName; + private String badgeDescription; + private final int dateCreated; + private int dateEdit; + + public CustomBadge(ResultSet set) throws SQLException { + this.id = set.getInt("id"); + this.userId = set.getInt("user_id"); + this.badgeId = set.getString("badge_id"); + this.badgeName = set.getString("badge_name"); + this.badgeDescription = set.getString("badge_description"); + this.dateCreated = set.getInt("date_created"); + this.dateEdit = set.getInt("date_edit"); + } + + public CustomBadge(int id, int userId, String badgeId, String badgeName, String badgeDescription, int dateCreated, int dateEdit) { + this.id = id; + this.userId = userId; + this.badgeId = badgeId; + this.badgeName = badgeName; + this.badgeDescription = badgeDescription; + this.dateCreated = dateCreated; + this.dateEdit = dateEdit; + } + + public int getId() { + return this.id; + } + + public int getUserId() { + return this.userId; + } + + public String getBadgeId() { + return this.badgeId; + } + + public String getBadgeName() { + return this.badgeName; + } + + public String getBadgeDescription() { + return this.badgeDescription; + } + + public int getDateCreated() { + return this.dateCreated; + } + + public int getDateEdit() { + return this.dateEdit; + } + + public void setBadgeName(String badgeName) { + this.badgeName = badgeName; + } + + public void setBadgeDescription(String badgeDescription) { + this.badgeDescription = badgeDescription; + } + + public void setDateEdit(int dateEdit) { + this.dateEdit = dateEdit; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeException.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeException.java new file mode 100644 index 00000000..4a543e06 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeException.java @@ -0,0 +1,15 @@ +package com.eu.habbo.habbohotel.users.custombadge; + +public class CustomBadgeException extends Exception { + + private final String code; + + public CustomBadgeException(String code, String message) { + super(message); + this.code = code; + } + + public String getCode() { + return this.code; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeManager.java new file mode 100644 index 00000000..af6113a9 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeManager.java @@ -0,0 +1,579 @@ +package com.eu.habbo.habbohotel.users.custombadge; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.users.Habbo; +import com.eu.habbo.habbohotel.users.HabboBadge; +import com.eu.habbo.habbohotel.users.inventory.BadgesComponent; +import com.eu.habbo.messages.outgoing.inventory.InventoryBadgesComposer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import java.awt.image.BufferedImage; +import java.awt.image.IndexColorModel; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.SecureRandom; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +public class CustomBadgeManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(CustomBadgeManager.class); + + public static final int MAX_PER_USER = 5; + public static final int BADGE_WIDTH = 40; + public static final int BADGE_HEIGHT = 40; + public static final int MAX_BADGE_SIZE_BYTES = 40960; + + private static final int RANDOM_SUFFIX_LENGTH = 5; + private static final char[] RANDOM_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toCharArray(); + private static final Pattern BADGE_ID_PATTERN = Pattern.compile("^CUST[A-Z0-9]{" + RANDOM_SUFFIX_LENGTH + "}-\\d+$"); + + private static final byte[] PNG_MAGIC = { (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + + private static final int RATE_LIMIT_OPS = 5; + private static final long RATE_LIMIT_WINDOW_MS = 60_000L; + + private final SecureRandom random = new SecureRandom(); + private final Map rateBuckets = new ConcurrentHashMap<>(); + private final Map textCache = new ConcurrentHashMap<>(); + + private volatile CustomBadgeSettings settings; + + public CustomBadgeManager() { + this.reload(); + } + + public static final class BadgeText { + public final String name; + public final String description; + public BadgeText(String name, String description) { + this.name = name == null ? "" : name; + this.description = description == null ? "" : description; + } + } + + public Map getTextCache() { + return java.util.Collections.unmodifiableMap(this.textCache); + } + + private void loadTextCache() { + Map next = new java.util.HashMap<>(); + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "SELECT `badge_id`, `badge_name`, `badge_description` FROM `user_custom_badge`")) { + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + next.put(resultSet.getString("badge_id"), + new BadgeText( + resultSet.getString("badge_name"), + resultSet.getString("badge_description"))); + } + } + } catch (SQLException e) { + LOGGER.error("CustomBadgeManager -> Failed to load badge text cache.", e); + return; + } + this.textCache.clear(); + this.textCache.putAll(next); + LOGGER.info("CustomBadgeManager -> loaded {} custom badge texts into memory.", next.size()); + } + + public void reload() { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "SELECT `badge_path`, `badge_url`, `price_badge`, `currency_type` FROM `users_custom_badge_settings` ORDER BY `id` ASC LIMIT 1")) { + + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + this.settings = new CustomBadgeSettings( + resultSet.getString("badge_path"), + resultSet.getString("badge_url"), + resultSet.getInt("price_badge"), + resultSet.getInt("currency_type")); + } else { + this.settings = new CustomBadgeSettings( + "/var/www/gamedata/c_images/album1584", + "/gamedata/c_images/album1584", + 0, -1); + LOGGER.warn("CustomBadgeManager -> No row found in users_custom_badge_settings, falling back to defaults."); + } + } + } catch (SQLException e) { + LOGGER.error("CustomBadgeManager -> Failed to load settings.", e); + } + + loadTextCache(); + } + + public CustomBadgeSettings getSettings() { + return this.settings; + } + + public List listForUser(int userId) { + List result = new ArrayList<>(); + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "SELECT * FROM `user_custom_badge` WHERE `user_id` = ? ORDER BY `date_created` ASC")) { + statement.setInt(1, userId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + result.add(new CustomBadge(resultSet)); + } + } + } catch (SQLException e) { + LOGGER.error("CustomBadgeManager -> Failed to list badges for user " + userId, e); + } + return result; + } + + public CustomBadge getByBadgeId(String badgeId) { + if (badgeId == null || badgeId.isEmpty()) return null; + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "SELECT * FROM `user_custom_badge` WHERE `badge_id` = ? LIMIT 1")) { + statement.setString(1, badgeId); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return new CustomBadge(resultSet); + } + } + } catch (SQLException e) { + LOGGER.error("CustomBadgeManager -> Failed to load badge " + badgeId, e); + } + return null; + } + + public int countForUser(int userId) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "SELECT COUNT(*) FROM `user_custom_badge` WHERE `user_id` = ?")) { + statement.setInt(1, userId); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return resultSet.getInt(1); + } + } + } catch (SQLException e) { + LOGGER.error("CustomBadgeManager -> Failed to count badges for user " + userId, e); + } + return 0; + } + + public CustomBadge create(int userId, String name, String description, byte[] pngBytes) throws CustomBadgeException { + enforceRateLimit(userId); + + if (this.countForUser(userId) >= MAX_PER_USER) { + throw new CustomBadgeException("limit_reached", "Maximum of " + MAX_PER_USER + " custom badges reached."); + } + + BufferedImage image = validatePng(pngBytes); + + chargeForCreate(userId); + + String badgeId = generateBadgeId(); + int now = (int) (System.currentTimeMillis() / 1000L); + + try { + writeBadgeFile(badgeId, image); + } catch (CustomBadgeException e) { + refundForCreate(userId); + throw e; + } + + String safeName = sanitize(name, 64); + String safeDesc = sanitize(description, 255); + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO `user_custom_badge` (`user_id`, `badge_id`, `badge_name`, `badge_description`, `date_created`, `date_edit`) VALUES (?, ?, ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS)) { + statement.setInt(1, userId); + statement.setString(2, badgeId); + statement.setString(3, safeName); + statement.setString(4, safeDesc); + statement.setInt(5, now); + statement.setInt(6, now); + statement.executeUpdate(); + + int generatedId = 0; + try (ResultSet keys = statement.getGeneratedKeys()) { + if (keys.next()) generatedId = keys.getInt(1); + } + + this.textCache.put(badgeId, new BadgeText(safeName, safeDesc)); + issueBadgeToInventory(userId, badgeId); + + return new CustomBadge(generatedId, userId, badgeId, safeName, safeDesc, now, now); + } catch (SQLException e) { + deleteBadgeFileQuietly(badgeId); + refundForCreate(userId); + LOGGER.error("CustomBadgeManager -> Failed to insert badge for user " + userId, e); + throw new CustomBadgeException("db_error", "Could not save the badge."); + } + } + + public CustomBadge update(int userId, String oldBadgeId, String name, String description, byte[] pngBytes) throws CustomBadgeException { + enforceRateLimit(userId); + + CustomBadge existing = getByBadgeId(oldBadgeId); + if (existing == null || existing.getUserId() != userId) { + throw new CustomBadgeException("not_found", "Badge not found."); + } + + BufferedImage image = validatePng(pngBytes); + + String newBadgeId = generateBadgeId(); + int now = (int) (System.currentTimeMillis() / 1000L); + + writeBadgeFile(newBadgeId, image); + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "UPDATE `user_custom_badge` SET `badge_id` = ?, `badge_name` = ?, `badge_description` = ?, `date_edit` = ? WHERE `id` = ?")) { + statement.setString(1, newBadgeId); + statement.setString(2, sanitize(name, 64)); + statement.setString(3, sanitize(description, 255)); + statement.setInt(4, now); + statement.setInt(5, existing.getId()); + statement.executeUpdate(); + } catch (SQLException e) { + deleteBadgeFileQuietly(newBadgeId); + LOGGER.error("CustomBadgeManager -> Failed to update badge " + oldBadgeId, e); + throw new CustomBadgeException("db_error", "Could not update the badge."); + } + + String safeName = sanitize(name, 64); + String safeDesc = sanitize(description, 255); + this.textCache.remove(oldBadgeId); + this.textCache.put(newBadgeId, new BadgeText(safeName, safeDesc)); + renameBadgeInInventory(userId, oldBadgeId, newBadgeId); + deleteBadgeFileQuietly(oldBadgeId); + return new CustomBadge(existing.getId(), userId, newBadgeId, safeName, safeDesc, existing.getDateCreated(), now); + } + + public void delete(int userId, String badgeId) throws CustomBadgeException { + enforceRateLimit(userId); + + CustomBadge existing = getByBadgeId(badgeId); + if (existing == null || existing.getUserId() != userId) { + throw new CustomBadgeException("not_found", "Badge not found."); + } + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "DELETE FROM `user_custom_badge` WHERE `id` = ?")) { + statement.setInt(1, existing.getId()); + statement.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("CustomBadgeManager -> Failed to delete badge " + badgeId, e); + throw new CustomBadgeException("db_error", "Could not delete the badge."); + } + + this.textCache.remove(badgeId); + revokeBadgeFromInventory(userId, badgeId); + deleteBadgeFileQuietly(badgeId); + } + + public boolean isCustomBadgeId(String badgeId) { + return badgeId != null && BADGE_ID_PATTERN.matcher(badgeId).matches(); + } + + public String generateBadgeId() { + long timestamp = System.currentTimeMillis() / 1000L; + for (int attempt = 0; attempt < 8; attempt++) { + StringBuilder suffix = new StringBuilder(RANDOM_SUFFIX_LENGTH); + for (int i = 0; i < RANDOM_SUFFIX_LENGTH; i++) { + suffix.append(RANDOM_ALPHABET[this.random.nextInt(RANDOM_ALPHABET.length)]); + } + String candidate = "CUST" + suffix + "-" + timestamp; + if (getByBadgeId(candidate) == null) return candidate; + timestamp++; + } + throw new IllegalStateException("Could not allocate a unique custom badge id after 8 attempts."); + } + + public String publicUrlFor(String badgeId) { + CustomBadgeSettings current = this.settings; + if (current == null) return ""; + String base = current.getBadgeUrl(); + if (base == null || base.isEmpty()) return ""; + if (base.endsWith("/")) return base + badgeId + ".gif"; + return base + "/" + badgeId + ".gif"; + } + + private void chargeForCreate(int userId) throws CustomBadgeException { + CustomBadgeSettings current = this.settings; + if (current == null) return; + int price = current.getPriceBadge(); + if (price <= 0) return; + + Habbo habbo = Emulator.getGameServer().getGameClientManager().getHabbo(userId); + if (habbo == null) { + throw new CustomBadgeException("must_be_online", + "You must be online in the hotel to create a paid badge."); + } + + int currencyType = current.getCurrencyType(); + if (currencyType == -1) { + if (habbo.getHabboInfo().getCredits() < price) { + throw new CustomBadgeException("insufficient_funds", + "You don't have enough credits (need " + price + ")."); + } + habbo.giveCredits(-price); + } else { + if (habbo.getHabboInfo().getCurrencyAmount(currencyType) < price) { + throw new CustomBadgeException("insufficient_funds", + "You don't have enough of that currency (need " + price + ")."); + } + habbo.givePoints(currencyType, -price); + } + } + + private void issueBadgeToInventory(int userId, String badgeId) { + Habbo online = Emulator.getGameServer().getGameClientManager().getHabbo(userId); + if (online != null) { + BadgesComponent.createBadge(badgeId, online); + if (online.getClient() != null) { + online.getClient().sendResponse(new InventoryBadgesComposer(online)); + } + return; + } + + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO `users_badges` (`user_id`, `slot_id`, `badge_code`) VALUES (?, 0, ?)")) { + statement.setInt(1, userId); + statement.setString(2, badgeId); + statement.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("CustomBadgeManager -> Failed to issue offline badge " + badgeId + " to user " + userId, e); + } + } + + private void renameBadgeInInventory(int userId, String oldBadgeId, String newBadgeId) { + try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement statement = connection.prepareStatement( + "UPDATE `users_badges` SET `badge_code` = ? WHERE `user_id` = ? AND `badge_code` = ?")) { + statement.setString(1, newBadgeId); + statement.setInt(2, userId); + statement.setString(3, oldBadgeId); + statement.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("CustomBadgeManager -> Failed to rename badge in users_badges " + oldBadgeId + " -> " + newBadgeId, e); + } + + Habbo online = Emulator.getGameServer().getGameClientManager().getHabbo(userId); + if (online == null) return; + + HabboBadge existing = online.getInventory().getBadgesComponent().getBadge(oldBadgeId); + if (existing != null) existing.setCode(newBadgeId); + + if (online.getClient() != null) { + online.getClient().sendResponse(new InventoryBadgesComposer(online)); + } + } + + private void revokeBadgeFromInventory(int userId, String badgeId) { + BadgesComponent.deleteBadge(userId, badgeId); + + Habbo online = Emulator.getGameServer().getGameClientManager().getHabbo(userId); + if (online == null) return; + + online.getInventory().getBadgesComponent().removeBadge(badgeId); + if (online.getClient() != null) { + online.getClient().sendResponse(new InventoryBadgesComposer(online)); + } + } + + private BufferedImage validatePng(byte[] data) throws CustomBadgeException { + if (data == null || data.length == 0) { + throw new CustomBadgeException("empty", "Badge image is empty."); + } + if (data.length > MAX_BADGE_SIZE_BYTES) { + throw new CustomBadgeException("too_large", "Badge image exceeds " + MAX_BADGE_SIZE_BYTES + " bytes."); + } + + if (data.length < PNG_MAGIC.length) { + throw new CustomBadgeException("invalid_image", "Badge image must be a PNG."); + } + for (int i = 0; i < PNG_MAGIC.length; i++) { + if (data[i] != PNG_MAGIC[i]) { + throw new CustomBadgeException("invalid_image", "Badge image must be a PNG."); + } + } + + try (ImageInputStream peek = ImageIO.createImageInputStream(new ByteArrayInputStream(data))) { + if (peek == null) throw new IOException("no input stream"); + Iterator readers = ImageIO.getImageReaders(peek); + if (!readers.hasNext()) { + throw new CustomBadgeException("invalid_image", "Badge image format not recognised."); + } + ImageReader reader = readers.next(); + try { + reader.setInput(peek, true, true); + int w = reader.getWidth(0); + int h = reader.getHeight(0); + if (w != BADGE_WIDTH || h != BADGE_HEIGHT) { + throw new CustomBadgeException("wrong_dimensions", + "Badge image must be " + BADGE_WIDTH + "x" + BADGE_HEIGHT + " pixels."); + } + } finally { + reader.dispose(); + } + } catch (IOException e) { + throw new CustomBadgeException("invalid_image", "Badge image header could not be read."); + } + + BufferedImage image; + try { + image = ImageIO.read(new ByteArrayInputStream(data)); + } catch (IOException e) { + throw new CustomBadgeException("invalid_image", "Badge image could not be decoded."); + } + if (image == null + || image.getWidth() != BADGE_WIDTH + || image.getHeight() != BADGE_HEIGHT) { + throw new CustomBadgeException("invalid_image", "Badge image could not be decoded."); + } + return image; + } + + private void enforceRateLimit(int userId) throws CustomBadgeException { + long now = System.currentTimeMillis(); + long[] bucket = this.rateBuckets.computeIfAbsent(userId, id -> new long[RATE_LIMIT_OPS]); + synchronized (bucket) { + long oldest = Long.MAX_VALUE; + int oldestIdx = 0; + for (int i = 0; i < bucket.length; i++) { + if (bucket[i] < oldest) { oldest = bucket[i]; oldestIdx = i; } + } + if (oldest > now - RATE_LIMIT_WINDOW_MS) { + throw new CustomBadgeException("rate_limited", + "Too many badge operations. Try again in a moment."); + } + bucket[oldestIdx] = now; + } + } + + private void refundForCreate(int userId) { + CustomBadgeSettings current = this.settings; + if (current == null) return; + int price = current.getPriceBadge(); + if (price <= 0) return; + + Habbo habbo = Emulator.getGameServer().getGameClientManager().getHabbo(userId); + if (habbo == null) { + LOGGER.warn("CustomBadgeManager -> Could not refund {} (price {}): user offline", userId, price); + return; + } + int currencyType = current.getCurrencyType(); + if (currencyType == -1) habbo.giveCredits(price); + else habbo.givePoints(currencyType, price); + } + + private void writeBadgeFile(String badgeId, BufferedImage source) throws CustomBadgeException { + CustomBadgeSettings current = this.settings; + if (current == null || current.getBadgePath() == null || current.getBadgePath().isEmpty()) { + throw new CustomBadgeException("not_configured", "Custom badge storage path is not configured."); + } + try { + Path dir = Paths.get(current.getBadgePath()).toAbsolutePath(); + Files.createDirectories(dir); + Path target = dir.resolve(badgeId + ".gif"); + + BufferedImage indexed = toIndexedGifImage(source); + if (!ImageIO.write(indexed, "gif", target.toFile())) { + throw new IOException("No GIF ImageWriter available."); + } + + LOGGER.info("CustomBadgeManager -> wrote badge {} ({} bytes) to {}", + badgeId, Files.size(target), target); + } catch (IOException e) { + LOGGER.error("CustomBadgeManager -> Failed to write badge " + badgeId + + " to " + current.getBadgePath(), e); + throw new CustomBadgeException("write_failed", "Could not save the badge file."); + } + } + + private static BufferedImage toIndexedGifImage(BufferedImage source) { + int w = source.getWidth(); + int h = source.getHeight(); + int[] pixels = source.getRGB(0, 0, w, h, null, 0, w); + + Map indexByColor = new LinkedHashMap<>(); + indexByColor.put(0, 0); + + for (int p : pixels) { + int alpha = (p >>> 24) & 0xff; + int key = (alpha < 128) ? 0 : (p | 0xFF000000); + if (key == 0) continue; + if (indexByColor.size() >= 256) break; + indexByColor.computeIfAbsent(key, k -> indexByColor.size()); + } + + int n = indexByColor.size(); + byte[] r = new byte[n]; + byte[] g = new byte[n]; + byte[] b = new byte[n]; + int i = 0; + for (Integer color : indexByColor.keySet()) { + r[i] = (byte) ((color >>> 16) & 0xff); + g[i] = (byte) ((color >>> 8) & 0xff); + b[i] = (byte) (color & 0xff); + i++; + } + + IndexColorModel colorModel = new IndexColorModel(8, n, r, g, b, 0); + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_BYTE_INDEXED, colorModel); + + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int p = pixels[y * w + x]; + int alpha = (p >>> 24) & 0xff; + int key = (alpha < 128) ? 0 : (p | 0xFF000000); + Integer idx = indexByColor.get(key); + out.getRaster().setSample(x, y, 0, idx == null ? 0 : idx); + } + } + + return out; + } + + private void deleteBadgeFileQuietly(String badgeId) { + CustomBadgeSettings current = this.settings; + if (current == null || current.getBadgePath() == null) return; + File file = new File(current.getBadgePath(), badgeId + ".gif"); + if (file.exists() && !file.delete()) { + LOGGER.warn("CustomBadgeManager -> Could not delete stale badge file: {}", file.getAbsolutePath()); + } + } + + private static String sanitize(String value, int maxLength) { + if (value == null) return ""; + StringBuilder out = new StringBuilder(Math.min(value.length(), maxLength)); + for (int i = 0; i < value.length() && out.length() < maxLength; i++) { + char c = value.charAt(i); + if (c < 0x20 || c == 0x7F) continue; + out.append(c); + } + return out.toString().trim(); + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeSettings.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeSettings.java new file mode 100644 index 00000000..b7b82f86 --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/users/custombadge/CustomBadgeSettings.java @@ -0,0 +1,32 @@ +package com.eu.habbo.habbohotel.users.custombadge; + +public class CustomBadgeSettings { + + private final String badgePath; + private final String badgeUrl; + private final int priceBadge; + private final int currencyType; + + public CustomBadgeSettings(String badgePath, String badgeUrl, int priceBadge, int currencyType) { + this.badgePath = badgePath; + this.badgeUrl = badgeUrl; + this.priceBadge = priceBadge; + this.currencyType = currencyType; + } + + public String getBadgePath() { + return this.badgePath; + } + + public String getBadgeUrl() { + return this.badgeUrl; + } + + public int getPriceBadge() { + return this.priceBadge; + } + + public int getCurrencyType() { + return this.currencyType; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java index dfba3f75..4558d9af 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/WebSocketChannelInitializer.java @@ -3,6 +3,7 @@ package com.eu.habbo.networking.gameserver; import com.eu.habbo.Emulator; import com.eu.habbo.messages.PacketManager; import com.eu.habbo.networking.gameserver.auth.AuthHttpHandler; +import com.eu.habbo.networking.gameserver.badges.BadgeHttpHandler; import com.eu.habbo.networking.gameserver.codec.WebSocketCodec; import com.eu.habbo.networking.gameserver.crypto.WsHandshakeHandler; import com.eu.habbo.networking.gameserver.decoders.*; @@ -53,6 +54,7 @@ public class WebSocketChannelInitializer extends ChannelInitializer 128) { + AuthRateLimiter.recordFailure(ip); + sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, errorPayload("Missing or invalid ssoTicket.")); + return; + } + + try (Connection conn = Emulator.getDatabase().getDataSource().getConnection(); + PreparedStatement lookup = conn.prepareStatement( + "SELECT id, username FROM users WHERE auth_ticket = ? LIMIT 1")) { + lookup.setString(1, ssoTicket); + try (ResultSet rs = lookup.executeQuery()) { + if (!rs.next()) { + AuthRateLimiter.recordFailure(ip); + sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, errorPayload("SSO ticket not recognised.")); + return; + } + int userId = rs.getInt("id"); + String username = rs.getString("username"); + + AuthRateLimiter.recordSuccess(ip); + + AccessTokenService.Issued access = AccessTokenService.issue(userId); + JsonObject ok = new JsonObject(); + ok.addProperty("username", username); + ok.addProperty("accessToken", access.token); + ok.addProperty("accessTokenExpiresAt", access.expiresAt); + sendJson(ctx, req, HttpResponseStatus.OK, ok); + } + } catch (Exception e) { + LOGGER.error("[auth/sso-token] lookup failed", e); + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Server error.")); + } + } + private void handleRefresh(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) { String jwt = readString(body, "rememberToken").trim(); if (jwt.isEmpty()) { @@ -365,6 +410,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { JsonObject ok = new JsonObject(); ok.addProperty("rememberToken", rot.jwt); ok.addProperty("expiresAt", rot.expiresAt); + AccessTokenService.Issued access = AccessTokenService.issue(rot.userId); + ok.addProperty("accessToken", access.token); + ok.addProperty("accessTokenExpiresAt", access.expiresAt); sendJson(ctx, req, HttpResponseStatus.OK, ok); } catch (Exception e) { LOGGER.error("Refresh failed", e); @@ -456,6 +504,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter { ok.addProperty("ssoTicket", ssoTicket); ok.addProperty("username", rs.getString("username")); if (rememberToken != null) ok.addProperty("rememberToken", rememberToken); + AccessTokenService.Issued access = AccessTokenService.issue(userId); + ok.addProperty("accessToken", access.token); + ok.addProperty("accessTokenExpiresAt", access.expiresAt); sendJson(ctx, req, HttpResponseStatus.OK, ok); } } diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/badges/BadgeHttpHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/badges/BadgeHttpHandler.java new file mode 100644 index 00000000..e1e803cb --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/badges/BadgeHttpHandler.java @@ -0,0 +1,339 @@ +package com.eu.habbo.networking.gameserver.badges; + +import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.users.custombadge.CustomBadge; +import com.eu.habbo.habbohotel.users.custombadge.CustomBadgeException; +import com.eu.habbo.habbohotel.users.custombadge.CustomBadgeManager; +import com.eu.habbo.networking.gameserver.GameServerAttributes; +import com.eu.habbo.networking.gameserver.auth.AccessTokenService; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.handler.codec.http.*; +import io.netty.util.ReferenceCountUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; + +public class BadgeHttpHandler extends ChannelInboundHandlerAdapter { + + private static final Logger LOGGER = LoggerFactory.getLogger(BadgeHttpHandler.class); + + private static final String BASE_PATH = "/api/badges/custom"; + private static final int MAX_BODY_BYTES = 128 * 1024; + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + if (!(msg instanceof FullHttpRequest req)) { + super.channelRead(ctx, msg); + return; + } + + String path = new QueryStringDecoder(req.uri()).path(); + if (!path.equals(BASE_PATH) && !path.startsWith(BASE_PATH + "/")) { + super.channelRead(ctx, msg); + return; + } + + try { + handle(ctx, req, path); + } finally { + ReferenceCountUtil.release(req); + } + } + + private void handle(ChannelHandlerContext ctx, FullHttpRequest req, String path) { + if (req.method() == HttpMethod.OPTIONS) { + sendCors(ctx, req); + return; + } + + if (path.equals(BASE_PATH + "/texts")) { + if (req.method() == HttpMethod.GET || req.method() == HttpMethod.HEAD) { + handleTexts(ctx, req); + return; + } + sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, error("Use GET.")); + return; + } + + int userId = authenticate(req); + if (userId == 0) { + sendJson(ctx, req, HttpResponseStatus.UNAUTHORIZED, error("Authentication required.")); + return; + } + + if (req.content().readableBytes() > MAX_BODY_BYTES) { + sendJson(ctx, req, HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE, error("Payload too large.")); + return; + } + + String trailing = path.length() > BASE_PATH.length() ? path.substring(BASE_PATH.length() + 1) : ""; + + try { + if (trailing.isEmpty()) { + if (req.method() == HttpMethod.GET || req.method() == HttpMethod.HEAD) { + handleList(ctx, req, userId); + return; + } + if (req.method() == HttpMethod.POST) { + handleCreate(ctx, req, userId); + return; + } + sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, error("Use GET or POST.")); + return; + } + + String badgeId = trailing; + CustomBadgeManager manager = Emulator.getGameEnvironment().getCustomBadgeManager(); + if (!manager.isCustomBadgeId(badgeId)) { + sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, error("Invalid badge id.")); + return; + } + + if (req.method() == HttpMethod.PUT || req.method() == HttpMethod.POST) { + handleUpdate(ctx, req, userId, badgeId); + return; + } + if (req.method() == HttpMethod.DELETE) { + handleDelete(ctx, req, userId, badgeId); + return; + } + sendJson(ctx, req, HttpResponseStatus.METHOD_NOT_ALLOWED, error("Use PUT or DELETE.")); + } catch (Exception e) { + LOGGER.error("[badges/custom] unexpected error path=" + path, e); + sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, error("Server error.")); + } + } + + private void handleTexts(ChannelHandlerContext ctx, FullHttpRequest req) { + CustomBadgeManager manager = Emulator.getGameEnvironment().getCustomBadgeManager(); + java.util.Map cache = manager.getTextCache(); + + JsonObject texts = new JsonObject(); + for (java.util.Map.Entry entry : cache.entrySet()) { + String badgeId = entry.getKey(); + CustomBadgeManager.BadgeText value = entry.getValue(); + texts.addProperty("badge_name_" + badgeId, value.name); + texts.addProperty("badge_desc_" + badgeId, value.description); + } + + JsonObject ok = new JsonObject(); + ok.add("texts", texts); + ok.addProperty("count", cache.size()); + sendJson(ctx, req, HttpResponseStatus.OK, ok); + } + + private void handleList(ChannelHandlerContext ctx, FullHttpRequest req, int userId) { + CustomBadgeManager manager = Emulator.getGameEnvironment().getCustomBadgeManager(); + List badges = manager.listForUser(userId); + + JsonArray arr = new JsonArray(); + for (CustomBadge b : badges) arr.add(toJson(b, manager)); + + JsonObject ok = new JsonObject(); + ok.add("badges", arr); + ok.addProperty("max", CustomBadgeManager.MAX_PER_USER); + ok.addProperty("badgeWidth", CustomBadgeManager.BADGE_WIDTH); + ok.addProperty("badgeHeight", CustomBadgeManager.BADGE_HEIGHT); + ok.addProperty("maxBadgeSizeBytes", CustomBadgeManager.MAX_BADGE_SIZE_BYTES); + if (manager.getSettings() != null) { + ok.addProperty("priceBadge", manager.getSettings().getPriceBadge()); + ok.addProperty("currencyType", manager.getSettings().getCurrencyType()); + } + sendJson(ctx, req, HttpResponseStatus.OK, ok); + } + + private void handleCreate(ChannelHandlerContext ctx, FullHttpRequest req, int userId) { + JsonObject body = readJsonBody(req); + if (body == null) { + sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, error("Invalid JSON body.")); + return; + } + + byte[] png = decodeImage(body); + if (png == null) { + sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, error("Missing or invalid image.")); + return; + } + + String name = optString(body, "name"); + String description = optString(body, "description"); + + CustomBadgeManager manager = Emulator.getGameEnvironment().getCustomBadgeManager(); + try { + CustomBadge created = manager.create(userId, name, description, png); + sendJson(ctx, req, HttpResponseStatus.CREATED, toJson(created, manager)); + } catch (CustomBadgeException e) { + sendJson(ctx, req, statusFor(e), error(e.getMessage(), e.getCode())); + } + } + + private void handleUpdate(ChannelHandlerContext ctx, FullHttpRequest req, int userId, String badgeId) { + JsonObject body = readJsonBody(req); + if (body == null) { + sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, error("Invalid JSON body.")); + return; + } + + byte[] png = decodeImage(body); + if (png == null) { + sendJson(ctx, req, HttpResponseStatus.BAD_REQUEST, error("Missing or invalid image.")); + return; + } + + String name = optString(body, "name"); + String description = optString(body, "description"); + + CustomBadgeManager manager = Emulator.getGameEnvironment().getCustomBadgeManager(); + try { + CustomBadge updated = manager.update(userId, badgeId, name, description, png); + sendJson(ctx, req, HttpResponseStatus.OK, toJson(updated, manager)); + } catch (CustomBadgeException e) { + sendJson(ctx, req, statusFor(e), error(e.getMessage(), e.getCode())); + } + } + + private void handleDelete(ChannelHandlerContext ctx, FullHttpRequest req, int userId, String badgeId) { + CustomBadgeManager manager = Emulator.getGameEnvironment().getCustomBadgeManager(); + try { + manager.delete(userId, badgeId); + JsonObject ok = new JsonObject(); + ok.addProperty("deleted", badgeId); + sendJson(ctx, req, HttpResponseStatus.OK, ok); + } catch (CustomBadgeException e) { + sendJson(ctx, req, statusFor(e), error(e.getMessage(), e.getCode())); + } + } + + private static byte[] decodeImage(JsonObject body) { + if (!body.has("image")) return null; + try { + String raw = body.get("image").getAsString(); + if (raw == null || raw.isEmpty()) return null; + int comma = raw.indexOf(','); + String b64 = raw.startsWith("data:") && comma >= 0 ? raw.substring(comma + 1) : raw; + return Base64.getDecoder().decode(b64.replaceAll("\\s+", "")); + } catch (Exception e) { + return null; + } + } + + private static JsonObject readJsonBody(FullHttpRequest req) { + try { + String text = req.content().toString(StandardCharsets.UTF_8); + if (text.isEmpty()) return new JsonObject(); + return JsonParser.parseString(text).getAsJsonObject(); + } catch (Exception e) { + return null; + } + } + + private static String optString(JsonObject body, String key) { + if (body == null || !body.has(key) || body.get(key).isJsonNull()) return ""; + try { return body.get(key).getAsString(); } + catch (Exception e) { return ""; } + } + + private static int authenticate(FullHttpRequest req) { + String header = req.headers().get(HttpHeaderNames.AUTHORIZATION); + if (header == null || header.isEmpty()) return 0; + String token; + if (header.startsWith("Bearer ")) token = header.substring(7).trim(); + else token = header.trim(); + return AccessTokenService.verify(token); + } + + private static HttpResponseStatus statusFor(CustomBadgeException e) { + return switch (e.getCode()) { + case "not_found" -> HttpResponseStatus.NOT_FOUND; + case "insufficient_funds" -> HttpResponseStatus.PAYMENT_REQUIRED; + case "must_be_online" -> HttpResponseStatus.CONFLICT; + case "rate_limited" -> HttpResponseStatus.TOO_MANY_REQUESTS; + case "limit_reached", "wrong_dimensions", "too_large", "empty", "invalid_image", "not_configured" -> + HttpResponseStatus.BAD_REQUEST; + default -> HttpResponseStatus.INTERNAL_SERVER_ERROR; + }; + } + + private static JsonObject toJson(CustomBadge badge, CustomBadgeManager manager) { + JsonObject obj = new JsonObject(); + obj.addProperty("badgeId", badge.getBadgeId()); + obj.addProperty("badgeCode", badge.getBadgeId()); + obj.addProperty("name", badge.getBadgeName()); + obj.addProperty("description", badge.getBadgeDescription()); + obj.addProperty("dateCreated", badge.getDateCreated()); + obj.addProperty("dateEdit", badge.getDateEdit()); + obj.addProperty("url", manager.publicUrlFor(badge.getBadgeId())); + return obj; + } + + private static JsonObject error(String message) { + return error(message, null); + } + + private static JsonObject error(String message, String code) { + JsonObject obj = new JsonObject(); + obj.addProperty("error", message); + if (code != null) obj.addProperty("code", code); + return obj; + } + + private static void sendJson(ChannelHandlerContext ctx, FullHttpRequest req, + HttpResponseStatus status, JsonObject body) { + byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8); + FullHttpResponse response = new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, status, Unpooled.wrappedBuffer(bytes)); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8"); + response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length); + applyCors(req, response); + boolean keepAlive = isKeepAlive(req); + if (keepAlive) response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE); + var future = ctx.writeAndFlush(response); + if (!keepAlive) future.addListener(ChannelFutureListener.CLOSE); + } + + private static void sendCors(ChannelHandlerContext ctx, FullHttpRequest req) { + FullHttpResponse response = new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT); + applyCors(req, response); + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + private static void applyCors(FullHttpRequest req, FullHttpResponse response) { + String origin = req.headers().get(HttpHeaderNames.ORIGIN); + if (origin != null && !origin.isEmpty()) { + response.headers().set("Access-Control-Allow-Origin", origin); + response.headers().set("Vary", "Origin"); + response.headers().set("Access-Control-Allow-Credentials", "true"); + } + response.headers().set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS"); + response.headers().set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With"); + } + + private static boolean isKeepAlive(FullHttpRequest req) { + String connection = req.headers().get(HttpHeaderNames.CONNECTION); + if (connection != null && connection.equalsIgnoreCase("close")) return false; + if (connection != null && connection.equalsIgnoreCase("keep-alive")) return true; + return req.protocolVersion().isKeepAliveDefault(); + } + + @SuppressWarnings("unused") + private static String resolveClientIp(ChannelHandlerContext ctx, FullHttpRequest req) { + if (ctx.channel().attr(GameServerAttributes.WS_IP).get() != null) { + return ctx.channel().attr(GameServerAttributes.WS_IP).get(); + } + if (ctx.channel().remoteAddress() instanceof InetSocketAddress addr) { + return addr.getAddress().getHostAddress(); + } + return ""; + } +}