Compare commits

...

11 Commits

Author SHA1 Message Date
github-actions[bot] 7271506262 🆙 Bump version to 4.2.10 [skip ci] 2026-05-19 09:42:32 +00:00
DuckieTM 09710fc5d6 Merge pull request #107 from duckietm/dev
SMall fix for CORS
2026-05-19 11:41:32 +02:00
duckietm d958fbc0ab SMall fix for CORS 2026-05-19 11:41:17 +02:00
github-actions[bot] dca405ffb5 🆙 Bump version to 4.2.9 [skip ci] 2026-05-19 08:07:32 +00:00
DuckieTM 4190fa96d4 Merge pull request #106 from duckietm/dev
Dev
2026-05-19 10:06:40 +02:00
duckietm 033faaeab6 🆙 Update Database 2026-05-19 10:04:59 +02:00
DuckieTM 98326e11af Merge pull request #104 from duckietm/main
Main to DEV
2026-05-19 10:03:12 +02:00
github-actions[bot] 0f2666916f 🆙 Bump version to 4.2.8 [skip ci] 2026-05-19 07:58:26 +00:00
DuckieTM 46041eedfe Merge pull request #103 from medievalshell/Dev
feat(furnieditor): split-aware FurniDataManager + JSON5 tolerance
2026-05-19 09:57:34 +02:00
medievalshell e334a3e0ac feat(auth): backward-compatible TTL check on SSO auth_ticket
Pairs with the CMS-side change introducing auth_ticket_expires_at (60s
expiry written on every ticket issuance). Without an emulator-side
verification the column was advisory only — this commit gates every
SELECT that resolves a user by auth_ticket on

    auth_ticket = ?
    AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW())

The NULL branch preserves backward-compatibility: CMS deployments that
do not yet populate the column keep working exactly like before
(every ticket passes the WHERE clause as soon as auth_ticket matches),
and the TTL takes effect automatically the moment a CMS starts writing
the expiry value.

Five SELECTs touched:
- SessionEndpoints.java (cms-issued SSO + remember-token flow)
- HabboManager.loadHabbo (game client login by ticket)
- SecureLoginEvent (legacy handshake path)

DB schema delivered both ways:
- Database Updates/Own_Database_RunFirst/020_auth_ticket_ttl.sql:
  idempotent ALTER, skips if column already present (information_schema
  guard so re-running the bundle is safe).
- Default Database/FullDatabase.sql: column added to the `users` table
  definition for fresh installs.

Bumps the emulator version to 4.2.7.
2026-05-19 00:46:58 +02:00
medievalshell 53b7dba185 feat(furnieditor): split-aware FurniDataManager + JSON5 tolerance
Aligns the :furnidata in-game admin command with the split-aware gamedata
layout shipped by the Nitro V3 client. FurniDataManager now resolves the
furnidata source through three accepted shapes:

- legacy single-file path (filesystem or http URL ending in .json/.json5)
- split-mode directory (URL ending with '/') — walks core/custom/seasonal
  tiers via manifest.json5 files and merges by item id, with later tiers
  overriding earlier ones (same semantics as the client-side loader)
- fallback to furni.editor.asset.base.path when the renderer config is
  missing or contains an unresolved placeholder

Adds a small JSON5 sanitiser (stripJson5) that removes line and block
comments and trailing commas before handing the content to Gson, so both
the renderer config and the split-mode files can be JSON or JSON5
without pulling in a JSON5 dependency. String contents are preserved
verbatim — comment-looking substrings inside strings (e.g. URLs) are
not touched.

