You've already forked Arcturus-Morningstar-Extended
mirror of
https://github.com/duckietm/Arcturus-Morningstar-Extended.git
synced 2026-06-20 15:36:17 +00:00
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `users` ADD COLUMN IF NOT EXISTS `background_card_id` INT(11) NOT NULL DEFAULT 0 AFTER `background_overlay_id`;
|
||||||
+8
-41
@@ -1,8 +1,3 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Custom Prefix System - Complete Setup
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- 1. Main user prefixes table
|
|
||||||
CREATE TABLE IF NOT EXISTS `user_prefixes` (
|
CREATE TABLE IF NOT EXISTS `user_prefixes` (
|
||||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
`user_id` INT(11) NOT NULL,
|
`user_id` INT(11) NOT NULL,
|
||||||
@@ -46,34 +41,6 @@ INSERT IGNORE INTO `custom_prefix_blacklist` (`word`) VALUES
|
|||||||
('mod'),
|
('mod'),
|
||||||
('owner');
|
('owner');
|
||||||
|
|
||||||
-- 4. Add effect column (if table already exists without it)
|
|
||||||
-- ALTER TABLE `user_prefixes` ADD COLUMN IF NOT EXISTS `effect` VARCHAR(50) NOT NULL DEFAULT '' AFTER `icon`;
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- Catalog page for custom prefixes
|
|
||||||
-- ============================================================
|
|
||||||
-- NOTE: Adjust parent_id to match your catalog parent category ID.
|
|
||||||
-- Example: parent_id = -1 for root, or the ID of your "Extra" / "Specials" category
|
|
||||||
|
|
||||||
INSERT INTO `catalog_pages` (
|
|
||||||
`parent_id`, `caption`, `caption_save`, `icon_image`, `visible`, `enabled`,
|
|
||||||
`min_rank`, `page_layout`, `page_strings_1`, `page_strings_2`
|
|
||||||
) VALUES (
|
|
||||||
-1,
|
|
||||||
'Custom Prefix',
|
|
||||||
'custom_prefix',
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
'custom_prefix',
|
|
||||||
'Create your own custom prefix!\rChoose text, colors, icon and effects to stand out in chat.',
|
|
||||||
''
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- Command texts (insert into emulator_texts if not present)
|
|
||||||
-- ============================================================
|
|
||||||
INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES
|
INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES
|
||||||
-- GivePrefix command
|
-- GivePrefix command
|
||||||
('commands.keys.cmd_give_prefix', 'giveprefix'),
|
('commands.keys.cmd_give_prefix', 'giveprefix'),
|
||||||
@@ -105,11 +72,11 @@ INSERT IGNORE INTO `emulator_texts` (`key`, `value`) VALUES
|
|||||||
('commands.succes.cmd_prefix_blacklist.added', 'Word "%word%" added to prefix blacklist.'),
|
('commands.succes.cmd_prefix_blacklist.added', 'Word "%word%" added to prefix blacklist.'),
|
||||||
('commands.succes.cmd_prefix_blacklist.removed', 'Word "%word%" removed from prefix blacklist.');
|
('commands.succes.cmd_prefix_blacklist.removed', 'Word "%word%" removed from prefix blacklist.');
|
||||||
|
|
||||||
-- ============================================================
|
INSERT IGNORE INTO permission_definitions
|
||||||
-- Permissions for prefix commands (add to permissions table)
|
(permission_key, max_value, rank_1, rank_2, rank_3, rank_4, rank_5, rank_6, rank_7)
|
||||||
-- ============================================================
|
VALUES
|
||||||
INSERT IGNORE INTO `permissions` (`id`, `rank_id`, `permission_name`, `setting_type`) VALUES
|
('cmd_give_prefix', '1', '0', '0', '0', '0', '0', '0', '1'),
|
||||||
(NULL, 7, 'cmd_give_prefix', '1'),
|
('cmd_list_prefixes', '1', '0', '0', '0', '0', '0', '0', '1'),
|
||||||
(NULL, 7, 'cmd_list_prefixes', '1'),
|
('cmd_remove_prefix', '1', '0', '0', '0', '0', '0', '0', '1'),
|
||||||
(NULL, 7, 'cmd_remove_prefix', '1'),
|
('cmd_prefix_blacklist', '1', '0', '0', '0', '0', '0', '0', '1');
|
||||||
(NULL, 7, 'cmd_prefix_blacklist', '1');
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Catalog & Furni Admin Permission
|
|
||||||
-- Adds acc_catalogfurni permission to the permissions table
|
|
||||||
-- Required by: CatalogAdmin packet handlers (10050-10059)
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- 1. Add the column to the permissions table
|
|
||||||
ALTER TABLE `permissions`
|
|
||||||
ADD COLUMN `acc_catalogfurni` ENUM('0','1') NOT NULL DEFAULT '0'
|
|
||||||
AFTER `acc_catalog_ids`;
|
|
||||||
|
|
||||||
-- 2. Enable for Administrator (rank 7) by default
|
|
||||||
UPDATE `permissions` SET `acc_catalogfurni` = '1' WHERE `id` = 7;
|
|
||||||
|
|
||||||
-- Optional: enable for other ranks as needed
|
|
||||||
-- UPDATE `permissions` SET `acc_catalogfurni` = '1' WHERE `id` = 6; -- Super Mod
|
|
||||||
-- UPDATE `permissions` SET `acc_catalogfurni` = '1' WHERE `id` = 5; -- Moderator
|
|
||||||
@@ -28,6 +28,7 @@ public class RoomUserPetComposer extends MessageComposer {
|
|||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
|
this.response.appendInt(0);
|
||||||
this.response.appendString(this.petType + " " + this.race + " " + this.color + " 2 2 -1 0 3 -1 0");
|
this.response.appendString(this.petType + " " + this.race + " " + this.color + " 2 2 -1 0 3 -1 0");
|
||||||
this.response.appendInt(this.habbo.getRoomUnit().getId());
|
this.response.appendInt(this.habbo.getRoomUnit().getId());
|
||||||
this.response.appendInt(this.habbo.getRoomUnit().getX());
|
this.response.appendInt(this.habbo.getRoomUnit().getX());
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import com.eu.habbo.habbohotel.polls.PollManager;
|
|||||||
import com.eu.habbo.habbohotel.rooms.RoomChatBubbleManager;
|
import com.eu.habbo.habbohotel.rooms.RoomChatBubbleManager;
|
||||||
import com.eu.habbo.habbohotel.rooms.RoomManager;
|
import com.eu.habbo.habbohotel.rooms.RoomManager;
|
||||||
import com.eu.habbo.habbohotel.users.HabboManager;
|
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.SubscriptionManager;
|
||||||
import com.eu.habbo.habbohotel.users.subscriptions.SubscriptionScheduler;
|
import com.eu.habbo.habbohotel.users.subscriptions.SubscriptionScheduler;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -58,6 +59,7 @@ public class GameEnvironment {
|
|||||||
private SubscriptionManager subscriptionManager;
|
private SubscriptionManager subscriptionManager;
|
||||||
private CalendarManager calendarManager;
|
private CalendarManager calendarManager;
|
||||||
private RoomChatBubbleManager roomChatBubbleManager;
|
private RoomChatBubbleManager roomChatBubbleManager;
|
||||||
|
private CustomBadgeManager customBadgeManager;
|
||||||
|
|
||||||
public void load() throws Exception {
|
public void load() throws Exception {
|
||||||
LOGGER.info("GameEnvironment -> Loading...");
|
LOGGER.info("GameEnvironment -> Loading...");
|
||||||
@@ -84,6 +86,7 @@ public class GameEnvironment {
|
|||||||
this.pollManager = new PollManager();
|
this.pollManager = new PollManager();
|
||||||
this.calendarManager = new CalendarManager();
|
this.calendarManager = new CalendarManager();
|
||||||
this.roomChatBubbleManager = new RoomChatBubbleManager();
|
this.roomChatBubbleManager = new RoomChatBubbleManager();
|
||||||
|
this.customBadgeManager = new CustomBadgeManager();
|
||||||
|
|
||||||
this.roomManager.loadPublicRooms();
|
this.roomManager.loadPublicRooms();
|
||||||
this.navigatorManager.loadNavigator();
|
this.navigatorManager.loadNavigator();
|
||||||
@@ -219,4 +222,8 @@ public class GameEnvironment {
|
|||||||
public RoomChatBubbleManager getRoomChatBubbleManager() {
|
public RoomChatBubbleManager getRoomChatBubbleManager() {
|
||||||
return roomChatBubbleManager;
|
return roomChatBubbleManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CustomBadgeManager getCustomBadgeManager() {
|
||||||
|
return this.customBadgeManager;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-37
@@ -1,23 +1,16 @@
|
|||||||
package com.eu.habbo.habbohotel.gameclients;
|
package com.eu.habbo.habbohotel.gameclients;
|
||||||
|
|
||||||
import com.eu.habbo.Emulator;
|
import com.eu.habbo.Emulator;
|
||||||
|
import com.eu.habbo.habbohotel.rooms.Room;
|
||||||
|
import com.eu.habbo.habbohotel.rooms.RoomUnit;
|
||||||
import com.eu.habbo.habbohotel.users.Habbo;
|
import com.eu.habbo.habbohotel.users.Habbo;
|
||||||
|
import com.eu.habbo.messages.outgoing.rooms.users.RoomUserEffectComposer;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages a grace period for disconnected users. Instead of immediately
|
|
||||||
* disposing a Habbo when their WebSocket drops, the Habbo is held in
|
|
||||||
* a "ghost" state for a configurable number of seconds. If the same
|
|
||||||
* user reconnects (via SSO ticket) within the grace window, their
|
|
||||||
* existing Habbo object is resumed on the new connection — keeping
|
|
||||||
* them in their room, preserving inventory state, etc.
|
|
||||||
*
|
|
||||||
* Config key: session.reconnect.grace.seconds (default: 30)
|
|
||||||
*/
|
|
||||||
public class SessionResumeManager {
|
public class SessionResumeManager {
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(SessionResumeManager.class);
|
private static final Logger LOGGER = LoggerFactory.getLogger(SessionResumeManager.class);
|
||||||
@@ -37,12 +30,10 @@ public class SessionResumeManager {
|
|||||||
return Emulator.getConfig().getInt("session.reconnect.grace.seconds", 30);
|
return Emulator.getConfig().getInt("session.reconnect.grace.seconds", 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public int getPausedEffectId() {
|
||||||
* Park a disconnected Habbo in ghost mode. Their room presence is
|
return Emulator.getConfig().getInt("session.reconnect.effect.id", 170);
|
||||||
* preserved, but the old GameClient channel is closed.
|
}
|
||||||
*
|
|
||||||
* @return true if the habbo was parked (grace period > 0), false if immediate dispose should happen
|
|
||||||
*/
|
|
||||||
public boolean parkHabbo(Habbo habbo, String ssoTicket) {
|
public boolean parkHabbo(Habbo habbo, String ssoTicket) {
|
||||||
int graceSeconds = getGracePeriodSeconds();
|
int graceSeconds = getGracePeriodSeconds();
|
||||||
if (graceSeconds <= 0) {
|
if (graceSeconds <= 0) {
|
||||||
@@ -51,7 +42,6 @@ public class SessionResumeManager {
|
|||||||
|
|
||||||
int userId = habbo.getHabboInfo().getId();
|
int userId = habbo.getHabboInfo().getId();
|
||||||
|
|
||||||
// Cancel any existing ghost session for this user
|
|
||||||
GhostSession existing = ghostSessions.remove(userId);
|
GhostSession existing = ghostSessions.remove(userId);
|
||||||
if (existing != null && existing.disposeFuture != null) {
|
if (existing != null && existing.disposeFuture != null) {
|
||||||
existing.disposeFuture.cancel(false);
|
existing.disposeFuture.cancel(false);
|
||||||
@@ -60,12 +50,18 @@ public class SessionResumeManager {
|
|||||||
LOGGER.info("[SessionResume] Parking {} (id={}) for {}s grace period",
|
LOGGER.info("[SessionResume] Parking {} (id={}) for {}s grace period",
|
||||||
habbo.getHabboInfo().getUsername(), userId, graceSeconds);
|
habbo.getHabboInfo().getUsername(), userId, graceSeconds);
|
||||||
|
|
||||||
// Restore the SSO ticket so the client can reconnect with the same ticket
|
|
||||||
if (ssoTicket != null && !ssoTicket.isEmpty()) {
|
if (ssoTicket != null && !ssoTicket.isEmpty()) {
|
||||||
restoreSsoTicket(userId, ssoTicket);
|
restoreSsoTicket(userId, ssoTicket);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule the final disconnect after the grace period
|
int previousEffectId = 0;
|
||||||
|
int previousEffectEnd = 0;
|
||||||
|
RoomUnit unit = habbo.getRoomUnit();
|
||||||
|
if (unit != null) {
|
||||||
|
previousEffectId = unit.getEffectId();
|
||||||
|
previousEffectEnd = unit.getEffectEndTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
ScheduledFuture<?> future = Emulator.getThreading().run(() -> {
|
ScheduledFuture<?> future = Emulator.getThreading().run(() -> {
|
||||||
GhostSession ghost = ghostSessions.remove(userId);
|
GhostSession ghost = ghostSessions.remove(userId);
|
||||||
if (ghost != null) {
|
if (ghost != null) {
|
||||||
@@ -75,22 +71,19 @@ public class SessionResumeManager {
|
|||||||
}
|
}
|
||||||
}, graceSeconds * 1000);
|
}, graceSeconds * 1000);
|
||||||
|
|
||||||
ghostSessions.put(userId, new GhostSession(habbo, ssoTicket, future));
|
ghostSessions.put(userId, new GhostSession(habbo, ssoTicket, future, previousEffectId, previousEffectEnd));
|
||||||
|
|
||||||
|
applyPausedEffect(habbo);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to resume a ghost session for the given user ID.
|
|
||||||
*
|
|
||||||
* @return the parked Habbo if found within grace period, null otherwise
|
|
||||||
*/
|
|
||||||
public Habbo resumeSession(int userId) {
|
public Habbo resumeSession(int userId) {
|
||||||
GhostSession ghost = ghostSessions.remove(userId);
|
GhostSession ghost = ghostSessions.remove(userId);
|
||||||
if (ghost == null) {
|
if (ghost == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel the scheduled dispose
|
|
||||||
if (ghost.disposeFuture != null) {
|
if (ghost.disposeFuture != null) {
|
||||||
ghost.disposeFuture.cancel(false);
|
ghost.disposeFuture.cancel(false);
|
||||||
}
|
}
|
||||||
@@ -98,19 +91,15 @@ public class SessionResumeManager {
|
|||||||
LOGGER.info("[SessionResume] Resuming session for {} (id={})",
|
LOGGER.info("[SessionResume] Resuming session for {} (id={})",
|
||||||
ghost.habbo.getHabboInfo().getUsername(), userId);
|
ghost.habbo.getHabboInfo().getUsername(), userId);
|
||||||
|
|
||||||
|
restorePausedEffect(ghost);
|
||||||
|
|
||||||
return ghost.habbo;
|
return ghost.habbo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a user has a ghost session (is in grace period).
|
|
||||||
*/
|
|
||||||
public boolean hasGhostSession(int userId) {
|
public boolean hasGhostSession(int userId) {
|
||||||
return ghostSessions.containsKey(userId);
|
return ghostSessions.containsKey(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Immediately expire all ghost sessions (e.g. on emulator shutdown).
|
|
||||||
*/
|
|
||||||
public void disposeAll() {
|
public void disposeAll() {
|
||||||
for (GhostSession ghost : ghostSessions.values()) {
|
for (GhostSession ghost : ghostSessions.values()) {
|
||||||
if (ghost.disposeFuture != null) {
|
if (ghost.disposeFuture != null) {
|
||||||
@@ -121,9 +110,6 @@ public class SessionResumeManager {
|
|||||||
ghostSessions.clear();
|
ghostSessions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform the actual full disconnect that normally happens in Habbo.disconnect().
|
|
||||||
*/
|
|
||||||
private void performFullDisconnect(Habbo habbo) {
|
private void performFullDisconnect(Habbo habbo) {
|
||||||
try {
|
try {
|
||||||
habbo.getHabboInfo().setOnline(false);
|
habbo.getHabboInfo().setOnline(false);
|
||||||
@@ -132,7 +118,6 @@ public class SessionResumeManager {
|
|||||||
LOGGER.error("[SessionResume] Error during deferred disconnect", e);
|
LOGGER.error("[SessionResume] Error during deferred disconnect", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the SSO ticket now that the grace period is truly over
|
|
||||||
clearSsoTicket(habbo.getHabboInfo().getId());
|
clearSsoTicket(habbo.getHabboInfo().getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +133,38 @@ public class SessionResumeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applyPausedEffect(Habbo habbo) {
|
||||||
|
int effectId = getPausedEffectId();
|
||||||
|
if (effectId <= 0) return;
|
||||||
|
try {
|
||||||
|
RoomUnit unit = habbo.getRoomUnit();
|
||||||
|
Room room = habbo.getHabboInfo() == null ? null : habbo.getHabboInfo().getCurrentRoom();
|
||||||
|
if (unit == null || room == null) return;
|
||||||
|
int endTimestamp = Emulator.getIntUnixTimestamp() + getGracePeriodSeconds() + 10;
|
||||||
|
unit.setEffectId(effectId, endTimestamp);
|
||||||
|
room.sendComposer(new RoomUserEffectComposer(unit).compose());
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("[SessionResume] Failed to apply paused effect", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void restorePausedEffect(GhostSession ghost) {
|
||||||
|
try {
|
||||||
|
Habbo habbo = ghost.habbo;
|
||||||
|
RoomUnit unit = habbo.getRoomUnit();
|
||||||
|
Room room = habbo.getHabboInfo() == null ? null : habbo.getHabboInfo().getCurrentRoom();
|
||||||
|
if (unit == null || room == null) return;
|
||||||
|
|
||||||
|
int pausedEffectId = getPausedEffectId();
|
||||||
|
if (unit.getEffectId() == pausedEffectId) {
|
||||||
|
unit.setEffectId(ghost.previousEffectId, ghost.previousEffectEnd);
|
||||||
|
room.sendComposer(new RoomUserEffectComposer(unit).compose());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("[SessionResume] Failed to restore previous effect", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void clearSsoTicket(int userId) {
|
private void clearSsoTicket(int userId) {
|
||||||
try (var connection = Emulator.getDatabase().getDataSource().getConnection();
|
try (var connection = Emulator.getDatabase().getDataSource().getConnection();
|
||||||
var statement = connection.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? LIMIT 1")) {
|
var statement = connection.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? LIMIT 1")) {
|
||||||
@@ -163,11 +180,16 @@ public class SessionResumeManager {
|
|||||||
final Habbo habbo;
|
final Habbo habbo;
|
||||||
final String ssoTicket;
|
final String ssoTicket;
|
||||||
final ScheduledFuture<?> disposeFuture;
|
final ScheduledFuture<?> disposeFuture;
|
||||||
|
final int previousEffectId;
|
||||||
|
final int previousEffectEnd;
|
||||||
|
|
||||||
GhostSession(Habbo habbo, String ssoTicket, ScheduledFuture<?> disposeFuture) {
|
GhostSession(Habbo habbo, String ssoTicket, ScheduledFuture<?> disposeFuture,
|
||||||
|
int previousEffectId, int previousEffectEnd) {
|
||||||
this.habbo = habbo;
|
this.habbo = habbo;
|
||||||
this.ssoTicket = ssoTicket;
|
this.ssoTicket = ssoTicket;
|
||||||
this.disposeFuture = disposeFuture;
|
this.disposeFuture = disposeFuture;
|
||||||
|
this.previousEffectId = previousEffectId;
|
||||||
|
this.previousEffectEnd = previousEffectEnd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,25 +15,19 @@ import com.eu.habbo.habbohotel.items.interactions.games.tag.InteractionTagField;
|
|||||||
import com.eu.habbo.habbohotel.items.interactions.games.tag.InteractionTagPole;
|
import com.eu.habbo.habbohotel.items.interactions.games.tag.InteractionTagPole;
|
||||||
import com.eu.habbo.habbohotel.items.interactions.pets.*;
|
import com.eu.habbo.habbohotel.items.interactions.pets.*;
|
||||||
import com.eu.habbo.habbohotel.items.interactions.wired.effects.WiredEffectSendSignal;
|
import com.eu.habbo.habbohotel.items.interactions.wired.effects.WiredEffectSendSignal;
|
||||||
import com.eu.habbo.habbohotel.items.interactions.wired.extra.WiredBlob;
|
import com.eu.habbo.habbohotel.items.interactions.wired.extra.*;
|
||||||
import com.eu.habbo.habbohotel.items.interactions.wired.extra.WiredExtraFurniVariable;
|
|
||||||
import com.eu.habbo.habbohotel.items.interactions.wired.extra.WiredExtraRoomVariable;
|
|
||||||
import com.eu.habbo.habbohotel.items.interactions.wired.extra.WiredExtraUserVariable;
|
|
||||||
import com.eu.habbo.habbohotel.items.interactions.wired.extra.WiredExtraVariableEcho;
|
|
||||||
import com.eu.habbo.habbohotel.items.interactions.wired.extra.WiredExtraVariableReference;
|
|
||||||
import com.eu.habbo.habbohotel.items.interactions.wired.extra.WiredExtraVariableTextConnector;
|
|
||||||
import com.eu.habbo.habbohotel.items.interactions.wired.extra.WiredExtraContextVariable;
|
|
||||||
import com.eu.habbo.habbohotel.wired.core.WiredContextVariableSupport;
|
|
||||||
import com.eu.habbo.habbohotel.items.interactions.wired.triggers.WiredTriggerReceiveSignal;
|
import com.eu.habbo.habbohotel.items.interactions.wired.triggers.WiredTriggerReceiveSignal;
|
||||||
import com.eu.habbo.habbohotel.permissions.Permission;
|
import com.eu.habbo.habbohotel.permissions.Permission;
|
||||||
import com.eu.habbo.habbohotel.users.Habbo;
|
import com.eu.habbo.habbohotel.users.Habbo;
|
||||||
import com.eu.habbo.habbohotel.users.HabboInfo;
|
import com.eu.habbo.habbohotel.users.HabboInfo;
|
||||||
import com.eu.habbo.habbohotel.users.HabboItem;
|
import com.eu.habbo.habbohotel.users.HabboItem;
|
||||||
import com.eu.habbo.habbohotel.users.HabboManager;
|
import com.eu.habbo.habbohotel.users.HabboManager;
|
||||||
|
import com.eu.habbo.habbohotel.wired.core.WiredContextVariableSupport;
|
||||||
import com.eu.habbo.habbohotel.wired.core.WiredManager;
|
import com.eu.habbo.habbohotel.wired.core.WiredManager;
|
||||||
import com.eu.habbo.habbohotel.wired.core.WiredMovementPhysics;
|
import com.eu.habbo.habbohotel.wired.core.WiredMovementPhysics;
|
||||||
import com.eu.habbo.habbohotel.wired.tick.WiredTickable;
|
import com.eu.habbo.habbohotel.wired.tick.WiredTickable;
|
||||||
import com.eu.habbo.messages.outgoing.inventory.AddHabboItemComposer;
|
import com.eu.habbo.messages.outgoing.inventory.AddHabboItemComposer;
|
||||||
|
import com.eu.habbo.messages.outgoing.inventory.InventoryRefreshComposer;
|
||||||
import com.eu.habbo.messages.outgoing.rooms.items.*;
|
import com.eu.habbo.messages.outgoing.rooms.items.*;
|
||||||
import com.eu.habbo.plugin.Event;
|
import com.eu.habbo.plugin.Event;
|
||||||
import com.eu.habbo.plugin.events.furniture.*;
|
import com.eu.habbo.plugin.events.furniture.*;
|
||||||
@@ -1114,6 +1108,7 @@ public class RoomItemManager {
|
|||||||
if (habbo != null && !inventoryItems.isEmpty()) {
|
if (habbo != null && !inventoryItems.isEmpty()) {
|
||||||
habbo.getInventory().getItemsComponent().addItems(inventoryItems);
|
habbo.getInventory().getItemsComponent().addItems(inventoryItems);
|
||||||
habbo.getClient().sendResponse(new AddHabboItemComposer(inventoryItems));
|
habbo.getClient().sendResponse(new AddHabboItemComposer(inventoryItems));
|
||||||
|
habbo.getClient().sendResponse(new InventoryRefreshComposer());
|
||||||
}
|
}
|
||||||
|
|
||||||
for (HabboItem i : items) {
|
for (HabboItem i : items) {
|
||||||
@@ -1182,6 +1177,7 @@ public class RoomItemManager {
|
|||||||
if (user != null && !inventoryItems.isEmpty()) {
|
if (user != null && !inventoryItems.isEmpty()) {
|
||||||
user.getInventory().getItemsComponent().addItems(inventoryItems);
|
user.getInventory().getItemsComponent().addItems(inventoryItems);
|
||||||
user.getClient().sendResponse(new AddHabboItemComposer(inventoryItems));
|
user.getClient().sendResponse(new AddHabboItemComposer(inventoryItems));
|
||||||
|
user.getClient().sendResponse(new InventoryRefreshComposer());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public class HabboInfo implements Runnable {
|
|||||||
private int InfostandBg;
|
private int InfostandBg;
|
||||||
private int InfostandStand;
|
private int InfostandStand;
|
||||||
private int InfostandOverlay;
|
private int InfostandOverlay;
|
||||||
|
private int InfostandCardBg;
|
||||||
private int loadingRoom;
|
private int loadingRoom;
|
||||||
private Room currentRoom;
|
private Room currentRoom;
|
||||||
private String roomEntryMethod = "door";
|
private String roomEntryMethod = "door";
|
||||||
@@ -91,6 +92,7 @@ public class HabboInfo implements Runnable {
|
|||||||
this.InfostandBg = set.getInt("background_id");
|
this.InfostandBg = set.getInt("background_id");
|
||||||
this.InfostandStand = set.getInt("background_stand_id");
|
this.InfostandStand = set.getInt("background_stand_id");
|
||||||
this.InfostandOverlay = set.getInt("background_overlay_id");
|
this.InfostandOverlay = set.getInt("background_overlay_id");
|
||||||
|
this.InfostandCardBg = set.getInt("background_card_id");
|
||||||
this.currentRoom = null;
|
this.currentRoom = null;
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
LOGGER.error("Caught SQL exception", e);
|
LOGGER.error("Caught SQL exception", e);
|
||||||
@@ -290,6 +292,14 @@ public class HabboInfo implements Runnable {
|
|||||||
public void setInfostandOverlay(int infostandOverlay) {
|
public void setInfostandOverlay(int infostandOverlay) {
|
||||||
InfostandOverlay = infostandOverlay;
|
InfostandOverlay = infostandOverlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getInfostandCardBg() {
|
||||||
|
return InfostandCardBg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setInfostandCardBg(int infostandCardBg) {
|
||||||
|
InfostandCardBg = infostandCardBg;
|
||||||
|
}
|
||||||
public Rank getRank() {
|
public Rank getRank() {
|
||||||
return this.rank;
|
return this.rank;
|
||||||
}
|
}
|
||||||
@@ -577,7 +587,7 @@ public class HabboInfo implements Runnable {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
SqlQueries.update(
|
SqlQueries.update(
|
||||||
"UPDATE users SET motto = ?, online = ?, look = ?, gender = ?, credits = ?, last_login = ?, last_online = ?, home_room = ?, ip_current = ?, `rank` = ?, machine_id = ?, username = ?, background_id = ?, background_stand_id = ?, background_overlay_id = ? WHERE id = ?",
|
"UPDATE users SET motto = ?, online = ?, look = ?, gender = ?, credits = ?, last_login = ?, last_online = ?, home_room = ?, ip_current = ?, `rank` = ?, machine_id = ?, username = ?, background_id = ?, background_stand_id = ?, background_overlay_id = ?, background_card_id = ? WHERE id = ?",
|
||||||
this.motto,
|
this.motto,
|
||||||
this.online ? "1" : "0",
|
this.online ? "1" : "0",
|
||||||
this.look,
|
this.look,
|
||||||
@@ -593,6 +603,7 @@ public class HabboInfo implements Runnable {
|
|||||||
this.InfostandBg,
|
this.InfostandBg,
|
||||||
this.InfostandStand,
|
this.InfostandStand,
|
||||||
this.InfostandOverlay,
|
this.InfostandOverlay,
|
||||||
|
this.InfostandCardBg,
|
||||||
this.id);
|
this.id);
|
||||||
} catch (SqlQueries.DataAccessException e) {
|
} catch (SqlQueries.DataAccessException e) {
|
||||||
LOGGER.error("Caught SQL exception", e);
|
LOGGER.error("Caught SQL exception", e);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+588
@@ -0,0 +1,588 @@
|
|||||||
|
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<Integer, long[]> rateBuckets = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, BadgeText> textCache = new ConcurrentHashMap<>();
|
||||||
|
private final java.util.concurrent.atomic.AtomicLong textCacheVersion = new java.util.concurrent.atomic.AtomicLong();
|
||||||
|
|
||||||
|
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<String, BadgeText> getTextCache() {
|
||||||
|
return java.util.Collections.unmodifiableMap(this.textCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTextCacheVersion() {
|
||||||
|
return this.textCacheVersion.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadTextCache() {
|
||||||
|
Map<String, BadgeText> 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);
|
||||||
|
this.textCacheVersion.incrementAndGet();
|
||||||
|
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<CustomBadge> listForUser(int userId) {
|
||||||
|
List<CustomBadge> 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));
|
||||||
|
this.textCacheVersion.incrementAndGet();
|
||||||
|
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));
|
||||||
|
this.textCacheVersion.incrementAndGet();
|
||||||
|
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);
|
||||||
|
this.textCacheVersion.incrementAndGet();
|
||||||
|
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<ImageReader> 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<Integer, Integer> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -9,10 +9,12 @@ public class ChangeInfostandBgEvent extends MessageHandler {
|
|||||||
int backgroundImage = this.packet.readInt();
|
int backgroundImage = this.packet.readInt();
|
||||||
int backgroundStand = this.packet.readInt();
|
int backgroundStand = this.packet.readInt();
|
||||||
int backgroundOverlay = this.packet.readInt();
|
int backgroundOverlay = this.packet.readInt();
|
||||||
|
int backgroundCard = this.packet.bytesAvailable() >= 4 ? this.packet.readInt() : 0;
|
||||||
|
|
||||||
this.client.getHabbo().getHabboInfo().setInfostandBg(backgroundImage);
|
this.client.getHabbo().getHabboInfo().setInfostandBg(backgroundImage);
|
||||||
this.client.getHabbo().getHabboInfo().setInfostandStand(backgroundStand);
|
this.client.getHabbo().getHabboInfo().setInfostandStand(backgroundStand);
|
||||||
this.client.getHabbo().getHabboInfo().setInfostandOverlay(backgroundOverlay);
|
this.client.getHabbo().getHabboInfo().setInfostandOverlay(backgroundOverlay);
|
||||||
|
this.client.getHabbo().getHabboInfo().setInfostandCardBg(backgroundCard);
|
||||||
this.client.getHabbo().getHabboInfo().run();
|
this.client.getHabbo().getHabboInfo().run();
|
||||||
|
|
||||||
if (this.client.getHabbo().getHabboInfo().getCurrentRoom() != null) {
|
if (this.client.getHabbo().getHabboInfo().getCurrentRoom() != null) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public class RoomPetComposer extends MessageComposer implements TIntObjectProced
|
|||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
|
this.response.appendInt(0);
|
||||||
if (pet instanceof IPetLook) {
|
if (pet instanceof IPetLook) {
|
||||||
this.response.appendString(((IPetLook) pet).getLook());
|
this.response.appendString(((IPetLook) pet).getLook());
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+1
@@ -23,6 +23,7 @@ public class RoomUserDataComposer extends MessageComposer {
|
|||||||
this.response.appendInt(this.habbo.getHabboInfo().getInfostandBg());
|
this.response.appendInt(this.habbo.getHabboInfo().getInfostandBg());
|
||||||
this.response.appendInt(this.habbo.getHabboInfo().getInfostandStand());
|
this.response.appendInt(this.habbo.getHabboInfo().getInfostandStand());
|
||||||
this.response.appendInt(this.habbo.getHabboInfo().getInfostandOverlay());
|
this.response.appendInt(this.habbo.getHabboInfo().getInfostandOverlay());
|
||||||
|
this.response.appendInt(this.habbo.getHabboInfo().getInfostandCardBg());
|
||||||
return this.response;
|
return this.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
@@ -43,6 +43,7 @@ public class RoomUsersComposer extends MessageComposer {
|
|||||||
this.response.appendInt(this.habbo.getHabboInfo().getInfostandBg());
|
this.response.appendInt(this.habbo.getHabboInfo().getInfostandBg());
|
||||||
this.response.appendInt(this.habbo.getHabboInfo().getInfostandStand());
|
this.response.appendInt(this.habbo.getHabboInfo().getInfostandStand());
|
||||||
this.response.appendInt(this.habbo.getHabboInfo().getInfostandOverlay());
|
this.response.appendInt(this.habbo.getHabboInfo().getInfostandOverlay());
|
||||||
|
this.response.appendInt(this.habbo.getHabboInfo().getInfostandCardBg());
|
||||||
this.response.appendString(this.habbo.getHabboInfo().getLook());
|
this.response.appendString(this.habbo.getHabboInfo().getLook());
|
||||||
this.response.appendInt(this.habbo.getRoomUnit().getId()); //Room Unit ID
|
this.response.appendInt(this.habbo.getRoomUnit().getId()); //Room Unit ID
|
||||||
this.response.appendInt(this.habbo.getRoomUnit().getX());
|
this.response.appendInt(this.habbo.getRoomUnit().getX());
|
||||||
@@ -78,6 +79,7 @@ public class RoomUsersComposer extends MessageComposer {
|
|||||||
this.response.appendInt(habbo.getHabboInfo().getInfostandBg());
|
this.response.appendInt(habbo.getHabboInfo().getInfostandBg());
|
||||||
this.response.appendInt(habbo.getHabboInfo().getInfostandStand());
|
this.response.appendInt(habbo.getHabboInfo().getInfostandStand());
|
||||||
this.response.appendInt(habbo.getHabboInfo().getInfostandOverlay());
|
this.response.appendInt(habbo.getHabboInfo().getInfostandOverlay());
|
||||||
|
this.response.appendInt(habbo.getHabboInfo().getInfostandCardBg());
|
||||||
this.response.appendString(habbo.getHabboInfo().getLook());
|
this.response.appendString(habbo.getHabboInfo().getLook());
|
||||||
this.response.appendInt(habbo.getRoomUnit().getId()); //Room Unit ID
|
this.response.appendInt(habbo.getRoomUnit().getId()); //Room Unit ID
|
||||||
this.response.appendInt(habbo.getRoomUnit().getX());
|
this.response.appendInt(habbo.getRoomUnit().getX());
|
||||||
@@ -111,6 +113,7 @@ public class RoomUsersComposer extends MessageComposer {
|
|||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
|
this.response.appendInt(0);
|
||||||
this.response.appendString(this.bot.getFigure());
|
this.response.appendString(this.bot.getFigure());
|
||||||
this.response.appendInt(this.bot.getRoomUnit().getId());
|
this.response.appendInt(this.bot.getRoomUnit().getId());
|
||||||
this.response.appendInt(this.bot.getRoomUnit().getX());
|
this.response.appendInt(this.bot.getRoomUnit().getX());
|
||||||
@@ -143,6 +146,7 @@ public class RoomUsersComposer extends MessageComposer {
|
|||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
this.response.appendInt(0);
|
this.response.appendInt(0);
|
||||||
|
this.response.appendInt(0);
|
||||||
this.response.appendString(bot.getFigure());
|
this.response.appendString(bot.getFigure());
|
||||||
this.response.appendInt(bot.getRoomUnit().getId());
|
this.response.appendInt(bot.getRoomUnit().getId());
|
||||||
this.response.appendInt(bot.getRoomUnit().getX());
|
this.response.appendInt(bot.getRoomUnit().getX());
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ public class UserProfileComposer extends MessageComposer {
|
|||||||
this.response.appendInt(this.habboInfo.getInfostandBg());
|
this.response.appendInt(this.habboInfo.getInfostandBg());
|
||||||
this.response.appendInt(this.habboInfo.getInfostandStand());
|
this.response.appendInt(this.habboInfo.getInfostandStand());
|
||||||
this.response.appendInt(this.habboInfo.getInfostandOverlay());
|
this.response.appendInt(this.habboInfo.getInfostandOverlay());
|
||||||
|
this.response.appendInt(this.habboInfo.getInfostandCardBg());
|
||||||
|
|
||||||
return this.response;
|
return this.response;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -3,6 +3,7 @@ package com.eu.habbo.networking.gameserver;
|
|||||||
import com.eu.habbo.Emulator;
|
import com.eu.habbo.Emulator;
|
||||||
import com.eu.habbo.messages.PacketManager;
|
import com.eu.habbo.messages.PacketManager;
|
||||||
import com.eu.habbo.networking.gameserver.auth.AuthHttpHandler;
|
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.codec.WebSocketCodec;
|
||||||
import com.eu.habbo.networking.gameserver.crypto.WsHandshakeHandler;
|
import com.eu.habbo.networking.gameserver.crypto.WsHandshakeHandler;
|
||||||
import com.eu.habbo.networking.gameserver.decoders.*;
|
import com.eu.habbo.networking.gameserver.decoders.*;
|
||||||
@@ -53,6 +54,7 @@ public class WebSocketChannelInitializer extends ChannelInitializer<SocketChanne
|
|||||||
ch.pipeline().addLast("httpAggregator", new HttpObjectAggregator(MAX_FRAME_SIZE));
|
ch.pipeline().addLast("httpAggregator", new HttpObjectAggregator(MAX_FRAME_SIZE));
|
||||||
ch.pipeline().addLast("wsHttpHandler", new WebSocketHttpHandler());
|
ch.pipeline().addLast("wsHttpHandler", new WebSocketHttpHandler());
|
||||||
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
|
ch.pipeline().addLast("authHttpHandler", new AuthHttpHandler());
|
||||||
|
ch.pipeline().addLast("badgeHttpHandler", new BadgeHttpHandler());
|
||||||
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
|
ch.pipeline().addLast("wsProtocolHandler", new WebSocketServerProtocolHandler(this.wsConfig));
|
||||||
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
|
ch.pipeline().addLast("wsCodec", new WebSocketCodec());
|
||||||
|
|
||||||
|
|||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
package com.eu.habbo.networking.gameserver.auth;
|
||||||
|
|
||||||
|
import com.eu.habbo.Emulator;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
public final class AccessTokenService {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(AccessTokenService.class);
|
||||||
|
private static final SecureRandom RNG = new SecureRandom();
|
||||||
|
private static final Base64.Encoder URL_ENC = Base64.getUrlEncoder().withoutPadding();
|
||||||
|
private static final Base64.Decoder URL_DEC = Base64.getUrlDecoder();
|
||||||
|
|
||||||
|
private static volatile String cachedSecret = null;
|
||||||
|
|
||||||
|
private AccessTokenService() {}
|
||||||
|
|
||||||
|
public static final class Issued {
|
||||||
|
public final String token;
|
||||||
|
public final long expiresAt;
|
||||||
|
|
||||||
|
Issued(String token, long expiresAt) {
|
||||||
|
this.token = token;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long ttlSeconds() {
|
||||||
|
return Math.max(60L, Emulator.getConfig().getInt("login.access.jwt.ttl.seconds", 86400));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Issued issue(int userId) {
|
||||||
|
long now = Emulator.getIntUnixTimestamp();
|
||||||
|
long exp = now + ttlSeconds();
|
||||||
|
|
||||||
|
JsonObject header = new JsonObject();
|
||||||
|
header.addProperty("alg", "HS256");
|
||||||
|
header.addProperty("typ", "JWT");
|
||||||
|
|
||||||
|
JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("sub", userId);
|
||||||
|
payload.addProperty("iat", now);
|
||||||
|
payload.addProperty("exp", exp);
|
||||||
|
payload.addProperty("typ", "access");
|
||||||
|
|
||||||
|
String h = URL_ENC.encodeToString(header.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
String p = URL_ENC.encodeToString(payload.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
String signingInput = h + "." + p;
|
||||||
|
String sig = URL_ENC.encodeToString(hmacSha256(secret().getBytes(StandardCharsets.UTF_8),
|
||||||
|
signingInput.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
return new Issued(signingInput + "." + sig, exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int verify(String token) {
|
||||||
|
if (token == null || token.isEmpty()) return 0;
|
||||||
|
|
||||||
|
String[] parts = token.split("\\.");
|
||||||
|
if (parts.length != 3) return 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
String signingInput = parts[0] + "." + parts[1];
|
||||||
|
byte[] expected = hmacSha256(secret().getBytes(StandardCharsets.UTF_8),
|
||||||
|
signingInput.getBytes(StandardCharsets.UTF_8));
|
||||||
|
byte[] provided = URL_DEC.decode(parts[2]);
|
||||||
|
if (!constantTimeEquals(expected, provided)) return 0;
|
||||||
|
|
||||||
|
byte[] payloadBytes = URL_DEC.decode(parts[1]);
|
||||||
|
JsonObject payload = JsonParser.parseString(new String(payloadBytes, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||||
|
|
||||||
|
if (!payload.has("typ") || !"access".equals(payload.get("typ").getAsString())) return 0;
|
||||||
|
long exp = payload.get("exp").getAsLong();
|
||||||
|
if (exp <= Emulator.getIntUnixTimestamp()) return 0;
|
||||||
|
return payload.get("sub").getAsInt();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String secret() {
|
||||||
|
String s = cachedSecret;
|
||||||
|
if (s != null && !s.isEmpty()) return s;
|
||||||
|
|
||||||
|
synchronized (AccessTokenService.class) {
|
||||||
|
if (cachedSecret != null && !cachedSecret.isEmpty()) return cachedSecret;
|
||||||
|
|
||||||
|
String configured = Emulator.getConfig().getValue("login.access.jwt.secret", "");
|
||||||
|
if (configured != null && !configured.isEmpty()) {
|
||||||
|
cachedSecret = configured;
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] buf = new byte[48];
|
||||||
|
RNG.nextBytes(buf);
|
||||||
|
String generated = Base64.getEncoder().withoutPadding().encodeToString(buf);
|
||||||
|
|
||||||
|
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
|
||||||
|
PreparedStatement stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO emulator_settings (`key`, `value`) VALUES ('login.access.jwt.secret', ?) "
|
||||||
|
+ "ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)")) {
|
||||||
|
stmt.setString(1, generated);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
LOGGER.error("Could not persist generated login.access.jwt.secret; using in-memory only", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Emulator.getConfig().update("login.access.jwt.secret", generated);
|
||||||
|
cachedSecret = generated;
|
||||||
|
LOGGER.info("[auth/access] generated new access token signing secret (persisted to emulator_settings)");
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] hmacSha256(byte[] key, byte[] data) {
|
||||||
|
try {
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA256");
|
||||||
|
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||||
|
return mac.doFinal(data);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("HmacSHA256 unavailable", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean constantTimeEquals(byte[] a, byte[] b) {
|
||||||
|
if (a == null || b == null || a.length != b.length) return false;
|
||||||
|
int r = 0;
|
||||||
|
for (int i = 0; i < a.length; i++) r |= a[i] ^ b[i];
|
||||||
|
return r == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
private static final String REMEMBER_PATH = "/api/auth/remember";
|
private static final String REMEMBER_PATH = "/api/auth/remember";
|
||||||
private static final String REFRESH_PATH = "/api/auth/refresh";
|
private static final String REFRESH_PATH = "/api/auth/refresh";
|
||||||
private static final String SERVER_KEY_PATH = "/api/auth/server-key";
|
private static final String SERVER_KEY_PATH = "/api/auth/server-key";
|
||||||
|
private static final String SSO_TOKEN_PATH = "/api/auth/sso-token";
|
||||||
private static final String HEALTH_PATH = "/api/health";
|
private static final String HEALTH_PATH = "/api/health";
|
||||||
|
|
||||||
private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$");
|
private static final Pattern USERNAME_RE = Pattern.compile("^[A-Za-z0-9._-]{3,32}$");
|
||||||
@@ -62,6 +63,7 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
&& !path.equals(REMEMBER_PATH)
|
&& !path.equals(REMEMBER_PATH)
|
||||||
&& !path.equals(REFRESH_PATH)
|
&& !path.equals(REFRESH_PATH)
|
||||||
&& !path.equals(SERVER_KEY_PATH)
|
&& !path.equals(SERVER_KEY_PATH)
|
||||||
|
&& !path.equals(SSO_TOKEN_PATH)
|
||||||
&& !path.equals(HEALTH_PATH)) {
|
&& !path.equals(HEALTH_PATH)) {
|
||||||
super.channelRead(ctx, msg);
|
super.channelRead(ctx, msg);
|
||||||
return;
|
return;
|
||||||
@@ -174,6 +176,10 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
handleRefresh(ctx, req, body, ip);
|
handleRefresh(ctx, req, body, ip);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (path.equals(SSO_TOKEN_PATH)) {
|
||||||
|
handleSsoToken(ctx, req, body, ip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String turnstileToken = readString(body, "turnstileToken");
|
String turnstileToken = readString(body, "turnstileToken");
|
||||||
if (!TurnstileVerifier.verify(turnstileToken, ip)) {
|
if (!TurnstileVerifier.verify(turnstileToken, ip)) {
|
||||||
@@ -342,6 +348,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
ok.addProperty("username", rot.username);
|
ok.addProperty("username", rot.username);
|
||||||
ok.addProperty("rememberToken", rot.jwt);
|
ok.addProperty("rememberToken", rot.jwt);
|
||||||
ok.addProperty("expiresAt", rot.expiresAt);
|
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);
|
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOGGER.error("Remember login failed", e);
|
LOGGER.error("Remember login failed", e);
|
||||||
@@ -349,6 +358,42 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleSsoToken(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||||
|
String ssoTicket = readString(body, "ssoTicket").trim();
|
||||||
|
if (ssoTicket.isEmpty() || ssoTicket.length() > 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) {
|
private void handleRefresh(ChannelHandlerContext ctx, FullHttpRequest req, JsonObject body, String ip) {
|
||||||
String jwt = readString(body, "rememberToken").trim();
|
String jwt = readString(body, "rememberToken").trim();
|
||||||
if (jwt.isEmpty()) {
|
if (jwt.isEmpty()) {
|
||||||
@@ -365,6 +410,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
JsonObject ok = new JsonObject();
|
JsonObject ok = new JsonObject();
|
||||||
ok.addProperty("rememberToken", rot.jwt);
|
ok.addProperty("rememberToken", rot.jwt);
|
||||||
ok.addProperty("expiresAt", rot.expiresAt);
|
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);
|
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOGGER.error("Refresh failed", e);
|
LOGGER.error("Refresh failed", e);
|
||||||
@@ -456,6 +504,9 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
|
|||||||
ok.addProperty("ssoTicket", ssoTicket);
|
ok.addProperty("ssoTicket", ssoTicket);
|
||||||
ok.addProperty("username", rs.getString("username"));
|
ok.addProperty("username", rs.getString("username"));
|
||||||
if (rememberToken != null) ok.addProperty("rememberToken", rememberToken);
|
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);
|
sendJson(ctx, req, HttpResponseStatus.OK, ok);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+371
@@ -0,0 +1,371 @@
|
|||||||
|
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.eu.habbo.networking.gameserver.auth.AuthRateLimiter;
|
||||||
|
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;
|
||||||
|
|
||||||
|
private static volatile JsonObject cachedTextsResponse = null;
|
||||||
|
private static volatile long cachedTextsVersion = -1L;
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
String ip = resolveClientIp(ctx, req);
|
||||||
|
if (!AuthRateLimiter.tryProbe(ip)) {
|
||||||
|
long secs = AuthRateLimiter.secondsUntilProbeReset(ip);
|
||||||
|
sendJson(ctx, req, HttpResponseStatus.TOO_MANY_REQUESTS,
|
||||||
|
error("Too many requests. Try again in " + secs + "s."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
long version = manager.getTextCacheVersion();
|
||||||
|
JsonObject ok = cachedTextsResponse;
|
||||||
|
if (ok == null || cachedTextsVersion != version) {
|
||||||
|
java.util.Map<String, CustomBadgeManager.BadgeText> cache = manager.getTextCache();
|
||||||
|
JsonObject texts = new JsonObject();
|
||||||
|
for (java.util.Map.Entry<String, CustomBadgeManager.BadgeText> 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 built = new JsonObject();
|
||||||
|
built.add("texts", texts);
|
||||||
|
built.addProperty("count", cache.size());
|
||||||
|
built.addProperty("version", version);
|
||||||
|
cachedTextsResponse = built;
|
||||||
|
cachedTextsVersion = version;
|
||||||
|
ok = built;
|
||||||
|
}
|
||||||
|
sendJsonCached(ctx, req, HttpResponseStatus.OK, ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleList(ChannelHandlerContext ctx, FullHttpRequest req, int userId) {
|
||||||
|
CustomBadgeManager manager = Emulator.getGameEnvironment().getCustomBadgeManager();
|
||||||
|
List<CustomBadge> 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 sendJsonCached(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);
|
||||||
|
response.headers().set(HttpHeaderNames.CACHE_CONTROL, "public, max-age=30");
|
||||||
|
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 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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Reference in New Issue
Block a user