diff --git a/backend/src/controllers/UserController.cpp b/backend/src/controllers/UserController.cpp index 8abe78a..e9ae93e 100644 --- a/backend/src/controllers/UserController.cpp +++ b/backend/src/controllers/UserController.cpp @@ -445,7 +445,7 @@ void UserController::getCurrentUser(const HttpRequestPtr &req, } auto dbClient = app().getDbClient(); - *dbClient << "SELECT id, username, is_admin, is_streamer, is_restreamer, is_bot, is_texter, is_sticker_creator, is_uploader, is_watch_creator, is_pgp_only, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color, ubercoin_balance, created_at " + *dbClient << "SELECT id, username, is_admin, is_streamer, is_restreamer, is_bot, is_texter, is_sticker_creator, is_uploader, is_watch_creator, is_pgp_only, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color, ubercoin_balance, created_at, screensaver_enabled, screensaver_timeout_minutes, screensaver_type " "FROM users WHERE id = $1" << user.id >> [callback](const Result& r) { @@ -478,6 +478,9 @@ void UserController::getCurrentUser(const HttpRequestPtr &req, resp["user"]["colorCode"] = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as(); resp["user"]["ubercoinBalance"] = r[0]["ubercoin_balance"].isNull() ? 0.0 : r[0]["ubercoin_balance"].as(); resp["user"]["createdAt"] = r[0]["created_at"].isNull() ? "" : r[0]["created_at"].as(); + resp["user"]["screensaverEnabled"] = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as(); + resp["user"]["screensaverTimeoutMinutes"] = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as(); + resp["user"]["screensaverType"] = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as(); callback(jsonResp(resp)); } >> DB_ERROR(callback, "get user data"); @@ -2703,19 +2706,26 @@ void UserController::updateScreensaver(const HttpRequestPtr &req, bool enabled = (*json).isMember("enabled") ? (*json)["enabled"].asBool() : false; int timeoutMinutes = (*json).isMember("timeout_minutes") ? (*json)["timeout_minutes"].asInt() : 5; + std::string type = (*json).isMember("type") ? (*json)["type"].asString() : "snowfall"; // Validate timeout range (1-30 minutes) if (timeoutMinutes < 1) timeoutMinutes = 1; if (timeoutMinutes > 30) timeoutMinutes = 30; + // Validate screensaver type + if (type != "snowfall" && type != "fractal_crystalline" && type != "random") { + type = "snowfall"; + } + auto dbClient = app().getDbClient(); - *dbClient << "UPDATE users SET screensaver_enabled = $1, screensaver_timeout_minutes = $2 WHERE id = $3" - << enabled << timeoutMinutes << user.id - >> [callback, enabled, timeoutMinutes](const Result&) { + *dbClient << "UPDATE users SET screensaver_enabled = $1, screensaver_timeout_minutes = $2, screensaver_type = $3 WHERE id = $4" + << enabled << timeoutMinutes << type << user.id + >> [callback, enabled, timeoutMinutes, type](const Result&) { Json::Value resp; resp["success"] = true; resp["screensaver"]["enabled"] = enabled; resp["screensaver"]["timeout_minutes"] = timeoutMinutes; + resp["screensaver"]["type"] = type; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "update screensaver settings", "Failed to update screensaver settings"); diff --git a/backend/src/services/AuthService.cpp b/backend/src/services/AuthService.cpp index b387433..88b74e5 100644 --- a/backend/src/services/AuthService.cpp +++ b/backend/src/services/AuthService.cpp @@ -443,7 +443,7 @@ void AuthService::loginUser(const std::string& username, const std::string& pass return; } - *dbClient << "SELECT id, username, password_hash, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, is_disabled, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color, screensaver_enabled, screensaver_timeout_minutes " + *dbClient << "SELECT id, username, password_hash, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, is_disabled, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color, screensaver_enabled, screensaver_timeout_minutes, screensaver_type " "FROM users WHERE username = $1 LIMIT 1" << username >> [password, callback, this](const Result& r) { @@ -506,6 +506,7 @@ void AuthService::loginUser(const std::string& username, const std::string& pass user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as(); user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as(); user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as(); + user.screensaverType = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as(); std::string token = generateToken(user); callback(true, token, user); @@ -601,7 +602,7 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string& } *dbClient << "SELECT pk.public_key, u.id, u.username, u.is_admin, u.is_moderator, u.is_streamer, u.is_restreamer, u.is_bot, u.is_texter, " - "u.is_pgp_only, u.is_disabled, u.bio, u.avatar_url, u.banner_url, u.banner_position, u.banner_zoom, u.banner_position_x, u.graffiti_url, u.pgp_only_enabled_at, u.user_color, u.screensaver_enabled, u.screensaver_timeout_minutes " + "u.is_pgp_only, u.is_disabled, u.bio, u.avatar_url, u.banner_url, u.banner_position, u.banner_zoom, u.banner_position_x, u.graffiti_url, u.pgp_only_enabled_at, u.user_color, u.screensaver_enabled, u.screensaver_timeout_minutes, u.screensaver_type " "FROM pgp_keys pk JOIN users u ON pk.user_id = u.id " "WHERE u.username = $1 ORDER BY pk.created_at DESC LIMIT 1" << username @@ -654,6 +655,7 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string& user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as(); user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as(); user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as(); + user.screensaverType = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as(); std::string token = generateToken(user); callback(true, token, user); @@ -921,7 +923,7 @@ void AuthService::fetchUserInfo(int64_t userId, std::function> [callback](const Result& r) { @@ -952,6 +954,7 @@ void AuthService::fetchUserInfo(int64_t userId, std::function(); user.screensaverEnabled = r[0]["screensaver_enabled"].isNull() ? false : r[0]["screensaver_enabled"].as(); user.screensaverTimeoutMinutes = r[0]["screensaver_timeout_minutes"].isNull() ? 5 : r[0]["screensaver_timeout_minutes"].as(); + user.screensaverType = r[0]["screensaver_type"].isNull() ? "snowfall" : r[0]["screensaver_type"].as(); callback(true, user); } catch (const std::exception& e) { @@ -1075,7 +1078,7 @@ void AuthService::validateAndRotateRefreshToken(const std::string& refreshToken, *dbClient << "SELECT rtf.id, rtf.user_id, rtf.family_id, rtf.expires_at, rtf.revoked, " "u.username, u.is_admin, u.is_moderator, u.is_streamer, u.is_restreamer, " "u.is_bot, u.is_texter, u.is_pgp_only, u.is_disabled, u.user_color, u.avatar_url, " - "u.token_version, u.screensaver_enabled, u.screensaver_timeout_minutes " + "u.token_version, u.screensaver_enabled, u.screensaver_timeout_minutes, u.screensaver_type " "FROM refresh_token_families rtf " "JOIN users u ON rtf.user_id = u.id " "WHERE rtf.current_token_hash = $1" @@ -1138,6 +1141,7 @@ void AuthService::validateAndRotateRefreshToken(const std::string& refreshToken, user.tokenVersion = row["token_version"].isNull() ? 1 : row["token_version"].as(); user.screensaverEnabled = row["screensaver_enabled"].isNull() ? false : row["screensaver_enabled"].as(); user.screensaverTimeoutMinutes = row["screensaver_timeout_minutes"].isNull() ? 5 : row["screensaver_timeout_minutes"].as(); + user.screensaverType = row["screensaver_type"].isNull() ? "snowfall" : row["screensaver_type"].as(); // Generate new tokens (rotation) std::string newRefreshToken = generateRefreshToken(); diff --git a/backend/src/services/AuthService.h b/backend/src/services/AuthService.h index 88749de..d62d513 100644 --- a/backend/src/services/AuthService.h +++ b/backend/src/services/AuthService.h @@ -31,6 +31,7 @@ struct UserInfo { int tokenVersion = 1; // SECURITY FIX #10: Token version for revocation bool screensaverEnabled = false; // Screensaver feature enabled int screensaverTimeoutMinutes = 5; // Idle timeout before screensaver activates (1-30) + std::string screensaverType = "snowfall"; // Screensaver type: snowfall, fractal_crystalline, random }; // Result structure for refresh token operations diff --git a/chat-service/src/controllers/ChatWebSocketController.cpp b/chat-service/src/controllers/ChatWebSocketController.cpp index 2fb8ba5..a4d3d76 100644 --- a/chat-service/src/controllers/ChatWebSocketController.cpp +++ b/chat-service/src/controllers/ChatWebSocketController.cpp @@ -1166,7 +1166,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& const std::string& apiKey) { LOG_INFO << "Bot attempting to authenticate via message-based API key"; - // Get connection info to extract realmId if provided + // Get connection info to extract realmId if provided, and mark as pending std::string realmId; { std::lock_guard lock(connectionsMutex_); @@ -1174,6 +1174,8 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& if (it != connections_.end()) { realmId = it->second.realmId; } + // Mark connection as pending API key validation (for timeout cleanup) + pendingConnections_[wsConnPtr] = std::chrono::steady_clock::now(); } // Make HTTP request to backend to validate API key @@ -1202,6 +1204,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& if (result != ReqResult::Ok || !resp) { LOG_ERROR << "Failed to validate API key - backend request failed"; sendError(wsConnPtr, "API key validation failed - service unavailable"); + wsConnPtr->shutdown(CloseCode::kViolation); return; } @@ -1209,6 +1212,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& if (!json || !(*json)["valid"].asBool()) { LOG_WARN << "Invalid API key - rejecting authentication"; sendError(wsConnPtr, "Invalid API key"); + wsConnPtr->shutdown(CloseCode::kViolation); return; } @@ -1218,9 +1222,13 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& auto it = connections_.find(wsConnPtr); if (it == connections_.end()) { LOG_WARN << "Connection closed before API key validation completed"; + pendingConnections_.erase(wsConnPtr); // Clean up pending state return; } + // Successfully validated - remove from pending + pendingConnections_.erase(wsConnPtr); + // Get API key ID for connection tracking int64_t keyId = (*json)["keyId"].asInt64(); @@ -1229,6 +1237,7 @@ void ChatWebSocketController::handleBotApiKeyAuth(const WebSocketConnectionPtr& if (existingConn != apiKeyConnections_.end()) { LOG_WARN << "API key " << keyId << " already has an active connection - rejecting"; sendError(wsConnPtr, "Only 1 connection per API key allowed. Disconnect the existing connection first."); + wsConnPtr->shutdown(CloseCode::kViolation); return; } diff --git a/chat-service/src/controllers/ChatWebSocketController.h b/chat-service/src/controllers/ChatWebSocketController.h index e192166..3ae367a 100644 --- a/chat-service/src/controllers/ChatWebSocketController.h +++ b/chat-service/src/controllers/ChatWebSocketController.h @@ -34,6 +34,9 @@ public: // Check and disconnect guests that have exceeded their session timeout static void checkGuestTimeouts(); + // Check and disconnect pending bot connections that have exceeded validation timeout + static void checkPendingConnectionTimeouts(); + private: struct ConnectionInfo { std::string realmId; diff --git a/database/init.sql b/database/init.sql index 49e89d5..df0340a 100644 --- a/database/init.sql +++ b/database/init.sql @@ -1252,6 +1252,17 @@ BEGIN END IF; END $$; +-- Add screensaver_type column to users table (default 'snowfall', options: 'snowfall', 'fractal_crystalline', 'random') +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'screensaver_type' + ) THEN + ALTER TABLE users ADD COLUMN screensaver_type VARCHAR(20) DEFAULT 'snowfall'; + END IF; +END $$; + -- ============================================ -- LIVE STREAM DURATION TRACKING -- ============================================ diff --git a/frontend/src/lib/components/ScreensaverOverlay.svelte b/frontend/src/lib/components/ScreensaverOverlay.svelte index 56e5122..4718531 100644 --- a/frontend/src/lib/components/ScreensaverOverlay.svelte +++ b/frontend/src/lib/components/ScreensaverOverlay.svelte @@ -1,34 +1,22 @@ {#if $isScreensaverActive} @@ -41,21 +29,7 @@ tabindex="0" aria-label="Click or press any key to dismiss screensaver" > -
- {#each snowflakes as flake (flake.id)} -
- {/each} -
+
Click or press any key to dismiss @@ -73,35 +47,6 @@ overflow: hidden; } - .snowfall { - position: absolute; - inset: 0; - pointer-events: none; - } - - .snowflake { - position: absolute; - left: var(--x); - top: -10px; - width: var(--size); - height: var(--size); - background: white; - border-radius: 50%; - opacity: var(--opacity); - animation: fall linear infinite; - animation-duration: calc(10s / var(--speed)); - animation-delay: var(--delay); - } - - @keyframes fall { - 0% { - transform: translateY(-10px) translateX(0); - } - 100% { - transform: translateY(100vh) translateX(calc(var(--drift) * 100px)); - } - } - .screensaver-hint { position: absolute; bottom: 2rem; diff --git a/frontend/src/lib/components/screensavers/FractalCrystalline.svelte b/frontend/src/lib/components/screensavers/FractalCrystalline.svelte new file mode 100644 index 0000000..d7927d4 --- /dev/null +++ b/frontend/src/lib/components/screensavers/FractalCrystalline.svelte @@ -0,0 +1,268 @@ + + + + + diff --git a/frontend/src/lib/components/screensavers/Snowfall.svelte b/frontend/src/lib/components/screensavers/Snowfall.svelte new file mode 100644 index 0000000..0df22ac --- /dev/null +++ b/frontend/src/lib/components/screensavers/Snowfall.svelte @@ -0,0 +1,73 @@ + + +
+ {#each snowflakes as flake (flake.id)} +
+ {/each} +
+ + diff --git a/frontend/src/lib/stores/screensaver.js b/frontend/src/lib/stores/screensaver.js index 897c3fb..95fd232 100644 --- a/frontend/src/lib/stores/screensaver.js +++ b/frontend/src/lib/stores/screensaver.js @@ -5,9 +5,11 @@ const defaultState = { // User settings (from backend) enabled: false, timeoutMinutes: 5, + type: 'snowfall', // User preference: 'snowfall', 'fractal_crystalline', 'random' // Runtime state active: false, // Is screensaver currently showing + activeType: 'snowfall', // Resolved type when activated (for 'random' resolution) idleTime: 0, // Current idle time in seconds tabVisible: true, // Is tab currently visible mediaPlaying: false // Is any media currently playing @@ -90,7 +92,11 @@ function createScreensaverStore() { // Check if idle time exceeds timeout if (newIdleTime >= state.timeoutMinutes * 60) { - return { ...newState, active: true }; + // Resolve random type at activation time + const activeType = state.type === 'random' + ? (Math.random() < 0.5 ? 'snowfall' : 'fractal_crystalline') + : state.type; + return { ...newState, active: true, activeType }; } return newState; @@ -122,11 +128,13 @@ function createScreensaverStore() { init(settings) { const enabled = settings?.screensaverEnabled || false; const timeoutMinutes = settings?.screensaverTimeoutMinutes || 5; + const type = settings?.screensaverType || 'snowfall'; update(state => ({ ...state, enabled, - timeoutMinutes + timeoutMinutes, + type })); if (browser && enabled) { @@ -135,11 +143,12 @@ function createScreensaverStore() { }, // Update settings from API response - updateSettings(enabled, timeoutMinutes) { + updateSettings(enabled, timeoutMinutes, type = 'snowfall') { update(state => ({ ...state, enabled, - timeoutMinutes + timeoutMinutes, + type })); if (browser) { @@ -168,3 +177,6 @@ export const screensaver = createScreensaverStore(); // Derived store for whether screensaver is active export const isScreensaverActive = derived(screensaver, $s => $s.active); + +// Derived store for the active screensaver type (resolved from random at activation) +export const activeScreensaverType = derived(screensaver, $s => $s.activeType); diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 0cbf215..f5cf034 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -112,6 +112,7 @@ // Screensaver settings let screensaverEnabled = false; let screensaverTimeoutMinutes = 5; + let screensaverType = 'snowfall'; let screensaverLoading = false; let screensaverMessage = ''; let screensaverError = ''; @@ -161,6 +162,7 @@ newColor = userColor; screensaverEnabled = data.user.screensaverEnabled || false; screensaverTimeoutMinutes = data.user.screensaverTimeoutMinutes || 5; + screensaverType = data.user.screensaverType || 'snowfall'; currentUser = data.user; // Update auth store with fresh data @@ -586,7 +588,8 @@ credentials: 'include', body: JSON.stringify({ enabled: screensaverEnabled, - timeout_minutes: screensaverTimeoutMinutes + timeout_minutes: screensaverTimeoutMinutes, + type: screensaverType }) }); @@ -595,7 +598,7 @@ if (response.ok && data.success) { screensaverMessage = 'Screensaver settings saved'; // Update the screensaver store - screensaver.updateSettings(screensaverEnabled, screensaverTimeoutMinutes); + screensaver.updateSettings(screensaverEnabled, screensaverTimeoutMinutes, screensaverType); setTimeout(() => { screensaverMessage = ''; }, 3000); } else { screensaverError = data.error || 'Failed to save settings'; @@ -3289,11 +3292,27 @@ bot.connect(); Enable screensaver

- When enabled, a snowfall animation will appear after the idle timeout. + When enabled, an animation will appear after the idle timeout.

{#if screensaverEnabled} +
+ + +

+ Choose a screensaver animation. "Random" will pick one at activation. +

+
+