diff --git a/backend/src/controllers/UserController.cpp b/backend/src/controllers/UserController.cpp index 1bb4201..4ad4fe6 100644 --- a/backend/src/controllers/UserController.cpp +++ b/backend/src/controllers/UserController.cpp @@ -31,17 +31,28 @@ namespace { return ss.str(); } - // Helper to set httpOnly auth cookie + // Helper to set httpOnly access token cookie (2.5 hours to match JWT expiry) void setAuthCookie(const HttpResponsePtr& resp, const std::string& token) { Cookie authCookie("auth_token", token); authCookie.setPath("/"); authCookie.setHttpOnly(true); authCookie.setSecure(false); // Set to true in production with HTTPS - authCookie.setMaxAge(86400); // 24 hours + authCookie.setMaxAge(9000); // 2.5 hours (150 minutes, matches JWT expiry) authCookie.setSameSite(Cookie::SameSite::kLax); resp->addCookie(authCookie); } - + + // Helper to set httpOnly refresh token cookie (long-lived: 90 days) + void setRefreshCookie(const HttpResponsePtr& resp, const std::string& token) { + Cookie refreshCookie("refresh_token", token); + refreshCookie.setPath("/"); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(false); // Set to true in production with HTTPS + refreshCookie.setMaxAge(90 * 24 * 60 * 60); // 90 days + refreshCookie.setSameSite(Cookie::SameSite::kLax); + resp->addCookie(refreshCookie); + } + // Helper to clear auth cookie void clearAuthCookie(const HttpResponsePtr& resp) { Cookie authCookie("auth_token", ""); @@ -50,6 +61,15 @@ namespace { authCookie.setMaxAge(0); // Expire immediately resp->addCookie(authCookie); } + + // Helper to clear refresh cookie + void clearRefreshCookie(const HttpResponsePtr& resp) { + Cookie refreshCookie("refresh_token", ""); + refreshCookie.setPath("/"); + refreshCookie.setHttpOnly(true); + refreshCookie.setMaxAge(0); // Expire immediately + resp->addCookie(refreshCookie); + } } void UserController::register_(const HttpRequestPtr &req, @@ -179,31 +199,44 @@ void UserController::login(const HttpRequestPtr &req, if (success) { LOG_INFO << "Login successful for user: " << username; - Json::Value resp; - resp["success"] = true; - // Send token in body for WebSocket auth (cookie still used for HTTP requests) - resp["token"] = token; - resp["user"]["id"] = static_cast(user.id); - resp["user"]["username"] = user.username; - resp["user"]["isAdmin"] = user.isAdmin; - resp["user"]["isStreamer"] = user.isStreamer; - resp["user"]["isRestreamer"] = user.isRestreamer; - resp["user"]["isBot"] = user.isBot; - resp["user"]["isTexter"] = user.isTexter; - resp["user"]["isPgpOnly"] = user.isPgpOnly; - resp["user"]["bio"] = user.bio; - resp["user"]["avatarUrl"] = user.avatarUrl; - resp["user"]["bannerUrl"] = user.bannerUrl; - resp["user"]["bannerPosition"] = user.bannerPosition; - resp["user"]["bannerZoom"] = user.bannerZoom; - resp["user"]["bannerPositionX"] = user.bannerPositionX; - resp["user"]["graffitiUrl"] = user.graffitiUrl; - resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt; - resp["user"]["colorCode"] = user.colorCode; + // Create refresh token family for long-lived session + AuthService::getInstance().createRefreshTokenFamily(user.id, + [callback, username, token, user](bool refreshSuccess, const std::string& refreshToken, + const std::string& familyId) { + Json::Value resp; + resp["success"] = true; + // Send token in body for WebSocket auth (cookie still used for HTTP requests) + resp["token"] = token; + resp["user"]["id"] = static_cast(user.id); + resp["user"]["username"] = user.username; + resp["user"]["isAdmin"] = user.isAdmin; + resp["user"]["isStreamer"] = user.isStreamer; + resp["user"]["isRestreamer"] = user.isRestreamer; + resp["user"]["isBot"] = user.isBot; + resp["user"]["isTexter"] = user.isTexter; + resp["user"]["isPgpOnly"] = user.isPgpOnly; + resp["user"]["bio"] = user.bio; + resp["user"]["avatarUrl"] = user.avatarUrl; + resp["user"]["bannerUrl"] = user.bannerUrl; + resp["user"]["bannerPosition"] = user.bannerPosition; + resp["user"]["bannerZoom"] = user.bannerZoom; + resp["user"]["bannerPositionX"] = user.bannerPositionX; + resp["user"]["graffitiUrl"] = user.graffitiUrl; + resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt; + resp["user"]["colorCode"] = user.colorCode; - auto response = jsonResp(resp); - setAuthCookie(response, token); - callback(response); + auto response = jsonResp(resp); + setAuthCookie(response, token); + + // Also set refresh token cookie if created successfully + if (refreshSuccess) { + setRefreshCookie(response, refreshToken); + } else { + LOG_WARN << "Failed to create refresh token for user: " << username; + } + + callback(response); + }); } else { LOG_WARN << "Login failed for user: " << username; callback(jsonError(token.empty() ? "Invalid credentials" : token, k401Unauthorized)); @@ -224,9 +257,10 @@ void UserController::logout(const HttpRequestPtr &req, Json::Value resp; resp["success"] = true; resp["message"] = "Logged out successfully"; - + auto response = jsonResp(resp); clearAuthCookie(response); + clearRefreshCookie(response); // Also clear refresh token callback(response); } catch (const std::exception& e) { LOG_ERROR << "Exception in logout: " << e.what(); @@ -234,6 +268,58 @@ void UserController::logout(const HttpRequestPtr &req, } } +void UserController::refresh(const HttpRequestPtr &req, + std::function &&callback) { + try { + // Get refresh token from cookie + std::string refreshToken = req->getCookie("refresh_token"); + + if (refreshToken.empty()) { + LOG_DEBUG << "No refresh token provided"; + callback(jsonError("No refresh token", k401Unauthorized)); + return; + } + + // Validate and rotate the refresh token + AuthService::getInstance().validateAndRotateRefreshToken(refreshToken, + [callback](RefreshTokenResult result) { + if (result.success) { + LOG_DEBUG << "Token refresh successful for user: " << result.user.username; + + Json::Value resp; + resp["success"] = true; + resp["token"] = result.accessToken; + resp["user"]["id"] = static_cast(result.user.id); + resp["user"]["username"] = result.user.username; + resp["user"]["isAdmin"] = result.user.isAdmin; + resp["user"]["isStreamer"] = result.user.isStreamer; + resp["user"]["isRestreamer"] = result.user.isRestreamer; + resp["user"]["isBot"] = result.user.isBot; + resp["user"]["isTexter"] = result.user.isTexter; + resp["user"]["isPgpOnly"] = result.user.isPgpOnly; + resp["user"]["colorCode"] = result.user.colorCode; + resp["user"]["avatarUrl"] = result.user.avatarUrl; + + auto response = jsonResp(resp); + setAuthCookie(response, result.accessToken); + setRefreshCookie(response, result.refreshToken); + callback(response); + } else { + LOG_DEBUG << "Token refresh failed: " << result.error; + + // Clear cookies on failure + auto response = jsonError(result.error, k401Unauthorized); + clearAuthCookie(response); + clearRefreshCookie(response); + callback(response); + } + }); + } catch (const std::exception& e) { + LOG_ERROR << "Exception in refresh: " << e.what(); + callback(jsonError("Internal server error")); + } +} + void UserController::pgpChallenge(const HttpRequestPtr &req, std::function &&callback) { try { @@ -301,31 +387,44 @@ void UserController::pgpVerify(const HttpRequestPtr &req, AuthService::getInstance().verifyPgpLogin(username, signature, challenge, [callback](bool success, const std::string& token, const UserInfo& user) { if (success) { - Json::Value resp; - resp["success"] = true; - // Send token in body for WebSocket auth (cookie still used for HTTP requests) - resp["token"] = token; - resp["user"]["id"] = static_cast(user.id); - resp["user"]["username"] = user.username; - resp["user"]["isAdmin"] = user.isAdmin; - resp["user"]["isStreamer"] = user.isStreamer; - resp["user"]["isRestreamer"] = user.isRestreamer; - resp["user"]["isBot"] = user.isBot; - resp["user"]["isTexter"] = user.isTexter; - resp["user"]["isPgpOnly"] = user.isPgpOnly; - resp["user"]["bio"] = user.bio; - resp["user"]["avatarUrl"] = user.avatarUrl; - resp["user"]["bannerUrl"] = user.bannerUrl; - resp["user"]["bannerPosition"] = user.bannerPosition; - resp["user"]["bannerZoom"] = user.bannerZoom; - resp["user"]["bannerPositionX"] = user.bannerPositionX; - resp["user"]["graffitiUrl"] = user.graffitiUrl; - resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt; - resp["user"]["colorCode"] = user.colorCode; + // Create refresh token family for long-lived session + AuthService::getInstance().createRefreshTokenFamily(user.id, + [callback, token, user](bool refreshSuccess, const std::string& refreshToken, + const std::string& familyId) { + Json::Value resp; + resp["success"] = true; + // Send token in body for WebSocket auth (cookie still used for HTTP requests) + resp["token"] = token; + resp["user"]["id"] = static_cast(user.id); + resp["user"]["username"] = user.username; + resp["user"]["isAdmin"] = user.isAdmin; + resp["user"]["isStreamer"] = user.isStreamer; + resp["user"]["isRestreamer"] = user.isRestreamer; + resp["user"]["isBot"] = user.isBot; + resp["user"]["isTexter"] = user.isTexter; + resp["user"]["isPgpOnly"] = user.isPgpOnly; + resp["user"]["bio"] = user.bio; + resp["user"]["avatarUrl"] = user.avatarUrl; + resp["user"]["bannerUrl"] = user.bannerUrl; + resp["user"]["bannerPosition"] = user.bannerPosition; + resp["user"]["bannerZoom"] = user.bannerZoom; + resp["user"]["bannerPositionX"] = user.bannerPositionX; + resp["user"]["graffitiUrl"] = user.graffitiUrl; + resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt; + resp["user"]["colorCode"] = user.colorCode; - auto response = jsonResp(resp); - setAuthCookie(response, token); - callback(response); + auto response = jsonResp(resp); + setAuthCookie(response, token); + + // Also set refresh token cookie if created successfully + if (refreshSuccess) { + setRefreshCookie(response, refreshToken); + } else { + LOG_WARN << "Failed to create refresh token for PGP login"; + } + + callback(response); + }); } else { callback(jsonError("Invalid signature", k401Unauthorized)); } diff --git a/backend/src/controllers/UserController.h b/backend/src/controllers/UserController.h index de7fa8a..0a41059 100644 --- a/backend/src/controllers/UserController.h +++ b/backend/src/controllers/UserController.h @@ -10,6 +10,7 @@ public: ADD_METHOD_TO(UserController::register_, "/api/auth/register", Post); ADD_METHOD_TO(UserController::login, "/api/auth/login", Post); ADD_METHOD_TO(UserController::logout, "/api/auth/logout", Post); + ADD_METHOD_TO(UserController::refresh, "/api/auth/refresh", Post); ADD_METHOD_TO(UserController::pgpChallenge, "/api/auth/pgp-challenge", Post); ADD_METHOD_TO(UserController::pgpVerify, "/api/auth/pgp-verify", Post); ADD_METHOD_TO(UserController::getCurrentUser, "/api/user/me", Get); @@ -57,7 +58,10 @@ public: void logout(const HttpRequestPtr &req, std::function &&callback); - + + void refresh(const HttpRequestPtr &req, + std::function &&callback); + void pgpChallenge(const HttpRequestPtr &req, std::function &&callback); diff --git a/backend/src/services/AuthService.cpp b/backend/src/services/AuthService.cpp index 3947ecd..5788bee 100644 --- a/backend/src/services/AuthService.cpp +++ b/backend/src/services/AuthService.cpp @@ -720,12 +720,13 @@ std::string AuthService::generateToken(const UserInfo& user) { try { validateAndLoadJwtSecret(); - // SECURITY FIX: Reduced JWT expiry from 24h to 1h to limit token exposure window + // Access token: Short-lived (15 minutes) for security + // Use refresh tokens for session persistence auto token = jwt::create() .set_issuer("streaming-app") .set_type("JWT") .set_issued_at(std::chrono::system_clock::now()) - .set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(1)) + .set_expires_at(std::chrono::system_clock::now() + std::chrono::minutes(ACCESS_TOKEN_EXPIRY_MINUTES)) .set_payload_claim("user_id", jwt::claim(std::to_string(user.id))) .set_payload_claim("username", jwt::claim(user.username)) .set_payload_claim("is_admin", jwt::claim(std::to_string(user.isAdmin))) @@ -869,12 +870,24 @@ void AuthService::updatePassword(int64_t userId, const std::string& oldPassword, return; } - // SECURITY FIX #10: Increment token_version to invalidate all existing tokens + // SECURITY FIX #10: Increment token_version to invalidate all existing access tokens + // Also revoke all refresh token families for complete session invalidation *dbClient << "UPDATE users SET password_hash = $1, token_version = COALESCE(token_version, 0) + 1 WHERE id = $2" << newHash << userId - >> [callback, userId](const Result&) { - LOG_INFO << "Password updated and token_version incremented for user " << userId; - callback(true, ""); + >> [callback, userId, dbClient](const Result&) { + // Revoke all refresh token families for this user + *dbClient << "UPDATE refresh_token_families SET revoked = TRUE, revoked_at = NOW() " + "WHERE user_id = $1 AND revoked = FALSE" + << userId + >> [callback, userId](const Result&) { + LOG_INFO << "Password updated and all sessions invalidated for user " << userId; + callback(true, ""); + } + >> [callback](const DrogonDbException& e) { + LOG_ERROR << "Failed to revoke refresh tokens: " << e.base().what(); + // Still consider success - password was changed + callback(true, ""); + }; } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to update password: " << e.base().what(); @@ -948,4 +961,272 @@ void AuthService::fetchUserInfo(int64_t userId, std::function bytes; + for (auto& b : bytes) { + b = static_cast(rd()); + } + return drogon::utils::base64Encode(bytes.data(), bytes.size()); +} + +std::string AuthService::hashToken(const std::string& token) { + // Use Drogon's MD5 for simplicity (SHA256 would be better but MD5 is sufficient for token hashing) + // The token itself is already random and unpredictable + return drogon::utils::getMd5(token); +} + +std::string AuthService::generateUUID() { + // Generate a UUID v4 + std::random_device rd; + std::uniform_int_distribution dist(0, UINT32_MAX); + + char uuid[37]; + uint32_t a = dist(rd); + uint32_t b = dist(rd); + uint32_t c = dist(rd); + uint32_t d = dist(rd); + + // Set version (4) and variant bits + b = (b & 0xFFFF0FFF) | 0x00004000; // Version 4 + c = (c & 0x3FFFFFFF) | 0x80000000; // Variant 1 + + snprintf(uuid, sizeof(uuid), "%08x-%04x-%04x-%04x-%04x%08x", + a, + (b >> 16) & 0xFFFF, + b & 0xFFFF, + (c >> 16) & 0xFFFF, + c & 0xFFFF, + d); + + return std::string(uuid); +} + +void AuthService::createRefreshTokenFamily(int64_t userId, + std::function callback) { + try { + auto dbClient = app().getDbClient(); + if (!dbClient) { + LOG_ERROR << "Database client is null"; + callback(false, "", ""); + return; + } + + // Generate refresh token and family ID + std::string refreshToken = generateRefreshToken(); + std::string familyId = generateUUID(); + std::string tokenHash = hashToken(refreshToken); + + // Calculate expiry (90 days from now) + auto expiresAt = std::chrono::system_clock::now() + std::chrono::hours(24 * REFRESH_TOKEN_EXPIRY_DAYS); + auto expiresAtTime = std::chrono::system_clock::to_time_t(expiresAt); + + // Format as ISO timestamp + char expiresAtStr[32]; + std::strftime(expiresAtStr, sizeof(expiresAtStr), "%Y-%m-%d %H:%M:%S", std::gmtime(&expiresAtTime)); + + // Insert into database + *dbClient << "INSERT INTO refresh_token_families (user_id, family_id, current_token_hash, expires_at) " + "VALUES ($1, $2::uuid, $3, $4::timestamp)" + << userId << familyId << tokenHash << std::string(expiresAtStr) + >> [callback, refreshToken, familyId](const Result&) { + LOG_DEBUG << "Created refresh token family: " << familyId; + callback(true, refreshToken, familyId); + } + >> [callback](const DrogonDbException& e) { + LOG_ERROR << "Failed to create refresh token family: " << e.base().what(); + callback(false, "", ""); + }; + } catch (const std::exception& e) { + LOG_ERROR << "Exception in createRefreshTokenFamily: " << e.what(); + callback(false, "", ""); + } +} + +void AuthService::validateAndRotateRefreshToken(const std::string& refreshToken, + std::function callback) { + try { + auto dbClient = app().getDbClient(); + if (!dbClient) { + LOG_ERROR << "Database client is null"; + RefreshTokenResult result; + result.error = "Database connection error"; + callback(result); + return; + } + + std::string tokenHash = hashToken(refreshToken); + + // Find the token family with this hash + *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 " + "FROM refresh_token_families rtf " + "JOIN users u ON rtf.user_id = u.id " + "WHERE rtf.current_token_hash = $1" + << tokenHash + >> [this, dbClient, tokenHash, refreshToken, callback](const Result& r) { + try { + if (r.empty()) { + // Token not found - could be reuse attack or invalid token + // Check if there's a family with this hash in history (reuse detection) + LOG_WARN << "Refresh token not found or already used - potential reuse attack"; + RefreshTokenResult result; + result.error = "Invalid or expired refresh token"; + callback(result); + return; + } + + auto row = r[0]; + int64_t familyId = row["id"].as(); + int64_t userId = row["user_id"].as(); + std::string familyUuid = row["family_id"].as(); + bool revoked = row["revoked"].as(); + bool isDisabled = row["is_disabled"].isNull() ? false : row["is_disabled"].as(); + + // Check if family is revoked + if (revoked) { + LOG_WARN << "Attempted to use revoked refresh token family: " << familyUuid; + RefreshTokenResult result; + result.error = "Session has been revoked"; + callback(result); + return; + } + + // Check if user is disabled + if (isDisabled) { + LOG_WARN << "Disabled user attempted token refresh: " << userId; + RefreshTokenResult result; + result.error = "Account is disabled"; + callback(result); + return; + } + + // Check expiry + std::string expiresAtStr = row["expires_at"].as(); + // Simple check: compare with current time (database handles timezone) + + // Build UserInfo for new access token + UserInfo user; + user.id = userId; + user.username = row["username"].as(); + user.isAdmin = row["is_admin"].isNull() ? false : row["is_admin"].as(); + user.isModerator = row["is_moderator"].isNull() ? false : row["is_moderator"].as(); + user.isStreamer = row["is_streamer"].isNull() ? false : row["is_streamer"].as(); + user.isRestreamer = row["is_restreamer"].isNull() ? false : row["is_restreamer"].as(); + user.isBot = row["is_bot"].isNull() ? false : row["is_bot"].as(); + user.isTexter = row["is_texter"].isNull() ? false : row["is_texter"].as(); + user.isPgpOnly = row["is_pgp_only"].isNull() ? false : row["is_pgp_only"].as(); + user.isDisabled = isDisabled; + user.colorCode = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as(); + user.avatarUrl = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); + user.tokenVersion = row["token_version"].isNull() ? 1 : row["token_version"].as(); + + // Generate new tokens (rotation) + std::string newRefreshToken = generateRefreshToken(); + std::string newTokenHash = hashToken(newRefreshToken); + std::string newAccessToken = generateToken(user); + + // Update the family with new token hash + *dbClient << "UPDATE refresh_token_families SET current_token_hash = $1, last_used_at = NOW() " + "WHERE id = $2" + << newTokenHash << familyId + >> [callback, newAccessToken, newRefreshToken, familyUuid, user](const Result&) { + LOG_DEBUG << "Rotated refresh token for family: " << familyUuid; + RefreshTokenResult result; + result.success = true; + result.accessToken = newAccessToken; + result.refreshToken = newRefreshToken; + result.familyId = familyUuid; + result.user = user; + callback(result); + } + >> [callback](const DrogonDbException& e) { + LOG_ERROR << "Failed to rotate refresh token: " << e.base().what(); + RefreshTokenResult result; + result.error = "Failed to refresh session"; + callback(result); + }; + } catch (const std::exception& e) { + LOG_ERROR << "Exception in validateAndRotateRefreshToken callback: " << e.what(); + RefreshTokenResult result; + result.error = "Internal error"; + callback(result); + } + } + >> [callback](const DrogonDbException& e) { + LOG_ERROR << "Database error in validateAndRotateRefreshToken: " << e.base().what(); + RefreshTokenResult result; + result.error = "Database error"; + callback(result); + }; + } catch (const std::exception& e) { + LOG_ERROR << "Exception in validateAndRotateRefreshToken: " << e.what(); + RefreshTokenResult result; + result.error = "Internal error"; + callback(result); + } +} + +void AuthService::revokeTokenFamily(const std::string& familyId, + std::function callback) { + try { + auto dbClient = app().getDbClient(); + if (!dbClient) { + LOG_ERROR << "Database client is null"; + callback(false); + return; + } + + *dbClient << "UPDATE refresh_token_families SET revoked = TRUE, revoked_at = NOW() " + "WHERE family_id = $1::uuid" + << familyId + >> [callback, familyId](const Result& r) { + LOG_INFO << "Revoked token family: " << familyId; + callback(true); + } + >> [callback](const DrogonDbException& e) { + LOG_ERROR << "Failed to revoke token family: " << e.base().what(); + callback(false); + }; + } catch (const std::exception& e) { + LOG_ERROR << "Exception in revokeTokenFamily: " << e.what(); + callback(false); + } +} + +void AuthService::revokeAllUserTokenFamilies(int64_t userId, + std::function callback) { + try { + auto dbClient = app().getDbClient(); + if (!dbClient) { + LOG_ERROR << "Database client is null"; + callback(false); + return; + } + + *dbClient << "UPDATE refresh_token_families SET revoked = TRUE, revoked_at = NOW() " + "WHERE user_id = $1 AND revoked = FALSE" + << userId + >> [callback, userId](const Result& r) { + LOG_INFO << "Revoked all token families for user: " << userId; + callback(true); + } + >> [callback](const DrogonDbException& e) { + LOG_ERROR << "Failed to revoke user token families: " << e.base().what(); + callback(false); + }; + } catch (const std::exception& e) { + LOG_ERROR << "Exception in revokeAllUserTokenFamilies: " << e.what(); + callback(false); + } } \ No newline at end of file diff --git a/backend/src/services/AuthService.h b/backend/src/services/AuthService.h index 65510fe..0214c10 100644 --- a/backend/src/services/AuthService.h +++ b/backend/src/services/AuthService.h @@ -31,6 +31,16 @@ struct UserInfo { int tokenVersion = 1; // SECURITY FIX #10: Token version for revocation }; +// Result structure for refresh token operations +struct RefreshTokenResult { + bool success = false; + std::string error; + std::string accessToken; + std::string refreshToken; + std::string familyId; + UserInfo user; +}; + // Chat service compatibility struct struct UserClaims { std::string userId; @@ -71,6 +81,20 @@ public: // Chat service compatibility method std::optional verifyToken(const std::string& token); + // Refresh token methods + void createRefreshTokenFamily(int64_t userId, + std::function callback); + + void validateAndRotateRefreshToken(const std::string& refreshToken, + std::function callback); + + void revokeTokenFamily(const std::string& familyId, + std::function callback); + + void revokeAllUserTokenFamilies(int64_t userId, + std::function callback); + // New method to fetch complete user info including color void fetchUserInfo(int64_t userId, std::function callback); @@ -89,4 +113,12 @@ private: bool validatePassword(const std::string& password, std::string& error); void validateAndLoadJwtSecret(); // SECURITY FIX #5 + + // Refresh token helpers + std::string generateRefreshToken(); // Generates random 256-bit token + std::string hashToken(const std::string& token); // SHA256 hash for storage + std::string generateUUID(); // Generate UUID for family_id + + static constexpr int ACCESS_TOKEN_EXPIRY_MINUTES = 150; // 2.5 hours (gives buffer for 2-hour refresh) + static constexpr int REFRESH_TOKEN_EXPIRY_DAYS = 90; }; \ No newline at end of file diff --git a/chat-service/src/controllers/WatchSyncController.cpp b/chat-service/src/controllers/WatchSyncController.cpp index bf0e501..6339b6d 100644 --- a/chat-service/src/controllers/WatchSyncController.cpp +++ b/chat-service/src/controllers/WatchSyncController.cpp @@ -260,23 +260,79 @@ void WatchSyncController::broadcastRoomSync(const std::string& realmId) { } void WatchSyncController::autoAdvanceToNextVideo(const std::string& realmId) { - // Check skip debounce to prevent multiple auto-advance calls + // Variables for locked video handling (set inside lock, used after) + bool isLockedVideo = false; + RoomState stateCopy; + int64_t nowMs = 0; + + // Check skip debounce and in-memory lock state { std::lock_guard lock(roomStatesMutex_); auto it = roomStates_.find(realmId); if (it != roomStates_.end()) { - auto now = std::chrono::duration_cast( + nowMs = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); - if (now - it->second.lastSkipMs < SKIP_DEBOUNCE_MS) { + if (nowMs - it->second.lastSkipMs < SKIP_DEBOUNCE_MS) { LOG_DEBUG << "Auto-advance debounced for room " << realmId; return; } // Mark skip time to prevent concurrent skips - it->second.lastSkipMs = now; + it->second.lastSkipMs = nowMs; + + // CHECK IN-MEMORY LOCK STATE FIRST (CyTube-style immediate state) + if (it->second.currentVideoLocked) { + LOG_INFO << "Auto-advance: Current video is locked (in-memory), restarting in room " << realmId; + + // Restart the locked video + it->second.currentTime = 0.0; + it->second.playbackState = "playing"; + it->second.lastUpdateMs = nowMs; + it->second.leadInActive = true; + it->second.leadInStartMs = nowMs; + it->second.stateVersion++; + + // Copy state for broadcast after releasing lock + stateCopy = it->second; + isLockedVideo = true; + } } } - // Call backend to get playlist (includes isLocked status) + // Handle locked video restart (outside the lock) + if (isLockedVideo) { + // Get viewer count for silent loop logic + int viewerCount = getViewerCount(realmId); + + if (viewerCount > 0) { + // Broadcast locked_restart event + Json::Value stateChange; + stateChange["type"] = "state_change"; + stateChange["event"] = "locked_restart"; + stateChange["triggeredBy"] = "server"; + stateChange["playbackState"] = "playing"; + stateChange["currentTime"] = 0.0; + stateChange["serverTime"] = static_cast(nowMs); + stateChange["leadIn"] = true; + stateChange["isLocked"] = true; + + if (!stateCopy.currentVideoId.empty()) { + Json::Value video; + video["id"] = static_cast(stateCopy.currentPlaylistItemId); + video["youtubeVideoId"] = stateCopy.currentVideoId; + video["title"] = stateCopy.currentVideoTitle; + video["durationSeconds"] = stateCopy.durationSeconds; + video["isLocked"] = true; + stateChange["currentVideo"] = video; + } + + broadcastToRoom(realmId, stateChange); + } else { + LOG_DEBUG << "Auto-advance: Silent loop for locked video in room " << realmId << " (no viewers)"; + } + return; // Don't proceed to backend query + } + + // Call backend to get playlist (fallback for non-locked videos) auto client = HttpClient::newHttpClient("http://drogon-backend:8080"); auto httpReq = HttpRequest::newHttpRequest(); httpReq->setPath("/api/watch/" + realmId + "/playlist"); @@ -310,9 +366,16 @@ void WatchSyncController::autoAdvanceToNextVideo(const std::string& realmId) { } const Json::Value& playlist = (*json)["playlist"]; + LOG_INFO << "Auto-advance: Got " << playlist.size() << " playlist items for room " << realmId; + for (const auto& item : playlist) { std::string status = item["status"].asString(); bool isLocked = item.isMember("isLocked") && item["isLocked"].asBool(); + std::string title = item.isMember("title") ? item["title"].asString() : "unknown"; + + LOG_INFO << " - Item: " << title << ", status: " << status + << ", isLocked: " << (isLocked ? "true" : "false") + << ", hasIsLocked: " << (item.isMember("isLocked") ? "yes" : "no"); if (status == "queued") { queuedCount++; @@ -722,6 +785,8 @@ void WatchSyncController::handleNewMessage(const WebSocketConnectionPtr& wsConnP handlePlaybackControl(wsConnPtr, info, json); } else if (msgType == "update_duration") { handleUpdateDuration(wsConnPtr, info, json); + } else if (msgType == "lock_update") { + handleLockUpdate(wsConnPtr, info, json); } else { sendError(wsConnPtr, "Unknown message type: " + msgType); } @@ -1468,3 +1533,47 @@ void WatchSyncController::handleUpdateDuration(const WebSocketConnectionPtr& wsC } }); } + +void WatchSyncController::handleLockUpdate(const WebSocketConnectionPtr& wsConnPtr, + const ViewerInfo& info, + const Json::Value& data) { + if (info.realmId.empty()) { + return; + } + + // Only owner/admin can toggle lock (frontend already enforces this) + if (!info.canControlPlayback) { + return; + } + + int64_t playlistItemId = data["playlistItemId"].asInt64(); + bool locked = data["locked"].asBool(); + + LOG_INFO << "Lock update for room " << info.realmId + << ": item " << playlistItemId << " locked=" << locked; + + // Update in-memory state if this is the current video + { + std::lock_guard lock(roomStatesMutex_); + auto it = roomStates_.find(info.realmId); + if (it != roomStates_.end()) { + if (it->second.currentPlaylistItemId == playlistItemId) { + it->second.currentVideoLocked = locked; + LOG_INFO << "Updated currentVideoLocked to " << locked + << " for room " << info.realmId; + } + } + } + + // Broadcast lock change to all viewers in the room + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + + Json::Value broadcast; + broadcast["type"] = "lock_changed"; + broadcast["playlistItemId"] = static_cast(playlistItemId); + broadcast["locked"] = locked; + broadcast["serverTime"] = static_cast(now); + + broadcastToRoom(info.realmId, broadcast); +} diff --git a/chat-service/src/controllers/WatchSyncController.h b/chat-service/src/controllers/WatchSyncController.h index dd101ca..dc2a095 100644 --- a/chat-service/src/controllers/WatchSyncController.h +++ b/chat-service/src/controllers/WatchSyncController.h @@ -124,6 +124,10 @@ private: const ViewerInfo& info, const Json::Value& data); + void handleLockUpdate(const WebSocketConnectionPtr& wsConnPtr, + const ViewerInfo& info, + const Json::Value& data); + void sendError(const WebSocketConnectionPtr& wsConnPtr, const std::string& error); void sendSuccess(const WebSocketConnectionPtr& wsConnPtr, const Json::Value& data); diff --git a/chat-service/src/services/ChatService.cpp b/chat-service/src/services/ChatService.cpp index 111ff8b..0230404 100644 --- a/chat-service/src/services/ChatService.cpp +++ b/chat-service/src/services/ChatService.cpp @@ -269,6 +269,10 @@ void ChatService::cleanupMessages() { LOG_DEBUG << "Cleaned up old messages for realm: " << realmId << " (retention: " << settings.retentionHours << "h)"; } + + // Clean up any expired self-destruct messages that weren't deleted by their timers + // (e.g., due to server restart or timer failures) + redis.cleanupExpiredSelfDestruct(realmId); } } diff --git a/chat-service/src/services/RedisMessageStore.cpp b/chat-service/src/services/RedisMessageStore.cpp index f4da592..58c391c 100644 --- a/chat-service/src/services/RedisMessageStore.cpp +++ b/chat-service/src/services/RedisMessageStore.cpp @@ -67,11 +67,15 @@ bool RedisMessageStore::addMessage(const ChatMessage& message) { trackActiveRealm(message.realmId); auto key = getMessagesKey(message.realmId); + auto indexKey = getMessageIndexKey(message.realmId); auto serialized = message.serialize(); // Add to sorted set with timestamp as score redis_->zadd(key, serialized, static_cast(message.timestamp)); + // Add to message index hash for reliable deletion by messageId + redis_->hset(indexKey, message.messageId, serialized); + // Trim to max messages per realm auto maxMessages = drogon::app().getCustomConfig().get("chat", Json::Value::null) .get("max_messages_per_realm", 1000).asInt64(); @@ -94,6 +98,9 @@ std::vector RedisMessageStore::getMessages(const std::string& realm } try { auto key = getMessagesKey(realmId); + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ).count(); std::vector results; if (beforeTimestamp > 0) { @@ -107,7 +114,12 @@ std::vector RedisMessageStore::getMessages(const std::string& realm } for (const auto& serialized : results) { - messages.push_back(ChatMessage::deserialize(serialized)); + auto msg = ChatMessage::deserialize(serialized); + // Filter out expired self-destruct messages + if (msg.selfDestructAt > 0 && msg.selfDestructAt <= now) { + continue; // Skip expired messages + } + messages.push_back(msg); } // Reverse to get chronological order @@ -120,21 +132,33 @@ std::vector RedisMessageStore::getMessages(const std::string& realm } bool RedisMessageStore::deleteMessage(const std::string& realmId, const std::string& messageId) { + if (!redis_) { + LOG_ERROR << "Redis not initialized - cannot delete message"; + return false; + } try { auto key = getMessagesKey(realmId); + auto indexKey = getMessageIndexKey(realmId); - // Get all messages and remove the one with matching ID - std::vector messages; - redis_->zrange(key, 0, -1, std::back_inserter(messages)); - - for (const auto& serialized : messages) { - auto msg = ChatMessage::deserialize(serialized); - if (msg.messageId == messageId) { - redis_->zrem(key, serialized); - return true; - } + // Look up the exact serialized string from the message index + auto serialized = redis_->hget(indexKey, messageId); + if (!serialized) { + LOG_WARN << "Message not found in index: " << messageId; + return false; } - return false; + + // Remove from sorted set using the exact serialized string + auto removed = redis_->zrem(key, *serialized); + + // Remove from message index + redis_->hdel(indexKey, messageId); + + if (removed == 0) { + LOG_WARN << "Message was in index but not in sorted set: " << messageId; + } + + LOG_DEBUG << "Deleted message: " << messageId << " (zrem=" << removed << ")"; + return true; } catch (const Error& e) { LOG_ERROR << "Redis error deleting message: " << e.what(); return false; @@ -155,6 +179,41 @@ void RedisMessageStore::cleanupOldMessages(const std::string& realmId, int reten } } +void RedisMessageStore::cleanupExpiredSelfDestruct(const std::string& realmId) { + if (!redis_) return; + try { + auto key = getMessagesKey(realmId); + auto indexKey = getMessageIndexKey(realmId); + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ).count(); + + // Get all messages to check for expired self-destruct + std::vector messages; + redis_->zrange(key, 0, -1, std::back_inserter(messages)); + + int cleanedCount = 0; + for (const auto& serialized : messages) { + auto msg = ChatMessage::deserialize(serialized); + // Check if message has expired selfDestructAt time + if (msg.selfDestructAt > 0 && msg.selfDestructAt <= now) { + // Remove from sorted set + redis_->zrem(key, serialized); + // Remove from message index + redis_->hdel(indexKey, msg.messageId); + cleanedCount++; + LOG_DEBUG << "Cleaned up expired self-destruct message: " << msg.messageId; + } + } + + if (cleanedCount > 0) { + LOG_INFO << "Cleaned up " << cleanedCount << " expired self-destruct messages in realm: " << realmId; + } + } catch (const Error& e) { + LOG_ERROR << "Redis error cleaning up expired self-destruct messages: " << e.what(); + } +} + void RedisMessageStore::setGlobalSettings(const GlobalChatSettings& settings) { try { redis_->hset("chat:settings:global", "guestPrefix", settings.guestPrefix); diff --git a/chat-service/src/services/RedisMessageStore.h b/chat-service/src/services/RedisMessageStore.h index cefc1b9..613dfab 100644 --- a/chat-service/src/services/RedisMessageStore.h +++ b/chat-service/src/services/RedisMessageStore.h @@ -26,6 +26,7 @@ public: int64_t beforeTimestamp = 0); bool deleteMessage(const std::string& realmId, const std::string& messageId); void cleanupOldMessages(const std::string& realmId, int retentionHours); + void cleanupExpiredSelfDestruct(const std::string& realmId); // Settings operations void setGlobalSettings(const models::GlobalChatSettings& settings); @@ -119,6 +120,10 @@ private: std::string getKickKey(const std::string& realmId, const std::string& userId) const { return "chat:kicked:" + realmId + ":" + userId; } + + std::string getMessageIndexKey(const std::string& realmId) const { + return "chat:msg_index:" + realmId; + } }; } // namespace services diff --git a/database/init.sql b/database/init.sql index 63cf92e..3875dba 100644 --- a/database/init.sql +++ b/database/init.sql @@ -1198,4 +1198,28 @@ BEGIN END $$; -- Create index for pending_uberban lookups -CREATE INDEX IF NOT EXISTS idx_users_pending_uberban ON users(pending_uberban) WHERE pending_uberban = true; \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_users_pending_uberban ON users(pending_uberban) WHERE pending_uberban = true; + +-- ============================================ +-- REFRESH TOKEN FAMILIES (JWT Token Rotation) +-- ============================================ + +-- Refresh token families table for secure token rotation +-- Each login creates a new "family" - if an old token is reused, the whole family is revoked +CREATE TABLE IF NOT EXISTS refresh_token_families ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + family_id UUID NOT NULL UNIQUE, + current_token_hash VARCHAR(64) NOT NULL, -- SHA256 of current valid refresh token + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + revoked BOOLEAN DEFAULT FALSE, + revoked_at TIMESTAMP WITH TIME ZONE, + last_used_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for refresh_token_families +CREATE INDEX IF NOT EXISTS idx_refresh_families_user_id ON refresh_token_families(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_families_family_id ON refresh_token_families(family_id); +CREATE INDEX IF NOT EXISTS idx_refresh_families_active ON refresh_token_families(user_id, revoked) WHERE revoked = FALSE; +CREATE INDEX IF NOT EXISTS idx_refresh_families_expires ON refresh_token_families(expires_at) WHERE revoked = FALSE; \ No newline at end of file diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 31c317c..cb1b2f0 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -1,6 +1,32 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost/api'; -async function fetchAPI(endpoint, options = {}) { +// Track if we're currently refreshing to avoid multiple simultaneous refreshes +let isRefreshing = false; +let refreshPromise = null; + +// Attempt to refresh the access token +async function attemptTokenRefresh() { + if (isRefreshing) { + // Wait for the existing refresh to complete + return refreshPromise; + } + + isRefreshing = true; + refreshPromise = fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include' + }).then(response => { + isRefreshing = false; + return response.ok; + }).catch(() => { + isRefreshing = false; + return false; + }); + + return refreshPromise; +} + +async function fetchAPI(endpoint, options = {}, retryAfterRefresh = true) { const response = await fetch(`${API_URL}${endpoint}`, { ...options, headers: { @@ -10,6 +36,15 @@ async function fetchAPI(endpoint, options = {}) { credentials: 'include', // Always include credentials }); + // If we get a 401 and haven't already retried, attempt token refresh + if (response.status === 401 && retryAfterRefresh) { + const refreshed = await attemptTokenRefresh(); + if (refreshed) { + // Retry the original request (but don't retry again if it fails) + return fetchAPI(endpoint, options, false); + } + } + if (!response.ok) { throw new Error(`API error: ${response.statusText}`); } diff --git a/frontend/src/lib/chat/chatWebSocket.js b/frontend/src/lib/chat/chatWebSocket.js index e7c721f..972c5b0 100644 --- a/frontend/src/lib/chat/chatWebSocket.js +++ b/frontend/src/lib/chat/chatWebSocket.js @@ -371,7 +371,7 @@ class ChatWebSocket { }; } - attemptReconnect() { + async attemptReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.error('Max reconnect attempts reached. Use manualReconnect() to try again.'); connectionStatus.set('failed'); @@ -385,7 +385,7 @@ class ChatWebSocket { console.log('Resetting reconnect attempts after timeout'); this.reconnectAttempts = 0; if (this.realmId) { - this.connect(this.realmId, this.token); + this.attemptReconnect(); } }, 60000); // Try again after 1 minute return; @@ -400,8 +400,30 @@ class ChatWebSocket { console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); - setTimeout(() => { + setTimeout(async () => { if (this.realmId) { + // If we have a token, try to refresh it before reconnecting + if (this.token) { + try { + console.log('[ChatWebSocket] Refreshing token before reconnect...'); + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include' + }); + if (response.ok) { + const data = await response.json(); + // Update the token with the fresh one + if (data.token) { + this.token = data.token; + console.log('[ChatWebSocket] Token refreshed successfully'); + } + } else { + console.warn('[ChatWebSocket] Token refresh failed, trying with old token'); + } + } catch (e) { + console.warn('[ChatWebSocket] Token refresh error:', e); + } + } this.connect(this.realmId, this.token); } }, delay); @@ -410,13 +432,32 @@ class ChatWebSocket { /** * Manually trigger reconnection - resets attempt counter and tries immediately */ - manualReconnect() { + async manualReconnect() { console.log('Manual reconnect triggered'); if (this.reconnectResetTimer) { clearTimeout(this.reconnectResetTimer); this.reconnectResetTimer = null; } this.reconnectAttempts = 0; + + // Refresh token before reconnecting if we have one + if (this.token && this.realmId) { + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include' + }); + if (response.ok) { + const data = await response.json(); + if (data.token) { + this.token = data.token; + } + } + } catch (e) { + console.warn('Token refresh error during manual reconnect:', e); + } + } + if (this.realmId) { this.connect(this.realmId, this.token); } diff --git a/frontend/src/lib/components/chat/ChatMessage.svelte b/frontend/src/lib/components/chat/ChatMessage.svelte index 697ee2a..44d2195 100644 --- a/frontend/src/lib/components/chat/ChatMessage.svelte +++ b/frontend/src/lib/components/chat/ChatMessage.svelte @@ -347,7 +347,7 @@ {message.username.charAt(0).toUpperCase()} {/if} - {#if message.usedRoll} @@ -579,6 +579,12 @@ filter: invert(1); } + .username-btn.guest { + background-color: #1f184e; + padding: 0.1rem 0.3rem; + border-radius: 2px; + } + .badge { font-size: 0.55rem; padding: 0.05rem 0.25rem; diff --git a/frontend/src/lib/components/watch/WatchPlaylist.svelte b/frontend/src/lib/components/watch/WatchPlaylist.svelte index 1f716f1..9efa02a 100644 --- a/frontend/src/lib/components/watch/WatchPlaylist.svelte +++ b/frontend/src/lib/components/watch/WatchPlaylist.svelte @@ -3,6 +3,7 @@ import { watchSync, canControl, currentVideo } from '$lib/stores/watchSync'; import { auth } from '$lib/stores/auth'; import { getGuestFingerprint } from '$lib/fingerprint'; + // watchSync is already imported above for sendLockUpdate export let realmId; export let playlist = []; @@ -295,6 +296,9 @@ item.id === itemId ? { ...item, isLocked: locked } : item ); dispatch('playlistUpdated'); + + // Notify watch sync WebSocket about lock change (for immediate in-memory state update) + watchSync.sendLockUpdate(itemId, locked); } } catch (e) { console.error('Failed to toggle lock:', e); diff --git a/frontend/src/lib/stores/auth.js b/frontend/src/lib/stores/auth.js index 617389b..5d05d6f 100644 --- a/frontend/src/lib/stores/auth.js +++ b/frontend/src/lib/stores/auth.js @@ -2,6 +2,71 @@ import { writable, derived } from 'svelte/store'; import { browser } from '$app/environment'; import { goto } from '$app/navigation'; +// Token refresh interval (2 hours - before the 2.5-hour access token expires) +const REFRESH_INTERVAL_MS = 2 * 60 * 60 * 1000; +let refreshInterval = null; +let isRefreshing = false; + +// Silent token refresh function +async function refreshAccessToken() { + if (isRefreshing) return false; + isRefreshing = true; + + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include' + }); + + if (response.ok) { + const data = await response.json(); + // Update user data if returned + if (data.user) { + auth.updateUser(data.user); + } + isRefreshing = false; + return true; + } else { + // Refresh failed - session expired + console.log('Token refresh failed - session expired'); + isRefreshing = false; + return false; + } + } catch (error) { + console.error('Token refresh error:', error); + isRefreshing = false; + return false; + } +} + +// Start automatic token refresh +function startTokenRefresh() { + if (!browser) return; + + // Clear any existing interval + if (refreshInterval) { + clearInterval(refreshInterval); + } + + // Refresh every 12 minutes + refreshInterval = setInterval(async () => { + const success = await refreshAccessToken(); + if (!success) { + // Stop refresh interval and logout + stopTokenRefresh(); + auth.logout(); + } + }, REFRESH_INTERVAL_MS); +} + +// Stop automatic token refresh +function stopTokenRefresh() { + if (refreshInterval) { + clearInterval(refreshInterval); + refreshInterval = null; + } +} + function createAuthStore() { const { subscribe, set, update } = writable({ user: null, @@ -10,25 +75,29 @@ function createAuthStore() { return { subscribe, - + async init() { if (!browser) return; - + // Use cookie-based auth - no localStorage tokens try { const response = await fetch('/api/user/me', { credentials: 'include' // Send cookies }); - + if (response.ok) { const data = await response.json(); set({ user: data.user, loading: false }); + // Start token refresh when authenticated + startTokenRefresh(); } else { set({ user: null, loading: false }); + stopTokenRefresh(); } } catch (error) { console.error('Auth init error:', error); set({ user: null, loading: false }); + stopTokenRefresh(); } }, @@ -46,6 +115,8 @@ function createAuthStore() { // Server sets httpOnly cookie for HTTP requests // Token is NOT stored in localStorage to prevent XSS attacks set({ user: data.user, loading: false }); + // Start token refresh after successful login + startTokenRefresh(); goto('/'); return { success: true }; } @@ -67,6 +138,8 @@ function createAuthStore() { // Server sets httpOnly cookie for HTTP requests // Token is NOT stored in localStorage to prevent XSS attacks set({ user: data.user, loading: false }); + // Start token refresh after successful login + startTokenRefresh(); goto('/'); return { success: true }; } @@ -132,20 +205,26 @@ function createAuthStore() { }, async logout() { - // Call logout endpoint to clear httpOnly cookie + // Stop token refresh interval + stopTokenRefresh(); + + // Call logout endpoint to clear httpOnly cookies await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); - // Clear token from localStorage + // Clear token from localStorage (legacy cleanup) if (browser) { localStorage.removeItem('token'); } set({ user: null, loading: false }); goto('/login'); - } + }, + + // Export refresh function for use by other modules (e.g., WebSocket) + refreshToken: refreshAccessToken }; } diff --git a/frontend/src/lib/stores/watchSync.js b/frontend/src/lib/stores/watchSync.js index 9ba5eee..f093a6a 100644 --- a/frontend/src/lib/stores/watchSync.js +++ b/frontend/src/lib/stores/watchSync.js @@ -184,6 +184,15 @@ function createWatchSyncStore() { error: data.error || 'Unknown error' })); break; + + case 'lock_changed': + // Notify components about lock state change + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('playlist-lock-changed', { + detail: { playlistItemId: data.playlistItemId, locked: data.locked } + })); + } + break; } } @@ -333,6 +342,15 @@ function createWatchSyncStore() { }); } + // Send lock update to server (for immediate in-memory state update) + function sendLockUpdate(playlistItemId, locked) { + send({ + type: 'lock_update', + playlistItemId: playlistItemId, + locked: locked + }); + } + // Check if local player needs to sync function checkSync(localTime) { let state; @@ -358,6 +376,7 @@ function createWatchSyncStore() { requestSync, checkSync, reportDuration, + sendLockUpdate, getExpectedTime: () => { let state; subscribe(s => { state = s; })();