Compare commits

..

17 Commits

Author SHA1 Message Date
github-actions[bot] 585f4dd3aa 🆙 Bump version to 4.2.27 [skip ci] 2026-06-01 06:28:06 +00:00
DuckieTM afa114d511 Merge pull request #139 from duckietm/dev
Dev
2026-06-01 08:27:01 +02:00
DuckieTM 0aadd01493 Merge pull request #138 from simoleo89/feat/wheel-admin-add-remove
feat(wheel): add & remove fortune-wheel prizes from the editor
2026-05-31 15:45:10 +02:00
simoleo89 9d98fbf9ee feat(wheel): support adding & removing fortune-wheel prizes from the editor
The prize editor could only update existing rows; savePrize was UPDATE-only,
so the admin panel had no way to add a new slice or remove an old one.

- WheelManager.savePrize now takes a sortOrder and inserts when id <= 0
  (returning the generated id) or updates + re-enables when id > 0, so a
  previously removed prize can be brought back. sort_order is persisted to
  match the editor's display order.
- New WheelManager.disablePrizesNotIn(keptIds) soft-deletes (enabled = 0)
  any prize absent from the saved authoritative list. Non-destructive: rows
  stay in the table and loadPrizes already filters enabled = 1.
- WheelAdminSavePrizesEvent collects the saved ids and disables the rest
  before reloading.

No schema change (wheel_prizes already has enabled + sort_order) and no
packet change (id = 0 / omission express insert / delete on the existing
wire). Pairs with the Nitro-V3 client editor add/remove buttons.
2026-05-31 10:49:10 +02:00
DuckieTM b38274e134 Merge pull request #137 from medievalshell/Dev
fix(bans): persist client machine fingerprint so machine/super bans work
2026-05-31 10:37:54 +02:00
medievalshell 02ab30180c fix(chat): relay unknown chat bubble ids instead of resetting to default
getBubble() fell back to NORMAL (bubble 0) for any id not in the registered
BUBBLES map, so custom client-side chat bubbles (e.g. ids 253+) rendered as
the default bubble for everyone. Now unknown positive ids (<=1000) pass
through as a transient bubble carrying that id, so the server relays it and
clients render their own .bubble-<id> style. No need to enumerate each one.
2026-05-31 03:39:23 +02:00
medievalshell da63439d53 fix(bans): persist client machine fingerprint so machine/super bans work
The Nitro client already sends a strong machine fingerprint (Thumbmark,
"IID-<hash>") via the UniqueID packet (header 2490 -> MachineIDEvent), but
the emulator only stored it on the GameClient and never copied it onto the
Habbo's HabboInfo, so it was never written to users.machine_id. As a result
machine/super bans (which read users.machine_id) matched nobody.

- MachineIDEvent: when the fingerprint arrives and the Habbo is already
  loaded, copy it onto HabboInfo and persist (run the Habbo save).
- SecureLoginEvent: if the fingerprint arrived before login, copy it onto
  HabboInfo right before the login save.

This makes machine/super bans effective without changing the client.
2026-05-31 00:04:00 +02:00
github-actions[bot] bf1a29a6e8 🆙 Bump version to 4.2.26 [skip ci] 2026-05-30 05:53:48 +00:00
DuckieTM 6391d721ff Merge pull request #136 from duckietm/dev
Dev
2026-05-30 07:52:43 +02:00
DuckieTM dfea6bcf83 🆙 Updated SQL 2026-05-30 07:52:02 +02:00
DuckieTM a7f207bb76 Merge pull request #134 from medievalshell/Dev
feat: persist `scale` for room ads / branding furni
2026-05-30 07:13:59 +02:00
duckietm b7915884b6 🆙 Update Rare-Value page 2026-05-29 08:28:01 +02:00
medievalshell 478f7bdba0 feat/fix: RCON wheel+soundboard reload, robust SSO reconnect behind Cloudflare
- RCON: add updatewheel/updatesoundboard (reload WheelManager/SoundboardManager live) so the CMS admin pages apply changes without an emulator restart.

- SSO ticket is no longer single-use: loadHabbo, session-resume and performFullDisconnect no longer clear auth_ticket. Behind Cloudflare the WS is dropped and the client retries with the same ticket; clearing it caused 'non-existing SSO token' and the 'refresh twice' / kicked-on-reconnect symptoms. The ticket now lives until its TTL (auth_ticket_expires_at), is overwritten by the CMS on the next /client load, or cleared on logout.