Bumps the emulator version to 4.2.6.
2026-05-18 22:00:16 +02:00
9 changed files with 365 additions and 83 deletions
+43
View File
@@ -0,0 +1,43 @@
-- ============================================================================
-- 020_auth_ticket_ttl.sql
--
-- Adds an explicit expiry timestamp to the SSO auth_ticket on `users`.
--
-- The CMS issuing the ticket is expected to populate auth_ticket_expires_at
-- (e.g. NOW() + INTERVAL 60 SECOND) on every login redirect. The emulator-
-- side SELECT queries that look up a user by auth_ticket have been changed to
--
-- WHERE auth_ticket = ?
-- AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW())
--
-- The NULL branch keeps backward-compatibility with CMS deployments that do
-- not populate the column yet: existing rows continue to authenticate the
-- same way they always did, and the TTL kicks in only once the CMS starts
-- writing the expiry value.
--
-- Idempotent: skips the ALTER if the column already exists.
-- ============================================================================
SET @col_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'auth_ticket_expires_at'
);
SET @ddl = IF(@col_exists = 0,
'ALTER TABLE `users` ADD COLUMN `auth_ticket_expires_at` TIMESTAMP NULL DEFAULT NULL AFTER `auth_ticket`',
'SELECT ''auth_ticket_expires_at already present, skipping'' AS info'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE emulator_settings SET `key`='ws.whitelist' WHERE `key`='websockets.whitelist';
UPDATE emulator_settings SET `key`='ws.host' WHERE `key`='ws.nitro.host';
UPDATE emulator_settings SET `key`='ws.port' WHERE `key`='ws.nitro.port';
INSERT emulator_settings (`key`, `value`) VALUES ('ws.ip.header', 'X-Forwarded-For');
INSERT emulator_settings (`key`, `value`) VALUES ('ws.enabled', 'true');
+1
View File
@@ -30682,6 +30682,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`points` int(11) NOT NULL DEFAULT 10,
`online` enum('0','1','2') CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '0',
`auth_ticket` varchar(256) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL DEFAULT '',
`auth_ticket_expires_at` timestamp NULL DEFAULT NULL,
`remember_token_hash` varchar(64) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '',
`remember_token_expires_at` int(11) unsigned NOT NULL DEFAULT 0,
`ip_register` varchar(45) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
+1 -1
View File
@@ -6,7 +6,7 @@
<groupId>com.eu.habbo</groupId>
<artifactId>Habbo</artifactId>
<version>4.1.16</version>
<version>4.2.10</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -95,7 +95,7 @@ public class HabboManager {
int userId = 0;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) {
PreparedStatement statement = connection.prepareStatement("SELECT id FROM users WHERE auth_ticket = ? AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW()) LIMIT 1")) {
statement.setString(1, sso);
try (ResultSet s = statement.executeQuery()) {
if (s.next()) {
@@ -121,7 +121,7 @@ public class HabboManager {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT * FROM users WHERE auth_ticket = ? LIMIT 1")) {
PreparedStatement statement = connection.prepareStatement("SELECT * FROM users WHERE auth_ticket = ? AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW()) LIMIT 1")) {
statement.setString(1, sso);
try (ResultSet set = statement.executeQuery()) {
if (set.next()) {
@@ -13,107 +13,317 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
/**
* Manages reading and writing of FurnitureData.json entries.
* Resolves the file path from emulator config keys.
* Manages reading and writing of FurnitureData entries.
*
* Accepts both legacy single-file layouts (FurnitureData.json) and the split
* directory layout introduced by the split-aware loader on the Nitro V3 side:
*
* <base>/
* manifest.json5 OPTIONAL { "tiers": ["core", "custom", "seasonal"] }
* core/manifest.json5 REQUIRED { "files": ["floor-001.json5", ...] }
* core/*.json5
* custom/manifest.json5 OPTIONAL
* seasonal/manifest.json5 OPTIONAL
*
* The path is resolved from the emulator config:
*
* furni.editor.renderer.config.path -> renderer-config.json (read for the
* furnidata.url value)
* furni.editor.asset.base.path -> filesystem base used to derive the
* local path from an http(s) URL
*/
public class FurniDataManager {
private static final Logger LOGGER = LoggerFactory.getLogger(FurniDataManager.class);
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
private static final List<String> SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes");
/**
* Get the JSON string for a specific item from FurnitureData.json.
* Get the JSON string for a specific item.
* Returns "{}" if not found or on error.
*/
public static String getItemJson(int itemId) {
try {
Path furniDataPath = resolveFurniDataPath();
if (furniDataPath == null || !Files.exists(furniDataPath)) {
return "{}";
ResolvedSource source = resolveSource();
if (source == null) return "{}";
if (source.directory) {
return findItemInSplitDir(source.path, itemId);
}
String content = Files.readString(furniDataPath, StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(content).getAsJsonObject();
if (!Files.exists(source.path)) return "{}";
// Search in both "roomitemtypes" and "wallitemtypes"
for (String section : new String[]{"roomitemtypes", "wallitemtypes"}) {
if (!root.has(section)) continue;
JsonObject sectionObj = root.getAsJsonObject(section);
if (!sectionObj.has("furnitype")) continue;
JsonArray types = sectionObj.getAsJsonArray("furnitype");
for (JsonElement el : types) {
JsonObject obj = el.getAsJsonObject();
if (obj.has("id") && obj.get("id").getAsInt() == itemId) {
return obj.toString();
}
}
}
String content = readJson5(source.path);
return findItemInRoot(JsonParser.parseString(content).getAsJsonObject(), itemId);
} catch (Exception e) {
LOGGER.warn("Failed to read FurnitureData.json for item " + itemId, e);
LOGGER.warn("Failed to read FurnitureData for item " + itemId, e);
}
return "{}";
}
private static String findItemInRoot(JsonObject root, int itemId) {
for (String section : SECTIONS) {
if (!root.has(section)) continue;
JsonObject sectionObj = root.getAsJsonObject(section);
if (!sectionObj.has("furnitype")) continue;
JsonArray types = sectionObj.getAsJsonArray("furnitype");
for (JsonElement el : types) {
JsonObject obj = el.getAsJsonObject();
if (obj.has("id") && obj.get("id").getAsInt() == itemId) {
return obj.toString();
}
}
}
return null;
}
/**
* Resolve the path to FurnitureData.json from emulator config.
* Walk the split directory layout looking for an item by id.
* Later tiers (custom, then seasonal) override earlier ones.
*/
private static Path resolveFurniDataPath() {
private static String findItemInSplitDir(Path baseDir, int itemId) {
if (!Files.isDirectory(baseDir)) return "{}";
List<String> tiers = readTiersManifest(baseDir);
String found = null;
for (String tier : tiers) {
Path tierDir = baseDir.resolve(tier);
if (!Files.isDirectory(tierDir)) continue;
List<String> files = readFilesManifest(tierDir);
for (String fileName : files) {
Path file = tierDir.resolve(fileName);
if (!Files.exists(file)) continue;
try {
String content = readJson5(file);
JsonObject obj = JsonParser.parseString(content).getAsJsonObject();
String match = findItemInRoot(obj, itemId);
if (match != null) found = match;
} catch (Exception e) {
LOGGER.warn("Failed to parse split gamedata file " + file, e);
}
}
}
return found != null ? found : "{}";
}
@SuppressWarnings("unchecked")
private static List<String> readTiersManifest(Path baseDir) {
Path manifest = firstExisting(baseDir, MANIFEST_NAMES);
if (manifest == null) return DEFAULT_TIERS;
try {
String content = readJson5(manifest);
JsonObject obj = JsonParser.parseString(content).getAsJsonObject();
if (obj.has("tiers") && obj.get("tiers").isJsonArray()) {
JsonArray arr = obj.getAsJsonArray("tiers");
List<String> out = new java.util.ArrayList<>();
for (JsonElement el : arr) out.add(el.getAsString());
if (!out.isEmpty()) return out;
}
} catch (Exception e) {
LOGGER.warn("Failed to read root manifest " + manifest + ", falling back to default tiers", e);
}
return DEFAULT_TIERS;
}
private static List<String> readFilesManifest(Path tierDir) {
Path manifest = firstExisting(tierDir, MANIFEST_NAMES);
if (manifest == null) return java.util.Collections.emptyList();
try {
String content = readJson5(manifest);
JsonObject obj = JsonParser.parseString(content).getAsJsonObject();
if (obj.has("files") && obj.get("files").isJsonArray()) {
JsonArray arr = obj.getAsJsonArray("files");
List<String> out = new java.util.ArrayList<>();
for (JsonElement el : arr) out.add(el.getAsString());
return out;
}
} catch (Exception e) {
LOGGER.warn("Failed to read tier manifest " + manifest, e);
}
return java.util.Collections.emptyList();
}
private static Path firstExisting(Path dir, List<String> names) {
for (String name : names) {
Path p = dir.resolve(name);
if (Files.exists(p)) return p;
}
return null;
}
/**
* Read a JSON or JSON5 file. Strips line and block comments and trailing
* commas so Gson can parse the result. String contents are preserved
* verbatim; comments embedded inside strings are not removed.
*/
private static String readJson5(Path path) throws IOException {
String raw = Files.readString(path, StandardCharsets.UTF_8);
return stripJson5(raw);
}
static String stripJson5(String content) {
if (content == null || content.isEmpty()) return content;
StringBuilder out = new StringBuilder(content.length());
int i = 0;
int len = content.length();
boolean inString = false;
char stringChar = 0;
boolean escape = false;
while (i < len) {
char c = content.charAt(i);
if (inString) {
out.append(c);
if (escape) {
escape = false;
} else if (c == '\\') {
escape = true;
} else if (c == stringChar) {
inString = false;
}
i++;
continue;
}
if (c == '"' || c == '\'') {
inString = true;
stringChar = c;
out.append(c);
i++;
continue;
}
if (c == '/' && i + 1 < len) {
char next = content.charAt(i + 1);
if (next == '/') {
int eol = content.indexOf('\n', i + 2);
if (eol < 0) { i = len; break; }
i = eol;
continue;
}
if (next == '*') {
int end = content.indexOf("*/", i + 2);
if (end < 0) { i = len; break; }
i = end + 2;
continue;
}
}
out.append(c);
i++;
}
String stripped = out.toString();
// Remove trailing commas before } or ]
stripped = stripped.replaceAll(",(\\s*[}\\]])", "$1");
return stripped;
}
/**
* Represents the resolved location of the furnidata source: either a single
* file or a directory in split-layout mode.
*/
private static class ResolvedSource {
final Path path;
final boolean directory;
ResolvedSource(Path path, boolean directory) {
this.path = path;
this.directory = directory;
}
}
/**
* Resolve the location of the furnidata source. Returns null if no
* candidate can be found.
*/
private static ResolvedSource resolveSource() {
try {
String configPath = Emulator.getConfig().getValue("furni.editor.renderer.config.path", "");
if (configPath.isEmpty()) {
// Fallback: try common locations
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (!basePath.isEmpty()) {
Path candidate = Paths.get(basePath, "FurnitureData.json");
if (Files.exists(candidate)) return candidate;
}
return null;
Path fallback = fallbackToBasePath();
return fallback != null ? new ResolvedSource(fallback, Files.isDirectory(fallback)) : null;
}
// Read the renderer config to find the furnidata URL/path
Path rendererConfig = Paths.get(configPath);
if (!Files.exists(rendererConfig)) return null;
String rendererContent = Files.readString(rendererConfig, StandardCharsets.UTF_8);
String rendererContent = readJson5(rendererConfig);
JsonObject rendererObj = JsonParser.parseString(rendererContent).getAsJsonObject();
if (rendererObj.has("furnidata.url")) {
String furniUrl = rendererObj.get("furnidata.url").getAsString();
if (!rendererObj.has("furnidata.url")) return null;
// Skip unresolved placeholders like ${gamedata.url}
if (furniUrl.contains("${")) {
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (!basePath.isEmpty()) {
Path candidate = Paths.get(basePath, "FurnitureData.json");
if (Files.exists(candidate)) return candidate;
}
return null;
}
String furniUrl = rendererObj.get("furnidata.url").getAsString();
// Strip query string (?v=1 etc.)
String cleanUrl = furniUrl.contains("?") ? furniUrl.substring(0, furniUrl.indexOf('?')) : furniUrl;
// If it's a local file path (not http), use it directly
if (!cleanUrl.startsWith("http")) {
return Paths.get(cleanUrl);
}
// For http URLs, try to derive local path from base path
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (!basePath.isEmpty()) {
// Extract filename from URL (without query string)
String filename = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1);
return Paths.get(basePath, filename);
}
if (furniUrl.contains("${")) {
Path fallback = fallbackToBasePath();
return fallback != null ? new ResolvedSource(fallback, Files.isDirectory(fallback)) : null;
}
// Strip query string and fragment (e.g. ?v=123 or #anchor)
String cleanUrl = furniUrl;
int q = cleanUrl.indexOf('?');
if (q >= 0) cleanUrl = cleanUrl.substring(0, q);
int h = cleanUrl.indexOf('#');
if (h >= 0) cleanUrl = cleanUrl.substring(0, h);
boolean splitMode = cleanUrl.endsWith("/");
// Local file path (not http) — return as-is, the caller will check
// whether it points at a file or a directory.
if (!cleanUrl.startsWith("http")) {
Path local = Paths.get(cleanUrl);
return new ResolvedSource(local, splitMode || Files.isDirectory(local));
}
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (basePath.isEmpty()) return null;
if (splitMode) {
// Derive the directory name from the URL: take the last non-empty
// segment before the trailing slash. e.g. https://x/y/furnidata/ -> "furnidata"
String trimmed = cleanUrl.endsWith("/") ? cleanUrl.substring(0, cleanUrl.length() - 1) : cleanUrl;
String dirName = trimmed.substring(trimmed.lastIndexOf('/') + 1);
Path candidate = Paths.get(basePath, dirName);
return new ResolvedSource(candidate, true);
}
String filename = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1);
Path candidate = Paths.get(basePath, filename);
return new ResolvedSource(candidate, false);
} catch (Exception e) {
LOGGER.warn("Failed to resolve FurnitureData.json path", e);
LOGGER.warn("Failed to resolve FurnitureData source", e);
}
return null;
}
private static Path fallbackToBasePath() {
String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (basePath.isEmpty()) return null;
Path dir = Paths.get(basePath);
// Prefer the split layout if it exists, then the legacy file.
Path splitCandidate = dir.resolve("furnidata");
if (Files.isDirectory(splitCandidate)) return splitCandidate;
Path legacy = dir.resolve("FurnitureData.json");
if (Files.exists(legacy)) return legacy;
return null;
}
}
@@ -104,7 +104,7 @@ public class SecureLoginEvent extends MessageHandler {
// First, look up the user ID to check for ghost sessions
int lookupUserId = 0;
try (java.sql.Connection conn = Emulator.getDatabase().getDataSource().getConnection();
java.sql.PreparedStatement stmt = conn.prepareStatement("SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) {
java.sql.PreparedStatement stmt = conn.prepareStatement("SELECT id FROM users WHERE auth_ticket = ? AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW()) LIMIT 1")) {
stmt.setString(1, sso);
try (java.sql.ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
@@ -50,7 +50,7 @@ final class SessionEndpoints {
if (ssoTicket != null && !ssoTicket.isEmpty()) {
try (PreparedStatement lookup = conn.prepareStatement(
"SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) {
"SELECT id FROM users WHERE auth_ticket = ? AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW()) LIMIT 1")) {
lookup.setString(1, ssoTicket);
try (ResultSet rs = lookup.executeQuery()) {
if (rs.next()) userId = rs.getInt("id");
@@ -134,7 +134,7 @@ final class SessionEndpoints {
try (Connection conn = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement lookup = conn.prepareStatement(
"SELECT id, username FROM users WHERE auth_ticket = ? LIMIT 1")) {
"SELECT id, username FROM users WHERE auth_ticket = ? AND (auth_ticket_expires_at IS NULL OR auth_ticket_expires_at >= NOW()) LIMIT 1")) {
lookup.setString(1, ssoTicket);
try (ResultSet rs = lookup.executeQuery()) {
if (!rs.next()) {
@@ -8,10 +8,13 @@ import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.*;
import io.netty.util.ReferenceCountUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(WebSocketHttpHandler.class);
private static final String ORIGIN_HEADER = "Origin";
@Override
@@ -27,6 +30,12 @@ public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter {
}
private boolean handleHttpRequest(ChannelHandlerContext ctx, HttpMessage req) {
captureForwardedIp(ctx, req);
if (!isWebSocketUpgrade(req)) {
return true;
}
String origin = "error";
try {
@@ -38,27 +47,47 @@ public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter {
String whitelist = Emulator.getConfig().getValue("ws.whitelist", "localhost");
if (!isWhitelisted(origin, whitelist.split(","))) {
LOGGER.warn("WebSocket upgrade rejected — origin '{}' not in ws.whitelist='{}'",
req.headers().get(ORIGIN_HEADER), whitelist);
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.FORBIDDEN,
Unpooled.wrappedBuffer("Origin forbidden".getBytes())
);
response.headers().set("Vary", "Origin");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
return false;
}
String ipHeader = Emulator.getConfig().getValue("ws.ip.header", "");
if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) {
String ip = req.headers().get(ipHeader);
ctx.channel().attr(GameServerAttributes.WS_IP).set(ip);
}
return true;
}
private static void captureForwardedIp(ChannelHandlerContext ctx, HttpMessage req) {
String ipHeader = Emulator.getConfig().getValue("ws.ip.header", "");
if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) {
String ip = req.headers().get(ipHeader);
ctx.channel().attr(GameServerAttributes.WS_IP).set(ip);
}
}
private static boolean isWebSocketUpgrade(HttpMessage req) {
String upgrade = req.headers().get(HttpHeaderNames.UPGRADE);
if (upgrade == null || !"websocket".equalsIgnoreCase(upgrade)) return false;
String connection = req.headers().get(HttpHeaderNames.CONNECTION);
if (connection == null) return false;
for (String token : connection.split(",")) {
if ("upgrade".equalsIgnoreCase(token.trim())) return true;
}
return false;
}
private static String getDomainNameFromUrl(String url) throws Exception {
URI uri = new URI(url);
String domain = uri.getHost();
if (domain == null) return "error";
return domain.startsWith("www.") ? domain.substring(4) : domain;
}
+9 -10
View File
@@ -27,16 +27,6 @@ rcon.host=127.0.0.1
rcon.port=3001
rcon.allowed=127.0.0.1;127.0.0.2
#WebSocket Configuration (for Nitro)
#Set ws.enabled to true to enable WebSocket connections.
ws.enabled=false
ws.host=0.0.0.0
ws.port=2096
#Comma-separated whitelist of allowed origins. Supports wildcards: *.example.com, * (allow all)
ws.whitelist=localhost
#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=
# Databse configuration
db.pool.connection_timeout_ms = 10000
db.pool.idle_timeout_ms = 600000
@@ -69,3 +59,12 @@ login.remember.jwt.secret=
# Login news API.
login.news.limit=5
#WebSocket Configuration (for Nitro)
#Please adjust this setting in the Database !!!!
### ws.enabled=false
### ws.host=0.0.0.0
### ws.port=2096
### 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.