Compare commits

...

37 Commits

Author SHA1 Message Date
github-actions[bot] d95e09e64f 🆙 Bump version to 4.2.42 [skip ci] 2026-06-10 13:10:40 +00:00
DuckieTM ebe0690e46 Merge pull request #166 from duckietm/dev
🆙 Fix multiheight
2026-06-10 15:09:31 +02:00
duckietm 0dda0ae0f7 🆙 Fix multiheight 2026-06-10 15:09:14 +02:00
github-actions[bot] 54ab6613f1 🆙 Bump version to 4.2.41 [skip ci] 2026-06-10 12:18:32 +00:00
DuckieTM 9fda766ba5 Merge pull request #165 from duckietm/dev
🆙 Fix Group Forum buy
2026-06-10 14:17:32 +02:00
duckietm 3da9325344 🆙 Fix Group Forum buy 2026-06-10 14:17:17 +02:00
github-actions[bot] 770739c256 🆙 Bump version to 4.2.40 [skip ci] 2026-06-10 08:16:33 +00:00
DuckieTM 3ec468993a Merge pull request #164 from duckietm/dev
Dev
2026-06-10 10:15:20 +02:00
duckietm 0e0f1cbb15 🆙 Navigator Group Filter 2026-06-10 10:14:49 +02:00
DuckieTM daeda761cd Merge pull request #163 from simoleo89/feat/security-concurrency-economy-hardening
Security, concurrency & economy hardening + dependency upgrades and modernization
2026-06-10 06:43:45 +02:00
DuckieTM 0906048a3a Merge pull request #162 from RemcoEpicnabbo/main
Handle '.' in vending_ids parsing
2026-06-10 06:43:31 +02:00
simoleo89 19cde45d3e fix(marketplace): avoid inventory desync on failed offer insert
Expose whether a marketplace offer was persisted before mutating inventory state, refuse sells whose database insert failed, and synchronize the sold timestamp into the online seller's in-memory offer when present. This keeps failed or racing marketplace operations from desynchronizing credits/items.
2026-06-09 22:02:53 +02:00
simoleo89 8161e3d7e5 fix(moderation): harden ban and modtool edge cases
Use executeUpdate with generated keys for offline ban inserts, return an empty result when an offline target cannot be loaded, and make ban commands handle empty results instead of indexing blindly. Modtool chatlog requests now guard missing users instead of dereferencing null.
2026-06-09 22:02:33 +02:00
simoleo89 5c0f2d2855 fix(session): separate forced disconnects from resume parking
Add a forced dispose path for bans, RCON disconnects, logout/account endpoints, plugin-cancelled login, duplicate login replacement, and late MAC-ban enforcement. Soft channel closes still park a session for reconnect, while security-driven closes now bypass session resume. Also null-guard client/channel disposal and cover the contract with focused tests.
2026-06-09 22:02:07 +02:00
simoleo89 d984461cc0 fix(login): don't reject login when the machine fingerprint arrives after the SSO ticket
The Nitro renderer sends the UniqueID (machine fingerprint) packet right AFTER the SSOTicket, so Habbo.connect() ran before the machineId was set and returned false on the empty machineId — silently disposing the client (WS closed with Netty's default "Bye"), so login never completed and no SecureLoginOK was sent.

- Habbo.connect(): only set machineID + run the MAC-ban check when the fingerprint is already present; never reject the login solely for a missing machineId (also drop a duplicated MAC/IP-ban block).

- MachineIDEvent: enforce the MAC ban when the fingerprint arrives after login, preserving the ban check that connect() now defers.
2026-06-09 20:50:12 +02:00
simoleo89 61ea33ac28 docs(config): document new networking/threading keys from the hardening batch
Add commented examples for the config keys introduced by this PR so operators
can discover and tune them (defaults apply if unset):
- ws.ip.header.trusted (trusted reverse-proxy gate for the forwarded-IP header)
- io.packet.handler.threads (game packet-handler pool, off the Netty I/O loop)
- auth.http.pool.size (dedicated /api/auth/* worker pool)
- io.netty.allocator.pooled (opt-in pooled ByteBuf allocator)
2026-06-09 20:06:31 +02:00
simoleo89 b6ee400b83 refactor: drop Joda-Time (-> java.time) and make protocol charsets explicit
Modernization following the dependency upgrades:
- Joda-Time was used in exactly one place (ModToolSanctionInfoComposer, to
  subtract probation days from a Date). Migrated to java.time
  (Instant/ZoneId.systemDefault, calendar-accurate like the old Joda call) and
  removed the joda-time dependency entirely — confirmed gone from the shaded jar.
- Make string<->bytes conversions explicitly UTF-8 instead of relying on the
  platform default. Most importantly the wire codec (ClientMessage.readString /
  ServerMessage.appendString) — both sides now pinned to UTF-8 so international
  characters are robust regardless of -Dfile.encoding. Also RCONServerHandler,
  PluginManager and the WS origin-forbidden response.

Verified: clean compile, 15/15 tests, shaded jar.
2026-06-09 20:05:31 +02:00
simoleo89 62104596ac refactor(netty): migrate off the deprecated NioEventLoopGroup (Netty 4.2)
Netty 4.2 deprecates NioEventLoopGroup in favour of the generic
MultiThreadIoEventLoopGroup driven by an IoHandlerFactory. Server.java now
builds its boss/worker groups with MultiThreadIoEventLoopGroup(..,
NioIoHandler.newFactory()) — functionally equivalent, and the codebase now
compiles with zero deprecation warnings. Verified: compile, 15/15 tests, shaded
jar.
2026-06-09 20:05:31 +02:00
simoleo89 fad6be158a chore(deps): upgrade Netty (4.2), HikariCP (7) and JUnit (6) to latest major
Major-version upgrades of the three the previous bump deliberately held back.
Verified: clean compile, all 15 tests run green (surefire 3.5.2 drives the JUnit 6
platform fine — no extra launcher dep needed), and the shaded jar assembles.

- io.netty:netty-all            4.1.135.Final -> 4.2.15.Final
- com.zaxxer:HikariCP           6.3.3         -> 7.0.2
- org.junit.jupiter:junit-jupiter 5.14.4      -> 6.1.0

Notes:
- Stayed on Netty 4.2 (GA), not 5.0 which is still Alpha. No source changes
  needed; the channel ALLOCATOR is set explicitly so 4.2's new default adaptive
  allocator doesn't apply. NioEventLoopGroup is deprecated in 4.2 but still
  functions as before (left as-is to avoid an event-loop behavioural change).
  netty-all 4.2 pulls more transitive modules, so the fat jar grows (~20->32 MB).
- HikariCP 7.x baselines Java 17; our HikariConfig usage is unchanged.
2026-06-09 20:05:31 +02:00
simoleo89 a9f1903465 chore(deps): update dependencies to latest stable
Bumped to the latest stable within each safe major line (no source changes
needed — all APIs compatible; verified with clean compile + test + shaded jar):

- io.netty:netty-all            4.1.118 -> 4.1.135.Final
- com.google.code.gson:gson     2.11.0  -> 2.14.0
- org.mariadb.jdbc:*            3.5.1    -> 3.5.8
- com.zaxxer:HikariCP           6.2.1    -> 6.3.3
- org.apache.commons:commons-lang3 3.17.0 -> 3.20.0
- org.jsoup:jsoup               1.18.3   -> 1.22.2
- org.slf4j:slf4j-api           2.0.16   -> 2.0.18
- ch.qos.logback:logback-classic 1.5.15  -> 1.5.34
- org.fusesource.jansi:jansi    2.4.1    -> 2.4.3
- joda-time:joda-time           2.13.0   -> 2.14.2
- org.eclipse.angus:jakarta.mail 2.0.3   -> 2.0.5
- org.junit.jupiter:junit-jupiter 5.10.2 -> 5.14.4
- maven-surefire-plugin         3.2.5    -> 3.5.2

Deliberately NOT changed:
- Stayed on the netty 4.1.x line (4.2/5.0 are new majors with API changes),
  slf4j 2.0.x (logback 1.5.x requires it), junit 5.x (6.x needs a new baseline),
  and HikariCP 6.x (kept the current major for DB-pool stability; 7.x available).
- trove4j 3.0.3, commons-math3 3.6.1, jbcrypt 0.4 — already at their final
  (unmaintained) releases.
- compiler source/target=19 / release=21 — intentional per project convention.
2026-06-09 20:05:31 +02:00
simoleo89 af82352f24 feat: configurable pool sizes (#2) + pool-safe buffers and opt-in pooled allocator (#5)
#2 — tunable thread pools (sensible defaults kept):
- io.packet.handler.threads overrides the packet-handler EventExecutorGroup size
  (default max(16, 2x cores)).
- auth.http.pool.size overrides the auth HTTP pool max threads (default 16).

#5 — Netty buffer pooling:
- Make the crypto handlers pool-safe: GameByteEncryption/GameByteDecryption no
  longer call ByteBuf.array() on a readBytes-derived buffer (whose arrayOffset is
  non-zero under a pooled allocator, which would have read/encrypted the wrong
  region). They now copy the readable region into a plain byte[] (offset-safe)
  and wrap the result — also drops one intermediate buffer allocation. This is
  correct for the current unpooled allocator too. (ServerMessage uses its own
  Unpooled buffer, and ClientMessage reads via buffer methods, so both are
  already offset-safe.)
- Add a shared channel allocator selected by io.netty.allocator.pooled
  (default false = unpooled-heap, unchanged). Set true for a pooled HEAP
  allocator (preferDirect=false, so array-backed paths keep working) to cut
  per-packet alloc/GC churn. Opt-in until validated under load with the Netty
  leak detector, since unreleased pooled buffers accumulate rather than being
  GC-reclaimed.

New optional config keys (insert into emulator_settings to set/silence the
"key not found" notice): io.packet.handler.threads, auth.http.pool.size,
io.netty.allocator.pooled.
2026-06-09 20:05:30 +02:00
simoleo89 dcc23ba744 feat: housekeeping audit log + shared Gson instances
Security:
- HousekeepingAuditLog: append-only audit trail of privileged actions. There was
  no record of which operator granted ranks/currency to whom. SetUserRank,
  GiveCredits and GiveCurrency now log operator id+name, action, target, detail
  and IP. Writes are async; the housekeeping_log table is created on first use
  (CREATE TABLE IF NOT EXISTS) so no manual migration is needed.

Speed (minor):
- RCONServerHandler / PluginManager: reuse a shared Gson instead of allocating a
  new parser per request/plugin-config load (Gson is thread-safe). The wired
  Gson builders were already cached singletons.
2026-06-09 20:05:30 +02:00
simoleo89 f7556138aa feat: LIKE-wildcard escaping (security) + recycle/craft reward rollback (stability)
Security / speed:
- New util SqlLikeEscaper: escapes %, _ and \ in user search input. Applied to
  the user-facing LIKE searches (messenger user search, marketplace search,
  furni-editor search, housekeeping room search, guild member search) so a query
  like "%" can no longer match everything or trigger a needless full scan, and
  usernames containing "_" are matched literally.

Stability (item-loss fixes):
- RecycleEvent: compute the recycler reward BEFORE consuming the 8 inputs. The
  inputs were deleted from the DB first, so a null reward (misconfig) destroyed
  them permanently with nothing back. Now the inputs are only removed once the
  reward is confirmed.
- CraftingCraftItemEvent: restore the pulled ingredients to the inventory if the
  recipe can't be completed (not enough ingredients mid-pull, or reward creation
  returns null) — previously they silently vanished from the inventory.
2026-06-09 20:05:30 +02:00
simoleo89 a0910d822c fix: deep-analysis pass — self-review regressions + pre-existing logic bugs
Regressions found by an adversarial review of this branch's own diff:
- RoomCycleManager: stop holding the currentBots/currentPets monitor across the
  whole bot/pet tick — snapshot under the lock then cycle off-lock. The previous
  fix blocked place/pickup and room dispose for the full tick and inverted lock
  order vs roomUnitLock->currentBots (latent deadlock for any future cycle code
  touching roomUnitLock).
- HabboInfo: complete the currencyLock invariant — getCurrencies() now returns a
  snapshot under the lock (UserInfoCommand iterated the live Trove map off-lock,
  the exact rehash corruption the lock guards); canBuy() uses the lock-guarded
  getCredits()/getCurrencyAmount(); run() reads credits under the lock for save.
- RoomSpecialTypes: synchronize the by-id pet getters (getNest/getPetDrink/
  getPetFood/getPetToy/getPetTree) to match their now-synchronized mutators.
- AuthHttpUtil.isTrustedProxy: exact-match trusted IPs; only treat an entry as a
  range when it ends with "."/":" so "10.0.0.1" can't also trust "10.0.0.12".

Pre-existing logic bugs found by the deep subsystem analysis:
- RoomUsersComposer: the bulk (room-entry) branch wrote the guild id twice;
  the second field must be the 1/-1 group-membership flag (matches the single
  branch) — every user showed a wrong group indicator on room entry.
- BotManager.pickUpBot: room owners (and ACC_PLACEFURNI) couldn't pick up bots
  placed in their own room — added the room-owner clause that placeBot has.
- PetPickupEvent: compared user id to pet.getId() instead of pet.getUserId(), so
  a pet owner who isn't the room owner couldn't pick up their own pet.
- RoomRightsManager.refreshRightsForHabbo: in guild rooms, explicit room_rights
  were stripped (overwritten by guild level NONE); now takes the stronger of
  explicit rights and guild level.
- RoomRequestBannedUsersEvent: `!hasRights || !ACC_ANYROOMOWNER` required BOTH,
  denying legitimate owners the banned-users list — corrected to `&&`.
- InteractionPetBreedingNest.breed: a crafted packet on a not-full nest deleted
  the nest furni then NPE'd (furni loss); guard petOne/petTwo/room before the
  destructive delete; ConfirmPetBreedingEvent null-checks the room.
- WiredEffectTeleport/UserFurniBase: appended item id instead of sprite id in the
  incompatible-triggers list (cosmetic wired-dialog mismatch) — matched the ~10
  other effects' getBaseItem().getSpriteId() convention.
2026-06-09 20:05:30 +02:00
simoleo89 4eb1484daf perf: run game packet handlers off the Netty I/O loop + bound A* pathfinding (P2)
Root cause from the CPU audit: every incoming packet handler ran on the Netty
I/O event loop (MULTI_THREADED_PACKET_HANDLING is false by default), so any
blocking handler — login DB + loadHabbo, friends/polls/catalog/guild-forum
JDBC (~48 handlers), synchronous A* per walk — stalled socket I/O for every
other client sharing that I/O thread.

- WebSocketChannelInitializer: register GameMessageHandler on a dedicated
  DefaultEventExecutorGroup (max(16, 2x cores), daemon). Netty pins each channel
  to one executor in the group, so a client's packets stay strictly ordered (no
  new intra-client races) while blocking work moves off the I/O loop. The
  cross-client concurrency degree matches the already-multi-threaded I/O group,
  and this is strictly safer than the existing (order-losing) shared-pool
  MULTI_THREADED_PACKET_HANDLING mode the codebase already supported.
- GameMessageHandler: always run the handler inline (now on the group thread);
  drop the shared-pool branch (which would break per-channel ordering and also
  removes the rejectable-pool ByteBuf-drop path).
- PathfinderImpl: default the A* execution-time guard ON (25ms) so a pathological
  search returns an empty path instead of running unbounded on its thread.

Note: this changes the server's packet-threading model — verified to compile,
unit-test, and assemble the shaded jar, but should be load-tested before prod.
Group size is currently derived from CPU count; can be made a config key if
tuning is needed.
2026-06-09 20:05:30 +02:00
simoleo89 45d01876c1 fix: bound the move-blocking Future.get in RoomUserWalkEvent
roomUnit.getMoveBlockingTask().get() blocked the Netty event loop with no
timeout; a stuck/delayed move-blocking task would park the worker thread (and
every client on it) indefinitely. Wait at most 2s, then proceed with the walk.
2026-06-09 20:05:30 +02:00
simoleo89 1c4449fb88 perf: run auth HTTP endpoints off the Netty event loop (P1)
The /api/auth/* handlers ran inline on the Netty worker event loop, so their
blocking work — BCrypt (cost 12 ~tens of ms), JDBC, the Turnstile HTTPS
round-trip and SMTP — stalled every other client multiplexed on that thread; a
burst of logins/registers could freeze game traffic.

Dispatch each auth request to a dedicated bounded pool (4..16 daemon threads,
bounded queue, 503 on saturation) instead. It is deliberately SEPARATE from the
shared game ThreadPooling so auth load can't starve room cycles either. Netty
writes are thread-safe, so the endpoints' sendJson calls work unchanged from the
worker; the FullHttpRequest is released when the task finishes.

Caveat: this allows concurrent handling of pipelined requests on a single
keep-alive connection (out-of-order responses) — not a concern for the Nitro
client which is strictly request/response, but worth load-testing before prod.
2026-06-09 20:05:29 +02:00
simoleo89 373d0399c1 fix: trusted-proxy gate for forwarded IP, wired-var cache + ghost-session cleanup
Security (S3):
- AuthHttpUtil/WebSocketHttpHandler: only honour the configured ws.ip.header
  forwarded-IP header when the DIRECT peer is a trusted reverse proxy, instead
  of trusting it unconditionally. Loopback is always trusted; extra proxies can
  be allow-listed (exact IP or string prefix, comma-separated) via the new
  `ws.ip.header.trusted` config key — default-deny so the header can't be
  spoofed from the open internet to evade per-IP rate limiting and IP bans.
  Also take only the first comma token when setting the game-session WS_IP.

Leak cleanup (C4):
- WiredVariableReferenceSupport.invalidateRoom(): drop a room's shared
  wired-variable assignment caches; called from Room.dispose so the static
  USER/ROOM_ASSIGNMENT_CACHE maps don't retain entries for the JVM lifetime.
- SessionResumeManager.parkHabbo: if the scheduler refuses the grace-expiry
  task (future == null), disconnect immediately instead of parking an
  un-reapable GhostSession that would pin the Habbo + room refs forever.

Note: ws.ip.header.trusted defaults to loopback-only; deployments whose proxy
is on another host must add its IP/prefix to that key or client IPs will
collapse to the proxy address.
2026-06-09 20:05:29 +02:00
simoleo89 01c17c0511 fix: wired double-fire guard, RoomUnit path race, roomItems iteration, Netty CVE
Continuation of the concurrency hardening from the audit:
- InteractionWired/WiredHandler (E4): add an atomic per-box processing guard so
  one trigger box is handled by a single thread at a time, making the cooldown
  check-and-set effectively atomic; mark `cooldown` volatile. Prevents a packet
  thread and the room cycle thread from double-firing the same wired stack
  (double teleport/reward).
- RoomUnit (C1): the walk path is now a volatile ConcurrentLinkedDeque instead of
  a plain LinkedList, so the room cycle popping steps can't corrupt it while a
  walk packet rebuilds it via findPath/setPath.
- RoomItemManager (C2): iterate roomItems under its own monitor in getFloorItems/
  getWallItems/getPostItNotes/getUserUniqueFurniCount/getItemsAt, matching the
  existing put/remove sync sites — stops place/pickup from corrupting the
  traversal into a silently-incomplete item set.
- pom.xml (S4): bump netty-all 4.1.115 -> 4.1.118.Final (CVE-2025-24970 SslHandler
  pre-auth DoS, CVE-2025-25193).
2026-06-09 20:05:29 +02:00
simoleo89 d1570d3574 fix: economy-integrity, currency thread-safety, and resource-leak hardening
From the full-codebase audit. Economy/security (Batch A):
- CatalogBuyItemEvent: clamp client `count` to 1..100 — the club-offer branch
  accumulated cost in plain ints, so a huge count overflowed to a negative
  total, bypassed the affordability checks and CREDITED the buyer (free
  currency/subscription exploit).
- HousekeepingGiveCredits/GiveCurrency: bound `amount` to +/-1e9 to stop
  overflow/negative-balance grants via the privileged path.
- RoomTrade: synchronize accept/confirm/offer/remove and add a `completed`
  re-entry guard so two simultaneous confirms can't run tradeItems() twice
  (item/credit duplication).
- HabboInfo: serialize credits + currencies read-modify-write and the
  saveCurrencies snapshot on a dedicated lock (never held across DB I/O) —
  fixes lost updates and Trove rehash-during-iteration corruption between the
  credit-roller thread and purchase/trade handlers.
- AchievementManager/HabboStats: atomic incrementProgress() so concurrent
  progress sources don't lose updates.

Resource/stability (Batch B):
- GameMessageRateLimit: release the wrapped ByteBuf on every drop path
  (ClientMessage isn't ReferenceCounted, so the decoder's auto-release is a
  no-op) — fixes a refcount leak on pre-auth/rate-limited packets.
- AuthRateLimiter: opportunistically evict window-expired STATE/PROBE_STATE
  entries — previously grew unbounded, one entry per unique client IP.
- ForumThread/ForumThreadComment: close getGeneratedKeys() ResultSets via
  try-with-resources, and create the first comment after the thread's
  connection is released (was holding two pooled connections at once).
- DatabasePool: add socketTimeout/connectTimeout/tcpKeepAlive so a stalled
  MariaDB can't pin a pooled connection (and its thread) indefinitely.

Concurrency visibility (Batch C, partial):
- Room: mark allowBotsWalk/allowPets/allowPetsEat volatile (read every cycle,
  written from settings handlers on another thread).
2026-06-09 20:05:29 +02:00
simoleo89 c98d3a3205 fix: guard double gift-open and harden client string reads
- InteractionGift/OpenRecycleBoxEvent: add an atomic open-once guard so two
  near-simultaneous OpenRecycleBox packets can't both schedule the async,
  delayed OpenGift before the wrapper is removed (redundant double-process).
- ClientMessage.readString: treat the length prefix as unsigned (mask 0xFFFF)
  and clamp to the buffered bytes, so a bogus/oversized length no longer
  throws mid-read and desyncs the remaining fields of the packet.
2026-06-09 20:05:29 +02:00
simoleo89 da1fd01074 fix: address bug-hunt findings across security, concurrency, trade & wired
Security
- HousekeepingSetUserRankEvent: add rank-ceiling guard (reject granting a
  rank above the operator's own and modifying a higher-ranked target),
  mirroring GiveRankCommand — closes a privilege-escalation path.

Trade integrity
- RoomTrade.clearAccepted now also resets confirmed; a stale confirmed=true
  let a user strip their side and still complete once the partner re-confirms.

Concurrency
- RoomCycleManager: iterate the synchronized bot/pet maps under their own
  monitor (lock order stays one-directional vs addBot/addPet — no deadlock).
- RoomSpecialTypes: synchronize nest/petDrink/petFood/petToy/petTree writers
  on the same monitor their getters already use.
- HabboStats: synchronize achievement-progress map accessors.
- RebugKickBallAction: drop redundant direct mutation of the shared tile-cache
  sets (updateTile invalidates them right after) — removes a data race.

Robustness
- Wired legacy parsers (HabboCount, NotHabboCount, MatchStatePosition,
  MoveRotateFurni): guard length/format so one malformed row no longer aborts
  the whole room's wired load.
- RoomLayout: fill malformed/short heightmap rows with INVALID tiles instead
  of leaving nulls, and bounds-check door coordinates.
- FurnidataWatcher: defer (instead of drop) a throttled delta so furni-name
  changes are never lost between broadcasts.
- GuildManager.getGuildMembers: fix LIMIT row-count (page size 14, not
  offset+14) so member pages no longer overlap from page 1 on.
2026-06-09 20:05:29 +02:00
Remco Epicnabbo f7bd452cb0 Handle '.' in vending_ids parsing
Normalize vending_ids by replacing semicolons and dots with commas before splitting. This ensures values separated by '.' are treated like other delimiters and parsed correctly as integers, avoiding parsing errors from unexpected separators.
2026-06-09 17:20:02 +02:00
duckietm 48fcd3f78b 🆙 Update SQL 2026-06-08 11:26:03 +02:00
DuckieTM 1275254fa0 Merge pull request #161 from duckietm/main
Sync Main to DEV
2026-06-08 07:31:07 +02:00
duckietm 7ed7a1ec5a 🆙 update the SQL 2026-06-08 07:18:37 +02:00
DuckieTM 1f4eef8e2e 🆙 Added null check to wall /floor and background 2026-06-07 23:14:25 +02:00
96 changed files with 1365 additions and 519 deletions
+47 -1
View File
@@ -19,4 +19,50 @@ CREATE TABLE IF NOT EXISTS `furnidata_edit_log` (
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
('items.furnidata.edit.backup.keep','10'), ('items.furnidata.edit.backup.keep','10'),
('items.furnidata.edit.ratelimit.ms','2000'); ('items.furnidata.edit.ratelimit.ms','2000'),
-- Server-authoritative furni names (source of truth = furnidata JSON)
('items.furnidata.names.enabled','true'),
('items.furnidata.path',''),
('items.furnidata.max.bytes','67108864'),
-- Live-reload watcher
('items.furnidata.watch.enabled','true'),
('items.furnidata.watch.debounce.ms','750'),
('items.furnidata.watch.min.interval.ms','5000'),
('items.furnidata.delta.cap','500'),
-- Furni editor: import official names/descriptions from Habbo
('furni.editor.import.url','https://www.habbo.com/gamedata/furnidata_json/1'),
('furni.editor.import.cache.ms','600000');
START TRANSACTION;
DROP TEMPORARY TABLE IF EXISTS cleanup_furnidata_settings;
CREATE TEMPORARY TABLE cleanup_furnidata_settings (
`key` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL PRIMARY KEY
);
INSERT INTO cleanup_furnidata_settings (`key`) VALUES
('items.furnidata.names.enabled'),
('items.furnidata.path'),
('items.furnidata.max.bytes'),
('items.furnidata.watch.enabled'),
('items.furnidata.watch.debounce.ms'),
('items.furnidata.watch.min.interval.ms'),
('items.furnidata.delta.cap'),
('furni.editor.import.url'),
('furni.editor.import.cache.ms');
-- Preview rows that will be removed.
SELECT es.`key`, es.`value`
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`
ORDER BY es.`key`;
DELETE es
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`;
-- Preview remaining matching rows inside the transaction.
SELECT COUNT(*) AS remaining_furnidata_settings
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`;
-- Safe default. Change to COMMIT after reviewing the preview.
ROLLBACK;
@@ -0,0 +1,9 @@
-- Navigator search filters - companion to the gameserver fix for the catalog
-- 'Find groups to join!' button (navigator/search/hotel_view/group:).
INSERT IGNORE INTO `navigator_filter` (`key`, `field`, `compare`, `database_query`) VALUES
('anything', 'getName', 'contains', 'SELECT rooms.* FROM rooms WHERE rooms.name LIKE ?'),
('roomname', 'getName', 'contains', 'SELECT rooms.* FROM rooms WHERE rooms.name LIKE ?'),
('owner', 'getOwnerName', 'equals_ignore_case', 'SELECT rooms.* FROM rooms WHERE rooms.owner_name LIKE ?'),
('tag', 'getTags', 'contains', 'SELECT rooms.* FROM rooms WHERE rooms.tags LIKE ?'),
('group', 'getGuildName', 'contains', 'SELECT rooms.* FROM rooms INNER JOIN guilds ON guilds.room_id = rooms.id WHERE guilds.name LIKE ?');
@@ -1,27 +0,0 @@
-- 021_furnidata_config.sql
-- Seeds the furnidata feature config keys read at runtime by
-- FurnitureTextProvider / FurnidataReader / FurnidataWatcher and
-- FurniEditorImportTextEvent. Without these rows a fresh install logs
-- "Config key not found" for each (ConfigurationManager logs ERROR even
-- when a default is supplied) and the values are not editable from the DB.
--
-- Notes:
-- * *.enabled keys are read via Boolean.parseBoolean → use true/false (NOT 1/0).
-- * items.furnidata.path is intentionally empty: when blank the source is
-- derived from furni.editor.asset.base.path (seeded by 004_furni_editor.sql)
-- → <base>/furnidata (split-tier) or <base>/FurnitureData.json (single file).
-- * Editor write-path keys (items.furnidata.edit.*) are seeded by 020.
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
-- Server-authoritative furni names (source of truth = furnidata JSON)
('items.furnidata.names.enabled','true'),
('items.furnidata.path',''),
('items.furnidata.max.bytes','67108864'),
-- Live-reload watcher
('items.furnidata.watch.enabled','true'),
('items.furnidata.watch.debounce.ms','750'),
('items.furnidata.watch.min.interval.ms','5000'),
('items.furnidata.delta.cap','500'),
-- Furni editor: import official names/descriptions from Habbo
('furni.editor.import.url','https://www.habbo.it/gamedata/furnidata_json/1'),
('furni.editor.import.cache.ms','600000');
@@ -0,0 +1,15 @@
-- Fix NULL room paint columns
--
-- Some legacy/imported rooms have NULL in paper_wall / paper_floor / paper_landscape.
-- The server compares these with .equals("0.0") on room entry, which throws a
-- NullPointerException (RoomManager.openRoom) and prevents the room from loading.
-- This normalizes existing NULL values and re-enforces the NOT NULL DEFAULT '0.0'
-- constraint so it cannot happen again.
UPDATE `rooms` SET `paper_wall` = '0.0' WHERE `paper_wall` IS NULL;
UPDATE `rooms` SET `paper_floor` = '0.0' WHERE `paper_floor` IS NULL;
UPDATE `rooms` SET `paper_landscape` = '0.0' WHERE `paper_landscape` IS NULL;
ALTER TABLE `rooms` MODIFY COLUMN `paper_wall` VARCHAR(50) NOT NULL DEFAULT '0.0';
ALTER TABLE `rooms` MODIFY COLUMN `paper_floor` VARCHAR(50) NOT NULL DEFAULT '0.0';
ALTER TABLE `rooms` MODIFY COLUMN `paper_landscape` VARCHAR(50) NOT NULL DEFAULT '0.0';
+13 -20
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.39</version> <version>4.2.42</version>
<properties> <properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -66,7 +66,7 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version> <version>3.5.2</version>
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
@@ -83,21 +83,21 @@
<dependency> <dependency>
<groupId>io.netty</groupId> <groupId>io.netty</groupId>
<artifactId>netty-all</artifactId> <artifactId>netty-all</artifactId>
<version>4.1.115.Final</version> <version>4.2.15.Final</version>
</dependency> </dependency>
<!-- GSON --> <!-- GSON -->
<dependency> <dependency>
<groupId>com.google.code.gson</groupId> <groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId> <artifactId>gson</artifactId>
<version>2.11.0</version> <version>2.14.0</version>
</dependency> </dependency>
<!-- MariaDB Connector/J (native driver for MariaDB) --> <!-- MariaDB Connector/J (native driver for MariaDB) -->
<dependency> <dependency>
<groupId>org.mariadb.jdbc</groupId> <groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId> <artifactId>mariadb-java-client</artifactId>
<version>3.5.1</version> <version>3.5.8</version>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
@@ -113,7 +113,7 @@
<dependency> <dependency>
<groupId>com.zaxxer</groupId> <groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId> <artifactId>HikariCP</artifactId>
<version>6.2.1</version> <version>7.0.2</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
@@ -121,7 +121,7 @@
<dependency> <dependency>
<groupId>org.apache.commons</groupId> <groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId> <artifactId>commons-lang3</artifactId>
<version>3.17.0</version> <version>3.20.0</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
@@ -137,7 +137,7 @@
<dependency> <dependency>
<groupId>org.jsoup</groupId> <groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId> <artifactId>jsoup</artifactId>
<version>1.18.3</version> <version>1.22.2</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
@@ -145,14 +145,14 @@
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
<version>2.0.16</version> <version>2.0.18</version>
</dependency> </dependency>
<!-- Logback --> <!-- Logback -->
<dependency> <dependency>
<groupId>ch.qos.logback</groupId> <groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId> <artifactId>logback-classic</artifactId>
<version>1.5.15</version> <version>1.5.34</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
@@ -160,14 +160,7 @@
<dependency> <dependency>
<groupId>org.fusesource.jansi</groupId> <groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId> <artifactId>jansi</artifactId>
<version>2.4.1</version> <version>2.4.3</version>
</dependency>
<!-- Joda Time -->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.13.0</version>
</dependency> </dependency>
<!-- jBCrypt used by the built-in /api/auth/* HTTP login handler <!-- jBCrypt used by the built-in /api/auth/* HTTP login handler
@@ -183,14 +176,14 @@
<dependency> <dependency>
<groupId>org.eclipse.angus</groupId> <groupId>org.eclipse.angus</groupId>
<artifactId>jakarta.mail</artifactId> <artifactId>jakarta.mail</artifactId>
<version>2.0.3</version> <version>2.0.5</version>
</dependency> </dependency>
<!-- JUnit Jupiter --> <!-- JUnit Jupiter -->
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
<version>5.10.2</version> <version>6.1.0</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
</dependencies> </dependencies>
@@ -79,6 +79,14 @@ class DatabasePool {
databaseConfiguration.addDataSourceProperty("useLocalSessionState", "true"); databaseConfiguration.addDataSourceProperty("useLocalSessionState", "true");
databaseConfiguration.addDataSourceProperty("cacheResultSetMetadata", "true"); databaseConfiguration.addDataSourceProperty("cacheResultSetMetadata", "true");
databaseConfiguration.addDataSourceProperty("elideSetAutoCommits", "true"); databaseConfiguration.addDataSourceProperty("elideSetAutoCommits", "true");
// Fail fast instead of pinning a pooled connection (and its worker
// thread) indefinitely on a stalled/slow MariaDB. HikariCP's
// connectionTimeout only bounds the pool *borrow*; these bound the
// actual socket/connect round-trip. Overridable via db.params.
databaseConfiguration.addDataSourceProperty("socketTimeout", "30000");
databaseConfiguration.addDataSourceProperty("connectTimeout", "10000");
databaseConfiguration.addDataSourceProperty("tcpKeepAlive", "true");
databaseConfiguration.addDataSourceProperty("maintainTimeStats", "false"); databaseConfiguration.addDataSourceProperty("maintainTimeStats", "false");
databaseConfiguration.setPoolName("HabboHikariPool"); databaseConfiguration.setPoolName("HabboHikariPool");
@@ -100,9 +100,9 @@ public class AchievementManager {
if (oldLevel != null && (oldLevel.level == achievement.levels.size() && currentProgress >= oldLevel.progress)) //Maximum achievement gotten. if (oldLevel != null && (oldLevel.level == achievement.levels.size() && currentProgress >= oldLevel.progress)) //Maximum achievement gotten.
return; return;
habbo.getHabboStats().setProgress(achievement, currentProgress + amount); int newProgress = habbo.getHabboStats().incrementProgress(achievement, amount);
AchievementLevel newLevel = achievement.getLevelForProgress(currentProgress + amount); AchievementLevel newLevel = achievement.getLevelForProgress(newProgress);
if (AchievementManager.TALENTTRACK_ENABLED) { if (AchievementManager.TALENTTRACK_ENABLED) {
for (TalentTrackType type : TalentTrackType.values()) { for (TalentTrackType type : TalentTrackType.values()) {
@@ -188,7 +188,11 @@ public class BotManager {
if (pickedUpEvent.isCancelled()) if (pickedUpEvent.isCancelled())
return; return;
if (habbo == null || (bot.getOwnerId() == habbo.getHabboInfo().getId() || habbo.hasPermission(Permission.ACC_ANYROOMOWNER))) { Room currentRoom = habbo != null ? habbo.getHabboInfo().getCurrentRoom() : null;
if (habbo == null
|| bot.getOwnerId() == habbo.getHabboInfo().getId()
|| habbo.hasPermission(Permission.ACC_ANYROOMOWNER)
|| (currentRoom != null && (currentRoom.getOwnerId() == habbo.getHabboInfo().getId() || habbo.hasPermission(Permission.ACC_PLACEFURNI)))) {
if (habbo != null && !habbo.hasPermission(Permission.ACC_UNLIMITED_BOTS) && habbo.getInventory().getBotsComponent().getBots().size() >= BotManager.MAXIMUM_BOT_INVENTORY_SIZE) { if (habbo != null && !habbo.hasPermission(Permission.ACC_UNLIMITED_BOTS) && habbo.getInventory().getBotsComponent().getBots().size() >= BotManager.MAXIMUM_BOT_INVENTORY_SIZE) {
habbo.alert(Emulator.getTexts().getValue("error.bots.max.inventory").replace("%amount%", BotManager.MAXIMUM_BOT_INVENTORY_SIZE + "")); habbo.alert(Emulator.getTexts().getValue("error.bots.max.inventory").replace("%amount%", BotManager.MAXIMUM_BOT_INVENTORY_SIZE + ""));
return; return;
@@ -1247,6 +1247,11 @@ public class CatalogManager {
Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId); Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId);
if (guild != null && Emulator.getGameEnvironment().getGuildManager().getGuildMember(guild, habbo) != null) { if (guild != null && Emulator.getGameEnvironment().getGuildManager().getGuildMember(guild, habbo) != null) {
if (baseItem.getName().equals("guild_forum") && guild.getOwnerId() != habbo.getHabboInfo().getId()) {
habbo.getClient().sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR));
return;
}
InteractionGuildFurni habboItem = (InteractionGuildFurni) Emulator.getGameEnvironment().getItemManager().createItem(habbo.getClient().getHabbo().getHabboInfo().getId(), baseItem, limitedStack, limitedNumber, extradata); InteractionGuildFurni habboItem = (InteractionGuildFurni) Emulator.getGameEnvironment().getItemManager().createItem(habbo.getClient().getHabbo().getHabboInfo().getId(), baseItem, limitedStack, limitedNumber, extradata);
habboItem.setExtradata(""); habboItem.setExtradata("");
habboItem.needsUpdate(true); habboItem.needsUpdate(true);
@@ -171,8 +171,9 @@ public class MarketPlace {
statement.setInt(paramIndex++, maxPrice); statement.setInt(paramIndex++, maxPrice);
} }
if (!search.isEmpty()) { if (!search.isEmpty()) {
statement.setString(paramIndex++, "%" + search + "%"); String likeSearch = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(search) + "%";
statement.setString(paramIndex++, "%" + search + "%"); statement.setString(paramIndex++, likeSearch);
statement.setString(paramIndex++, likeSearch);
} }
try (ResultSet set = statement.executeQuery()) { try (ResultSet set = statement.executeQuery()) {
@@ -278,8 +279,9 @@ public class MarketPlace {
return; return;
} }
int soldTimestamp = Emulator.getIntUnixTimestamp();
try (PreparedStatement updateOffer = connection.prepareStatement("UPDATE marketplace_items SET state = 2, sold_timestamp = ? WHERE id = ? AND state = 1")) { try (PreparedStatement updateOffer = connection.prepareStatement("UPDATE marketplace_items SET state = 2, sold_timestamp = ? WHERE id = ? AND state = 1")) {
updateOffer.setInt(1, Emulator.getIntUnixTimestamp()); updateOffer.setInt(1, soldTimestamp);
updateOffer.setInt(2, offerId); updateOffer.setInt(2, offerId);
int updated = updateOffer.executeUpdate(); int updated = updateOffer.executeUpdate();
if (updated == 0) { if (updated == 0) {
@@ -306,7 +308,11 @@ public class MarketPlace {
client.sendResponse(new MarketplaceBuyErrorComposer(MarketplaceBuyErrorComposer.REFRESH, 0, offerId, price)); client.sendResponse(new MarketplaceBuyErrorComposer(MarketplaceBuyErrorComposer.REFRESH, 0, offerId, price));
if (habbo != null) { if (habbo != null) {
habbo.getInventory().getOffer(offerId).setState(MarketPlaceState.SOLD); MarketPlaceOffer offer = habbo.getInventory().getOffer(offerId);
if (offer != null) {
offer.setState(MarketPlaceState.SOLD);
offer.setSoldTimestamp(soldTimestamp);
}
} }
} }
} }
@@ -368,6 +374,11 @@ public class MarketPlace {
event.item.setFromGift(false); event.item.setFromGift(false);
MarketPlaceOffer offer = new MarketPlaceOffer(event.item, event.price, client.getHabbo()); MarketPlaceOffer offer = new MarketPlaceOffer(event.item, event.price, client.getHabbo());
if (!offer.isPersisted()) {
LOGGER.warn("Marketplace offer insert failed for user {} item {}", client.getHabbo().getHabboInfo().getId(), event.item.getId());
return false;
}
client.getHabbo().getInventory().addMarketplaceOffer(offer); client.getHabbo().getInventory().addMarketplaceOffer(offer);
client.getHabbo().getInventory().getItemsComponent().removeHabboItem(event.item); client.getHabbo().getInventory().getItemsComponent().removeHabboItem(event.item);
item.setUserId(-1); item.setUserId(-1);
@@ -98,6 +98,10 @@ public class MarketPlaceOffer implements Runnable {
return this.offerId; return this.offerId;
} }
public boolean isPersisted() {
return this.offerId > 0;
}
public void setOfferId(int offerId) { public void setOfferId(int offerId) {
this.offerId = offerId; this.offerId = offerId;
} }
@@ -9,6 +9,8 @@ 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.HabboManager; import com.eu.habbo.habbohotel.users.HabboManager;
import java.util.List;
public class BanCommand extends Command { public class BanCommand extends Command {
public BanCommand() { public BanCommand() {
super("cmd_ban", Emulator.getTexts().getValue("commands.keys.cmd_ban").split(";")); super("cmd_ban", Emulator.getTexts().getValue("commands.keys.cmd_ban").split(";"));
@@ -72,7 +74,13 @@ public class BanCommand extends Command {
} }
} }
ModToolBan ban = Emulator.getGameEnvironment().getModToolManager().ban(target.getId(), gameClient.getHabbo(), reason.toString(), banTime, ModToolBanType.ACCOUNT, -1).get(0); List<ModToolBan> bans = Emulator.getGameEnvironment().getModToolManager().ban(target.getId(), gameClient.getHabbo(), reason.toString(), banTime, ModToolBanType.ACCOUNT, -1);
if (bans == null || bans.isEmpty()) {
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_ban.user_offline"), RoomChatMessageBubbles.ALERT);
return true;
}
ModToolBan ban = bans.get(0);
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_ban.ban_issued").replace("%user%", target.getUsername()).replace("%time%", ban.expireDate - Emulator.getIntUnixTimestamp() + "").replace("%reason%", ban.reason), RoomChatMessageBubbles.ALERT); gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_ban.ban_issued").replace("%user%", target.getUsername()).replace("%time%", ban.expireDate - Emulator.getIntUnixTimestamp() + "").replace("%reason%", ban.reason), RoomChatMessageBubbles.ALERT);
return true; return true;
@@ -8,6 +8,8 @@ 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.HabboManager; import com.eu.habbo.habbohotel.users.HabboManager;
import java.util.List;
public class IPBanCommand extends Command { public class IPBanCommand extends Command {
public final static int TEN_YEARS = 315569260; public final static int TEN_YEARS = 315569260;
@@ -50,12 +52,12 @@ public class IPBanCommand extends Command {
return true; return true;
} }
Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1); List<?> bans = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1);
count++; count += bans != null ? bans.size() : 0;
for (Habbo h : Emulator.getGameServer().getGameClientManager().getHabbosWithIP(habbo.getIpLogin())) { for (Habbo h : Emulator.getGameServer().getGameClientManager().getHabbosWithIP(habbo.getIpLogin())) {
if (h != null) { if (h != null) {
count++; bans = Emulator.getGameEnvironment().getModToolManager().ban(h.getHabboInfo().getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1);
Emulator.getGameEnvironment().getModToolManager().ban(h.getHabboInfo().getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1); count += bans != null ? bans.size() : 0;
} }
} }
} else { } else {
@@ -8,6 +8,8 @@ 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.HabboManager; import com.eu.habbo.habbohotel.users.HabboManager;
import java.util.List;
public class MachineBanCommand extends Command { public class MachineBanCommand extends Command {
public MachineBanCommand() { public MachineBanCommand() {
super("cmd_machine_ban", Emulator.getTexts().getValue("commands.keys.cmd_machine_ban").split(";")); super("cmd_machine_ban", Emulator.getTexts().getValue("commands.keys.cmd_machine_ban").split(";"));
@@ -46,7 +48,8 @@ public class MachineBanCommand extends Command {
return true; return true;
} }
count = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), IPBanCommand.TEN_YEARS, ModToolBanType.MACHINE, -1).size(); List<?> bans = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), IPBanCommand.TEN_YEARS, ModToolBanType.MACHINE, -1);
count = bans != null ? bans.size() : 0;
} else { } else {
@@ -58,4 +61,4 @@ public class MachineBanCommand extends Command {
return true; return true;
} }
} }
@@ -149,6 +149,10 @@ public class GameClient {
} }
public void dispose() { public void dispose() {
this.dispose(true);
}
public void dispose(boolean allowSessionResume) {
try { try {
this.channel.close(); this.channel.close();
@@ -161,7 +165,7 @@ public class GameClient {
// appena ripristinata (era la causa del "Bye"/kick al 2° reconnect). // appena ripristinata (era la causa del "Bye"/kick al 2° reconnect).
if (this.habbo.getClient() == this && this.habbo.isOnline()) { 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 = allowSessionResume && SessionResumeManager.getInstance().parkHabbo(this.habbo, this.ssoTicket);
if (!parked) { if (!parked) {
// No grace period configured — immediate disconnect as before // No grace period configured — immediate disconnect as before
@@ -177,4 +181,4 @@ public class GameClient {
LOGGER.error("Caught exception", e); LOGGER.error("Caught exception", e);
} }
} }
} }
@@ -43,14 +43,34 @@ public class GameClientManager {
public void disposeClient(GameClient client) { public void disposeClient(GameClient client) {
this.disposeClient(client.getChannel()); if (client == null) {
return;
}
this.disposeClient(client.getChannel(), true);
}
public void forceDisposeClient(GameClient client) {
if (client == null) {
return;
}
this.disposeClient(client.getChannel(), false);
} }
private void disposeClient(Channel channel) { private void disposeClient(Channel channel) {
this.disposeClient(channel, true);
}
private void disposeClient(Channel channel, boolean allowSessionResume) {
if (channel == null) {
return;
}
GameClient client = channel.attr(GameServerAttributes.CLIENT).get(); GameClient client = channel.attr(GameServerAttributes.CLIENT).get();
if (client != null) { if (client != null) {
client.dispose(); client.dispose(allowSessionResume);
} }
channel.deregister(); channel.deregister();
channel.attr(GameServerAttributes.CLIENT).set(null); channel.attr(GameServerAttributes.CLIENT).set(null);
@@ -190,4 +210,4 @@ public class GameClientManager {
CFKeepAlive(); CFKeepAlive();
}, 30000); }, 30000);
} }
} }
@@ -71,6 +71,15 @@ public class SessionResumeManager {
} }
}, graceSeconds * 1000); }, graceSeconds * 1000);
if (future == null) {
// The scheduler refused the grace-expiry task (pool saturated or
// shutting down). Parking now would leave a GhostSession that nothing
// can ever reap (the Habbo + room refs pinned for the JVM lifetime),
// so disconnect immediately instead.
performFullDisconnect(habbo);
return false;
}
ghostSessions.put(userId, new GhostSession(habbo, ssoTicket, future, previousEffectId, previousEffectEnd)); ghostSessions.put(userId, new GhostSession(habbo, ssoTicket, future, previousEffectId, previousEffectEnd));
applyPausedEffect(habbo); applyPausedEffect(habbo);
@@ -421,9 +421,9 @@ public class GuildManager {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT users.username, users.look, guilds_members.* FROM guilds_members INNER JOIN users ON guilds_members.user_id = users.id WHERE guilds_members.guild_id = ? " + (rankQuery(levelId)) + " AND users.username LIKE ? ORDER BY level_id, member_since ASC LIMIT ?, ?")) { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT users.username, users.look, guilds_members.* FROM guilds_members INNER JOIN users ON guilds_members.user_id = users.id WHERE guilds_members.guild_id = ? " + (rankQuery(levelId)) + " AND users.username LIKE ? ORDER BY level_id, member_since ASC LIMIT ?, ?")) {
statement.setInt(1, guild.getId()); statement.setInt(1, guild.getId());
statement.setString(2, "%" + query + "%"); statement.setString(2, "%" + com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%");
statement.setInt(3, page * 14); statement.setInt(3, page * 14);
statement.setInt(4, (page * 14) + 14); statement.setInt(4, 14);
try (ResultSet set = statement.executeQuery()) { try (ResultSet set = statement.executeQuery()) {
while (set.next()) { while (set.next()) {
@@ -101,21 +101,27 @@ public class ForumThread implements Runnable, ISerialize {
if (statement.executeUpdate() < 1) if (statement.executeUpdate() < 1)
return null; return null;
ResultSet set = statement.getGeneratedKeys(); try (ResultSet set = statement.getGeneratedKeys()) {
if (set.next()) { if (set.next()) {
int threadId = set.getInt(1); int threadId = set.getInt(1);
createdThread = new ForumThread(threadId, guild.getId(), opener.getHabboInfo().getId(), subject, 0, timestamp, timestamp, ForumThreadState.OPEN, false, false, 0, null); createdThread = new ForumThread(threadId, guild.getId(), opener.getHabboInfo().getId(), subject, 0, timestamp, timestamp, ForumThreadState.OPEN, false, false, 0, null);
cacheThread(createdThread); cacheThread(createdThread);
}
ForumThreadComment comment = ForumThreadComment.create(createdThread, opener, message);
createdThread.addComment(comment);
Emulator.getPluginManager().fireEvent(new GuildForumThreadCreated(createdThread));
} }
} catch (SQLException e) { } catch (SQLException e) {
LOGGER.error("Caught SQL exception", e); LOGGER.error("Caught SQL exception", e);
} }
// ForumThreadComment.create() opens its OWN connection; do it after the
// thread's connection has been released to avoid holding two pooled
// connections simultaneously per forum-thread creation.
if (createdThread != null) {
ForumThreadComment comment = ForumThreadComment.create(createdThread, opener, message);
createdThread.addComment(comment);
Emulator.getPluginManager().fireEvent(new GuildForumThreadCreated(createdThread));
}
return createdThread; return createdThread;
} }
@@ -98,12 +98,13 @@ public class ForumThreadComment implements Runnable, ISerialize {
if (statement.executeUpdate() < 1) if (statement.executeUpdate() < 1)
return null; return null;
ResultSet set = statement.getGeneratedKeys(); try (ResultSet set = statement.getGeneratedKeys()) {
if (set.next()) { if (set.next()) {
int commentId = set.getInt(1); int commentId = set.getInt(1);
createdComment = new ForumThreadComment(commentId, thread.getThreadId(), poster.getHabboInfo().getId(), message, timestamp, ForumThreadState.OPEN, 0); createdComment = new ForumThreadComment(commentId, thread.getThreadId(), poster.getHabboInfo().getId(), message, timestamp, ForumThreadState.OPEN, 0);
Emulator.getPluginManager().fireEvent(new GuildForumThreadCommentCreated(createdComment)); Emulator.getPluginManager().fireEvent(new GuildForumThreadCommentCreated(createdComment));
}
} }
} catch (SQLException e) { } catch (SQLException e) {
LOGGER.error("Caught SQL exception", e); LOGGER.error("Caught SQL exception", e);
@@ -115,32 +115,39 @@ public class FurnidataWatcher {
} }
} }
private void onChange() { private void onChange() throws InterruptedException {
// Re-index under the shared furnidata lock so the watcher and editor
// writes never swap the index concurrently. The lock is released before
// the throttle/broadcast below so a slow broadcast can't stall editor saves.
List<FurnidataEntry> delta;
FurnidataLock.LOCK.lock(); FurnidataLock.LOCK.lock();
try { try {
Path source = this.provider.getSource(); Path source = this.provider.getSource();
if (source == null) return; if (source == null) return;
delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read());
List<FurnidataEntry> delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read());
if (delta.isEmpty()) return;
long now = System.currentTimeMillis();
if (now - this.lastBroadcast < this.minIntervalMs) {
LOGGER.info("FurnidataWatcher: {} changes indexed but broadcast skipped (min interval) — clients update on next change or reconnect", delta.size());
return;
}
this.lastBroadcast = now;
FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap)
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
broadcast(composer);
LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)",
delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size());
} finally { } finally {
FurnidataLock.LOCK.unlock(); FurnidataLock.LOCK.unlock();
} }
if (delta.isEmpty()) return;
// Min-interval throttle: the index has already been swapped, so we must
// not drop this delta (the next reindex would diff against the updated
// index and never re-emit it). Instead, defer the broadcast until the
// interval elapses. Running on a dedicated daemon thread, sleeping is
// safe; file events arriving meanwhile coalesce into the next cycle.
long sinceLast = System.currentTimeMillis() - this.lastBroadcast;
if (sinceLast < this.minIntervalMs) {
Thread.sleep(this.minIntervalMs - sinceLast);
}
this.lastBroadcast = System.currentTimeMillis();
FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap)
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
broadcast(composer);
LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)",
delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size());
} }
private void broadcast(FurnitureDataReloadComposer composer) { private void broadcast(FurnitureDataReloadComposer composer) {
@@ -123,7 +123,7 @@ public class Item implements ISerialize {
if (!set.getString("vending_ids").isEmpty()) { if (!set.getString("vending_ids").isEmpty()) {
this.vendingItems = new TIntArrayList(); this.vendingItems = new TIntArrayList();
String[] vendingIds = set.getString("vending_ids").replace(";", ",").split(","); String[] vendingIds = set.getString("vending_ids").replace(";", ",").replace(".", ",").split(",");
for (String s : vendingIds) { for (String s : vendingIds) {
this.vendingItems.add(Integer.parseInt(s.replace(" ", ""))); this.vendingItems.add(Integer.parseInt(s.replace(" ", "")));
} }
@@ -13,11 +13,13 @@ import org.slf4j.LoggerFactory;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.concurrent.atomic.AtomicBoolean;
public class InteractionGift extends HabboItem { public class InteractionGift extends HabboItem {
private static final Logger LOGGER = LoggerFactory.getLogger(InteractionGift.class); private static final Logger LOGGER = LoggerFactory.getLogger(InteractionGift.class);
public boolean explode = false; public boolean explode = false;
private final AtomicBoolean opening = new AtomicBoolean(false);
private int[] itemId; private int[] itemId;
private int colorId = 0; private int colorId = 0;
private int ribbonId = 0; private int ribbonId = 0;
@@ -46,6 +48,15 @@ public class InteractionGift extends HabboItem {
} }
} }
/**
* Claims the right to open this gift, returning true exactly once. Guards
* against two near-simultaneous OpenRecycleBox packets both scheduling an
* (async, delayed) OpenGift before the wrapper is removed from the room.
*/
public boolean tryStartOpening() {
return this.opening.compareAndSet(false, true);
}
@Override @Override
public void serializeExtradata(ServerMessage serverMessage) { public void serializeExtradata(ServerMessage serverMessage) {
//serverMessage.appendInt(this.colorId * 1000 + this.ribbonId); //serverMessage.appendInt(this.colorId * 1000 + this.ribbonId);
@@ -65,9 +65,8 @@ public class InteractionMultiHeight extends HabboItem {
if (this.getBaseItem().getMultiHeights().length > 0) { if (this.getBaseItem().getMultiHeights().length > 0) {
this.setExtradata("" + (Integer.parseInt(this.getExtradata()) + 1) % (this.getBaseItem().getMultiHeights().length)); this.setExtradata("" + (Integer.parseInt(this.getExtradata()) + 1) % (this.getBaseItem().getMultiHeights().length));
this.needsUpdate(true); this.needsUpdate(true);
room.updateTiles(room.getLayout().getTilesAt(room.getLayout().getTile(this.getX(), this.getY()), this.getBaseItem().getWidth(), this.getBaseItem().getLength(), this.getRotation())); room.updateItem(this);
room.updateItemState(this); this.updateUnitsOnItem(room);
//room.sendComposer(new UpdateStackHeightComposer(this.getX(), this.getY(), this.getBaseItem().getMultiHeights()[Integer.valueOf(this.getExtradata())] * 256.0D).compose());
} }
} }
} }
@@ -18,6 +18,7 @@ import java.sql.SQLException;
import java.util.Iterator; import java.util.Iterator;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* Base abstract class for all wired furniture items (triggers, effects, conditions, extras). * Base abstract class for all wired furniture items (triggers, effects, conditions, extras).
@@ -61,7 +62,11 @@ public abstract class InteractionWired extends InteractionDefault {
*/ */
private static final long CACHE_EXPIRY_MS = 5 * 60 * 1000; private static final long CACHE_EXPIRY_MS = 5 * 60 * 1000;
private long cooldown; private volatile long cooldown;
// Ensures one box is processed by a single thread at a time, so the
// cooldown check-and-set in WiredHandler can't double-fire when a packet
// thread and the room cycle thread trigger the same box concurrently.
private final AtomicBoolean processing = new AtomicBoolean(false);
private final ConcurrentHashMap<Long, Long> userExecutionCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap<Long, Long> userExecutionCache = new ConcurrentHashMap<>();
InteractionWired(ResultSet set, Item baseItem) throws SQLException { InteractionWired(ResultSet set, Item baseItem) throws SQLException {
@@ -149,6 +154,15 @@ public abstract class InteractionWired extends InteractionDefault {
this.cooldown = newMillis; this.cooldown = newMillis;
} }
/** Claims exclusive processing of this box; returns false if another thread is already in it. */
public boolean tryBeginProcessing() {
return this.processing.compareAndSet(false, true);
}
public void endProcessing() {
this.processing.set(false);
}
@Override @Override
public boolean allowWiredResetState() { public boolean allowWiredResetState() {
return false; return false;
@@ -190,6 +190,14 @@ public class InteractionPetBreedingNest extends HabboItem {
} }
public void breed(Habbo habbo, String name, int petOneId, int petTwoId) { public void breed(Habbo habbo, String name, int petOneId, int petTwoId) {
// Guard before the destructive delete below: a crafted packet can call
// this on a nest that isn't full, which would delete the nest furni and
// then NPE on petOne/petTwo in the async runnable (losing the furni).
if (habbo == null || this.petOne == null || this.petTwo == null
|| habbo.getHabboInfo().getCurrentRoom() == null) {
return;
}
Emulator.getThreading().run(new QueryDeleteHabboItem(this.getId())); Emulator.getThreading().run(new QueryDeleteHabboItem(this.getId()));
this.setExtradata("2"); this.setExtradata("2");
@@ -65,8 +65,14 @@ public class WiredConditionHabboCount extends InteractionWiredCondition {
} else { } else {
String[] data = wiredData.split(":"); String[] data = wiredData.split(":");
this.lowerLimit = Integer.parseInt(data[0]); if (data.length >= 2) {
this.upperLimit = Integer.parseInt(data[1]); try {
this.lowerLimit = Integer.parseInt(data[0].trim());
this.upperLimit = Integer.parseInt(data[1].trim());
} catch (NumberFormatException ignored) {
// malformed legacy data — keep the constructed defaults
}
}
this.userSource = WiredSourceUtil.SOURCE_TRIGGER; this.userSource = WiredSourceUtil.SOURCE_TRIGGER;
} }
} }
@@ -263,22 +263,29 @@ public class WiredConditionMatchStatePosition extends InteractionWiredCondition
} else { } else {
String[] data = wiredData.split(":"); String[] data = wiredData.split(":");
int itemCount = Integer.parseInt(data[0]); if (data.length >= 5) {
try {
int itemCount = Integer.parseInt(data[0]);
String[] items = data[1].split(";"); String[] items = data[1].split(";");
for (int i = 0; i < itemCount; i++) { for (int i = 0; i < itemCount && i < items.length; i++) {
String[] stuff = items[i].split("-"); String[] stuff = items[i].split("-");
if (stuff.length >= 6) if (stuff.length >= 6)
this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]), Double.parseDouble(stuff[5]))); this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]), Double.parseDouble(stuff[5])));
else if (stuff.length >= 5) else if (stuff.length >= 5)
this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]))); this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4])));
}
this.state = data[2].equals("1");
this.direction = data[3].equals("1");
this.position = data[4].equals("1");
} catch (NumberFormatException ignored) {
// malformed legacy data — keep whatever was parsed plus defaults
}
} }
this.state = data[2].equals("1");
this.direction = data[3].equals("1");
this.position = data[4].equals("1");
this.altitude = false; this.altitude = false;
this.furniSource = this.settings.isEmpty() ? WiredSourceUtil.SOURCE_TRIGGER : WiredSourceUtil.SOURCE_SELECTED; this.furniSource = this.settings.isEmpty() ? WiredSourceUtil.SOURCE_TRIGGER : WiredSourceUtil.SOURCE_SELECTED;
this.quantifier = QUANTIFIER_ALL; this.quantifier = QUANTIFIER_ALL;
@@ -64,8 +64,14 @@ public class WiredConditionNotHabboCount extends InteractionWiredCondition {
this.userSource = data.userSource; this.userSource = data.userSource;
} else { } else {
String[] data = wiredData.split(":"); String[] data = wiredData.split(":");
this.lowerLimit = Integer.parseInt(data[0]); if (data.length >= 2) {
this.upperLimit = Integer.parseInt(data[1]); try {
this.lowerLimit = Integer.parseInt(data[0].trim());
this.upperLimit = Integer.parseInt(data[1].trim());
} catch (NumberFormatException ignored) {
// malformed legacy data — keep the constructed defaults
}
}
this.userSource = WiredSourceUtil.SOURCE_TRIGGER; this.userSource = WiredSourceUtil.SOURCE_TRIGGER;
} }
} }
@@ -190,10 +190,15 @@ public class WiredEffectMoveRotateFurni extends InteractionWiredEffect implement
} }
for (String s : data[3].split("\r")) { for (String s : data[3].split("\r")) {
HabboItem item = room.getHabboItem(Integer.parseInt(s)); if (s.trim().isEmpty()) continue;
try {
HabboItem item = room.getHabboItem(Integer.parseInt(s.trim()));
if (item != null) if (item != null)
this.items.add(item); this.items.add(item);
} catch (NumberFormatException ignored) {
// skip malformed furni id token
}
} }
} }
this.furniSource = this.items.isEmpty() ? WiredSourceUtil.SOURCE_TRIGGER : WiredSourceUtil.SOURCE_SELECTED; this.furniSource = this.items.isEmpty() ? WiredSourceUtil.SOURCE_TRIGGER : WiredSourceUtil.SOURCE_SELECTED;
@@ -151,7 +151,7 @@ public class WiredEffectTeleport extends InteractionWiredEffect {
@Override @Override
public boolean execute(InteractionWiredTrigger object) { public boolean execute(InteractionWiredTrigger object) {
if (!object.isTriggeredByRoomUnit()) { if (!object.isTriggeredByRoomUnit()) {
invalidTriggers.add(object.getId()); invalidTriggers.add(object.getBaseItem().getSpriteId());
} }
return true; return true;
} }
@@ -252,7 +252,7 @@ public abstract class WiredEffectUserFurniBase extends InteractionWiredEffect {
@Override @Override
public boolean execute(InteractionWiredTrigger object) { public boolean execute(InteractionWiredTrigger object) {
if (!object.isTriggeredByRoomUnit()) { if (!object.isTriggeredByRoomUnit()) {
invalidTriggers.add(object.getId()); invalidTriggers.add(object.getBaseItem().getSpriteId());
} }
return true; return true;
} }
@@ -227,6 +227,18 @@ public final class WiredVariableReferenceSupport {
USER_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); USER_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix));
} }
/**
* Drops all cached shared-variable assignments belonging to a room. Both
* caches are keyed "roomId:itemId[:userId]", so the trailing colon makes the
* prefix match the exact room id. Called on room dispose so the static caches
* don't retain entries for the JVM lifetime.
*/
public static void invalidateRoom(int roomId) {
String prefix = roomId + ":";
USER_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix));
ROOM_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix));
}
public static SharedRoomAssignment getSharedRoomAssignment(WiredExtraVariableReference reference) { public static SharedRoomAssignment getSharedRoomAssignment(WiredExtraVariableReference reference) {
if (reference == null || !reference.isRoomReference()) { if (reference == null || !reference.isRoomReference()) {
return null; return null;
@@ -53,7 +53,7 @@ public class Messenger {
public static THashSet<MessengerBuddy> searchUsers(String username) { public static THashSet<MessengerBuddy> searchUsers(String username) {
THashSet<MessengerBuddy> users = new THashSet<>(); THashSet<MessengerBuddy> users = new THashSet<>();
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT * FROM users WHERE username LIKE ? ORDER BY username ASC LIMIT " + Emulator.getConfig().getInt("hotel.messenger.search.maxresults"))) { try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT * FROM users WHERE username LIKE ? ORDER BY username ASC LIMIT " + Emulator.getConfig().getInt("hotel.messenger.search.maxresults"))) {
statement.setString(1, username + "%"); statement.setString(1, com.eu.habbo.util.SqlLikeEscaper.escape(username) + "%");
try (ResultSet set = statement.executeQuery()) { try (ResultSet set = statement.executeQuery()) {
while (set.next()) { while (set.next()) {
users.add(new MessengerBuddy(set, false)); users.add(new MessengerBuddy(set, false));
@@ -0,0 +1,97 @@
package com.eu.habbo.habbohotel.modtool;
import com.eu.habbo.Emulator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
/**
* Append-only audit trail for privileged housekeeping/admin actions (rank grants,
* currency grants, etc.). There was previously no record of which operator did
* what to whom. Writes are dispatched off the calling thread; the backing table
* is created on first use so no manual migration is required.
*/
public final class HousekeepingAuditLog {
private static final Logger LOGGER = LoggerFactory.getLogger(HousekeepingAuditLog.class);
private static volatile boolean tableReady = false;
private HousekeepingAuditLog() {
}
/**
* Records a privileged action asynchronously.
*
* @param operatorId the acting staff member's user id
* @param operatorName the acting staff member's username
* @param action a short action key, e.g. {@code "user.set_rank"}
* @param targetUserId the affected user's id (0 if not applicable)
* @param detail free-form detail, e.g. {@code "rankId=6"} (capped to 512 chars)
* @param ip the operator's IP, for correlation
*/
public static void log(int operatorId, String operatorName, String action, int targetUserId, String detail, String ip) {
Emulator.getThreading().run(() -> writeEntry(operatorId, operatorName, action, targetUserId, detail, ip));
}
private static void writeEntry(int operatorId, String operatorName, String action, int targetUserId, String detail, String ip) {
ensureTable();
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO housekeeping_log (operator_id, operator_name, action, target_user_id, detail, ip, timestamp) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)")) {
statement.setInt(1, operatorId);
statement.setString(2, operatorName != null ? operatorName : "");
statement.setString(3, action != null ? action : "");
statement.setInt(4, targetUserId);
statement.setString(5, truncate(detail));
statement.setString(6, ip != null ? ip : "");
statement.setInt(7, Emulator.getIntUnixTimestamp());
statement.execute();
} catch (SQLException e) {
LOGGER.error("Failed to write housekeeping audit log entry", e);
}
}
private static String truncate(String detail) {
if (detail == null) return "";
return detail.length() > 512 ? detail.substring(0, 512) : detail;
}
private static void ensureTable() {
if (tableReady) {
return;
}
synchronized (HousekeepingAuditLog.class) {
if (tableReady) {
return;
}
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
Statement statement = connection.createStatement()) {
statement.execute(
"CREATE TABLE IF NOT EXISTS housekeeping_log (" +
"id INT UNSIGNED NOT NULL AUTO_INCREMENT, " +
"operator_id INT NOT NULL, " +
"operator_name VARCHAR(64) NOT NULL DEFAULT '', " +
"action VARCHAR(64) NOT NULL, " +
"target_user_id INT NOT NULL DEFAULT 0, " +
"detail VARCHAR(512) NOT NULL DEFAULT '', " +
"ip VARCHAR(64) NOT NULL DEFAULT '', " +
"timestamp INT NOT NULL, " +
"PRIMARY KEY (id), " +
"KEY idx_operator (operator_id), " +
"KEY idx_target (target_user_id), " +
"KEY idx_timestamp (timestamp)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
tableReady = true;
} catch (SQLException e) {
LOGGER.error("Failed to create housekeeping_log table", e);
}
}
}
}
@@ -378,7 +378,9 @@ public class ModToolManager {
statement.setString(6, reason); statement.setString(6, reason);
statement.setString(7, type.getType()); statement.setString(7, type.getType());
try (ResultSet set = statement.executeQuery()) { statement.executeUpdate();
try (ResultSet set = statement.getGeneratedKeys()) {
if (set.next()) { if (set.next()) {
try (PreparedStatement selectBanStatement = connection.prepareStatement("SELECT * FROM bans WHERE id = ? LIMIT 1")) { try (PreparedStatement selectBanStatement = connection.prepareStatement("SELECT * FROM bans WHERE id = ? LIMIT 1")) {
selectBanStatement.setInt(1, set.getInt(1)); selectBanStatement.setInt(1, set.getInt(1));
@@ -434,6 +436,10 @@ public class ModToolManager {
Habbo target = Emulator.getGameEnvironment().getHabboManager().getHabbo(targetUserId); Habbo target = Emulator.getGameEnvironment().getHabboManager().getHabbo(targetUserId);
HabboInfo offlineInfo = target != null ? target.getHabboInfo() : HabboManager.getOfflineHabboInfo(targetUserId); HabboInfo offlineInfo = target != null ? target.getHabboInfo() : HabboManager.getOfflineHabboInfo(targetUserId);
if (offlineInfo == null) {
return bans;
}
if (moderator.getHabboInfo().getRank().getId() < offlineInfo.getRank().getId()) { if (moderator.getHabboInfo().getRank().getId() < offlineInfo.getRank().getId()) {
return bans; return bans;
} }
@@ -454,7 +460,7 @@ public class ModToolManager {
bans.add(ban); bans.add(ban);
if (target != null) { if (target != null) {
Emulator.getGameServer().getGameClientManager().disposeClient(target.getClient()); Emulator.getGameServer().getGameClientManager().forceDisposeClient(target.getClient());
} }
if ((type == ModToolBanType.IP || type == ModToolBanType.SUPER) && target != null && !ban.ip.equals("offline")) { if ((type == ModToolBanType.IP || type == ModToolBanType.SUPER) && target != null && !ban.ip.equals("offline")) {
@@ -465,7 +471,7 @@ public class ModToolManager {
Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban)); Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban));
Emulator.getThreading().run(ban); Emulator.getThreading().run(ban);
bans.add(ban); bans.add(ban);
Emulator.getGameServer().getGameClientManager().disposeClient(h.getClient()); Emulator.getGameServer().getGameClientManager().forceDisposeClient(h.getClient());
} }
} }
@@ -477,7 +483,7 @@ public class ModToolManager {
Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban)); Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban));
Emulator.getThreading().run(ban); Emulator.getThreading().run(ban);
bans.add(ban); bans.add(ban);
Emulator.getGameServer().getGameClientManager().disposeClient(h.getClient()); Emulator.getGameServer().getGameClientManager().forceDisposeClient(h.getClient());
} }
} }
@@ -213,16 +213,16 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
public java.util.Set<Integer> getYoutubeWatchers() { return this.youtubeWatchers; } public java.util.Set<Integer> getYoutubeWatchers() { return this.youtubeWatchers; }
public void setYoutubeVideo(String videoId, String senderName, java.util.List<String> playlist) { public void setYoutubeVideo(String videoId, String senderName, java.util.List<String> playlist) {
this.youtubeCurrentVideo = videoId; this.youtubeCurrentVideo = videoId;
this.youtubeSenderName = senderName; this.youtubeSenderName = senderName;
this.youtubePlaylist.clear(); this.youtubePlaylist.clear();
if (playlist != null) this.youtubePlaylist.addAll(playlist); if (playlist != null) this.youtubePlaylist.addAll(playlist);
} }
public void clearYoutubeVideo() { public void clearYoutubeVideo() {
this.youtubeCurrentVideo = ""; this.youtubeCurrentVideo = "";
this.youtubeSenderName = ""; this.youtubeSenderName = "";
this.youtubePlaylist.clear(); this.youtubePlaylist.clear();
} }
public final THashMap<String, Object> cache; public final THashMap<String, Object> cache;
@@ -239,9 +239,9 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.usersMax = set.getInt("users_max"); this.usersMax = set.getInt("users_max");
this.score = set.getInt("score"); this.score = set.getInt("score");
this.category = set.getInt("category"); this.category = set.getInt("category");
this.floorPaint = set.getString("paper_floor"); this.floorPaint = set.getString("paper_floor") == null ? "0.0" : set.getString("paper_floor");
this.wallPaint = set.getString("paper_wall"); this.wallPaint = set.getString("paper_wall") == null ? "0.0" : set.getString("paper_wall");
this.backgroundPaint = set.getString("paper_landscape"); this.backgroundPaint = set.getString("paper_landscape") == null ? "0.0" : set.getString("paper_landscape");
this.wallSize = set.getInt("thickness_wall"); this.wallSize = set.getInt("thickness_wall");
this.wallHeight = set.getInt("wall_height"); this.wallHeight = set.getInt("wall_height");
this.floorSize = set.getInt("thickness_floor"); this.floorSize = set.getInt("thickness_floor");
@@ -464,7 +464,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
if (this.loaded || this.loadingInProgress || !this.preLoaded) { if (this.loaded || this.loadingInProgress || !this.preLoaded) {
return; return;
} }
this.loadingInProgress = true; this.loadingInProgress = true;
this.loadingFuture = CompletableFuture.runAsync(() -> { this.loadingFuture = CompletableFuture.runAsync(() -> {
this.loadDataInternal(); this.loadDataInternal();
@@ -484,7 +484,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
future = this.loadingFuture; future = this.loadingFuture;
} }
if (future != null) { if (future != null) {
try { try {
future.join(); future.join();
@@ -499,7 +499,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
public void loadData() { public void loadData() {
CompletableFuture<Void> futureToWait = null; CompletableFuture<Void> futureToWait = null;
boolean shouldLoad = false; boolean shouldLoad = false;
synchronized (this.loadLock) { synchronized (this.loadLock) {
if (this.loadingInProgress) { if (this.loadingInProgress) {
// Get the future to wait on outside the lock // Get the future to wait on outside the lock
@@ -509,7 +509,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
shouldLoad = true; shouldLoad = true;
} }
} }
// Wait for existing load outside the lock // Wait for existing load outside the lock
if (futureToWait != null) { if (futureToWait != null) {
try { try {
@@ -519,7 +519,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
return; return;
} }
// Load if needed // Load if needed
if (shouldLoad) { if (shouldLoad) {
this.loadDataInternal(); this.loadDataInternal();
@@ -559,7 +559,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
CompletableFuture.runAsync(() -> { CompletableFuture.runAsync(() -> {
try (Connection promoConnection = Emulator.getDatabase().getDataSource().getConnection(); try (Connection promoConnection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement stmt = promoConnection.prepareStatement( PreparedStatement stmt = promoConnection.prepareStatement(
"SELECT * FROM room_promotions WHERE room_id = ? AND end_timestamp > ? LIMIT 1")) { "SELECT * FROM room_promotions WHERE room_id = ? AND end_timestamp > ? LIMIT 1")) {
stmt.setInt(1, this.id); stmt.setInt(1, this.id);
stmt.setInt(2, Emulator.getIntUnixTimestamp()); stmt.setInt(2, Emulator.getIntUnixTimestamp());
try (ResultSet promoSet = stmt.executeQuery()) { try (ResultSet promoSet = stmt.executeQuery()) {
@@ -654,7 +654,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
this.roomCycleTask = Emulator.getThreading().getService() this.roomCycleTask = Emulator.getThreading().getService()
.scheduleAtFixedRate(this, 500, 500, TimeUnit.MILLISECONDS); .scheduleAtFixedRate(this, 500, 500, TimeUnit.MILLISECONDS);
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("Caught exception during room load", e); LOGGER.error("Caught exception during room load", e);
} }
@@ -673,7 +673,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
item.setExtradata("1"); item.setExtradata("1");
this.updateItem(item); this.updateItem(item);
} }
// Set loaded flag with lock // Set loaded flag with lock
synchronized (this.loadLock) { synchronized (this.loadLock) {
this.loaded = true; this.loaded = true;
@@ -690,7 +690,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.layout = Emulator.getGameEnvironment().getRoomManager().loadCustomLayout(this); this.layout = Emulator.getGameEnvironment().getRoomManager().loadCustomLayout(this);
} else { } else {
this.layout = Emulator.getGameEnvironment().getRoomManager() this.layout = Emulator.getGameEnvironment().getRoomManager()
.loadLayout(this.layoutName, this); .loadLayout(this.layoutName, this);
} }
} }
} }
@@ -722,7 +722,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.unitManager.clearBots(); this.unitManager.clearBots();
try (PreparedStatement statement = connection.prepareStatement( try (PreparedStatement statement = connection.prepareStatement(
"SELECT users.username AS owner_name, bots.* FROM bots INNER JOIN users ON bots.user_id = users.id WHERE room_id = ?")) { "SELECT users.username AS owner_name, bots.* FROM bots INNER JOIN users ON bots.user_id = users.id WHERE room_id = ?")) {
statement.setInt(1, this.id); statement.setInt(1, this.id);
try (ResultSet set = statement.executeQuery()) { try (ResultSet set = statement.executeQuery()) {
while (set.next()) { while (set.next()) {
@@ -733,11 +733,11 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
b.setRoomUnit(new RoomUnit()); b.setRoomUnit(new RoomUnit());
b.getRoomUnit().setPathFinderRoom(this); b.getRoomUnit().setPathFinderRoom(this);
b.getRoomUnit() b.getRoomUnit()
.setLocation(this.layout.getTile((short) set.getInt("x"), (short) set.getInt("y"))); .setLocation(this.layout.getTile((short) set.getInt("x"), (short) set.getInt("y")));
if (b.getRoomUnit().getCurrentLocation() == null) { if (b.getRoomUnit().getCurrentLocation() == null) {
b.getRoomUnit().setLocation(this.getLayout().getDoorTile()); b.getRoomUnit().setLocation(this.getLayout().getDoorTile());
b.getRoomUnit() b.getRoomUnit()
.setRotation(RoomUserRotation.fromValue(this.getLayout().getDoorDirection())); .setRotation(RoomUserRotation.fromValue(this.getLayout().getDoorDirection()));
} else { } else {
b.getRoomUnit().setZ(set.getDouble("z")); b.getRoomUnit().setZ(set.getDouble("z"));
b.getRoomUnit().setPreviousLocationZ(set.getDouble("z")); b.getRoomUnit().setPreviousLocationZ(set.getDouble("z"));
@@ -761,7 +761,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.unitManager.clearPets(); this.unitManager.clearPets();
try (PreparedStatement statement = connection.prepareStatement( try (PreparedStatement statement = connection.prepareStatement(
"SELECT users.username as pet_owner_name, users_pets.* FROM users_pets INNER JOIN users ON users_pets.user_id = users.id WHERE room_id = ?")) { "SELECT users.username as pet_owner_name, users_pets.* FROM users_pets INNER JOIN users ON users_pets.user_id = users.id WHERE room_id = ?")) {
statement.setInt(1, this.id); statement.setInt(1, this.id);
try (ResultSet set = statement.executeQuery()) { try (ResultSet set = statement.executeQuery()) {
while (set.next()) { while (set.next()) {
@@ -771,11 +771,11 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
pet.setRoomUnit(new RoomUnit()); pet.setRoomUnit(new RoomUnit());
pet.getRoomUnit().setPathFinderRoom(this); pet.getRoomUnit().setPathFinderRoom(this);
pet.getRoomUnit() pet.getRoomUnit()
.setLocation(this.layout.getTile((short) set.getInt("x"), (short) set.getInt("y"))); .setLocation(this.layout.getTile((short) set.getInt("x"), (short) set.getInt("y")));
if (pet.getRoomUnit().getCurrentLocation() == null) { if (pet.getRoomUnit().getCurrentLocation() == null) {
pet.getRoomUnit().setLocation(this.getLayout().getDoorTile()); pet.getRoomUnit().setLocation(this.getLayout().getDoorTile());
pet.getRoomUnit() pet.getRoomUnit()
.setRotation(RoomUserRotation.fromValue(this.getLayout().getDoorDirection())); .setRotation(RoomUserRotation.fromValue(this.getLayout().getDoorDirection()));
} else { } else {
pet.getRoomUnit().setZ(set.getDouble("z")); pet.getRoomUnit().setZ(set.getDouble("z"));
pet.getRoomUnit().setRotation(RoomUserRotation.values()[set.getInt("rot")]); pet.getRoomUnit().setRotation(RoomUserRotation.values()[set.getInt("rot")]);
@@ -849,7 +849,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
THashSet<RoomTile> updatedTiles = new THashSet<>(); THashSet<RoomTile> updatedTiles = new THashSet<>();
Rectangle rectangle = RoomLayout.getRectangle(item.getX(), item.getY(), Rectangle rectangle = RoomLayout.getRectangle(item.getX(), item.getY(),
item.getBaseItem().getWidth(), item.getBaseItem().getLength(), item.getRotation()); item.getBaseItem().getWidth(), item.getBaseItem().getLength(), item.getRotation());
for (short x = (short) rectangle.x; x < rectangle.x + rectangle.getWidth(); x++) { for (short x = (short) rectangle.x; x < rectangle.x + rectangle.getWidth(); x++) {
for (short y = (short) rectangle.y; y < rectangle.y + rectangle.getHeight(); y++) { for (short y = (short) rectangle.y; y < rectangle.y + rectangle.getHeight(); y++) {
@@ -878,7 +878,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
Habbo habbo = (picker != null && picker.getHabboInfo().getId() == item.getId() ? picker Habbo habbo = (picker != null && picker.getHabboInfo().getId() == item.getId() ? picker
: Emulator.getGameServer().getGameClientManager().getHabbo(item.getUserId())); : Emulator.getGameServer().getGameClientManager().getHabbo(item.getUserId()));
if (!trackedBuildersClubItem && habbo != null) { if (!trackedBuildersClubItem && habbo != null) {
habbo.getInventory().getItemsComponent().addItem(item); habbo.getInventory().getItemsComponent().addItem(item);
habbo.getClient().sendResponse(new AddHabboItemComposer(item)); habbo.getClient().sendResponse(new AddHabboItemComposer(item));
@@ -1116,7 +1116,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
message.appendInt(this.category); message.appendInt(this.category);
String[] tags = Arrays.stream(this.tags.split(";")).filter(t -> !t.isEmpty()) String[] tags = Arrays.stream(this.tags.split(";")).filter(t -> !t.isEmpty())
.toArray(String[]::new); .toArray(String[]::new);
message.appendInt(tags.length); message.appendInt(tags.length);
for (String s : tags) { for (String s : tags) {
message.appendString(s); message.appendString(s);
@@ -1183,8 +1183,8 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
public void save() { public void save() {
if (this.needsUpdate) { if (this.needsUpdate) {
try (Connection connection = Emulator.getDatabase().getDataSource() try (Connection connection = Emulator.getDatabase().getDataSource()
.getConnection(); PreparedStatement statement = connection.prepareStatement( .getConnection(); PreparedStatement statement = connection.prepareStatement(
"UPDATE rooms SET name = ?, description = ?, password = ?, state = ?, users_max = ?, category = ?, score = ?, paper_floor = ?, paper_wall = ?, paper_landscape = ?, thickness_wall = ?, wall_height = ?, thickness_floor = ?, moodlight_data = ?, tags = ?, allow_other_pets = ?, allow_other_pets_eat = ?, allow_walkthrough = ?, allow_hidewall = ?, chat_mode = ?, chat_weight = ?, chat_speed = ?, chat_hearing_distance = ?, chat_protection =?, who_can_mute = ?, who_can_kick = ?, who_can_ban = ?, poll_id = ?, guild_id = ?, roller_speed = ?, override_model = ?, is_staff_picked = ?, promoted = ?, trade_mode = ?, move_diagonally = ?, owner_id = ?, owner_name = ?, jukebox_active = ?, hidewired = ?, allow_underpass = ?, youtube_enabled = ?, builders_club_trial_locked = ?, builders_club_original_state = ? WHERE id = ?")) { "UPDATE rooms SET name = ?, description = ?, password = ?, state = ?, users_max = ?, category = ?, score = ?, paper_floor = ?, paper_wall = ?, paper_landscape = ?, thickness_wall = ?, wall_height = ?, thickness_floor = ?, moodlight_data = ?, tags = ?, allow_other_pets = ?, allow_other_pets_eat = ?, allow_walkthrough = ?, allow_hidewall = ?, chat_mode = ?, chat_weight = ?, chat_speed = ?, chat_hearing_distance = ?, chat_protection =?, who_can_mute = ?, who_can_kick = ?, who_can_ban = ?, poll_id = ?, guild_id = ?, roller_speed = ?, override_model = ?, is_staff_picked = ?, promoted = ?, trade_mode = ?, move_diagonally = ?, owner_id = ?, owner_name = ?, jukebox_active = ?, hidewired = ?, allow_underpass = ?, youtube_enabled = ?, builders_club_trial_locked = ?, builders_club_original_state = ? WHERE id = ?")) {
statement.setString(1, this.name); statement.setString(1, this.name);
statement.setString(2, this.description); statement.setString(2, this.description);
statement.setString(3, this.password); statement.setString(3, this.password);
@@ -1252,8 +1252,8 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
*/ */
public void updateDatabaseUserCount() { public void updateDatabaseUserCount() {
try (Connection connection = Emulator.getDatabase().getDataSource() try (Connection connection = Emulator.getDatabase().getDataSource()
.getConnection(); PreparedStatement statement = connection.prepareStatement( .getConnection(); PreparedStatement statement = connection.prepareStatement(
"UPDATE rooms SET users = ? WHERE id = ? LIMIT 1")) { "UPDATE rooms SET users = ? WHERE id = ? LIMIT 1")) {
statement.setInt(1, this.getUserCount()); statement.setInt(1, this.getUserCount());
statement.setInt(2, this.id); statement.setInt(2, this.id);
statement.executeUpdate(); statement.executeUpdate();
@@ -1493,6 +1493,10 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
return this.getGuildId() != 0; return this.getGuildId() != 0;
} }
public boolean belongsToGuild() {
return this.guild > 0;
}
public void setGuild(int guild) { public void setGuild(int guild) {
this.guild = guild; this.guild = guild;
} }
@@ -1600,7 +1604,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
if (extraData.length == 4) { if (extraData.length == 4) {
if (extraData[0].equalsIgnoreCase("1")) { if (extraData[0].equalsIgnoreCase("1")) {
return Color.getHSBColor(Integer.parseInt(extraData[1]), return Color.getHSBColor(Integer.parseInt(extraData[1]),
Integer.parseInt(extraData[2]), Integer.parseInt(extraData[3])); Integer.parseInt(extraData[2]), Integer.parseInt(extraData[3]));
} }
} }
} }
@@ -1707,7 +1711,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
public String[] filterAnything() { public String[] filterAnything() {
return new String[]{this.getOwnerName(), this.getGuildName(), this.getDescription(), return new String[]{this.getOwnerName(), this.getGuildName(), this.getDescription(),
this.getPromotionDesc()}; this.getPromotionDesc()};
} }
public long getCycleTimestamp() { public long getCycleTimestamp() {
@@ -1914,7 +1918,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
// If the broadcast sender leaves, stop the broadcast for everyone // If the broadcast sender leaves, stop the broadcast for everyone
if (!this.youtubeCurrentVideo.isEmpty() if (!this.youtubeCurrentVideo.isEmpty()
&& habbo.getHabboInfo().getUsername().equals(this.youtubeSenderName)) { && habbo.getHabboInfo().getUsername().equals(this.youtubeSenderName)) {
this.clearYoutubeVideo(); this.clearYoutubeVideo();
this.sendComposer(new com.eu.habbo.messages.outgoing.rooms.youtube.YouTubeRoomBroadcastComposer("", "", java.util.Collections.emptyList()).compose()); this.sendComposer(new com.eu.habbo.messages.outgoing.rooms.youtube.YouTubeRoomBroadcastComposer("", "", java.util.Collections.emptyList()).compose());
} }
@@ -2059,7 +2063,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
public void talk(final Habbo habbo, final RoomChatMessage roomChatMessage, RoomChatType chatType, public void talk(final Habbo habbo, final RoomChatMessage roomChatMessage, RoomChatType chatType,
boolean ignoreWired) { boolean ignoreWired) {
this.chatManager.talk(habbo, roomChatMessage, chatType, ignoreWired); this.chatManager.talk(habbo, roomChatMessage, chatType, ignoreWired);
} }
@@ -2204,7 +2208,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
private void loadRights(Connection connection) { private void loadRights(Connection connection) {
this.rights.clear(); this.rights.clear();
try (PreparedStatement statement = connection.prepareStatement( try (PreparedStatement statement = connection.prepareStatement(
"SELECT user_id FROM room_rights WHERE room_id = ?")) { "SELECT user_id FROM room_rights WHERE room_id = ?")) {
statement.setInt(1, this.id); statement.setInt(1, this.id);
try (ResultSet set = statement.executeQuery()) { try (ResultSet set = statement.executeQuery()) {
while (set.next()) { while (set.next()) {
@@ -2220,7 +2224,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.bannedHabbos.clear(); this.bannedHabbos.clear();
try (PreparedStatement statement = connection.prepareStatement( try (PreparedStatement statement = connection.prepareStatement(
"SELECT users.username, users.id, room_bans.* FROM room_bans INNER JOIN users ON room_bans.user_id = users.id WHERE ends > ? AND room_bans.room_id = ?")) { "SELECT users.username, users.id, room_bans.* FROM room_bans INNER JOIN users ON room_bans.user_id = users.id WHERE ends > ? AND room_bans.room_id = ?")) {
statement.setInt(1, Emulator.getIntUnixTimestamp()); statement.setInt(1, Emulator.getIntUnixTimestamp());
statement.setInt(2, this.id); statement.setInt(2, this.id);
try (ResultSet set = statement.executeQuery()) { try (ResultSet set = statement.executeQuery()) {
@@ -2338,24 +2342,24 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.wiredSettingsLoaded = true; this.wiredSettingsLoaded = true;
Emulator.getThreading().run(() -> { Emulator.getThreading().run(() -> {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement( PreparedStatement statement = connection.prepareStatement(
"INSERT INTO room_wired_settings (room_id, inspect_mask, modify_mask) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE inspect_mask = VALUES(inspect_mask), modify_mask = VALUES(modify_mask)")) { "INSERT INTO room_wired_settings (room_id, inspect_mask, modify_mask) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE inspect_mask = VALUES(inspect_mask), modify_mask = VALUES(modify_mask)")) {
statement.setInt(1, finalId); statement.setInt(1, finalId);
statement.setInt(2, finalInspectMask); statement.setInt(2, finalInspectMask);
statement.setInt(3, finalModifyMask); statement.setInt(3, finalModifyMask);
statement.executeUpdate(); statement.executeUpdate();
} catch (SQLException e) { } catch (SQLException e) {
synchronized (this.wiredSettingsLock) { synchronized (this.wiredSettingsLock) {
if (this.wiredInspectMask == finalInspectMask && this.wiredModifyMask == finalModifyMask) { if (this.wiredInspectMask == finalInspectMask && this.wiredModifyMask == finalModifyMask) {
this.wiredInspectMask = previousInspectMask; this.wiredInspectMask = previousInspectMask;
this.wiredModifyMask = previousModifyMask; this.wiredModifyMask = previousModifyMask;
}
} }
LOGGER.error("Caught SQL exception while saving wired room settings", e);
} }
LOGGER.error("Caught SQL exception while saving wired room settings", e);
}
}); });
this.pushWiredSettingsToCurrentHabbos(); this.pushWiredSettingsToCurrentHabbos();
return true; return true;
} }
@@ -2430,7 +2434,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement( PreparedStatement statement = connection.prepareStatement(
"SELECT inspect_mask, modify_mask FROM room_wired_settings WHERE room_id = ? LIMIT 1")) { "SELECT inspect_mask, modify_mask FROM room_wired_settings WHERE room_id = ? LIMIT 1")) {
statement.setInt(1, this.id); statement.setInt(1, this.id);
try (ResultSet set = statement.executeQuery()) { try (ResultSet set = statement.executeQuery()) {
@@ -2530,15 +2534,15 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
if (habbo.getRoomUnit().hasStatus(RoomUnitStatus.SIT) || !habbo.getRoomUnit() if (habbo.getRoomUnit().hasStatus(RoomUnitStatus.SIT) || !habbo.getRoomUnit()
.canForcePosture()) { .canForcePosture()) {
return; return;
} }
this.dance(habbo, DanceType.NONE); this.dance(habbo, DanceType.NONE);
habbo.getRoomUnit().cmdSit = true; habbo.getRoomUnit().cmdSit = true;
habbo.getRoomUnit().setBodyRotation( habbo.getRoomUnit().setBodyRotation(
RoomUserRotation.values()[habbo.getRoomUnit().getBodyRotation().getValue() RoomUserRotation.values()[habbo.getRoomUnit().getBodyRotation().getValue()
- habbo.getRoomUnit().getBodyRotation().getValue() % 2]); - habbo.getRoomUnit().getBodyRotation().getValue() % 2]);
habbo.getRoomUnit().setStatus(RoomUnitStatus.SIT, 0.5 + ""); habbo.getRoomUnit().setStatus(RoomUnitStatus.SIT, 0.5 + "");
this.sendComposer(new RoomUserStatusComposer(habbo.getRoomUnit()).compose()); this.sendComposer(new RoomUserStatusComposer(habbo.getRoomUnit()).compose());
WiredManager.triggerUserPerformsAction(this, habbo.getRoomUnit(), WiredUserActionType.SIT, -1); WiredManager.triggerUserPerformsAction(this, habbo.getRoomUnit(), WiredUserActionType.SIT, -1);
@@ -2552,11 +2556,11 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
HabboItem item = this.getTopItemAt(habbo.getRoomUnit().getX(), habbo.getRoomUnit().getY()); HabboItem item = this.getTopItemAt(habbo.getRoomUnit().getX(), habbo.getRoomUnit().getY());
if (item == null || !item.getBaseItem().allowSit() || !item.getBaseItem().allowLay()) { if (item == null || !item.getBaseItem().allowSit() || !item.getBaseItem().allowLay()) {
boolean wasSittingOrLaying = habbo.getRoomUnit().hasStatus(RoomUnitStatus.SIT) boolean wasSittingOrLaying = habbo.getRoomUnit().hasStatus(RoomUnitStatus.SIT)
|| habbo.getRoomUnit().hasStatus(RoomUnitStatus.LAY); || habbo.getRoomUnit().hasStatus(RoomUnitStatus.LAY);
habbo.getRoomUnit().cmdStand = true; habbo.getRoomUnit().cmdStand = true;
habbo.getRoomUnit().setBodyRotation( habbo.getRoomUnit().setBodyRotation(
RoomUserRotation.values()[habbo.getRoomUnit().getBodyRotation().getValue() RoomUserRotation.values()[habbo.getRoomUnit().getBodyRotation().getValue()
- habbo.getRoomUnit().getBodyRotation().getValue() % 2]); - habbo.getRoomUnit().getBodyRotation().getValue() % 2]);
habbo.getRoomUnit().removeStatus(RoomUnitStatus.SIT); habbo.getRoomUnit().removeStatus(RoomUnitStatus.SIT);
habbo.getRoomUnit().removeStatus(RoomUnitStatus.LAY); habbo.getRoomUnit().removeStatus(RoomUnitStatus.LAY);
this.sendComposer(new RoomUserStatusComposer(habbo.getRoomUnit()).compose()); this.sendComposer(new RoomUserStatusComposer(habbo.getRoomUnit()).compose());
@@ -2584,38 +2588,38 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
public void updateItem(HabboItem item) { public void updateItem(HabboItem item) {
if (this.isLoaded()) { if (this.isLoaded()) {
if (item != null && item.getRoomId() == this.id) { if (item != null && item.getRoomId() == this.id) {
if (item.getBaseItem() != null) { if (item.getBaseItem() != null) {
if (item.getBaseItem().getType() == FurnitureType.FLOOR) { if (item.getBaseItem().getType() == FurnitureType.FLOOR) {
this.sendComposer(new FloorItemUpdateComposer(item).compose()); this.sendComposer(new FloorItemUpdateComposer(item).compose());
this.updateTiles(this.getLayout() this.updateTiles(this.getLayout()
.getTilesAt(this.layout.getTile(item.getX(), item.getY()), .getTilesAt(this.layout.getTile(item.getX(), item.getY()),
item.getBaseItem().getWidth(), item.getBaseItem().getLength(), item.getBaseItem().getWidth(), item.getBaseItem().getLength(),
item.getRotation())); item.getRotation()));
if (RoomAreaHideSupport.isControllerItem(item)) { if (RoomAreaHideSupport.isControllerItem(item)) {
RoomAreaHideSupport.sendState(this, item); RoomAreaHideSupport.sendState(this, item);
}
} else if (item.getBaseItem().getType() == FurnitureType.WALL) {
this.sendComposer(new WallItemUpdateComposer(item).compose());
} }
} else if (item.getBaseItem().getType() == FurnitureType.WALL) {
this.sendComposer(new WallItemUpdateComposer(item).compose());
} }
} }
}
} }
} }
public void updateItemState(HabboItem item) { public void updateItemState(HabboItem item) {
if (item != null && RoomAreaHideSupport.isControllerItem(item)) { if (item != null && RoomAreaHideSupport.isControllerItem(item)) {
this.updateItem(item); this.updateItem(item);
return; return;
} }
if (!item.isLimited()) { if (!item.isLimited()) {
this.sendComposer(new ItemStateComposer(item).compose()); this.sendComposer(new ItemStateComposer(item).compose());
} else { } else {
this.sendComposer(new FloorItemUpdateComposer(item).compose()); this.sendComposer(new FloorItemUpdateComposer(item).compose());
} }
if (item.getBaseItem().getType() == FurnitureType.FLOOR) { if (item.getBaseItem().getType() == FurnitureType.FLOOR) {
if (this.layout == null) { if (this.layout == null) {
@@ -2623,8 +2627,8 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
this.updateTiles(this.getLayout() this.updateTiles(this.getLayout()
.getTilesAt(this.layout.getTile(item.getX(), item.getY()), item.getBaseItem().getWidth(), .getTilesAt(this.layout.getTile(item.getX(), item.getY()), item.getBaseItem().getWidth(),
item.getBaseItem().getLength(), item.getRotation())); item.getBaseItem().getLength(), item.getRotation()));
if (item instanceof InteractionMultiHeight) { if (item instanceof InteractionMultiHeight) {
((InteractionMultiHeight) item).updateUnitsOnItem(this); ((InteractionMultiHeight) item).updateUnitsOnItem(this);
@@ -2632,12 +2636,12 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
if (item.getBaseItem().getType() == FurnitureType.FLOOR if (item.getBaseItem().getType() == FurnitureType.FLOOR
&& (RoomConfInvisSupport.isControllerItem(item) || RoomConfInvisSupport.isTarget(item))) { && (RoomConfInvisSupport.isControllerItem(item) || RoomConfInvisSupport.isTarget(item))) {
RoomConfInvisSupport.sendState(this); RoomConfInvisSupport.sendState(this);
} }
if (item.getBaseItem().getType() == FurnitureType.FLOOR if (item.getBaseItem().getType() == FurnitureType.FLOOR
&& RoomHanditemBlockSupport.isControllerItem(item)) { && RoomHanditemBlockSupport.isControllerItem(item)) {
RoomHanditemBlockSupport.sendState(this); RoomHanditemBlockSupport.sendState(this);
} }
} }
@@ -2671,18 +2675,18 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
public void refreshGuild(Guild guild) { public void refreshGuild(Guild guild) {
if (guild.getRoomId() == this.id) { if (guild.getRoomId() == this.id) {
THashSet<GuildMember> members = Emulator.getGameEnvironment().getGuildManager() THashSet<GuildMember> members = Emulator.getGameEnvironment().getGuildManager()
.getGuildMembers(guild.getId()); .getGuildMembers(guild.getId());
for (Habbo habbo : this.getHabbos()) { for (Habbo habbo : this.getHabbos()) {
Optional<GuildMember> member = members.stream() Optional<GuildMember> member = members.stream()
.filter(m -> m.getUserId() == habbo.getHabboInfo().getId()).findAny(); .filter(m -> m.getUserId() == habbo.getHabboInfo().getId()).findAny();
if (!member.isPresent()) { if (!member.isPresent()) {
continue; continue;
} }
habbo.getClient() habbo.getClient()
.sendResponse(new GuildInfoComposer(guild, habbo.getClient(), false, member.get())); .sendResponse(new GuildInfoComposer(guild, habbo.getClient(), false, member.get()));
} }
} }
@@ -2717,7 +2721,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
if (habbo.getHabboInfo().getCurrentRoom() == this) { if (habbo.getHabboInfo().getCurrentRoom() == this) {
if (habbo.getHabboInfo().getId() != this.ownerId) { if (habbo.getHabboInfo().getId() != this.ownerId) {
if (!(habbo.hasPermission(Permission.ACC_ANYROOMOWNER) || habbo.hasPermission( if (!(habbo.hasPermission(Permission.ACC_ANYROOMOWNER) || habbo.hasPermission(
Permission.ACC_MOVEROTATE))) { Permission.ACC_MOVEROTATE))) {
this.refreshRightsForHabbo(habbo); this.refreshRightsForHabbo(habbo);
} }
} }
@@ -2803,18 +2807,18 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
} else { } else {
this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(), this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(),
this.roomSpecialTypes.getTriggers()).compose()); this.roomSpecialTypes.getTriggers()).compose());
this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(), this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(),
this.roomSpecialTypes.getEffects()).compose()); this.roomSpecialTypes.getEffects()).compose());
this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(), this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(),
this.roomSpecialTypes.getConditions()).compose()); this.roomSpecialTypes.getConditions()).compose());
this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(), this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(),
this.roomSpecialTypes.getExtras()).compose()); this.roomSpecialTypes.getExtras()).compose());
} }
} }
public FurnitureMovementError canPlaceFurnitureAt(HabboItem item, Habbo habbo, RoomTile tile, public FurnitureMovementError canPlaceFurnitureAt(HabboItem item, Habbo habbo, RoomTile tile,
int rotation) { int rotation) {
return this.itemManager.canPlaceFurnitureAt(item, habbo, tile, rotation); return this.itemManager.canPlaceFurnitureAt(item, habbo, tile, rotation);
} }
@@ -2823,17 +2827,17 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
public FurnitureMovementError furnitureFitsAt(RoomTile tile, HabboItem item, int rotation, public FurnitureMovementError furnitureFitsAt(RoomTile tile, HabboItem item, int rotation,
boolean checkForUnits) { boolean checkForUnits) {
return this.itemManager.furnitureFitsAt(tile, item, rotation, checkForUnits); return this.itemManager.furnitureFitsAt(tile, item, rotation, checkForUnits);
} }
public FurnitureMovementError furnitureFitsAtWithPhysics(RoomTile tile, HabboItem item, int rotation, public FurnitureMovementError furnitureFitsAtWithPhysics(RoomTile tile, HabboItem item, int rotation,
boolean checkForUnits, WiredMovementPhysics physics) { boolean checkForUnits, WiredMovementPhysics physics) {
return this.itemManager.furnitureFitsAtWithPhysics(tile, item, rotation, checkForUnits, physics); return this.itemManager.furnitureFitsAtWithPhysics(tile, item, rotation, checkForUnits, physics);
} }
public FurnitureMovementError placeFloorFurniAt(HabboItem item, RoomTile tile, int rotation, public FurnitureMovementError placeFloorFurniAt(HabboItem item, RoomTile tile, int rotation,
Habbo owner) { Habbo owner) {
return this.itemManager.placeFloorFurniAt(item, tile, rotation, owner); return this.itemManager.placeFloorFurniAt(item, tile, rotation, owner);
} }
@@ -2842,17 +2846,17 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
public FurnitureMovementError moveFurniTo(HabboItem item, RoomTile tile, int rotation, public FurnitureMovementError moveFurniTo(HabboItem item, RoomTile tile, int rotation,
Habbo actor) { Habbo actor) {
return this.itemManager.moveFurniTo(item, tile, rotation, actor); return this.itemManager.moveFurniTo(item, tile, rotation, actor);
} }
public FurnitureMovementError moveFurniTo(HabboItem item, RoomTile tile, int rotation, public FurnitureMovementError moveFurniTo(HabboItem item, RoomTile tile, int rotation,
Habbo actor, boolean sendUpdates) { Habbo actor, boolean sendUpdates) {
return this.itemManager.moveFurniTo(item, tile, rotation, actor, sendUpdates); return this.itemManager.moveFurniTo(item, tile, rotation, actor, sendUpdates);
} }
public FurnitureMovementError moveFurniTo(HabboItem item, RoomTile tile, int rotation, public FurnitureMovementError moveFurniTo(HabboItem item, RoomTile tile, int rotation,
Habbo actor, boolean sendUpdates, boolean checkForUnits) { Habbo actor, boolean sendUpdates, boolean checkForUnits) {
return this.itemManager.moveFurniTo(item, tile, rotation, actor, sendUpdates, checkForUnits); return this.itemManager.moveFurniTo(item, tile, rotation, actor, sendUpdates, checkForUnits);
} }
@@ -2869,12 +2873,12 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
} }
public FurnitureMovementError moveFurniToWithPhysics(HabboItem item, RoomTile tile, int rotation, public FurnitureMovementError moveFurniToWithPhysics(HabboItem item, RoomTile tile, int rotation,
Habbo actor, boolean sendUpdates, boolean checkForUnits, WiredMovementPhysics physics) { Habbo actor, boolean sendUpdates, boolean checkForUnits, WiredMovementPhysics physics) {
return this.itemManager.moveFurniToWithPhysics(item, tile, rotation, actor, sendUpdates, checkForUnits, physics); return this.itemManager.moveFurniToWithPhysics(item, tile, rotation, actor, sendUpdates, checkForUnits, physics);
} }
public FurnitureMovementError moveFurniToWithPhysics(HabboItem item, RoomTile tile, int rotation, double z, public FurnitureMovementError moveFurniToWithPhysics(HabboItem item, RoomTile tile, int rotation, double z,
Habbo actor, boolean sendUpdates, boolean checkForUnits, WiredMovementPhysics physics) { Habbo actor, boolean sendUpdates, boolean checkForUnits, WiredMovementPhysics physics) {
return this.itemManager.moveFurniToWithPhysics(item, tile, rotation, z, actor, sendUpdates, checkForUnits, physics); return this.itemManager.moveFurniToWithPhysics(item, tile, rotation, z, actor, sendUpdates, checkForUnits, physics);
} }
@@ -300,15 +300,20 @@ public class RoomCycleManager {
return; return;
} }
TIntObjectIterator<Bot> botIterator = currentBots.iterator(); // Snapshot under the map monitor (currentBots is a synchronizedMap whose
for (int i = currentBots.size(); i-- > 0; ) { // iterator isn't concurrency-safe), then cycle OFF-lock. Holding the
// monitor across the whole tick would block bot place/pickup and room
// dispose for the tick duration AND invert the lock order vs
// roomUnitLock -> currentBots taken by RoomUnitManager.addBot/clear.
final ArrayList<Bot> bots;
synchronized (currentBots) {
bots = new ArrayList<>(currentBots.valueCollection());
}
for (Bot bot : bots) {
try { try {
final Bot bot; if (bot == null || bot.getRoomUnit() == null) {
try { continue;
botIterator.advance();
bot = botIterator.value();
} catch (Exception e) {
break;
} }
if (!this.room.isAllowBotsWalk() && bot.getRoomUnit().isWalking()) { if (!this.room.isAllowBotsWalk() && bot.getRoomUnit().isWalking()) {
@@ -322,10 +327,8 @@ public class RoomCycleManager {
if (this.cycleRoomUnit(bot.getRoomUnit(), RoomUnitType.BOT)) { if (this.cycleRoomUnit(bot.getRoomUnit(), RoomUnitType.BOT)) {
updatedUnit.add(bot.getRoomUnit()); updatedUnit.add(bot.getRoomUnit());
} }
} catch (Exception e) {
} catch (NoSuchElementException e) {
LOGGER.error("Caught exception", e); LOGGER.error("Caught exception", e);
break;
} }
} }
} }
@@ -339,31 +342,37 @@ public class RoomCycleManager {
return; return;
} }
TIntObjectIterator<Pet> petIterator = currentPets.iterator(); // Snapshot under the monitor, then cycle off-lock (see processBots): avoids
for (int i = currentPets.size(); i-- > 0; ) { // holding currentPets for the whole tick and the roomUnitLock inversion.
final ArrayList<Pet> pets;
synchronized (currentPets) {
pets = new ArrayList<>(currentPets.valueCollection());
}
for (Pet pet : pets) {
try { try {
petIterator.advance(); if (pet == null || pet.getRoomUnit() == null) {
} catch (NoSuchElementException e) { continue;
}
if (this.cycleRoomUnit(pet.getRoomUnit(), RoomUnitType.PET)) {
updatedUnit.add(pet.getRoomUnit());
}
pet.cycle();
if (pet.packetUpdate) {
updatedUnit.add(pet.getRoomUnit());
pet.packetUpdate = false;
}
if (pet.getRoomUnit().isWalking() && pet.getRoomUnit().getPath().size() == 1
&& pet.getRoomUnit().hasStatus(RoomUnitStatus.GESTURE)) {
pet.getRoomUnit().removeStatus(RoomUnitStatus.GESTURE);
updatedUnit.add(pet.getRoomUnit());
}
} catch (Exception e) {
LOGGER.error("Caught exception", e); LOGGER.error("Caught exception", e);
break;
}
Pet pet = petIterator.value();
if (this.cycleRoomUnit(pet.getRoomUnit(), RoomUnitType.PET)) {
updatedUnit.add(pet.getRoomUnit());
}
pet.cycle();
if (pet.packetUpdate) {
updatedUnit.add(pet.getRoomUnit());
pet.packetUpdate = false;
}
if (pet.getRoomUnit().isWalking() && pet.getRoomUnit().getPath().size() == 1
&& pet.getRoomUnit().hasStatus(RoomUnitStatus.GESTURE)) {
pet.getRoomUnit().removeStatus(RoomUnitStatus.GESTURE);
updatedUnit.add(pet.getRoomUnit());
} }
} }
} }
@@ -167,17 +167,22 @@ public class RoomItemManager {
*/ */
public THashSet<HabboItem> getFloorItems() { public THashSet<HabboItem> getFloorItems() {
THashSet<HabboItem> items = new THashSet<>(); THashSet<HabboItem> items = new THashSet<>();
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator(); // roomItems is a TCollections.synchronizedMap; its iterator is not safe
// against concurrent put/remove (item place/pickup), so hold the map
// monitor for the whole traversal, matching the mutation sites.
synchronized (this.roomItems) {
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
for (int i = this.roomItems.size(); i-- > 0; ) { for (int i = this.roomItems.size(); i-- > 0; ) {
try { try {
iterator.advance(); iterator.advance();
} catch (Exception e) { } catch (Exception e) {
break; break;
} }
if (iterator.value().getBaseItem().getType() == FurnitureType.FLOOR) { if (iterator.value().getBaseItem().getType() == FurnitureType.FLOOR) {
items.add(iterator.value()); items.add(iterator.value());
}
} }
} }
@@ -189,17 +194,19 @@ public class RoomItemManager {
*/ */
public THashSet<HabboItem> getWallItems() { public THashSet<HabboItem> getWallItems() {
THashSet<HabboItem> items = new THashSet<>(); THashSet<HabboItem> items = new THashSet<>();
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator(); synchronized (this.roomItems) {
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
for (int i = this.roomItems.size(); i-- > 0; ) { for (int i = this.roomItems.size(); i-- > 0; ) {
try { try {
iterator.advance(); iterator.advance();
} catch (Exception e) { } catch (Exception e) {
break; break;
} }
if (iterator.value().getBaseItem().getType() == FurnitureType.WALL) { if (iterator.value().getBaseItem().getType() == FurnitureType.WALL) {
items.add(iterator.value()); items.add(iterator.value());
}
} }
} }
@@ -211,18 +218,20 @@ public class RoomItemManager {
*/ */
public THashSet<HabboItem> getPostItNotes() { public THashSet<HabboItem> getPostItNotes() {
THashSet<HabboItem> items = new THashSet<>(); THashSet<HabboItem> items = new THashSet<>();
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator(); synchronized (this.roomItems) {
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
for (int i = this.roomItems.size(); i-- > 0; ) { for (int i = this.roomItems.size(); i-- > 0; ) {
try { try {
iterator.advance(); iterator.advance();
} catch (Exception e) { } catch (Exception e) {
break; break;
} }
if (iterator.value().getBaseItem().getInteractionType().getType() if (iterator.value().getBaseItem().getInteractionType().getType()
== InteractionPostIt.class) { == InteractionPostIt.class) {
items.add(iterator.value()); items.add(iterator.value());
}
} }
} }
@@ -276,44 +285,49 @@ public class RoomItemManager {
} }
} }
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator(); // Cache miss: iterate roomItems under its monitor so a concurrent
// place/pickup can't rehash the map mid-traversal (which the per-advance
// try/catch would otherwise silently swallow into an incomplete result).
synchronized (this.roomItems) {
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
for (int i = this.roomItems.size(); i-- > 0; ) { for (int i = this.roomItems.size(); i-- > 0; ) {
HabboItem item; HabboItem item;
try { try {
iterator.advance(); iterator.advance();
item = iterator.value(); item = iterator.value();
} catch (Exception e) { } catch (Exception e) {
break; break;
} }
if (item == null) { if (item == null) {
continue; continue;
} }
if (item.getBaseItem().getType() != FurnitureType.FLOOR) { if (item.getBaseItem().getType() != FurnitureType.FLOOR) {
continue; continue;
} }
int width, length; int width, length;
if (item.getRotation() != 2 && item.getRotation() != 6) { if (item.getRotation() != 2 && item.getRotation() != 6) {
width = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1; width = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1;
length = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1; length = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1;
} else { } else {
width = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1; width = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1;
length = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1; length = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1;
} }
if (!(tile.x >= item.getX() && tile.x <= item.getX() + width - 1 && tile.y >= item.getY() if (!(tile.x >= item.getX() && tile.x <= item.getX() + width - 1 && tile.y >= item.getY()
&& tile.y <= item.getY() + length - 1)) { && tile.y <= item.getY() + length - 1)) {
continue; continue;
} }
items.add(item); items.add(item);
if (returnOnFirst) { if (returnOnFirst) {
return items; return items;
}
} }
} }
@@ -956,9 +970,11 @@ public class RoomItemManager {
public int getUserUniqueFurniCount(int userId) { public int getUserUniqueFurniCount(int userId) {
THashSet<Item> items = new THashSet<>(); THashSet<Item> items = new THashSet<>();
for (HabboItem item : this.roomItems.valueCollection()) { synchronized (this.roomItems) {
if (!items.contains(item.getBaseItem()) && item.getUserId() == userId) { for (HabboItem item : this.roomItems.valueCollection()) {
items.add(item.getBaseItem()); if (!items.contains(item.getBaseItem()) && item.getUserId() == userId) {
items.add(item.getBaseItem());
}
} }
} }
@@ -130,13 +130,16 @@ public class RoomLayout {
this.roomTiles = new RoomTile[this.mapSizeX][this.mapSizeY]; this.roomTiles = new RoomTile[this.mapSizeX][this.mapSizeY];
for (short y = 0; y < this.mapSizeY; y++) { for (short y = 0; y < this.mapSizeY; y++) {
if (modelTemp[y].isEmpty() || modelTemp[y].equalsIgnoreCase("\r")) { // A row shorter/longer than the model width (or empty) cannot be parsed
continue; // per-square. Previously such tiles were left null while tileExists()
} // still reported them present, causing NPEs in the coordinate accessors.
// Fill them with INVALID tiles so every in-bounds coordinate is non-null.
boolean validRow = !modelTemp[y].isEmpty() && modelTemp[y].length() == this.mapSizeX;
for (short x = 0; x < this.mapSizeX; x++) { for (short x = 0; x < this.mapSizeX; x++) {
if (modelTemp[y].length() != this.mapSizeX) { if (!validRow) {
break; this.roomTiles[x][y] = new RoomTile(x, y, (short) 0, RoomTileState.INVALID, true);
continue;
} }
String square = modelTemp[y].substring(x, x + 1).trim().toLowerCase(); String square = modelTemp[y].substring(x, x + 1).trim().toLowerCase();
@@ -159,7 +162,9 @@ public class RoomLayout {
} }
} }
this.doorTile = this.roomTiles[this.doorX][this.doorY]; this.doorTile = (this.doorX >= 0 && this.doorX < this.mapSizeX && this.doorY >= 0 && this.doorY < this.mapSizeY)
? this.roomTiles[this.doorX][this.doorY]
: null;
if (this.doorTile != null) { if (this.doorTile != null) {
this.doorTile.setAllowStack(false); this.doorTile.setAllowStack(false);
@@ -731,10 +731,10 @@ public class RoomManager {
habbo.getClient().sendResponse(new RoomModelComposer(room)); habbo.getClient().sendResponse(new RoomModelComposer(room));
if (!room.getWallPaint().equals("0.0")) if (room.getWallPaint() != null && !room.getWallPaint().equals("0.0"))
habbo.getClient().sendResponse(new RoomPaintComposer("wallpaper", room.getWallPaint())); habbo.getClient().sendResponse(new RoomPaintComposer("wallpaper", room.getWallPaint()));
if (!room.getFloorPaint().equals("0.0")) if (room.getFloorPaint() != null && !room.getFloorPaint().equals("0.0"))
habbo.getClient().sendResponse(new RoomPaintComposer("floor", room.getFloorPaint())); habbo.getClient().sendResponse(new RoomPaintComposer("floor", room.getFloorPaint()));
habbo.getClient().sendResponse(new RoomPaintComposer("landscape", room.getBackgroundPaint())); habbo.getClient().sendResponse(new RoomPaintComposer("landscape", room.getBackgroundPaint()));
@@ -272,10 +272,16 @@ public class RoomRightsManager {
} else if (this.isOwner(habbo)) { } else if (this.isOwner(habbo)) {
habbo.getClient().sendResponse(new RoomOwnerComposer()); habbo.getClient().sendResponse(new RoomOwnerComposer());
flatCtrl = RoomRightLevels.MODERATOR; flatCtrl = RoomRightLevels.MODERATOR;
} else if (this.hasRights(habbo) && !this.room.hasGuild()) {
flatCtrl = RoomRightLevels.RIGHTS;
} else if (this.room.hasGuild()) { } else if (this.room.hasGuild()) {
flatCtrl = this.getGuildRightLevel(habbo); // Explicit room rights must still be honoured in guild rooms (the old
// `&& !hasGuild()` guard stripped them for non-guild members) take
// whichever of the two is stronger.
RoomRightLevels guildLevel = this.getGuildRightLevel(habbo);
flatCtrl = (this.hasRights(habbo) && RoomRightLevels.RIGHTS.isEqualOrGreaterThan(guildLevel))
? RoomRightLevels.RIGHTS
: guildLevel;
} else if (this.hasRights(habbo)) {
flatCtrl = RoomRightLevels.RIGHTS;
} }
habbo.getClient().sendResponse(new RoomRightsComposer(flatCtrl)); habbo.getClient().sendResponse(new RoomRightsComposer(flatCtrl));
@@ -152,15 +152,23 @@ public class RoomSpecialTypes {
public InteractionNest getNest(int itemId) { public InteractionNest getNest(int itemId) {
return this.nests.get(itemId); synchronized (this.nests) {
return this.nests.get(itemId);
}
} }
public void addNest(InteractionNest item) { public void addNest(InteractionNest item) {
this.nests.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); synchronized (this.nests) {
this.nests.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
} }
public void removeNest(InteractionNest item) { public void removeNest(InteractionNest item) {
this.nests.remove(item.getId()); this.specialItemsById.remove(item.getId()); synchronized (this.nests) {
this.nests.remove(item.getId());
}
this.specialItemsById.remove(item.getId());
} }
public THashSet<InteractionNest> getNests() { public THashSet<InteractionNest> getNests() {
@@ -174,15 +182,23 @@ public class RoomSpecialTypes {
public InteractionPetDrink getPetDrink(int itemId) { public InteractionPetDrink getPetDrink(int itemId) {
return this.petDrinks.get(itemId); synchronized (this.petDrinks) {
return this.petDrinks.get(itemId);
}
} }
public void addPetDrink(InteractionPetDrink item) { public void addPetDrink(InteractionPetDrink item) {
this.petDrinks.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); synchronized (this.petDrinks) {
this.petDrinks.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
} }
public void removePetDrink(InteractionPetDrink item) { public void removePetDrink(InteractionPetDrink item) {
this.petDrinks.remove(item.getId()); this.specialItemsById.remove(item.getId()); synchronized (this.petDrinks) {
this.petDrinks.remove(item.getId());
}
this.specialItemsById.remove(item.getId());
} }
public THashSet<InteractionPetDrink> getPetDrinks() { public THashSet<InteractionPetDrink> getPetDrinks() {
@@ -196,15 +212,23 @@ public class RoomSpecialTypes {
public InteractionPetFood getPetFood(int itemId) { public InteractionPetFood getPetFood(int itemId) {
return this.petFoods.get(itemId); synchronized (this.petFoods) {
return this.petFoods.get(itemId);
}
} }
public void addPetFood(InteractionPetFood item) { public void addPetFood(InteractionPetFood item) {
this.petFoods.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); synchronized (this.petFoods) {
this.petFoods.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
} }
public void removePetFood(InteractionPetFood petFood) { public void removePetFood(InteractionPetFood petFood) {
this.petFoods.remove(petFood.getId()); this.specialItemsById.remove(petFood.getId()); synchronized (this.petFoods) {
this.petFoods.remove(petFood.getId());
}
this.specialItemsById.remove(petFood.getId());
} }
public THashSet<InteractionPetFood> getPetFoods() { public THashSet<InteractionPetFood> getPetFoods() {
@@ -218,15 +242,23 @@ public class RoomSpecialTypes {
public InteractionPetToy getPetToy(int itemId) { public InteractionPetToy getPetToy(int itemId) {
return this.petToys.get(itemId); synchronized (this.petToys) {
return this.petToys.get(itemId);
}
} }
public void addPetToy(InteractionPetToy item) { public void addPetToy(InteractionPetToy item) {
this.petToys.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); synchronized (this.petToys) {
this.petToys.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
} }
public void removePetToy(InteractionPetToy petToy) { public void removePetToy(InteractionPetToy petToy) {
this.petToys.remove(petToy.getId()); this.specialItemsById.remove(petToy.getId()); synchronized (this.petToys) {
this.petToys.remove(petToy.getId());
}
this.specialItemsById.remove(petToy.getId());
} }
public THashSet<InteractionPetToy> getPetToys() { public THashSet<InteractionPetToy> getPetToys() {
@@ -240,15 +272,23 @@ public class RoomSpecialTypes {
public InteractionPetTree getPetTree(int itemId) { public InteractionPetTree getPetTree(int itemId) {
return this.petTrees.get(itemId); synchronized (this.petTrees) {
return this.petTrees.get(itemId);
}
} }
public void addPetTree(InteractionPetTree item) { public void addPetTree(InteractionPetTree item) {
this.petTrees.put(item.getId(), item); this.specialItemsById.put(item.getId(), item); synchronized (this.petTrees) {
this.petTrees.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
} }
public void removePetTree(InteractionPetTree petTree) { public void removePetTree(InteractionPetTree petTree) {
this.petTrees.remove(petTree.getId()); this.specialItemsById.remove(petTree.getId()); synchronized (this.petTrees) {
this.petTrees.remove(petTree.getId());
}
this.specialItemsById.remove(petTree.getId());
} }
public THashSet<InteractionPetTree> getPetTrees() { public THashSet<InteractionPetTree> getPetTrees() {
@@ -26,6 +26,7 @@ public class RoomTrade {
private final List<RoomTradeUser> users; private final List<RoomTradeUser> users;
private final Room room; private final Room room;
private boolean completed = false;
public RoomTrade(Habbo userOne, Habbo userTwo, Room room) { public RoomTrade(Habbo userOne, Habbo userTwo, Room room) {
this.users = new ArrayList<>(); this.users = new ArrayList<>();
@@ -54,7 +55,7 @@ public class RoomTrade {
this.sendMessageToUsers(new TradeStartComposer(this)); this.sendMessageToUsers(new TradeStartComposer(this));
} }
public void offerItem(Habbo habbo, HabboItem item) { public synchronized void offerItem(Habbo habbo, HabboItem item) {
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
if (user.getItems().contains(item)) if (user.getItems().contains(item))
@@ -67,7 +68,7 @@ public class RoomTrade {
this.updateWindow(); this.updateWindow();
} }
public void offerMultipleItems(Habbo habbo, THashSet<HabboItem> items) { public synchronized void offerMultipleItems(Habbo habbo, THashSet<HabboItem> items) {
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
for (HabboItem item : items) { for (HabboItem item : items) {
@@ -81,7 +82,7 @@ public class RoomTrade {
this.updateWindow(); this.updateWindow();
} }
public void removeItem(Habbo habbo, HabboItem item) { public synchronized void removeItem(Habbo habbo, HabboItem item) {
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
if (!user.getItems().contains(item)) if (!user.getItems().contains(item))
@@ -94,7 +95,7 @@ public class RoomTrade {
this.updateWindow(); this.updateWindow();
} }
public void accept(Habbo habbo, boolean value) { public synchronized void accept(Habbo habbo, boolean value) {
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
user.setAccepted(value); user.setAccepted(value);
@@ -110,7 +111,13 @@ public class RoomTrade {
} }
} }
public void confirm(Habbo habbo) { public synchronized void confirm(Habbo habbo) {
// Re-entry guard: both participants confirm on their own EventLoop
// threads. Without this (and the method-level lock) two concurrent
// confirms could each observe "all confirmed" and run tradeItems()
// twice item/credit duplication.
if (this.completed) return;
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo); RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
user.confirm(); user.confirm();
@@ -122,6 +129,8 @@ public class RoomTrade {
accepted = false; accepted = false;
} }
if (accepted) { if (accepted) {
this.completed = true;
if (this.tradeItems()) { if (this.tradeItems()) {
this.closeWindow(); this.closeWindow();
this.sendMessageToUsers(new TradeCompleteComposer()); this.sendMessageToUsers(new TradeCompleteComposer());
@@ -264,6 +273,10 @@ public class RoomTrade {
protected void clearAccepted() { protected void clearAccepted() {
for (RoomTradeUser user : this.users) { for (RoomTradeUser user : this.users) {
user.setAccepted(false); user.setAccepted(false);
// Any change to the offered items invalidates a prior confirmation;
// without this a stale confirmed=true lets a user strip their side
// and still complete the trade once the partner re-confirms.
user.setConfirmed(false);
} }
} }
@@ -51,6 +51,10 @@ public class RoomTradeUser {
this.confirmed = true; this.confirmed = true;
} }
public void setConfirmed(boolean value) {
this.confirmed = value;
}
public void addItem(HabboItem item) { public void addItem(HabboItem item) {
this.items.add(item); this.items.add(item);
} }
@@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -71,7 +72,10 @@ public class RoomUnit {
private RoomUserRotation headRotation = RoomUserRotation.NORTH; private RoomUserRotation headRotation = RoomUserRotation.NORTH;
private DanceType danceType; private DanceType danceType;
private RoomUnitType roomUnitType; private RoomUnitType roomUnitType;
private Deque<RoomTile> path = new LinkedList<>(); // Concurrent + volatile: the room cycle thread polls/clears this path while a
// walk packet thread rebuilds it via findPath/setPath. A plain LinkedList would
// corrupt under the concurrent structural modification.
private volatile Deque<RoomTile> path = new ConcurrentLinkedDeque<>();
private int handItem; private int handItem;
private long handItemTimestamp; private long handItemTimestamp;
private long lastRollerTime; private long lastRollerTime;
@@ -587,7 +591,7 @@ public class RoomUnit {
Deque<RoomTile> newPath = this.room.getLayout().getPathfinder() Deque<RoomTile> newPath = this.room.getLayout().getPathfinder()
.findPath(this.currentLocation, this.goalLocation, this.goalLocation, this); .findPath(this.currentLocation, this.goalLocation, this.goalLocation, this);
if (newPath != null && !newPath.isEmpty()) { if (newPath != null && !newPath.isEmpty()) {
this.path = newPath; this.path = new ConcurrentLinkedDeque<>(newPath);
} }
} }
@@ -765,7 +769,7 @@ public class RoomUnit {
} }
public void setPath(Deque<RoomTile> path) { public void setPath(Deque<RoomTile> path) {
this.path = path; this.path = (path == null) ? new ConcurrentLinkedDeque<>() : new ConcurrentLinkedDeque<>(path);
} }
public RoomRightLevels getRightsLevel() { public RoomRightLevels getRightsLevel() {
@@ -24,8 +24,11 @@ public class PathfinderImpl implements Pathfinder {
private static final int CACHED_TIMEOUT_MS = Emulator.getConfig() private static final int CACHED_TIMEOUT_MS = Emulator.getConfig()
.getInt(CONFIG_EXECUTION_TIME, 25); .getInt(CONFIG_EXECUTION_TIME, 25);
// Default ON: bound A* to CACHED_TIMEOUT_MS (25ms) so a pathological search
// can't run unbounded and stall the thread. On timeout findPath returns an
// empty path (the unit simply doesn't move there) graceful degradation.
private static final boolean CACHED_TIMEOUT_ENABLED = Emulator.getConfig() private static final boolean CACHED_TIMEOUT_ENABLED = Emulator.getConfig()
.getBoolean(CONFIG_TIMEOUT_ENABLED, false); .getBoolean(CONFIG_TIMEOUT_ENABLED, true);
private static final long CACHED_TIMEOUT_NANOS = CACHED_TIMEOUT_MS * 1_000_000L; private static final long CACHED_TIMEOUT_NANOS = CACHED_TIMEOUT_MS * 1_000_000L;
private final Room room; private final Room room;
@@ -146,31 +146,23 @@ public class Habbo implements Runnable {
this.habboInfo.setIpLogin(ip); this.habboInfo.setIpLogin(ip);
} }
if (this.client.getMachineId() == null || this.client.getMachineId().length() == 0) { // The Nitro client sends the UniqueID (machine fingerprint) packet right
return false; // AFTER the SSO ticket, so client.getMachineId() may still be null here.
} // Do NOT reject the login for a missing machineId MachineIDEvent sets it
// and enforces the MAC ban as soon as the UniqueID packet arrives. Only
// MAC-ban check here when the fingerprint is already available.
String machineId = this.client.getMachineId();
if (machineId != null && !machineId.isEmpty()) {
this.habboInfo.setMachineID(machineId);
this.habboInfo.setMachineID(this.client.getMachineId()); if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) {
return false;
if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) { }
return false;
} }
if (Emulator.getGameEnvironment().getModToolManager().hasIPBan(this.habboInfo.getIpLogin())) { if (Emulator.getGameEnvironment().getModToolManager().hasIPBan(this.habboInfo.getIpLogin())) {
return false; return false;
} }
this.habboInfo.setMachineID(this.client.getMachineId());
if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) {
return false;
}
if (Emulator.getGameEnvironment().getModToolManager().hasIPBan(this.habboInfo.getIpLogin())) {
return false;
}
this.habboInfo.setMachineID(this.client.getMachineId());
this.isOnline(true); this.isOnline(true);
this.messenger.connectionChanged(this, true, false); this.messenger.connectionChanged(this, true, false);
@@ -55,6 +55,11 @@ public class HabboInfo implements Runnable {
private RideablePet riding; private RideablePet riding;
private Class<? extends Game> currentGame; private Class<? extends Game> currentGame;
private TIntIntHashMap currencies; private TIntIntHashMap currencies;
// Serializes credits + currencies read-modify-write and the saveCurrencies
// snapshot so the credit-roller thread and purchase/trade handler threads
// can't lose updates or rehash the Trove map mid-iteration. Never held
// across run()'s DB I/O.
private final Object currencyLock = new Object();
private GamePlayer gamePlayer; private GamePlayer gamePlayer;
private int photoRoomId; private int photoRoomId;
private int photoTimestamp; private int photoTimestamp;
@@ -123,11 +128,16 @@ public class HabboInfo implements Runnable {
} }
private void saveCurrencies() { private void saveCurrencies() {
List<int[]> entries = new ArrayList<>(this.currencies.size()); // Snapshot under the lock so a concurrent adjustOrPutValue/put can't
this.currencies.forEachEntry((type, amount) -> { // rehash the Trove map while we iterate; do the DB batch off-lock.
entries.add(new int[]{type, amount}); List<int[]> entries;
return true; synchronized (this.currencyLock) {
}); entries = new ArrayList<>(this.currencies.size());
this.currencies.forEachEntry((type, amount) -> {
entries.add(new int[]{type, amount});
return true;
});
}
try { try {
SqlQueries.batchUpdate( SqlQueries.batchUpdate(
@@ -238,20 +248,30 @@ public class HabboInfo implements Runnable {
} }
public int getCurrencyAmount(int type) { public int getCurrencyAmount(int type) {
return this.currencies.get(type); synchronized (this.currencyLock) {
return this.currencies.get(type);
}
} }
public TIntIntHashMap getCurrencies() { public TIntIntHashMap getCurrencies() {
return this.currencies; // Return a snapshot under the lock: callers iterate this map, which would
// otherwise corrupt during a concurrent adjustOrPutValue rehash.
synchronized (this.currencyLock) {
return new TIntIntHashMap(this.currencies);
}
} }
public void addCurrencyAmount(int type, int amount) { public void addCurrencyAmount(int type, int amount) {
this.currencies.adjustOrPutValue(type, amount, amount); synchronized (this.currencyLock) {
this.currencies.adjustOrPutValue(type, amount, amount);
}
this.run(); this.run();
} }
public void setCurrencyAmount(int type, int amount) { public void setCurrencyAmount(int type, int amount) {
this.currencies.put(type, amount); synchronized (this.currencyLock) {
this.currencies.put(type, amount);
}
this.run(); this.run();
} }
@@ -380,20 +400,26 @@ public class HabboInfo implements Runnable {
} }
public boolean canBuy(CatalogItem item) { public boolean canBuy(CatalogItem item) {
return this.credits >= item.getCredits() && this.getCurrencies().get(item.getPointsType()) >= item.getPoints(); return this.getCredits() >= item.getCredits() && this.getCurrencyAmount(item.getPointsType()) >= item.getPoints();
} }
public int getCredits() { public int getCredits() {
return this.credits; synchronized (this.currencyLock) {
return this.credits;
}
} }
public void setCredits(int credits) { public void setCredits(int credits) {
this.credits = credits; synchronized (this.currencyLock) {
this.credits = credits;
}
this.run(); this.run();
} }
public void addCredits(int credits) { public void addCredits(int credits) {
this.credits += credits; synchronized (this.currencyLock) {
this.credits += credits;
}
this.run(); this.run();
} }
@@ -600,6 +626,13 @@ public class HabboInfo implements Runnable {
public void run() { public void run() {
this.saveCurrencies(); this.saveCurrencies();
// Read credits under the lock so the persisted value is consistent with
// concurrent addCredits/setCredits (matches the currencyLock invariant).
final int creditsForSave;
synchronized (this.currencyLock) {
creditsForSave = this.credits;
}
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 = ?, background_card_id = ?, background_border_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 = ?, background_border_id = ? WHERE id = ?",
@@ -607,7 +640,7 @@ public class HabboInfo implements Runnable {
this.online ? "1" : "0", this.online ? "1" : "0",
this.look, this.look,
this.gender.name(), this.gender.name(),
this.credits, creditsForSave,
Emulator.getIntUnixTimestamp(), Emulator.getIntUnixTimestamp(),
this.lastOnline, this.lastOnline,
this.homeRoom, this.homeRoom,
@@ -111,7 +111,7 @@ public class HabboManager {
habbo = this.cloneCheck(userId); habbo = this.cloneCheck(userId);
if (habbo != null) { if (habbo != null) {
habbo.alert(Emulator.getTexts().getValue("loggedin.elsewhere")); habbo.alert(Emulator.getTexts().getValue("loggedin.elsewhere"));
Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient()); Emulator.getGameServer().getGameClientManager().forceDisposeClient(habbo.getClient());
habbo = null; habbo = null;
} }
@@ -448,14 +448,26 @@ public class HabboStats implements Runnable {
return 0; return 0;
} }
if (this.achievementProgress.containsKey(achievement)) synchronized (this.achievementProgress) {
return this.achievementProgress.get(achievement); Integer progress = this.achievementProgress.get(achievement);
return progress != null ? progress : -1;
return -1; }
} }
public void setProgress(Achievement achievement, int progress) { public void setProgress(Achievement achievement, int progress) {
this.achievementProgress.put(achievement, progress); synchronized (this.achievementProgress) {
this.achievementProgress.put(achievement, progress);
}
}
/** Atomic read-add-write so concurrent progress sources don't lose updates. Returns the new total. */
public int incrementProgress(Achievement achievement, int amount) {
synchronized (this.achievementProgress) {
Integer current = this.achievementProgress.get(achievement);
int next = (current != null ? current : 0) + amount;
this.achievementProgress.put(achievement, next);
return next;
}
} }
public int getRentedTimeEnd() { public int getRentedTimeEnd() {
@@ -178,6 +178,15 @@ public class WiredHandler {
private static boolean handle(InteractionWiredTrigger trigger, final RoomUnit roomUnit, final Room room, final Object[] stuff, final LegacyExecutionPlan executionPlan) { private static boolean handle(InteractionWiredTrigger trigger, final RoomUnit roomUnit, final Room room, final Object[] stuff, final LegacyExecutionPlan executionPlan) {
long millis = System.currentTimeMillis(); long millis = System.currentTimeMillis();
int roomUnitId = roomUnit != null ? roomUnit.getId() : -1; int roomUnitId = roomUnit != null ? roomUnit.getId() : -1;
// Only one thread may process a given trigger box at a time, so the
// cooldown check (below) and setCooldown (further down) act as one
// atomic claim preventing a concurrent packet/cycle double-fire.
if (!trigger.tryBeginProcessing()) {
return false;
}
try {
if (Emulator.isReady && ((Emulator.getConfig().getBoolean("wired.custom.enabled", false) && (trigger.canExecute(millis) || roomUnitId > -1) && trigger.userCanExecute(roomUnitId, millis)) || (!Emulator.getConfig().getBoolean("wired.custom.enabled", false) && trigger.canExecute(millis))) && trigger.execute(roomUnit, room, stuff)) { if (Emulator.isReady && ((Emulator.getConfig().getBoolean("wired.custom.enabled", false) && (trigger.canExecute(millis) || roomUnitId > -1) && trigger.userCanExecute(roomUnitId, millis)) || (!Emulator.getConfig().getBoolean("wired.custom.enabled", false) && trigger.canExecute(millis))) && trigger.execute(roomUnit, room, stuff)) {
THashSet<InteractionWiredCondition> conditions = room.getRoomSpecialTypes().getConditions(trigger.getX(), trigger.getY()); THashSet<InteractionWiredCondition> conditions = room.getRoomSpecialTypes().getConditions(trigger.getX(), trigger.getY());
THashSet<InteractionWiredEffect> effects = room.getRoomSpecialTypes().getEffects(trigger.getX(), trigger.getY()); THashSet<InteractionWiredEffect> effects = room.getRoomSpecialTypes().getEffects(trigger.getX(), trigger.getY());
@@ -272,6 +281,9 @@ public class WiredHandler {
} }
return false; return false;
} finally {
trigger.endProcessing();
}
} }
private static boolean evaluateConditions(THashSet<InteractionWiredCondition> conditions, RoomUnit roomUnit, Room room, Object[] stuff, int evaluationMode, int evaluationValue) { private static boolean evaluateConditions(THashSet<InteractionWiredCondition> conditions, RoomUnit roomUnit, Room room, Object[] stuff, int evaluationMode, int evaluationValue) {
@@ -4,6 +4,8 @@ import com.eu.habbo.util.PacketUtils;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled; import io.netty.buffer.Unpooled;
import java.nio.charset.StandardCharsets;
public class ClientMessage { public class ClientMessage {
private final int header; private final int header;
private final ByteBuf buffer; private final ByteBuf buffer;
@@ -61,10 +63,17 @@ public class ClientMessage {
public String readString() { public String readString() {
try { try {
int length = this.readShort(); // Length is an unsigned short in the protocol; mask to avoid a
// negative array size, and clamp to what's actually buffered so a
// bogus length can't throw mid-read and desync the remaining fields.
int length = this.readShort() & 0xFFFF;
int available = this.buffer.readableBytes();
if (length > available) {
length = available;
}
byte[] data = new byte[length]; byte[] data = new byte[length];
this.buffer.readBytes(data); this.buffer.readBytes(data);
return new String(data); return new String(data, StandardCharsets.UTF_8);
} catch (Exception e) { } catch (Exception e) {
return ""; return "";
} }
@@ -7,6 +7,7 @@ import io.netty.buffer.ByteBufOutputStream;
import io.netty.buffer.Unpooled; import io.netty.buffer.Unpooled;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class ServerMessage { public class ServerMessage {
@@ -61,7 +62,7 @@ public class ServerMessage {
} }
try { try {
byte[] data = obj.getBytes(); byte[] data = obj.getBytes(StandardCharsets.UTF_8);
this.stream.writeShort(data.length); this.stream.writeShort(data.length);
this.stream.write(data); this.stream.write(data);
} catch (IOException e) { } catch (IOException e) {
@@ -14,11 +14,7 @@ import com.eu.habbo.habbohotel.users.HabboBadge;
import com.eu.habbo.habbohotel.users.HabboInventory; import com.eu.habbo.habbohotel.users.HabboInventory;
import com.eu.habbo.habbohotel.users.subscriptions.Subscription; import com.eu.habbo.habbohotel.users.subscriptions.Subscription;
import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.catalog.AlertPurchaseFailedComposer; import com.eu.habbo.messages.outgoing.catalog.*;
import com.eu.habbo.messages.outgoing.catalog.AlertPurchaseUnavailableComposer;
import com.eu.habbo.messages.outgoing.catalog.BuildersClubFurniCountComposer;
import com.eu.habbo.messages.outgoing.catalog.BuildersClubSubscriptionStatusComposer;
import com.eu.habbo.messages.outgoing.catalog.PurchaseOKComposer;
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer; import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertComposer;
import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertKeys; import com.eu.habbo.messages.outgoing.generic.alerts.BubbleAlertKeys;
import com.eu.habbo.messages.outgoing.generic.alerts.HotelWillCloseInMinutesComposer; import com.eu.habbo.messages.outgoing.generic.alerts.HotelWillCloseInMinutesComposer;
@@ -52,6 +48,8 @@ public class CatalogBuyItemEvent extends MessageHandler {
int itemId = this.packet.readInt(); int itemId = this.packet.readInt();
String extraData = this.packet.readString(); String extraData = this.packet.readString();
int count = this.packet.readInt(); int count = this.packet.readInt();
if (count < 1) count = 1;
if (count > 100) count = 100;
try { try {
if (this.client.getHabbo().getInventory().getItemsComponent().itemCount() > HabboInventory.MAXIMUM_ITEMS) { if (this.client.getHabbo().getInventory().getItemsComponent().itemCount() > HabboInventory.MAXIMUM_ITEMS) {
@@ -203,12 +201,6 @@ public class CatalogBuyItemEvent extends MessageHandler {
else else
item = page.getCatalogItem(itemId); item = page.getCatalogItem(itemId);
// Search-results buy sends the catalog offer_id as itemId
// (FurnitureOffer.offerId is derived from furnidata's
// purchaseOfferId, which matches `catalog_items.offer_id`),
// not the `catalog_items.id` primary key that getCatalogItem
// expects. Fall back to scanning the page for the matching
// offer_id so the search buy flow works.
if (item == null && !(page instanceof RecentPurchasesLayout)) { if (item == null && !(page instanceof RecentPurchasesLayout)) {
for (CatalogItem candidate : page.getCatalogItems().valueCollection()) { for (CatalogItem candidate : page.getCatalogItems().valueCollection()) {
if (candidate != null && candidate.getOfferId() == itemId) { if (candidate != null && candidate.getOfferId() == itemId) {
@@ -217,13 +209,7 @@ public class CatalogBuyItemEvent extends MessageHandler {
} }
} }
} }
// Inventory cap check based on the actual base items the
// purchase will create, not the page layout - bots/pets
// can legitimately live on bundle pages, search results,
// recent-purchases, etc., and the layout-instanceof check
// missed all those paths. Mirrors the bot/pet branches
// inside CatalogManager.purchaseItem (Item.isBot / isPet
// and the same prefix check) so detection stays in sync.
boolean itemHasBot = false; boolean itemHasBot = false;
boolean itemHasPet = false; boolean itemHasPet = false;
@@ -29,6 +29,11 @@ public class OpenRecycleBoxEvent extends MessageHandler {
if (item.getUserId() != this.client.getHabbo().getHabboInfo().getId()) return; if (item.getUserId() != this.client.getHabbo().getHabboInfo().getId()) return;
if (item instanceof InteractionGift) { if (item instanceof InteractionGift) {
// The actual unwrap (OpenGift) runs async/delayed and only then
// removes the wrapper, so a second packet would otherwise pass
// the room/owner checks and double-process the gift. Claim it once.
if (!((InteractionGift) item).tryStartOpening()) return;
if (item.getBaseItem().getName().contains("present_wrap")) { if (item.getBaseItem().getName().contains("present_wrap")) {
((InteractionGift) item).explode = true; ((InteractionGift) item).explode = true;
room.updateItem(item); room.updateItem(item);
@@ -40,23 +40,26 @@ public class RecycleEvent extends MessageHandler {
} }
} }
if (items.size() == count) { if (items.size() != count) {
for (HabboItem item : items) {
this.client.getHabbo().getInventory().getItemsComponent().removeHabboItem(item);
this.client.sendResponse(new RemoveHabboItemComposer(item.getGiftAdjustedId()));
Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId()));
}
} else {
this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR)); this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR));
return; return;
} }
// Compute the reward BEFORE consuming the inputs. Previously the
// inputs were deleted first, so a null reward (misconfiguration)
// permanently destroyed the 8 furni with nothing in return.
HabboItem reward = Emulator.getGameEnvironment().getItemManager().handleRecycle(this.client.getHabbo(), Emulator.getGameEnvironment().getCatalogManager().getRandomRecyclerPrize().getId() + ""); HabboItem reward = Emulator.getGameEnvironment().getItemManager().handleRecycle(this.client.getHabbo(), Emulator.getGameEnvironment().getCatalogManager().getRandomRecyclerPrize().getId() + "");
if (reward == null) { if (reward == null) {
this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR)); this.client.sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR));
return; return;
} }
for (HabboItem item : items) {
this.client.getHabbo().getInventory().getItemsComponent().removeHabboItem(item);
this.client.sendResponse(new RemoveHabboItemComposer(item.getGiftAdjustedId()));
Emulator.getThreading().run(new QueryDeleteHabboItem(item.getId()));
}
this.client.sendResponse(new AddHabboItemComposer(reward)); this.client.sendResponse(new AddHabboItemComposer(reward));
this.client.getHabbo().getInventory().getItemsComponent().addItem(reward); this.client.getHabbo().getInventory().getItemsComponent().addItem(reward);
this.client.sendResponse(new RecyclerCompleteComposer(RecyclerCompleteComposer.RECYCLING_COMPLETE)); this.client.sendResponse(new RecyclerCompleteComposer(RecyclerCompleteComposer.RECYCLING_COMPLETE));
@@ -37,6 +37,8 @@ public class CraftingCraftItemEvent extends MessageHandler {
HabboItem habboItem = this.client.getHabbo().getInventory().getItemsComponent().getAndRemoveHabboItem(set.getKey()); HabboItem habboItem = this.client.getHabbo().getInventory().getItemsComponent().getAndRemoveHabboItem(set.getKey());
if (habboItem == null) { if (habboItem == null) {
// Not enough ingredients give back whatever we already pulled.
this.restoreItems(toRemove);
return; return;
} }
@@ -70,8 +72,23 @@ public class CraftingCraftItemEvent extends MessageHandler {
return; return;
} }
// Reward creation failed after we already pulled the ingredients
// restore them so the craft isn't a silent item sink.
this.restoreItems(toRemove);
} }
this.client.sendResponse(new CraftingResultComposer(null)); this.client.sendResponse(new CraftingResultComposer(null));
} }
private void restoreItems(TIntObjectHashMap<HabboItem> items) {
if (items.isEmpty()) {
return;
}
items.forEachValue(item -> {
this.client.getHabbo().getInventory().getItemsComponent().addItem(item);
this.client.sendResponse(new AddHabboItemComposer(item));
return true;
});
this.client.sendResponse(new InventoryRefreshComposer());
}
} }
@@ -49,15 +49,17 @@ public class FurniEditorSearchEvent extends MessageHandler {
try { try {
int numericQuery = Integer.parseInt(query); int numericQuery = Integer.parseInt(query);
isNumeric = true; isNumeric = true;
String likeQuery = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%";
whereClause.append(" AND (id = ? OR sprite_id = ? OR item_name LIKE ? OR public_name LIKE ?)"); whereClause.append(" AND (id = ? OR sprite_id = ? OR item_name LIKE ? OR public_name LIKE ?)");
params.add(numericQuery); params.add(numericQuery);
params.add(numericQuery); params.add(numericQuery);
params.add("%" + query + "%"); params.add(likeQuery);
params.add("%" + query + "%"); params.add(likeQuery);
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
String likeQuery = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%";
whereClause.append(" AND (item_name LIKE ? OR public_name LIKE ?)"); whereClause.append(" AND (item_name LIKE ? OR public_name LIKE ?)");
params.add("%" + query + "%"); params.add(likeQuery);
params.add("%" + query + "%"); params.add(likeQuery);
} }
} }
@@ -32,8 +32,15 @@ public class MachineIDEvent extends MessageHandler {
if (!storedMachineId.isEmpty() && this.client.getHabbo() != null && this.client.getHabbo().getHabboInfo() != null) { if (!storedMachineId.isEmpty() && this.client.getHabbo() != null && this.client.getHabbo().getHabboInfo() != null) {
this.client.getHabbo().getHabboInfo().setMachineID(storedMachineId); this.client.getHabbo().getHabboInfo().setMachineID(storedMachineId);
Emulator.getThreading().run(this.client.getHabbo()); Emulator.getThreading().run(this.client.getHabbo());
// The fingerprint can arrive AFTER login (UniqueID is sent right after the
// SSO ticket), so Habbo.connect() may have skipped the MAC-ban check for
// lack of a machineId. Enforce it now that the fingerprint is known.
if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) {
Emulator.getGameServer().getGameClientManager().forceDisposeClient(this.client);
}
} }
LOGGER.debug("Setting client MachineId to {}", storedMachineId); LOGGER.debug("Setting client MachineId to {}", storedMachineId);
} }
} }
@@ -306,7 +306,7 @@ public class SecureLoginEvent extends MessageHandler {
Emulator.getPluginManager().fireEvent(userLoginEvent); Emulator.getPluginManager().fireEvent(userLoginEvent);
if(userLoginEvent.isCancelled()) { if(userLoginEvent.isCancelled()) {
Emulator.getGameServer().getGameClientManager().disposeClient(this.client); Emulator.getGameServer().getGameClientManager().forceDisposeClient(this.client);
return; return;
} }
@@ -12,6 +12,7 @@ import java.sql.SQLException;
public class HousekeepingGiveCreditsEvent extends MessageHandler { public class HousekeepingGiveCreditsEvent extends MessageHandler {
private static final String ACTION_KEY = "user.give_credits"; private static final String ACTION_KEY = "user.give_credits";
private static final int MAX_GRANT = 1_000_000_000;
@Override @Override
public int getRatelimit() { public int getRatelimit() {
@@ -27,7 +28,7 @@ public class HousekeepingGiveCreditsEvent extends MessageHandler {
int userId = this.packet.readInt(); int userId = this.packet.readInt();
int amount = this.packet.readInt(); int amount = this.packet.readInt();
if (userId <= 0 || amount == 0) { if (userId <= 0 || amount == 0 || amount < -MAX_GRANT || amount > MAX_GRANT) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input")); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.invalid_input"));
return; return;
} }
@@ -38,6 +39,7 @@ public class HousekeepingGiveCreditsEvent extends MessageHandler {
// giveCredits already pushes UserCreditsComposer and persists via the // giveCredits already pushes UserCreditsComposer and persists via the
// standard HabboInfo write path; nothing extra needed for the online branch. // standard HabboInfo write path; nothing extra needed for the online branch.
online.giveCredits(amount); online.giveCredits(amount);
this.audit(userId, amount);
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, ""));
return; return;
} }
@@ -57,6 +59,15 @@ public class HousekeepingGiveCreditsEvent extends MessageHandler {
return; return;
} }
this.audit(userId, amount);
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, ""));
} }
private void audit(int userId, int amount) {
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, userId, "amount=" + amount,
this.client.getHabbo().getHabboInfo().getIpLogin());
}
} }
@@ -18,6 +18,7 @@ import java.sql.SQLException;
*/ */
public class HousekeepingGiveCurrencyEvent extends MessageHandler { public class HousekeepingGiveCurrencyEvent extends MessageHandler {
private static final int CURRENCY_DUCKETS = 0; private static final int CURRENCY_DUCKETS = 0;
private static final int MAX_GRANT = 1_000_000_000;
@Override @Override
public int getRatelimit() { public int getRatelimit() {
@@ -36,7 +37,7 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler {
String actionKey = "user.give_currency_" + currencyType; String actionKey = "user.give_currency_" + currencyType;
if (userId <= 0 || amount == 0) { if (userId <= 0 || amount == 0 || amount < -MAX_GRANT || amount > MAX_GRANT) {
this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.invalid_input")); this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, false, 0, "housekeeping.error.invalid_input"));
return; return;
} }
@@ -52,6 +53,7 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler {
online.givePoints(currencyType, amount); online.givePoints(currencyType, amount);
} }
this.audit(actionKey, userId, currencyType, amount);
this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, true, userId, "")); this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, true, userId, ""));
return; return;
} }
@@ -69,6 +71,15 @@ public class HousekeepingGiveCurrencyEvent extends MessageHandler {
return; return;
} }
this.audit(actionKey, userId, currencyType, amount);
this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, true, userId, "")); this.client.sendResponse(new HousekeepingActionResultComposer(actionKey, true, userId, ""));
} }
private void audit(String actionKey, int userId, int currencyType, int amount) {
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
actionKey, userId, "type=" + currencyType + " amount=" + amount,
this.client.getHabbo().getHabboInfo().getIpLogin());
}
} }
@@ -56,7 +56,7 @@ public class HousekeepingSearchRoomsEvent extends MessageHandler {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) { PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, exactMatch ? query : query + "%"); statement.setString(1, exactMatch ? query : com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%");
statement.setInt(2, limit); statement.setInt(2, limit);
try (ResultSet set = statement.executeQuery()) { try (ResultSet set = statement.executeQuery()) {
@@ -11,6 +11,7 @@ import com.eu.habbo.messages.outgoing.users.UserPermissionsComposer;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
public class HousekeepingSetUserRankEvent extends MessageHandler { public class HousekeepingSetUserRankEvent extends MessageHandler {
@@ -44,6 +45,43 @@ public class HousekeepingSetUserRankEvent extends MessageHandler {
Rank rank = permissions.getRank(rankId); Rank rank = permissions.getRank(rankId);
// Rank-ceiling guard: an operator must never be able to grant a rank
// above their own, nor modify a user who already outranks them. This
// mirrors GiveRankCommand and prevents privilege escalation through
// the housekeeping path (including self-promotion).
int operatorRankId = this.client.getHabbo().getHabboInfo().getRank().getId();
if (rank.getId() > operatorRankId) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
Habbo online = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId);
int targetRankId;
if (online != null) {
targetRankId = online.getHabboInfo().getRank().getId();
} else {
targetRankId = 0;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT rank FROM users WHERE id = ? LIMIT 1")) {
statement.setInt(1, userId);
try (ResultSet set = statement.executeQuery()) {
if (set.next()) {
targetRankId = set.getInt("rank");
}
}
} catch (SQLException e) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.db_failed"));
return;
}
}
if (targetRankId > operatorRankId) {
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, false, 0, "housekeeping.error.rank_too_high"));
return;
}
// Persist for the offline path. Online users get their in-memory // Persist for the offline path. Online users get their in-memory
// HabboInfo.rank rebound below so server-side hasPermission() // HabboInfo.rank rebound below so server-side hasPermission()
// checks land on the new permission set without a relogin. // checks land on the new permission set without a relogin.
@@ -57,8 +95,6 @@ public class HousekeepingSetUserRankEvent extends MessageHandler {
return; return;
} }
Habbo online = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId);
if (online != null) { if (online != null) {
online.getHabboInfo().setRank(rank); online.getHabboInfo().setRank(rank);
// Ship the refreshed permissions snapshot same payload the // Ship the refreshed permissions snapshot same payload the
@@ -66,6 +102,12 @@ public class HousekeepingSetUserRankEvent extends MessageHandler {
online.getClient().sendResponse(new UserPermissionsComposer(online)); online.getClient().sendResponse(new UserPermissionsComposer(online));
} }
com.eu.habbo.habbohotel.modtool.HousekeepingAuditLog.log(
this.client.getHabbo().getHabboInfo().getId(),
this.client.getHabbo().getHabboInfo().getUsername(),
ACTION_KEY, userId, "rankId=" + rankId,
this.client.getHabbo().getHabboInfo().getIpLogin());
this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, "")); this.client.sendResponse(new HousekeepingActionResultComposer(ACTION_KEY, true, userId, ""));
} }
} }
@@ -3,6 +3,7 @@ package com.eu.habbo.messages.incoming.modtool;
import com.eu.habbo.Emulator; import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.modtool.ScripterManager; import com.eu.habbo.habbohotel.modtool.ScripterManager;
import com.eu.habbo.habbohotel.permissions.Permission; import com.eu.habbo.habbohotel.permissions.Permission;
import com.eu.habbo.habbohotel.users.HabboInfo;
import com.eu.habbo.habbohotel.users.HabboManager; import com.eu.habbo.habbohotel.users.HabboManager;
import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.modtool.ModToolUserChatlogComposer; import com.eu.habbo.messages.outgoing.modtool.ModToolUserChatlogComposer;
@@ -12,7 +13,11 @@ public class ModToolRequestUserChatlogEvent extends MessageHandler {
public void handle() throws Exception { public void handle() throws Exception {
if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) { if (this.client.getHabbo().hasPermission(Permission.ACC_SUPPORTTOOL)) {
int userId = this.packet.readInt(); int userId = this.packet.readInt();
String username = HabboManager.getOfflineHabboInfo(userId).getUsername(); HabboInfo habboInfo = HabboManager.getOfflineHabboInfo(userId);
if (habboInfo == null) {
return;
}
String username = habboInfo.getUsername();
this.client.sendResponse(new ModToolUserChatlogComposer(Emulator.getGameEnvironment().getModToolManager().getUserRoomVisitsAndChatlogs(userId), userId, username)); this.client.sendResponse(new ModToolUserChatlogComposer(Emulator.getGameEnvironment().getModToolManager().getUserRoomVisitsAndChatlogs(userId), userId, username));
} else { } else {
@@ -5,6 +5,13 @@ import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.navigator.NewNavigatorSavedSearchesComposer; import com.eu.habbo.messages.outgoing.navigator.NewNavigatorSavedSearchesComposer;
public class AddSavedSearchEvent extends MessageHandler { public class AddSavedSearchEvent extends MessageHandler {
private static final int MAX_SAVED_SEARCHES = 50;
@Override
public int getRatelimit() {
return 1000;
}
@Override @Override
public void handle() throws Exception { public void handle() throws Exception {
String searchCode = this.packet.readString(); String searchCode = this.packet.readString();
@@ -13,6 +20,11 @@ public class AddSavedSearchEvent extends MessageHandler {
if (searchCode.length() > 255) searchCode = searchCode.substring(0, 255); if (searchCode.length() > 255) searchCode = searchCode.substring(0, 255);
if (filter.length() > 255) filter = filter.substring(0, 255); if (filter.length() > 255) filter = filter.substring(0, 255);
if (this.client.getHabbo().getHabboInfo().getSavedSearches().size() >= MAX_SAVED_SEARCHES) {
this.client.sendResponse(new NewNavigatorSavedSearchesComposer(this.client.getHabbo().getHabboInfo().getSavedSearches()));
return;
}
this.client.getHabbo().getHabboInfo().addSavedSearch(new NavigatorSavedSearch(searchCode, filter)); this.client.getHabbo().getHabboInfo().addSavedSearch(new NavigatorSavedSearch(searchCode, filter));
this.client.sendResponse(new NewNavigatorSavedSearchesComposer(this.client.getHabbo().getHabboInfo().getSavedSearches())); this.client.sendResponse(new NewNavigatorSavedSearchesComposer(this.client.getHabbo().getHabboInfo().getSavedSearches()));
@@ -5,6 +5,11 @@ import com.eu.habbo.messages.incoming.MessageHandler;
import com.eu.habbo.messages.outgoing.navigator.NewNavigatorSavedSearchesComposer; import com.eu.habbo.messages.outgoing.navigator.NewNavigatorSavedSearchesComposer;
public class DeleteSavedSearchEvent extends MessageHandler { public class DeleteSavedSearchEvent extends MessageHandler {
@Override
public int getRatelimit() {
return 500;
}
@Override @Override
public void handle() throws Exception { public void handle() throws Exception {
int searchId = this.packet.readInt(); int searchId = this.packet.readInt();
@@ -15,12 +15,20 @@ import java.util.*;
public class RequestNewNavigatorRoomsEvent extends MessageHandler { public class RequestNewNavigatorRoomsEvent extends MessageHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(RequestNewNavigatorRoomsEvent.class); private static final Logger LOGGER = LoggerFactory.getLogger(RequestNewNavigatorRoomsEvent.class);
private static final int MAX_VIEW_LENGTH = 32;
private static final int MAX_QUERY_LENGTH = 64;
@Override
public int getRatelimit() {
return 200;
}
@Override @Override
public void handle() throws Exception { public void handle() throws Exception {
String view = this.packet.readString(); String view = this.packet.readString();
String query = this.packet.readString(); String query = this.packet.readString();
if (view.length() > MAX_VIEW_LENGTH) return;
if (query.length() > MAX_QUERY_LENGTH) query = query.substring(0, MAX_QUERY_LENGTH);
if (view.equals("query")) view = "hotel_view"; if (view.equals("query")) view = "hotel_view";
if (view.equals("groups")) view = "hotel_view"; if (view.equals("groups")) view = "hotel_view";
@@ -43,7 +51,7 @@ public class RequestNewNavigatorRoomsEvent extends MessageHandler {
NavigatorFilterField field = Emulator.getGameEnvironment().getNavigatorManager().filterSettings.get(filterField); NavigatorFilterField field = Emulator.getGameEnvironment().getNavigatorManager().filterSettings.get(filterField);
if (filter != null) { if (filter != null) {
if (query.contains(":")) { if (query.contains(":")) {
String[] parts = query.split(":"); String[] parts = query.split(":", 2);
if (parts.length > 1) { if (parts.length > 1) {
filterField = parts[0]; filterField = parts[0];
@@ -53,6 +61,7 @@ public class RequestNewNavigatorRoomsEvent extends MessageHandler {
if (!Emulator.getGameEnvironment().getNavigatorManager().filterSettings.containsKey(filterField)) { if (!Emulator.getGameEnvironment().getNavigatorManager().filterSettings.containsKey(filterField)) {
filterField = "anything"; filterField = "anything";
} }
part = "";
} }
} }
@@ -95,28 +104,19 @@ public class RequestNewNavigatorRoomsEvent extends MessageHandler {
rooms.addAll(searchResultList.rooms); rooms.addAll(searchResultList.rooms);
resultLists.add(new SearchResultList(searchResultList.order, searchResultList.code, searchResultList.query, searchResultList.action, searchResultList.mode, searchResultList.hidden, rooms, searchResultList.filter, searchResultList.showInvisible, searchResultList.displayOrder, searchResultList.categoryOrder)); resultLists.add(new SearchResultList(searchResultList.order, searchResultList.code, searchResultList.query, searchResultList.action, searchResultList.mode, searchResultList.hidden, rooms, searchResultList.filter, searchResultList.showInvisible, searchResultList.displayOrder, searchResultList.categoryOrder));
} }
if ("group".equals(filterField)) {
final String needle = part.toLowerCase();
for (SearchResultList list : resultLists) {
list.rooms.removeIf(room -> !room.belongsToGuild()
|| (!needle.isEmpty() && !room.getGuildName().toLowerCase().contains(needle)));
}
}
filter.filter(field.field, part, resultLists); filter.filter(field.field, part, resultLists);
resultLists = toQueryResults(resultLists); resultLists = toQueryResults(resultLists);
this.client.sendResponse(new NewNavigatorSearchResultsComposer(view, query, resultLists)); this.client.sendResponse(new NewNavigatorSearchResultsComposer(view, query, resultLists));
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("Caught exception", e); LOGGER.error("Caught exception", e);
} }
/*
try
{
List<SearchResultList> resultLists = new ArrayList<>(filter.getResult(this.client.getHabbo(), field, part, category != null ? category.getId() : -1));
filter.filter(field.field, part, resultLists);
Collections.sort(resultLists);
this.client.sendResponse(new NewNavigatorSearchResultsComposer(view, query, resultLists));
}
catch (Exception e)
{
LOGGER.error("Caught exception", e);
}
*/
} }
private ArrayList<SearchResultList> toQueryResults(List<SearchResultList> resultLists) { private ArrayList<SearchResultList> toQueryResults(List<SearchResultList> resultLists) {
@@ -14,7 +14,7 @@ public class RoomRequestBannedUsersEvent extends MessageHandler {
Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId); Room room = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId);
if (room == null) return; if (room == null) return;
if (!room.hasRights(this.client.getHabbo()) || !this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) return; if (!room.hasRights(this.client.getHabbo()) && !this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) return;
this.client.sendResponse(new RoomBannedUsersComposer(room)); this.client.sendResponse(new RoomBannedUsersComposer(room));
@@ -1,6 +1,7 @@
package com.eu.habbo.messages.incoming.rooms.pets; package com.eu.habbo.messages.incoming.rooms.pets;
import com.eu.habbo.habbohotel.items.interactions.pets.InteractionPetBreedingNest; import com.eu.habbo.habbohotel.items.interactions.pets.InteractionPetBreedingNest;
import com.eu.habbo.habbohotel.rooms.Room;
import com.eu.habbo.habbohotel.users.HabboItem; import com.eu.habbo.habbohotel.users.HabboItem;
import com.eu.habbo.messages.incoming.MessageHandler; import com.eu.habbo.messages.incoming.MessageHandler;
@@ -13,7 +14,10 @@ public class ConfirmPetBreedingEvent extends MessageHandler {
int petOneId = this.packet.readInt(); int petOneId = this.packet.readInt();
int petTwoId = this.packet.readInt(); int petTwoId = this.packet.readInt();
HabboItem item = this.client.getHabbo().getHabboInfo().getCurrentRoom().getHabboItem(itemId); Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom();
if (room == null) return;
HabboItem item = room.getHabboItem(itemId);
if (item instanceof InteractionPetBreedingNest) { if (item instanceof InteractionPetBreedingNest) {
((InteractionPetBreedingNest) item).breed(this.client.getHabbo(), name, petOneId, petTwoId); ((InteractionPetBreedingNest) item).breed(this.client.getHabbo(), name, petOneId, petTwoId);
@@ -23,7 +23,7 @@ public class PetPickupEvent extends MessageHandler {
Pet pet = room.getPet(petId); Pet pet = room.getPet(petId);
if (pet != null) { if (pet != null) {
if (this.client.getHabbo().getHabboInfo().getId() == pet.getId() || room.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) { if (this.client.getHabbo().getHabboInfo().getId() == pet.getUserId() || room.getOwnerId() == this.client.getHabbo().getHabboInfo().getId() || this.client.getHabbo().hasPermission(Permission.ACC_ANYROOMOWNER)) {
if (!this.client.getHabbo().hasPermission(Permission.ACC_UNLIMITED_PETS) && this.client.getHabbo().getInventory().getPetsComponent().getPets().size() >= PetManager.MAXIMUM_PET_INVENTORY_SIZE) { if (!this.client.getHabbo().hasPermission(Permission.ACC_UNLIMITED_PETS) && this.client.getHabbo().getInventory().getPetsComponent().getPets().size() >= PetManager.MAXIMUM_PET_INVENTORY_SIZE) {
this.client.getHabbo().alert(Emulator.getTexts().getValue("error.pets.max.inventory").replace("%amount%", PetManager.MAXIMUM_PET_INVENTORY_SIZE + "")); this.client.getHabbo().alert(Emulator.getTexts().getValue("error.pets.max.inventory").replace("%amount%", PetManager.MAXIMUM_PET_INVENTORY_SIZE + ""));
return; return;
@@ -163,7 +163,13 @@ public class RoomUserWalkEvent extends MessageHandler {
} }
if (roomUnit.getMoveBlockingTask() != null) { if (roomUnit.getMoveBlockingTask() != null) {
roomUnit.getMoveBlockingTask().get(); try {
// Bound the wait so a stuck/delayed move-blocking task can't park
// the Netty event loop (and thus every client on it) indefinitely.
roomUnit.getMoveBlockingTask().get(2, java.util.concurrent.TimeUnit.SECONDS);
} catch (java.util.concurrent.TimeoutException | java.util.concurrent.ExecutionException | InterruptedException e) {
// proceed with the walk regardless
}
} }
boolean needsLocationResync = boolean needsLocationResync =
@@ -9,8 +9,8 @@ import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.outgoing.MessageComposer; import com.eu.habbo.messages.outgoing.MessageComposer;
import com.eu.habbo.messages.outgoing.Outgoing; import com.eu.habbo.messages.outgoing.Outgoing;
import gnu.trove.map.hash.THashMap; import gnu.trove.map.hash.THashMap;
import org.joda.time.DateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
@@ -47,7 +47,10 @@ public class ModToolSanctionInfoComposer extends MessageComposer {
if (item.probationTimestamp > 0) { if (item.probationTimestamp > 0) {
probationEndTime = new Date((long) item.probationTimestamp * 1000); probationEndTime = new Date((long) item.probationTimestamp * 1000);
probationStartTime = new DateTime(probationEndTime).minusDays(modToolSanctions.getProbationDays(modToolSanctionLevelItem)).toDate(); probationStartTime = Date.from(probationEndTime.toInstant()
.atZone(ZoneId.systemDefault())
.minusDays(modToolSanctions.getProbationDays(modToolSanctionLevelItem))
.toInstant());
Date tradeLockedUntil = null; Date tradeLockedUntil = null;
@@ -99,7 +99,7 @@ public class RoomUsersComposer extends MessageComposer {
this.response.appendInt(1); this.response.appendInt(1);
this.response.appendString(habbo.getHabboInfo().getGender().name().toUpperCase()); this.response.appendString(habbo.getHabboInfo().getGender().name().toUpperCase());
this.response.appendInt(habbo.getHabboStats().guild != 0 ? habbo.getHabboStats().guild : -1); this.response.appendInt(habbo.getHabboStats().guild != 0 ? habbo.getHabboStats().guild : -1);
this.response.appendInt(habbo.getHabboStats().guild != 0 ? habbo.getHabboStats().guild : -1); this.response.appendInt(habbo.getHabboStats().guild != 0 ? 1 : -1);
String name = ""; String name = "";
if (habbo.getHabboStats().guild != 0) { if (habbo.getHabboStats().guild != 0) {
Guild g = Emulator.getGameEnvironment().getGuildManager().getGuild(habbo.getHabboStats().guild); Guild g = Emulator.getGameEnvironment().getGuildManager().getGuild(habbo.getHabboStats().guild);
@@ -29,7 +29,7 @@ public class DisconnectUser extends RCONMessage<DisconnectUser.DisconnectUserJSO
return; return;
} }
Emulator.getGameServer().getGameClientManager().disposeClient(target.getClient()); Emulator.getGameServer().getGameClientManager().forceDisposeClient(target.getClient());
this.message = Emulator.getTexts().getValue("commands.succes.cmd_disconnect.disconnected").replace("%user%", target.getHabboInfo().getUsername()); this.message = Emulator.getTexts().getValue("commands.succes.cmd_disconnect.disconnected").replace("%user%", target.getHabboInfo().getUsername());
} }
@@ -40,4 +40,4 @@ public class DisconnectUser extends RCONMessage<DisconnectUser.DisconnectUserJSO
public String username; public String username;
} }
} }
@@ -1,12 +1,16 @@
package com.eu.habbo.networking; package com.eu.habbo.networking;
import com.eu.habbo.Emulator;
import io.netty.bootstrap.ServerBootstrap; import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.buffer.UnpooledByteBufAllocator; import io.netty.buffer.UnpooledByteBufAllocator;
import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelOption; import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup; import io.netty.channel.EventLoopGroup;
import io.netty.channel.FixedRecvByteBufAllocator; import io.netty.channel.FixedRecvByteBufAllocator;
import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.MultiThreadIoEventLoopGroup;
import io.netty.channel.nio.NioIoHandler;
import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.util.concurrent.DefaultThreadFactory; import io.netty.util.concurrent.DefaultThreadFactory;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -18,6 +22,30 @@ public abstract class Server {
private static final Logger LOGGER = LoggerFactory.getLogger(Server.class); private static final Logger LOGGER = LoggerFactory.getLogger(Server.class);
private static volatile ByteBufAllocator sharedAllocator;
/**
* Shared channel allocator. Defaults to unpooled-heap (the long-standing
* behaviour); set {@code io.netty.allocator.pooled=true} to switch to a
* pooled HEAP allocator (preferDirect=false, so the array-backed crypto
* paths keep working) which removes the per-packet alloc/GC churn. Opt-in
* until validated under load with the Netty leak detector, since pooled
* buffers that aren't released accumulate instead of being GC-reclaimed.
*/
protected static ByteBufAllocator allocator() {
if (sharedAllocator == null) {
synchronized (Server.class) {
if (sharedAllocator == null) {
boolean pooled = Emulator.getConfig() != null
&& "true".equalsIgnoreCase(Emulator.getConfig().getValue("io.netty.allocator.pooled", "false"));
sharedAllocator = pooled ? new PooledByteBufAllocator(false) : new UnpooledByteBufAllocator(false);
LOGGER.info("Netty ByteBuf allocator: {}", pooled ? "pooled-heap" : "unpooled-heap");
}
}
}
return sharedAllocator;
}
protected final ServerBootstrap serverBootstrap; protected final ServerBootstrap serverBootstrap;
protected final EventLoopGroup bossGroup; protected final EventLoopGroup bossGroup;
protected final EventLoopGroup workerGroup; protected final EventLoopGroup workerGroup;
@@ -32,8 +60,10 @@ public abstract class Server {
String threadName = name.replace("Server", "").replace(" ", ""); String threadName = name.replace("Server", "").replace(" ", "");
this.bossGroup = new NioEventLoopGroup(bossGroupThreads, new DefaultThreadFactory(threadName + "Boss")); // Netty 4.2: NioEventLoopGroup is deprecated in favour of the generic
this.workerGroup = new NioEventLoopGroup(workerGroupThreads, new DefaultThreadFactory(threadName + "Worker")); // MultiThreadIoEventLoopGroup driven by an IoHandlerFactory (NIO here).
this.bossGroup = new MultiThreadIoEventLoopGroup(bossGroupThreads, new DefaultThreadFactory(threadName + "Boss"), NioIoHandler.newFactory());
this.workerGroup = new MultiThreadIoEventLoopGroup(workerGroupThreads, new DefaultThreadFactory(threadName + "Worker"), NioIoHandler.newFactory());
this.serverBootstrap = new ServerBootstrap(); this.serverBootstrap = new ServerBootstrap();
} }
@@ -45,7 +75,7 @@ public abstract class Server {
this.serverBootstrap.childOption(ChannelOption.SO_REUSEADDR, true); this.serverBootstrap.childOption(ChannelOption.SO_REUSEADDR, true);
this.serverBootstrap.childOption(ChannelOption.SO_RCVBUF, 4096); this.serverBootstrap.childOption(ChannelOption.SO_RCVBUF, 4096);
this.serverBootstrap.childOption(ChannelOption.RCVBUF_ALLOCATOR, new FixedRecvByteBufAllocator(4096)); this.serverBootstrap.childOption(ChannelOption.RCVBUF_ALLOCATOR, new FixedRecvByteBufAllocator(4096));
this.serverBootstrap.childOption(ChannelOption.ALLOCATOR, new UnpooledByteBufAllocator(false)); this.serverBootstrap.childOption(ChannelOption.ALLOCATOR, allocator());
} }
public void connect() { public void connect() {
@@ -84,7 +84,7 @@ public class GameServer extends Server {
this.webSocketBootstrap.childOption(ChannelOption.SO_REUSEADDR, true); this.webSocketBootstrap.childOption(ChannelOption.SO_REUSEADDR, true);
this.webSocketBootstrap.childOption(ChannelOption.SO_RCVBUF, 4096); this.webSocketBootstrap.childOption(ChannelOption.SO_RCVBUF, 4096);
this.webSocketBootstrap.childOption(ChannelOption.RCVBUF_ALLOCATOR, new FixedRecvByteBufAllocator(4096)); this.webSocketBootstrap.childOption(ChannelOption.RCVBUF_ALLOCATOR, new FixedRecvByteBufAllocator(4096));
this.webSocketBootstrap.childOption(ChannelOption.ALLOCATOR, new UnpooledByteBufAllocator(false)); this.webSocketBootstrap.childOption(ChannelOption.ALLOCATOR, allocator());
this.webSocketBootstrap.childHandler(wsInitializer); this.webSocketBootstrap.childHandler(wsInitializer);
ChannelFuture wsFuture = this.webSocketBootstrap.bind(wsHost, wsPort); ChannelFuture wsFuture = this.webSocketBootstrap.bind(wsHost, wsPort);
@@ -26,12 +26,37 @@ import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.logging.LoggingHandler; import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslHandler; import io.netty.handler.ssl.SslHandler;
import io.netty.util.concurrent.DefaultEventExecutorGroup;
import io.netty.util.concurrent.DefaultThreadFactory;
import io.netty.util.concurrent.EventExecutorGroup;
import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLEngine;
public class WebSocketChannelInitializer extends ChannelInitializer<SocketChannel> { public class WebSocketChannelInitializer extends ChannelInitializer<SocketChannel> {
private static final int MAX_FRAME_SIZE = 500000; private static final int MAX_FRAME_SIZE = 500000;
// Runs the game packet handler OFF the Netty I/O event loop, so a blocking
// handler (login/friends/catalog/guild JDBC, A* pathfinding, etc.) can no
// longer stall socket I/O for every other client sharing that I/O thread.
// A DefaultEventExecutorGroup pins each channel to one executor, so a single
// client's packets stay strictly ordered (no new intra-client races); the
// cross-client concurrency degree is the same the multi-threaded I/O group
// already had. Daemon threads so they don't block JVM shutdown.
private static final EventExecutorGroup PACKET_HANDLER_GROUP = new DefaultEventExecutorGroup(
packetHandlerThreads(),
new DefaultThreadFactory("GamePacketHandler", true));
// Size of the packet-handler pool. Defaults to max(16, 2x CPU cores); set
// the optional `io.packet.handler.threads` config key to override.
private static int packetHandlerThreads() {
int fallback = Math.max(16, Runtime.getRuntime().availableProcessors() * 2);
if (Emulator.getConfig() == null) {
return fallback;
}
int configured = Emulator.getConfig().getInt("io.packet.handler.threads", fallback);
return configured > 0 ? configured : fallback;
}
private final SslContext sslContext; private final SslContext sslContext;
private final boolean sslEnabled; private final boolean sslEnabled;
private final WebSocketServerProtocolConfig wsConfig; private final WebSocketServerProtocolConfig wsConfig;
@@ -82,7 +107,7 @@ public class WebSocketChannelInitializer extends ChannelInitializer<SocketChanne
ch.pipeline().addLast("idleEventHandler", new IdleTimeoutHandler(30, 60)); ch.pipeline().addLast("idleEventHandler", new IdleTimeoutHandler(30, 60));
ch.pipeline().addLast(new GameMessageRateLimit()); ch.pipeline().addLast(new GameMessageRateLimit());
ch.pipeline().addLast(new GameMessageHandler()); ch.pipeline().addLast(PACKET_HANDLER_GROUP, "gameMessageHandler", new GameMessageHandler());
ch.pipeline().addLast("messageEncoder", new GameServerMessageEncoder()); ch.pipeline().addLast("messageEncoder", new GameServerMessageEncoder());
if (PacketManager.DEBUG_SHOW_PACKETS) { if (PacketManager.DEBUG_SHOW_PACKETS) {
@@ -467,7 +467,7 @@ final class AccountChangeEndpoints {
com.eu.habbo.habbohotel.users.Habbo habbo = com.eu.habbo.habbohotel.users.Habbo habbo =
Emulator.getGameServer().getGameClientManager().getHabbo(userId); Emulator.getGameServer().getGameClientManager().getHabbo(userId);
if (habbo != null && habbo.getClient() != null) { if (habbo != null && habbo.getClient() != null) {
Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient()); Emulator.getGameServer().getGameClientManager().forceDisposeClient(habbo.getClient());
} }
} }
@@ -9,8 +9,15 @@ import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.QueryStringDecoder; import io.netty.handler.codec.http.QueryStringDecoder;
import io.netty.util.ReferenceCountUtil; import io.netty.util.ReferenceCountUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.MAX_BODY_BYTES; import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.MAX_BODY_BYTES;
import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.errorPayload; import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.errorPayload;
@@ -21,6 +28,37 @@ import static com.eu.habbo.networking.gameserver.auth.AuthHttpUtil.sendJson;
public class AuthHttpHandler extends ChannelInboundHandlerAdapter { public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpHandler.class);
// Dedicated, bounded pool for the auth endpoints. Their work blocks on
// BCrypt, JDBC, the Turnstile HTTPS round-trip and SMTP running that on the
// Netty event loop stalls every client on the same worker. A SEPARATE pool
// (not the shared game ThreadPooling) also keeps it from starving room cycles.
private static final int AUTH_POOL_MAX = authPoolMax();
private static final ThreadPoolExecutor AUTH_EXECUTOR = new ThreadPoolExecutor(
Math.min(4, AUTH_POOL_MAX), AUTH_POOL_MAX, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(512),
new java.util.concurrent.ThreadFactory() {
private final AtomicInteger counter = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "auth-http-worker-" + counter.getAndIncrement());
t.setDaemon(true);
return t;
}
});
// Max threads for the auth pool. Defaults to 16; set the optional
// `auth.http.pool.size` config key to override.
private static int authPoolMax() {
int fallback = 16;
if (com.eu.habbo.Emulator.getConfig() == null) {
return fallback;
}
int configured = com.eu.habbo.Emulator.getConfig().getInt("auth.http.pool.size", fallback);
return configured > 0 ? configured : fallback;
}
static final String LOGIN_PATH = "/api/auth/login"; static final String LOGIN_PATH = "/api/auth/login";
static final String REGISTER_PATH = "/api/auth/register"; static final String REGISTER_PATH = "/api/auth/register";
static final String FORGOT_PATH = "/api/auth/forgot-password"; static final String FORGOT_PATH = "/api/auth/forgot-password";
@@ -52,10 +90,30 @@ public class AuthHttpHandler extends ChannelInboundHandlerAdapter {
return; return;
} }
// Offload the (potentially blocking) auth work off the event loop. Netty
// writes are thread-safe, so the endpoints' sendJson/writeAndFlush calls
// are fine from the worker; the request is released once the work ends.
try { try {
handle(ctx, req, path); AUTH_EXECUTOR.execute(() -> {
} finally { try {
ReferenceCountUtil.release(req); handle(ctx, req, path);
} catch (Throwable t) {
LOGGER.error("Auth handler failed for {}", path, t);
try {
sendJson(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, errorPayload("Internal error."));
} catch (Throwable ignored) {
// response may already be partially written nothing else to do
}
} finally {
ReferenceCountUtil.release(req);
}
});
} catch (RejectedExecutionException rejected) {
try {
sendJson(ctx, req, HttpResponseStatus.SERVICE_UNAVAILABLE, errorPayload("Server busy, try again shortly."));
} finally {
ReferenceCountUtil.release(req);
}
} }
} }
@@ -28,7 +28,7 @@ import java.sql.SQLException;
import java.util.Base64; import java.util.Base64;
import java.util.regex.Pattern; import java.util.regex.Pattern;
final class AuthHttpUtil { public final class AuthHttpUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpUtil.class); private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpUtil.class);
@@ -132,7 +132,10 @@ final class AuthHttpUtil {
String ipHeader = Emulator.getConfig() != null String ipHeader = Emulator.getConfig() != null
? Emulator.getConfig().getValue("ws.ip.header", "") ? Emulator.getConfig().getValue("ws.ip.header", "")
: ""; : "";
if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) { // Only trust a client-supplied forwarded-IP header when the DIRECT peer
// is a trusted reverse proxy; otherwise an attacker hitting the port
// directly could spoof it to evade per-IP rate limiting and IP bans.
if (!ipHeader.isEmpty() && req.headers().contains(ipHeader) && isTrustedProxy(ctx)) {
String hv = req.headers().get(ipHeader); String hv = req.headers().get(ipHeader);
if (hv != null && !hv.isEmpty()) { if (hv != null && !hv.isEmpty()) {
int comma = hv.indexOf(','); int comma = hv.indexOf(',');
@@ -148,6 +151,37 @@ final class AuthHttpUtil {
return ""; return "";
} }
/**
* Whether the channel's direct peer may set a forwarded-IP header. Loopback
* is always trusted; additional proxies can be allow-listed (exact IP or
* string prefix, comma-separated) via the {@code ws.ip.header.trusted}
* config key. Default-deny so the header can't be spoofed from the open net.
*/
public static boolean isTrustedProxy(ChannelHandlerContext ctx) {
String peerIp = (ctx.channel().remoteAddress() instanceof InetSocketAddress a)
? a.getAddress().getHostAddress() : null;
if (peerIp == null || peerIp.isEmpty()) return false;
if (peerIp.equals("127.0.0.1") || peerIp.equals("::1") || peerIp.equals("0:0:0:0:0:0:0:1")) {
return true;
}
String trusted = Emulator.getConfig() != null
? Emulator.getConfig().getValue("ws.ip.header.trusted", "")
: "";
if (trusted.isEmpty()) return false;
for (String entry : trusted.split(",")) {
String t = entry.trim();
if (t.isEmpty()) continue;
// Exact IP match, or a dotted/colon prefix range (e.g. "10.0.0." or
// "2001:db8:") never a bare-IP prefix, so "10.0.0.1" can't also
// trust "10.0.0.12".
boolean isRange = t.endsWith(".") || t.endsWith(":");
if (peerIp.equals(t) || (isRange && peerIp.startsWith(t))) {
return true;
}
}
return false;
}
static boolean checkPassword(String plain, String stored) { static boolean checkPassword(String plain, String stored) {
String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored; String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored;
try { try {
@@ -11,8 +11,34 @@ public final class AuthRateLimiter {
private static final Map<String, AtomicReference<State>> STATE = new ConcurrentHashMap<>(); private static final Map<String, AtomicReference<State>> STATE = new ConcurrentHashMap<>();
private static final Map<String, AtomicReference<ProbeState>> PROBE_STATE = new ConcurrentHashMap<>(); private static final Map<String, AtomicReference<ProbeState>> PROBE_STATE = new ConcurrentHashMap<>();
// Both maps are keyed by client IP and reachable by unauthenticated traffic.
// recordSuccess removes STATE on login, but failed-only and probe-only IPs
// never get removed otherwise unbounded growth over the JVM lifetime.
// Opportunistically evict window-expired entries once the maps get large.
private static final int SWEEP_THRESHOLD = 10_000;
private static final long SWEEP_MIN_INTERVAL_MS = 60_000L;
private static volatile long lastSweepMillis = 0L;
private AuthRateLimiter() {} private AuthRateLimiter() {}
private static void maybeSweep(long now) {
if (STATE.size() < SWEEP_THRESHOLD && PROBE_STATE.size() < SWEEP_THRESHOLD) return;
if (now - lastSweepMillis < SWEEP_MIN_INTERVAL_MS) return;
lastSweepMillis = now;
long stateWindowMs = configInt("login.ratelimit.window_sec", 60) * 1000L;
STATE.entrySet().removeIf(e -> {
State s = e.getValue().get();
return s == null || (s.lockedUntilMillis <= now && (now - s.windowStartMillis) > stateWindowMs);
});
long probeWindowMs = configInt("login.probe.window_sec", 60) * 1000L;
PROBE_STATE.entrySet().removeIf(e -> {
ProbeState p = e.getValue().get();
return p == null || (now - p.windowStartMillis) > probeWindowMs;
});
}
public static boolean isLocked(String ip) { public static boolean isLocked(String ip) {
if (!isEnabled() || ip == null || ip.isEmpty()) return false; if (!isEnabled() || ip == null || ip.isEmpty()) return false;
@@ -38,6 +64,7 @@ public final class AuthRateLimiter {
if (!isEnabled() || ip == null || ip.isEmpty()) return; if (!isEnabled() || ip == null || ip.isEmpty()) return;
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
maybeSweep(now);
long windowMs = configInt("login.ratelimit.window_sec", 60) * 1000L; long windowMs = configInt("login.ratelimit.window_sec", 60) * 1000L;
int maxAttempts = configInt("login.ratelimit.max_attempts", 5); int maxAttempts = configInt("login.ratelimit.max_attempts", 5);
long lockoutMs = configInt("login.ratelimit.lockout_sec", 120) * 1000L; long lockoutMs = configInt("login.ratelimit.lockout_sec", 120) * 1000L;
@@ -64,6 +91,7 @@ public final class AuthRateLimiter {
if (isLocked(ip)) return false; if (isLocked(ip)) return false;
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
maybeSweep(now);
long windowMs = configInt("login.probe.window_sec", 60) * 1000L; long windowMs = configInt("login.probe.window_sec", 60) * 1000L;
int maxAttempts = configInt("login.probe.max_attempts", 20); int maxAttempts = configInt("login.probe.max_attempts", 20);
@@ -69,7 +69,7 @@ final class SessionEndpoints {
com.eu.habbo.habbohotel.users.Habbo habbo = com.eu.habbo.habbohotel.users.Habbo habbo =
Emulator.getGameServer().getGameClientManager().getHabbo(userId); Emulator.getGameServer().getGameClientManager().getHabbo(userId);
if (habbo != null && habbo.getClient() != null) { if (habbo != null && habbo.getClient() != null) {
Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient()); Emulator.getGameServer().getGameClientManager().forceDisposeClient(habbo.getClient());
} }
} }
} }
@@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.decoders;
import com.eu.habbo.networking.gameserver.GameServerAttributes; import com.eu.habbo.networking.gameserver.GameServerAttributes;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder; import io.netty.handler.codec.ByteToMessageDecoder;
@@ -15,14 +16,17 @@ public class GameByteDecryption extends ByteToMessageDecoder {
@Override @Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
// Read all available bytes. // Copy the readable region into a plain array (offset-safe, so this is
ByteBuf data = in.readBytes(in.readableBytes()); // correct for pooled buffers too buf.array() would have read the wrong
// region for a pooled/sliced buffer).
byte[] bytes = new byte[in.readableBytes()];
in.readBytes(bytes);
// Decrypt. // Decrypt in place.
ctx.channel().attr(GameServerAttributes.CRYPTO_CLIENT).get().parse(data.array()); ctx.channel().attr(GameServerAttributes.CRYPTO_CLIENT).get().parse(bytes);
// Continue in the pipeline. // Continue in the pipeline.
out.add(data); out.add(Unpooled.wrappedBuffer(bytes));
} }
} }
@@ -56,14 +56,13 @@ public class GameMessageHandler extends ChannelInboundHandlerAdapter {
ClientMessage message = (ClientMessage) msg; ClientMessage message = (ClientMessage) msg;
try { try {
ChannelReadHandler handler = new ChannelReadHandler(ctx, message); // This handler is registered on a dedicated EventExecutorGroup
// (see WebSocketChannelInitializer), so channelRead already runs OFF
if (PacketManager.MULTI_THREADED_PACKET_HANDLING) { // the Netty I/O event loop, serialized per channel. Running the
Emulator.getThreading().run(handler); // handler inline here keeps that per-channel ordering submitting to
return; // the shared game pool instead would break ordering, so we no longer
} // branch on MULTI_THREADED_PACKET_HANDLING.
new ChannelReadHandler(ctx, message).run();
handler.run();
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("Caught exception", e); LOGGER.error("Caught exception", e);
} }
@@ -23,7 +23,12 @@ public class GameMessageRateLimit extends MessageToMessageDecoder<ClientMessage>
protected void decode(ChannelHandlerContext ctx, ClientMessage message, List<Object> out) throws Exception { protected void decode(ChannelHandlerContext ctx, ClientMessage message, List<Object> out) throws Exception {
GameClient client = ctx.channel().attr(GameServerAttributes.CLIENT).get(); GameClient client = ctx.channel().attr(GameServerAttributes.CLIENT).get();
// ClientMessage is not ReferenceCounted, so MessageToMessageDecoder's
// auto-release is a no-op for it; on every drop path we must release the
// wrapped ByteBuf ourselves or it leaks (it is only released downstream
// in ChannelReadHandler on the success path).
if (client == null) { if (client == null) {
message.release();
return; return;
} }
@@ -42,6 +47,7 @@ public class GameMessageRateLimit extends MessageToMessageDecoder<ClientMessage>
} }
if (count > MAX_COUNTER) { if (count > MAX_COUNTER) {
message.release();
return; return;
} }
@@ -53,6 +59,7 @@ public class GameMessageRateLimit extends MessageToMessageDecoder<ClientMessage>
LOGGER.warn("Global packet rate limit exceeded for {} ({} packets/sec) — dropping excess packets", LOGGER.warn("Global packet rate limit exceeded for {} ({} packets/sec) — dropping excess packets",
username, globalCount); username, globalCount);
} }
message.release();
return; return;
} }
@@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.encoders;
import com.eu.habbo.networking.gameserver.GameServerAttributes; import com.eu.habbo.networking.gameserver.GameServerAttributes;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelOutboundHandlerAdapter; import io.netty.channel.ChannelOutboundHandlerAdapter;
import io.netty.channel.ChannelPromise; import io.netty.channel.ChannelPromise;
@@ -14,16 +15,19 @@ public class GameByteEncryption extends ChannelOutboundHandlerAdapter {
// convert to Bytebuf // convert to Bytebuf
ByteBuf in = (ByteBuf) msg; ByteBuf in = (ByteBuf) msg;
// read available bytes // Copy the readable region into a plain array (respects readerIndex /
ByteBuf data = (in).readBytes(in.readableBytes()); // arrayOffset, so this is correct for pooled buffers too buf.array()
// would have returned the wrong region for a pooled/sliced buffer).
byte[] bytes = new byte[in.readableBytes()];
in.readBytes(bytes);
//release old object //release old object
ReferenceCountUtil.release(in); ReferenceCountUtil.release(in);
// Encrypt. // Encrypt in place.
ctx.channel().attr(GameServerAttributes.CRYPTO_SERVER).get().parse(data.array()); ctx.channel().attr(GameServerAttributes.CRYPTO_SERVER).get().parse(bytes);
// Continue in the pipeline. // Continue in the pipeline.
ctx.write(data, promise); ctx.write(Unpooled.wrappedBuffer(bytes), promise);
} }
} }
@@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.handlers;
import com.eu.habbo.Emulator; import com.eu.habbo.Emulator;
import com.eu.habbo.networking.gameserver.GameServerAttributes; import com.eu.habbo.networking.gameserver.GameServerAttributes;
import com.eu.habbo.networking.gameserver.auth.AuthHttpUtil;
import io.netty.buffer.Unpooled; import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
@@ -53,7 +54,7 @@ public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter {
FullHttpResponse response = new DefaultFullHttpResponse( FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, HttpVersion.HTTP_1_1,
HttpResponseStatus.FORBIDDEN, HttpResponseStatus.FORBIDDEN,
Unpooled.wrappedBuffer("Origin forbidden".getBytes()) Unpooled.wrappedBuffer("Origin forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8))
); );
response.headers().set("Vary", "Origin"); response.headers().set("Vary", "Origin");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
@@ -65,9 +66,14 @@ public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter {
private static void captureForwardedIp(ChannelHandlerContext ctx, HttpMessage req) { private static void captureForwardedIp(ChannelHandlerContext ctx, HttpMessage req) {
String ipHeader = Emulator.getConfig().getValue("ws.ip.header", ""); String ipHeader = Emulator.getConfig().getValue("ws.ip.header", "");
if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) { // Only honour the forwarded-IP header from a trusted reverse proxy,
// otherwise the game-session IP (used for bans/rate-limits) is spoofable.
if (!ipHeader.isEmpty() && req.headers().contains(ipHeader) && AuthHttpUtil.isTrustedProxy(ctx)) {
String ip = req.headers().get(ipHeader); String ip = req.headers().get(ipHeader);
ctx.channel().attr(GameServerAttributes.WS_IP).set(ip); if (ip != null && !ip.isEmpty()) {
int comma = ip.indexOf(',');
ctx.channel().attr(GameServerAttributes.WS_IP).set((comma > 0 ? ip.substring(0, comma) : ip).trim());
}
} }
} }
@@ -16,6 +16,10 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(RCONServerHandler.class); private static final Logger LOGGER = LoggerFactory.getLogger(RCONServerHandler.class);
// Gson is thread-safe and immutable once built share one instance instead
// of allocating a parser per RCON request.
private static final Gson GSON = new Gson();
@Override @Override
public void channelRegistered(ChannelHandlerContext ctx) throws Exception { public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
String adress = ctx.channel().remoteAddress().toString().split(":")[0].replace("/", ""); String adress = ctx.channel().remoteAddress().toString().split(":")[0].replace("/", "");
@@ -37,8 +41,8 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter {
byte[] d = new byte[data.readableBytes()]; byte[] d = new byte[data.readableBytes()];
data.getBytes(0, d); data.getBytes(0, d);
String message = new String(d); String message = new String(d, java.nio.charset.StandardCharsets.UTF_8);
Gson gson = new Gson(); Gson gson = GSON;
String response = "ERROR"; String response = "ERROR";
String key = ""; String key = "";
try { try {
@@ -52,7 +56,7 @@ public class RCONServerHandler extends ChannelInboundHandlerAdapter {
e.printStackTrace(); e.printStackTrace();
} }
ChannelFuture f = ctx.channel().write(Unpooled.copiedBuffer(response.getBytes()), ctx.channel().voidPromise()); ChannelFuture f = ctx.channel().write(Unpooled.copiedBuffer(response.getBytes(java.nio.charset.StandardCharsets.UTF_8)), ctx.channel().voidPromise());
ctx.channel().flush(); ctx.channel().flush();
ctx.flush(); ctx.flush();
f.channel().close(); f.channel().close();
@@ -67,6 +67,10 @@ public class PluginManager {
private static final Logger LOGGER = LoggerFactory.getLogger(PluginManager.class); private static final Logger LOGGER = LoggerFactory.getLogger(PluginManager.class);
// Gson is thread-safe and immutable once built reuse one instance instead
// of building a parser per plugin-config load.
private static final Gson PLUGIN_GSON = new GsonBuilder().create();
private final THashSet<HabboPlugin> plugins = new THashSet<>(); private final THashSet<HabboPlugin> plugins = new THashSet<>();
private final THashSet<Method> methods = new THashSet<>(); private final THashSet<Method> methods = new THashSet<>();
@@ -273,10 +277,9 @@ public class PluginManager {
byte[] content = new byte[stream.available()]; byte[] content = new byte[stream.available()];
if (stream.read(content) > 0) { if (stream.read(content) > 0) {
String body = new String(content); String body = new String(content, java.nio.charset.StandardCharsets.UTF_8);
Gson gson = new GsonBuilder().create(); HabboPluginConfiguration pluginConfigurtion = PLUGIN_GSON.fromJson(body, HabboPluginConfiguration.class);
HabboPluginConfiguration pluginConfigurtion = gson.fromJson(body, HabboPluginConfiguration.class);
try { try {
Class<?> clazz = urlClassLoader.loadClass(pluginConfigurtion.main); Class<?> clazz = urlClassLoader.loadClass(pluginConfigurtion.main);
@@ -165,12 +165,10 @@ public class RebugKickBallAction implements Runnable {
this.dead = true; this.dead = true;
} }
THashSet<HabboItem> oldItems = this.room.getItemsAt(oldTile); // updateTile() below removes both tiles from the item cache (rebuilt
if (oldItems != null && !oldItems.isEmpty()) { // lazily from the ball's already-updated position), so mutating the
oldItems.remove(this.ball); // shared cached THashSets here is both redundant and a data race
} // against the room-cycle/IO threads iterating those same sets.
this.room.getItemsAt(nextTile).add(this.ball);
this.room.updateTile(oldTile); this.room.updateTile(oldTile);
this.room.updateTile(nextTile); this.room.updateTile(nextTile);
@@ -0,0 +1,24 @@
package com.eu.habbo.util;
/**
* Escapes the LIKE wildcards {@code %} and {@code _} (and the escape char itself)
* in user-supplied search input, so they are matched literally instead of acting
* as wildcards. Prevents wildcard-driven over-broad matches and the expensive
* full-scans an attacker could trigger with a query like {@code "%"}. Uses
* MariaDB's default escape character {@code \}.
*/
public final class SqlLikeEscaper {
private SqlLikeEscaper() {
}
public static String escape(String input) {
if (input == null) {
return "";
}
return input
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
}
}
@@ -0,0 +1,13 @@
package com.eu.habbo.habbohotel.catalog.marketplace;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
class MarketPlaceOfferContractTest {
@Test
void exposesPersistenceState() {
assertDoesNotThrow(() -> MarketPlaceOffer.class.getDeclaredMethod("isPersisted"));
}
}
@@ -0,0 +1,22 @@
package com.eu.habbo.habbohotel.gameclients;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
class GameClientManagerContractTest {
@Test
void exposesExplicitForcedDisposePath() {
assertDoesNotThrow(() -> GameClient.class.getDeclaredMethod("dispose", boolean.class));
assertDoesNotThrow(() -> GameClientManager.class.getDeclaredMethod("forceDisposeClient", GameClient.class));
}
@Test
void disposeMethodsIgnoreNullClient() {
GameClientManager manager = new GameClientManager();
assertDoesNotThrow(() -> manager.disposeClient(null));
assertDoesNotThrow(() -> manager.forceDisposeClient(null));
}
}
@@ -68,3 +68,9 @@ login.news.limit=5
### ws.port=2096 ### ws.port=2096
### ws.whitelist=localhost #Comma-separated whitelist of allowed origins. Supports wildcards: *.example.com, * (allow all) ### ws.whitelist=localhost #Comma-separated whitelist of allowed origins. Supports wildcards: *.example.com, * (allow all)
### ws.ip.header=X-Forwarded-For #Header name for real client IP when behind a proxy (e.g., X-Forwarded-For, CF-Connecting-IP). Leave empty if not using a proxy. ### ws.ip.header=X-Forwarded-For #Header name for real client IP when behind a proxy (e.g., X-Forwarded-For, CF-Connecting-IP). Leave empty if not using a proxy.
### ws.ip.header.trusted= #Comma-separated trusted reverse-proxy IPs/prefixes (entries ending in '.' or ':' are prefix ranges, e.g. 10.0.0.) allowed to set ws.ip.header. Loopback (127.0.0.1/::1) is ALWAYS trusted; default-deny otherwise so the forwarded header can't be spoofed from the open net.
#Performance / concurrency (optional — sensible defaults apply if unset; adjust in the Database).
### io.packet.handler.threads=24 #Game packet-handler pool size; runs game handlers OFF the Netty I/O loop. Default max(16, 2 x CPU cores).
### auth.http.pool.size=16 #Dedicated worker pool for the /api/auth/* HTTP endpoints (BCrypt/JDBC/Turnstile/SMTP run off the event loop). Default 16.
### io.netty.allocator.pooled=false #Set true to opt into Netty's pooled ByteBuf allocator. Default false (unpooled-heap).