- SessionResume: restoreSsoTicket only restores when auth_ticket is empty (don't clobber a fresh CMS ticket); GameClient.dispose only parks/disconnects when the habbo is still attached to this client (a fast reconnect may have re-attached it to the new connection).
2026-05-29 04:45:34 +02:00
medievalshell c255f1e1b4 fix: guard RoomBundleLayout against null RoomManager during catalog init
CatalogManager.loadFurnitureValues() (rare-values feature) iterates every catalog page during GameEnvironment.load(); for a RoomBundleLayout this calls getRoomManager().loadRoom(), but RoomManager is constructed after CatalogManager so getRoomManager() returns null -> NullPointerException -> boot aborts. Null-guard the room load so the bundle resolves lazily at runtime instead.
2026-05-29 00:45:02 +02:00
medievalshell 9c831a9da4 feat: grant acc_wheeladmin to staff ranks for the wheel prize editor
The wheel prize editor is gated on acc_wheeladmin (client Settings button +
server WheelAdmin{Get,Save}PrizesEvent). Upstream 008_soundboard_fortune_wheel
registers the key but only grants rank_7 (its 7-rank hotel). This portable,
idempotent migration grants it to the same ranks as acc_ads_background via
dynamic SQL over the per-rank columns — no hardcoded rank ids. Apply then
:update_permissions or restart.
2026-05-28 22:47:15 +02:00
Medievalshell 08d1ae97a7 Merge branch 'duckietm:main' into Dev 2026-05-28 22:16:17 +02:00
medievalshell 8dd5155562 feat: persist scale for room ads / branding furni
InteractionRoomAds now carries a `scale` default value (100) alongside
imageUrl/clickUrl/offsetX/Y/Z, so the image zoom set in the client's
position editor is stored and broadcast like the other branding fields.
2026-05-28 15:30:33 +02:00
19 changed files with 337 additions and 112 deletions
@@ -1,7 +1,5 @@
-- Soundboard ALTER TABLE `rooms`
-- The room flag column + sounds table are also created at boot by ADD COLUMN IF NOT EXISTS `soundboard_enabled` TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE `rooms` ADD COLUMN IF NOT EXISTS `soundboard_enabled` TINYINT(1) NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS `soundboard_sounds` ( CREATE TABLE IF NOT EXISTS `soundboard_sounds` (
`id` INT(11) NOT NULL AUTO_INCREMENT, `id` INT(11) NOT NULL AUTO_INCREMENT,
@@ -12,11 +10,9 @@ CREATE TABLE IF NOT EXISTS `soundboard_sounds` (
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------------------------------------------------------
-- Fortune Wheel -- Fortune Wheel — tables
-- Tables are also created at boot by WheelManager (CREATE TABLE IF NOT EXISTS), -- ----------------------------------------------------------------------------
-- so applying this file is only needed to seed prizes + settings.
CREATE TABLE IF NOT EXISTS `wheel_prizes` ( CREATE TABLE IF NOT EXISTS `wheel_prizes` (
`id` INT(11) NOT NULL AUTO_INCREMENT, `id` INT(11) NOT NULL AUTO_INCREMENT,
`type` VARCHAR(16) NOT NULL DEFAULT 'nothing', -- item | badge | credits | points | spin | nothing `type` VARCHAR(16) NOT NULL DEFAULT 'nothing', -- item | badge | credits | points | spin | nothing
@@ -45,34 +41,49 @@ CREATE TABLE IF NOT EXISTS `wheel_recent_wins` (
`look` VARCHAR(255) NOT NULL DEFAULT '', `look` VARCHAR(255) NOT NULL DEFAULT '',
`prize_label` VARCHAR(64) NOT NULL DEFAULT '', `prize_label` VARCHAR(64) NOT NULL DEFAULT '',
`won_at` INT(11) NOT NULL DEFAULT 0, `won_at` INT(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`), PRIMARY KEY (`id`)
KEY `idx_wheel_recent_wins_id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `emulator_settings` (`key`, `value`, `comment`) VALUES INSERT INTO `emulator_settings` (`key`, `value`, `comment`) VALUES
('wheel.free_spins_per_day', '1', 'Fortune wheel: free spins granted each day.') ('wheel.free_spins_per_day', '1', 'Fortune wheel: free spins granted each day.'),
ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`); ('wheel.spin_cost', '50', 'Fortune wheel: cost of one extra spin.'),
INSERT INTO `emulator_settings` (`key`, `value`, `comment`) VALUES
('wheel.spin_cost', '50', 'Fortune wheel: cost of one extra spin.')
ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`);
INSERT INTO `emulator_settings` (`key`, `value`, `comment`) VALUES
('wheel.spin_cost_type', '5', 'Fortune wheel: currency type for the spin cost (5 = diamonds; -1 = credits).') ('wheel.spin_cost_type', '5', 'Fortune wheel: currency type for the spin cost (5 = diamonds; -1 = credits).')
ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`); ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`);
INSERT INTO `wheel_prizes` (`type`, `amount`, `points_type`, `weight`, `label`, `sort_order`)
SELECT `type`, `amount`, `points_type`, `weight`, `label`, `sort_order`
FROM (
SELECT 'points' AS `type`, 25 AS `amount`, 5 AS `points_type`, 20 AS `weight`, '25 diamonds' AS `label`, 1 AS `sort_order`
UNION ALL SELECT 'points', 50, 5, 12, '50 diamonds', 2
UNION ALL SELECT 'points', 200, 5, 3, '200 diamonds', 3
UNION ALL SELECT 'credits', 100, 0, 15, '100 credits', 4
UNION ALL SELECT 'spin', 1, 0, 15, '1 Extra spin', 5
UNION ALL SELECT 'spin', 2, 0, 6, '2 Extra spins', 6
UNION ALL SELECT 'nothing', 0, 0, 29, 'Oh to bad!', 7
) AS seed
WHERE NOT EXISTS (SELECT 1 FROM `wheel_prizes`);
INSERT INTO `wheel_prizes` (`type`, `amount`, `points_type`, `weight`, `label`, `sort_order`) VALUES INSERT IGNORE INTO `permission_definitions` (`permission_key`, `max_value`, `comment`)
('points',25, 5, 20, '25 diamonds',1), VALUES (
('points',50, 5, 12, '50 diamonds',2), 'acc_wheeladmin',
('points',200, 5, 3, '200 diamonds',3), 1,
('credits',100, 0, 15, '100 credits',4), 'Allows opening the Fortune Wheel prize editor (FortuneWheelSettingsView) to add/edit prize slices. Gated server-side by the same key.'
('spin',1, 0, 15, '1 Extra spin', 5), );
('spin',2, 0, 6, '2 Extra spins',6),
('nothing',0, 0, 29, 'Oh to bad!',7);
INSERT INTO `permission_definitions` SET @cols := NULL;
(`permission_key`, `max_value`, `comment`, SELECT GROUP_CONCAT(CONCAT('dst.`', `column_name`, '` = src.`', `column_name`, '`') SEPARATOR ', ')
`rank_1`, `rank_2`, `rank_3`, `rank_4`, `rank_5`, `rank_6`, `rank_7`) INTO @cols
VALUES FROM `information_schema`.`columns`
('acc_wheeladmin', 1, 'Required to open the Fortune Wheel settings popup and edit prize rows.', WHERE `table_schema` = DATABASE()
0, 0, 0, 0, 0, 0, 1) AND `table_name` = 'permission_definitions'
ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`); AND `column_name` REGEXP '^rank_[0-9]+$';
SET @sql := CONCAT(
'UPDATE `permission_definitions` dst ',
'JOIN `permission_definitions` src ON src.`permission_key` = ''acc_ads_background'' ',
'SET ', @cols, ' ',
'WHERE dst.`permission_key` = ''acc_wheeladmin'''
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+1 -1
View File
@@ -6,7 +6,7 @@
<groupId>com.eu.habbo</groupId> <groupId>com.eu.habbo</groupId>
<artifactId>Habbo</artifactId> <artifactId>Habbo</artifactId>
<version>4.2.25</version> <version>4.2.27</version>
<properties> <properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -202,8 +202,8 @@ public class CatalogManager {
public final Item ecotronItem; public final Item ecotronItem;
public final THashMap<Integer, CatalogLimitedConfiguration> limitedNumbers; public final THashMap<Integer, CatalogLimitedConfiguration> limitedNumbers;
private final List<Voucher> vouchers; private final List<Voucher> vouchers;
// spriteId -> [credits, points, pointsType], derived from catalog_items (see loadFurnitureValues)
public final TIntObjectMap<int[]> furnitureValues; public final TIntObjectMap<int[]> furnitureValues;
private volatile byte[] rareValuesPayloadCache;
public CatalogManager() { public CatalogManager() {
long millis = System.currentTimeMillis(); long millis = System.currentTimeMillis();
@@ -249,10 +249,6 @@ public class CatalogManager {
this.loadFurnitureValues(); this.loadFurnitureValues();
} }
// Builds spriteId -> [credits, points, pointsType] from catalog_items so the
// client can show a furni's "value" (toolbar price guide + infostand line).
// Only single-item, single-amount FLOOR/WALL sales are considered, so bundles
// and multi-packs don't pollute the per-rare price. First clean entry wins.
private synchronized void loadFurnitureValues() { private synchronized void loadFurnitureValues() {
this.furnitureValues.clear(); this.furnitureValues.clear();
final int diamondType = Emulator.getConfig().getInt("seasonal.currency.diamond", 5); final int diamondType = Emulator.getConfig().getInt("seasonal.currency.diamond", 5);
@@ -266,8 +262,6 @@ public class CatalogManager {
int points = catalogItem.getPoints(); int points = catalogItem.getPoints();
int pointsType = catalogItem.getPointsType(); int pointsType = catalogItem.getPointsType();
// Only diamond-priced items — both the "Valore Rari" panel and the
// infostand value line show diamonds only.
if (points <= 0 || pointsType != diamondType) if (points <= 0 || pointsType != diamondType)
continue; continue;
@@ -291,13 +285,39 @@ public class CatalogManager {
} }
} }
this.rebuildRareValuesPayloadCache();
LOGGER.info("Furniture Values -> Loaded! ({} entries)", this.furnitureValues.size()); LOGGER.info("Furniture Values -> Loaded! ({} entries)", this.furnitureValues.size());
} }
private void rebuildRareValuesPayloadCache() {
try (java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(this.furnitureValues.size() * 16 + 8);
java.io.DataOutputStream out = new java.io.DataOutputStream(baos)) {
out.writeInt(this.furnitureValues.size());
TIntObjectIterator<int[]> iterator = this.furnitureValues.iterator();
while (iterator.hasNext()) {
iterator.advance();
int[] value = iterator.value();
out.writeInt(iterator.key()); // spriteId
out.writeInt(value[0]); // credits
out.writeInt(value[1]); // points
out.writeInt(value[2]); // pointsType
}
this.rareValuesPayloadCache = baos.toByteArray();
} catch (java.io.IOException e) {
LOGGER.error("Failed to build rare values payload cache", e);
this.rareValuesPayloadCache = null;
}
}
public TIntObjectMap<int[]> getFurnitureValues() { public TIntObjectMap<int[]> getFurnitureValues() {
return this.furnitureValues; return this.furnitureValues;
} }
public byte[] getRareValuesPayloadSnapshot() {
return this.rareValuesPayloadCache;
}
private synchronized void loadLimitedNumbers() { private synchronized void loadLimitedNumbers() {
this.limitedNumbers.clear(); this.limitedNumbers.clear();
@@ -1104,9 +1124,6 @@ public class CatalogManager {
type = type.replace("bot_", ""); type = type.replace("bot_", "");
type = type.replace("visitor_logger", "visitor_log"); type = type.replace("visitor_logger", "visitor_log");
// Permission gate keyed on the canonical base-item name
// (admin-controlled but stable), not the catalog page name
// which can be renamed and bypass the check.
if (("bot_" + com.eu.habbo.habbohotel.bots.FrankBot.BOT_TYPE).equals(baseName) if (("bot_" + com.eu.habbo.habbohotel.bots.FrankBot.BOT_TYPE).equals(baseName)
|| ("rentable_bot_" + com.eu.habbo.habbohotel.bots.FrankBot.BOT_TYPE).equals(baseName)) { || ("rentable_bot_" + com.eu.habbo.habbohotel.bots.FrankBot.BOT_TYPE).equals(baseName)) {
if (!habbo.getClient().getHabbo().hasPermission(com.eu.habbo.habbohotel.bots.FrankBot.PERMISSION_USE)) { if (!habbo.getClient().getHabbo().hasPermission(com.eu.habbo.habbohotel.bots.FrankBot.PERMISSION_USE)) {
@@ -42,14 +42,18 @@ public class RoomBundleLayout extends SingleBundle {
} }
if (this.room == null) { if (this.room == null) {
if (this.roomId > 0) { RoomManager roomManager = Emulator.getGameEnvironment().getRoomManager();
this.room = Emulator.getGameEnvironment().getRoomManager().loadRoom(this.roomId); if (this.roomId > 0 && roomManager != null) {
this.room = roomManager.loadRoom(this.roomId);
if (this.room != null) if (this.room != null)
this.room.preventUnloading = true; this.room.preventUnloading = true;
} else { } else if (this.roomId <= 0) {
LOGGER.error("No room id specified for room bundle {}({})", this.getPageName(), this.getId()); LOGGER.error("No room id specified for room bundle {}({})", this.getPageName(), this.getId());
} }
// roomManager can be null when CatalogManager.loadFurnitureValues() runs
// during GameEnvironment.load() before RoomManager is constructed; in that
// case skip eager room loading — the bundle resolves lazily at runtime.
} }
if (this.room == null) { if (this.room == null) {
@@ -153,7 +153,13 @@ public class GameClient {
this.channel.close(); this.channel.close();
if (this.habbo != null) { if (this.habbo != null) {
if (this.habbo.isOnline()) { // Agisci sull'Habbo SOLO se è ancora attaccato a QUESTO client. Su un
// reconnect veloce (drop Cloudflare → il client riconnette) l'Habbo può
// essere già stato riassegnato alla NUOVA connessione (session resume):
// in quel caso questo dispose della vecchia connessione NON deve
// parcheggiarlo né disconnetterlo, altrimenti ucciderebbe la sessione
// appena ripristinata (era la causa del "Bye"/kick al 2° reconnect).
if (this.habbo.getClient() == this && this.habbo.isOnline()) {
// Try to park the habbo in the grace period instead of immediate disconnect // Try to park the habbo in the grace period instead of immediate disconnect
boolean parked = SessionResumeManager.getInstance().parkHabbo(this.habbo, this.ssoTicket); boolean parked = SessionResumeManager.getInstance().parkHabbo(this.habbo, this.ssoTicket);
@@ -118,16 +118,32 @@ public class SessionResumeManager {
LOGGER.error("[SessionResume] Error during deferred disconnect", e); LOGGER.error("[SessionResume] Error during deferred disconnect", e);
} }
clearSsoTicket(habbo.getHabboInfo().getId()); // NON svuotare il ticket SSO qui. Dietro Cloudflare la pagina si ricarica
// lentamente (~15s) e la grace (5s) scade prima che la nuova connessione
// arrivi: svuotando il ticket si cancellava quello NUOVO appena scritto dal
// CMS per il refresh → "non-existing SSO token" → bisognava refreshare 2 volte.
// Il ticket vive col suo TTL (auth_ticket_expires_at) e viene sovrascritto dal
// CMS al prossimo /client o azzerato al logout.
} }
private void restoreSsoTicket(int userId, String ssoTicket) { private void restoreSsoTicket(int userId, String ssoTicket) {
// Restore the old ticket ONLY if no fresh ticket has been written in the
// meantime. On a hard-refresh the CMS writes a NEW auth_ticket for the same
// user before this parking restore runs; without the guard we'd clobber it
// with the old ticket, so the new connection's SSO wouldn't be found and the
// client would get "session expired" on the first attempt. The guard means:
// normal reconnect (ticket cleared to '' after login) -> restore; hard-refresh
// (CMS already wrote a new ticket) -> leave the new ticket untouched.
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 = ? AND (auth_ticket = '' OR auth_ticket IS NULL) LIMIT 1")) {
statement.setString(1, ssoTicket); statement.setString(1, ssoTicket);
statement.setInt(2, userId); statement.setInt(2, userId);
statement.execute(); int updated = statement.executeUpdate();
if (updated > 0) {
LOGGER.info("[SessionResume] Restored SSO ticket for user {} during grace period", userId); LOGGER.info("[SessionResume] Restored SSO ticket for user {} during grace period", userId);
} else {
LOGGER.info("[SessionResume] Skipped SSO restore for user {} — a newer ticket is already present (likely a fresh login/hard-refresh)", userId);
}
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("[SessionResume] Failed to restore SSO ticket for user " + userId, e); LOGGER.error("[SessionResume] Failed to restore SSO ticket for user " + userId, e);
} }
@@ -29,6 +29,10 @@ public class InteractionRoomAds extends InteractionCustomValues {
{ {
this.put("offsetZ", "0"); this.put("offsetZ", "0");
} }
{
this.put("scale", "100");
}
}; };
public InteractionRoomAds(ResultSet set, Item baseItem) throws SQLException { public InteractionRoomAds(ResultSet set, Item baseItem) throws SQLException {
@@ -134,7 +134,18 @@ public class RoomChatMessageBubbles {
} }
public static RoomChatMessageBubbles getBubble(int id) { public static RoomChatMessageBubbles getBubble(int id) {
return BUBBLES.getOrDefault(id, NORMAL); RoomChatMessageBubbles bubble = BUBBLES.get(id);
if (bubble != null) return bubble;
// Custom chat bubbles (client-side only, e.g. ids 253+) are not registered
// above. Instead of falling back to NORMAL (which made them render as the
// default bubble), pass the id through so the server relays it as-is and
// the client renders its own .bubble-<id> style. Capped to avoid abuse.
if (id > 0 && id <= 1000) {
return new RoomChatMessageBubbles(id, "CUSTOM_" + id, "", true, false);
}
return NORMAL;
} }
private static void registerBubble(RoomChatMessageBubbles bubble) { private static void registerBubble(RoomChatMessageBubbles bubble) {
@@ -132,15 +132,12 @@ public class HabboManager {
Emulator.getPluginManager().fireEvent(new UserRegisteredEvent(habbo)); Emulator.getPluginManager().fireEvent(new UserRegisteredEvent(habbo));
} }
if (!Emulator.debugging) { // NB: il ticket SSO NON viene svuotato qui di proposito. Dietro
try (PreparedStatement stmt = connection.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? LIMIT 1")) { // Cloudflare il WebSocket viene droppato e il client ritenta più
stmt.setString(1, ""); // volte con lo STESSO ticket: se lo consumassimo al primo uso, i
stmt.setInt(2, habbo.getHabboInfo().getId()); // retry (e l'hard-refresh) fallirebbero con "non-existing SSO token".
stmt.execute(); // Il ticket resta valido fino alla scadenza (auth_ticket_expires_at,
} catch (SQLException e) { // TTL gestito dal CMS) o finché il CMS non ne scrive uno nuovo / logout.
LOGGER.error("Caught SQL exception", e);
}
}
} }
} }
} catch (SQLException e) { } catch (SQLException e) {
@@ -12,9 +12,11 @@ import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.StringJoiner;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
@@ -330,26 +332,88 @@ public class WheelManager {
return true; return true;
} }
public void savePrize(int id, String type, String value, int amount, int pointsType, int weight, String label) { /**
* Persists a single prize. An {@code id <= 0} inserts a brand-new prize and
* returns its generated id; a positive id updates the existing row (and
* re-enables it, so a previously soft-deleted prize can be brought back).
* {@code sortOrder} reflects the prize's position in the editor so the
* wheel layout matches what the admin sees. Returns the effective row id,
* or {@code 0} if the write failed.
*/
public int savePrize(int id, String type, String value, int amount, int pointsType, int weight, String label, int sortOrder) {
String safeType = (type != null && VALID_PRIZE_TYPES.contains(type)) ? type : "nothing"; String safeType = (type != null && VALID_PRIZE_TYPES.contains(type)) ? type : "nothing";
String safeValue = truncate(value, MAX_STRING_LEN); String safeValue = truncate(value, MAX_STRING_LEN);
String safeLabel = truncate(label, MAX_STRING_LEN); String safeLabel = truncate(label, MAX_STRING_LEN);
int safeAmount = clamp(amount, 0, MAX_PRIZE_AMOUNT); int safeAmount = clamp(amount, 0, MAX_PRIZE_AMOUNT);
int safeWeight = clamp(weight, 0, MAX_WEIGHT); int safeWeight = clamp(weight, 0, MAX_WEIGHT);
int safeSort = clamp(sortOrder, 0, MAX_PRIZES_PER_SAVE);
if (id > 0) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement( PreparedStatement statement = connection.prepareStatement(
"UPDATE wheel_prizes SET type = ?, value = ?, amount = ?, points_type = ?, weight = ?, label = ? WHERE id = ?")) { "UPDATE wheel_prizes SET type = ?, value = ?, amount = ?, points_type = ?, weight = ?, label = ?, sort_order = ?, enabled = 1 WHERE id = ?")) {
statement.setString(1, safeType); statement.setString(1, safeType);
statement.setString(2, safeValue); statement.setString(2, safeValue);
statement.setInt(3, safeAmount); statement.setInt(3, safeAmount);
statement.setInt(4, pointsType); statement.setInt(4, pointsType);
statement.setInt(5, safeWeight); statement.setInt(5, safeWeight);
statement.setString(6, safeLabel); statement.setString(6, safeLabel);
statement.setInt(7, id); statement.setInt(7, safeSort);
statement.setInt(8, id);
statement.executeUpdate(); statement.executeUpdate();
return id;
} catch (SQLException e) { } catch (SQLException e) {
LOGGER.error("Failed to save wheel prize {}", id, e); LOGGER.error("Failed to save wheel prize {}", id, e);
return 0;
}
}
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO wheel_prizes (type, value, amount, points_type, weight, label, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, 1, ?)",
Statement.RETURN_GENERATED_KEYS)) {
statement.setString(1, safeType);
statement.setString(2, safeValue);
statement.setInt(3, safeAmount);
statement.setInt(4, pointsType);
statement.setInt(5, safeWeight);
statement.setString(6, safeLabel);
statement.setInt(7, safeSort);
statement.executeUpdate();
try (ResultSet keys = statement.getGeneratedKeys()) {
if (keys.next()) return keys.getInt(1);
}
} catch (SQLException e) {
LOGGER.error("Failed to insert wheel prize", e);
}
return 0;
}
/**
* Soft-deletes every enabled prize whose id is not in {@code keptIds} by
* setting {@code enabled = 0}. This is intentionally non-destructive: rows
* stay in the table (so historical references and re-enabling remain
* possible) but {@link #loadPrizes()} only ever loads {@code enabled = 1}.
* An empty set disables all prizes.
*/
public void disablePrizesNotIn(Set<Integer> keptIds) {
if (keptIds == null) return;
StringBuilder sql = new StringBuilder("UPDATE wheel_prizes SET enabled = 0 WHERE enabled = 1");
if (!keptIds.isEmpty()) {
StringJoiner ids = new StringJoiner(",", " AND id NOT IN (", ")");
for (Integer keptId : keptIds) {
ids.add(Integer.toString(keptId));
}
sql.append(ids);
}
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(sql.toString())) {
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to disable removed wheel prizes", e);
} }
} }
@@ -1,5 +1,6 @@
package com.eu.habbo.messages.incoming.handshake; package com.eu.habbo.messages.incoming.handshake;
import com.eu.habbo.Emulator;
import com.eu.habbo.messages.NoAuthMessage; import com.eu.habbo.messages.NoAuthMessage;
import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.incoming.MessageHandler;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -24,6 +25,15 @@ public class MachineIDEvent extends MessageHandler {
this.client.setMachineId(storedMachineId); this.client.setMachineId(storedMachineId);
// Persist the machine fingerprint onto the user so machine/super bans can
// target it (createOfflineUserBan copies users.machine_id). The Nitro client
// sends this UniqueID packet right after the SSO ticket, so the Habbo is
// normally already loaded by the time we get here.
if (!storedMachineId.isEmpty() && this.client.getHabbo() != null && this.client.getHabbo().getHabboInfo() != null) {
this.client.getHabbo().getHabboInfo().setMachineID(storedMachineId);
Emulator.getThreading().run(this.client.getHabbo());
}
LOGGER.debug("Setting client MachineId to {}", storedMachineId); LOGGER.debug("Setting client MachineId to {}", storedMachineId);
} }
} }
@@ -133,17 +133,10 @@ public class SecureLoginEvent extends MessageHandler {
this.client.setHabbo(habbo); this.client.setHabbo(habbo);
this.client.setMachineId(habbo.getHabboInfo().getMachineID()); this.client.setMachineId(habbo.getHabboInfo().getMachineID());
// Clear the SSO ticket now that session is resumed (prevent reuse) // NB: NON svuotiamo il ticket SSO qui (vedi HabboManager.loadHabbo):
if (!Emulator.debugging) { // dietro Cloudflare il client ritenta la connessione con lo stesso
try (java.sql.Connection conn = Emulator.getDatabase().getDataSource().getConnection(); // ticket, quindi deve restare valido fino alla scadenza TTL. Consumarlo
java.sql.PreparedStatement stmt = conn.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? LIMIT 1")) { // farebbe fallire i retry / l'hard-refresh con "non-existing SSO token".
stmt.setString(1, "");
stmt.setInt(2, habbo.getHabboInfo().getId());
stmt.execute();
} catch (Exception e) {
LOGGER.error("Failed to clear SSO ticket after session resume", e);
}
}
} else { } else {
// Normal login — load from database // Normal login — load from database
habbo = Emulator.getGameEnvironment().getHabboManager().loadHabbo(sso); habbo = Emulator.getGameEnvironment().getHabboManager().loadHabbo(sso);
@@ -168,6 +161,12 @@ public class SecureLoginEvent extends MessageHandler {
throw new NullPointerException(habbo.getHabboInfo().getUsername() + " has a NON EXISTING RANK!"); throw new NullPointerException(habbo.getHabboInfo().getUsername() + " has a NON EXISTING RANK!");
} }
// If the machine fingerprint already arrived (UniqueID before login),
// persist it so machine/super bans can target this user.
if (this.client.getMachineId() != null && !this.client.getMachineId().isEmpty()) {
this.client.getHabbo().getHabboInfo().setMachineID(this.client.getMachineId());
}
Emulator.getThreading().run(habbo); Emulator.getThreading().run(habbo);
Emulator.getGameEnvironment().getHabboManager().addHabbo(habbo); Emulator.getGameEnvironment().getHabboManager().addHabbo(habbo);
} catch (Exception e) { } catch (Exception e) {
@@ -1,11 +1,10 @@
package com.eu.habbo.messages.incoming.rarevalues; package com.eu.habbo.messages.incoming.rarevalues;
import com.eu.habbo.Emulator; import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.catalog.CatalogManager;
import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.rarevalues.RareValuesComposer; import com.eu.habbo.messages.outgoing.rarevalues.RareValuesComposer;
// Client requests the furni value map once on load. Public info (catalog prices),
// no permission gate. Rate limited since the payload is large.
public class RequestRareValuesEvent extends MessageHandler { public class RequestRareValuesEvent extends MessageHandler {
@Override @Override
public int getRatelimit() { public int getRatelimit() {
@@ -14,8 +13,15 @@ public class RequestRareValuesEvent extends MessageHandler {
@Override @Override
public void handle() throws Exception { public void handle() throws Exception {
this.client.sendResponse(new RareValuesComposer( if (this.client.getHabbo() == null) return;
Emulator.getGameEnvironment().getCatalogManager().getFurnitureValues()
)); CatalogManager catalog = Emulator.getGameEnvironment().getCatalogManager();
byte[] snapshot = catalog.getRareValuesPayloadSnapshot();
if (snapshot != null) {
this.client.sendResponse(new RareValuesComposer(snapshot));
return;
}
this.client.sendResponse(new RareValuesComposer(catalog.getFurnitureValues()));
} }
} }
@@ -6,6 +6,9 @@ import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.wheel.WheelAdminPrizesComposer; import com.eu.habbo.messages.outgoing.wheel.WheelAdminPrizesComposer;
import com.eu.habbo.messages.outgoing.wheel.WheelDataComposer; import com.eu.habbo.messages.outgoing.wheel.WheelDataComposer;
import java.util.HashSet;
import java.util.Set;
public class WheelAdminSavePrizesEvent extends MessageHandler { public class WheelAdminSavePrizesEvent extends MessageHandler {
public static final String PERMISSION_KEY = "acc_wheeladmin"; public static final String PERMISSION_KEY = "acc_wheeladmin";
@@ -25,6 +28,12 @@ public class WheelAdminSavePrizesEvent extends MessageHandler {
int count = this.packet.readInt(); int count = this.packet.readInt();
if (count <= 0 || count > WheelManager.MAX_PRIZES_PER_SAVE) return; if (count <= 0 || count > WheelManager.MAX_PRIZES_PER_SAVE) return;
// The client sends the full authoritative list of prizes in display
// order. id <= 0 means "insert a new prize"; any existing prize whose
// id is absent from this list was removed in the editor and gets
// soft-disabled below.
Set<Integer> keptIds = new HashSet<>();
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
int id = this.packet.readInt(); int id = this.packet.readInt();
String type = this.packet.readString(); String type = this.packet.readString();
@@ -33,9 +42,11 @@ public class WheelAdminSavePrizesEvent extends MessageHandler {
int pointsType = this.packet.readInt(); int pointsType = this.packet.readInt();
int weight = this.packet.readInt(); int weight = this.packet.readInt();
String label = this.packet.readString(); String label = this.packet.readString();
wheel.savePrize(id, type, value, amount, pointsType, weight, label); int savedId = wheel.savePrize(id, type, value, amount, pointsType, weight, label, i);
if (savedId > 0) keptIds.add(savedId);
} }
wheel.disablePrizesNotIn(keptIds);
wheel.reload(); wheel.reload();
this.client.sendResponse(new WheelAdminPrizesComposer(wheel.getPrizes())); this.client.sendResponse(new WheelAdminPrizesComposer(wheel.getPrizes()));
@@ -6,18 +6,29 @@ import com.eu.habbo.messages.outgoing.Outgoing;
import gnu.trove.iterator.TIntObjectIterator; import gnu.trove.iterator.TIntObjectIterator;
import gnu.trove.map.TIntObjectMap; import gnu.trove.map.TIntObjectMap;
// Sends the full spriteId -> value map to the client. Consumed by the toolbar
// price guide and the furni infostand "value" line. See CatalogManager#loadFurnitureValues.
public class RareValuesComposer extends MessageComposer { public class RareValuesComposer extends MessageComposer {
private final TIntObjectMap<int[]> values; private final TIntObjectMap<int[]> values;
private final byte[] snapshot;
public RareValuesComposer(byte[] snapshot) {
this.values = null;
this.snapshot = snapshot;
}
public RareValuesComposer(TIntObjectMap<int[]> values) { public RareValuesComposer(TIntObjectMap<int[]> values) {
this.values = values; this.values = values;
this.snapshot = null;
} }
@Override @Override
protected ServerMessage composeInternal() { protected ServerMessage composeInternal() {
this.response.init(Outgoing.RareValuesComposer); this.response.init(Outgoing.RareValuesComposer);
if (this.snapshot != null) {
this.response.appendRawBytes(this.snapshot);
return this.response;
}
this.response.appendInt(this.values.size()); this.response.appendInt(this.values.size());
TIntObjectIterator<int[]> iterator = this.values.iterator(); TIntObjectIterator<int[]> iterator = this.values.iterator();
@@ -0,0 +1,21 @@
package com.eu.habbo.messages.rcon;
import com.eu.habbo.Emulator;
import com.google.gson.Gson;
// Ricarica i suoni della Soundboard dal DB (live), così i suoni aggiunti/caricati
// dal CMS (/admin/soundboard) si applicano senza riavviare l'emulatore.
public class UpdateSoundboard extends RCONMessage<UpdateSoundboard.SoundboardJSON> {
public UpdateSoundboard() {
super(SoundboardJSON.class);
}
@Override
public void handle(Gson gson, SoundboardJSON object) {
Emulator.getGameEnvironment().getSoundboardManager().reload();
}
static class SoundboardJSON {
}
}
@@ -0,0 +1,21 @@
package com.eu.habbo.messages.rcon;
import com.eu.habbo.Emulator;
import com.google.gson.Gson;
// Ricarica i premi/settings della Ruota della Fortuna dal DB (live), così le
// modifiche fatte dal CMS (/admin/wheel) si applicano senza riavviare l'emulatore.
public class UpdateWheel extends RCONMessage<UpdateWheel.WheelJSON> {
public UpdateWheel() {
super(WheelJSON.class);
}
@Override
public void handle(Gson gson, WheelJSON object) {
Emulator.getGameEnvironment().getWheelManager().reload();
}
static class WheelJSON {
}
}
@@ -11,6 +11,7 @@ import io.netty.handler.codec.DecoderException;
import io.netty.handler.codec.TooLongFrameException; import io.netty.handler.codec.TooLongFrameException;
import io.netty.handler.codec.UnsupportedMessageTypeException; import io.netty.handler.codec.UnsupportedMessageTypeException;
import io.netty.handler.ssl.NotSslRecordException; import io.netty.handler.ssl.NotSslRecordException;
import io.netty.util.ReferenceCountUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -39,6 +40,19 @@ public class GameMessageHandler extends ChannelInboundHandlerAdapter {
@Override @Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (!(msg instanceof ClientMessage)) {
try {
if (Emulator.getConfig().getBoolean("debug.mode")) {
LOGGER.debug("Discarding non-game message {} from {}",
msg.getClass().getSimpleName(), ctx.channel().remoteAddress());
}
} finally {
ReferenceCountUtil.release(msg);
ctx.channel().close();
}
return;
}
ClientMessage message = (ClientMessage) msg; ClientMessage message = (ClientMessage) msg;
try { try {
@@ -45,6 +45,8 @@ public class RCONServer extends Server {
this.addRCONMessage("sendroombundle", SendRoomBundle.class); this.addRCONMessage("sendroombundle", SendRoomBundle.class);
this.addRCONMessage("setrank", SetRank.class); this.addRCONMessage("setrank", SetRank.class);
this.addRCONMessage("updatewordfilter", UpdateWordfilter.class); this.addRCONMessage("updatewordfilter", UpdateWordfilter.class);
this.addRCONMessage("updatewheel", UpdateWheel.class);
this.addRCONMessage("updatesoundboard", UpdateSoundboard.class);
this.addRCONMessage("updatecatalog", UpdateCatalog.class); this.addRCONMessage("updatecatalog", UpdateCatalog.class);
this.addRCONMessage("executecommand", ExecuteCommand.class); this.addRCONMessage("executecommand", ExecuteCommand.class);
this.addRCONMessage("progressachievement", ProgressAchievement.class); this.addRCONMessage("progressachievement", ProgressAchievement.class);