fixes lol
All checks were successful
Build and Push / build-all (push) Successful in 4m48s

This commit is contained in:
doomtube 2026-01-09 00:49:07 -05:00
parent 804d6aed2a
commit ab1dd08225
7 changed files with 216 additions and 140 deletions

View file

@ -38,6 +38,10 @@ void ChatWebSocketController::broadcastParticipantJoined(const std::string& real
participant["isGuest"] = joinedUser.isGuest; participant["isGuest"] = joinedUser.isGuest;
participant["isModerator"] = joinedUser.isModerator; participant["isModerator"] = joinedUser.isModerator;
participant["isStreamer"] = joinedUser.isStreamer; participant["isStreamer"] = joinedUser.isStreamer;
// Include join timestamp for ordering (milliseconds since epoch)
auto joinedAtMs = std::chrono::duration_cast<std::chrono::milliseconds>(
joinedUser.connectionTime.time_since_epoch()).count();
participant["joinedAt"] = static_cast<Json::Int64>(joinedAtMs);
broadcast["participant"] = participant; broadcast["participant"] = participant;
// Count participants in realm // Count participants in realm
@ -867,6 +871,10 @@ void ChatWebSocketController::handleGetParticipants(const WebSocketConnectionPtr
participant["isGuest"] = connInfo.isGuest; participant["isGuest"] = connInfo.isGuest;
participant["isModerator"] = connInfo.isModerator; participant["isModerator"] = connInfo.isModerator;
participant["isStreamer"] = connInfo.isStreamer; participant["isStreamer"] = connInfo.isStreamer;
// Include join timestamp for ordering (milliseconds since epoch)
auto joinedAtMs = std::chrono::duration_cast<std::chrono::milliseconds>(
connInfo.connectionTime.time_since_epoch()).count();
participant["joinedAt"] = static_cast<Json::Int64>(joinedAtMs);
response["participants"].append(participant); response["participants"].append(participant);
} }
} }
@ -957,7 +965,10 @@ void ChatWebSocketController::handleRename(const WebSocketConnectionPtr& wsConnP
auto it = connections_.find(wsConnPtr); auto it = connections_.find(wsConnPtr);
if (it != connections_.end()) { if (it != connections_.end()) {
it->second.username = newName; it->second.username = newName;
// Also update userId to match new username (for guests, userId = "guest:" + username)
it->second.userId = "guest:" + newName;
info.username = newName; info.username = newName;
info.userId = it->second.userId;
// Update username lookup map: remove old, add new // Update username lookup map: remove old, add new
usernameToConnection_.erase(oldLowerName); usernameToConnection_.erase(oldLowerName);
@ -983,6 +994,15 @@ void ChatWebSocketController::handleRename(const WebSocketConnectionPtr& wsConnP
wsConnPtr->send(Json::writeString(Json::StreamWriterBuilder(), response)); wsConnPtr->send(Json::writeString(Json::StreamWriterBuilder(), response));
LOG_INFO << "Guest renamed from " << oldName << " to " << newName; LOG_INFO << "Guest renamed from " << oldName << " to " << newName;
// Broadcast updated participant info to all clients in the realm
if (!info.realmId.empty()) {
std::lock_guard<std::mutex> lock(connectionsMutex_);
auto it = connections_.find(wsConnPtr);
if (it != connections_.end()) {
broadcastParticipantJoined(info.realmId, it->second);
}
}
} }
// SECURITY FIX #9: Handle auth token/apiKey sent as WebSocket message (not in URL) // SECURITY FIX #9: Handle auth token/apiKey sent as WebSocket message (not in URL)
@ -1027,14 +1047,18 @@ void ChatWebSocketController::handleAuthMessage(const WebSocketConnectionPtr& ws
bool connectionFound = false; bool connectionFound = false;
std::string oldUsername; std::string oldUsername;
std::string oldUserId;
std::string realmId;
// Update connection info with authenticated user details // Update connection info with authenticated user details
{ {
std::lock_guard<std::mutex> lock(connectionsMutex_); std::lock_guard<std::mutex> lock(connectionsMutex_);
auto it = connections_.find(wsConnPtr); auto it = connections_.find(wsConnPtr);
if (it != connections_.end()) { if (it != connections_.end()) {
// Save old username to update usernameToConnection_ map // Save old identity info to update participants and usernameToConnection_ map
oldUsername = it->second.username; oldUsername = it->second.username;
oldUserId = it->second.userId;
realmId = it->second.realmId;
// Upgrade from guest to authenticated user // Upgrade from guest to authenticated user
it->second.userId = claims->userId; it->second.userId = claims->userId;
@ -1070,6 +1094,20 @@ void ChatWebSocketController::handleAuthMessage(const WebSocketConnectionPtr& ws
if (connectionFound) { if (connectionFound) {
LOG_INFO << "User authenticated via message: " << claims->username; LOG_INFO << "User authenticated via message: " << claims->username;
wsConnPtr->send(Json::writeString(Json::StreamWriterBuilder(), welcome)); wsConnPtr->send(Json::writeString(Json::StreamWriterBuilder(), welcome));
// Broadcast participant update to other clients in the realm
// First remove old guest identity, then add new authenticated identity
if (!realmId.empty()) {
// Broadcast participant_left for old guest identity
broadcastParticipantLeft(realmId, oldUserId, oldUsername);
// Broadcast participant_joined with new authenticated identity
std::lock_guard<std::mutex> lock(connectionsMutex_);
auto it = connections_.find(wsConnPtr);
if (it != connections_.end()) {
broadcastParticipantJoined(realmId, it->second);
}
}
} }
} }

View file

@ -162,6 +162,14 @@ class ChatWebSocket {
break; break;
case 'auth_success': case 'auth_success':
// Get old userId before updating (for participant cleanup)
const oldUserId = get(chatUserInfo).userId;
// Remove old participant entry (guest identity)
if (oldUserId) {
participants.update((list) => list.filter((p) => p.userId !== oldUserId));
}
// Update user info after successful authentication // Update user info after successful authentication
chatUserInfo.set({ chatUserInfo.set({
username: data.username, username: data.username,
@ -174,6 +182,9 @@ class ChatWebSocket {
avatarUrl: data.avatarUrl || '', avatarUrl: data.avatarUrl || '',
userColor: data.userColor || '#FFFFFF' userColor: data.userColor || '#FFFFFF'
}); });
// Request fresh participants list to ensure consistency
this.getParticipants();
console.log('Authentication successful:', data.username); console.log('Authentication successful:', data.username);
break; break;
@ -197,19 +208,27 @@ class ChatWebSocket {
break; break;
case 'participants_list': case 'participants_list':
participants.set(data.participants || []); // Sort by joinedAt ascending (oldest/longest-joined first)
const sortedList = (data.participants || []).sort(
(a, b) => (a.joinedAt || 0) - (b.joinedAt || 0)
);
participants.set(sortedList);
console.log(`Participants in realm: ${data.count}`); console.log(`Participants in realm: ${data.count}`);
break; break;
case 'participant_joined': case 'participant_joined':
// Add new participant to the list // Add new participant to the list
participants.update((list) => { participants.update((list) => {
// Check if already in list (avoid duplicates) // Remove any existing entry with same userId OR same username
const exists = list.some((p) => p.userId === data.participant.userId); // This handles auth transitions (userId changes) and reconnects
if (!exists) { const filtered = list.filter(
return [...list, data.participant]; (p) =>
} p.userId !== data.participant.userId &&
return list; p.username !== data.participant.username
);
// Add new participant and re-sort by joinedAt (oldest first)
const updated = [...filtered, data.participant];
return updated.sort((a, b) => (a.joinedAt || 0) - (b.joinedAt || 0));
}); });
console.log(`Participant joined: ${data.participant.username} (${data.participantCount} total)`); console.log(`Participant joined: ${data.participant.username} (${data.participantCount} total)`);
break; break;
@ -431,6 +450,7 @@ class ChatWebSocket {
/** /**
* Manually trigger reconnection - resets attempt counter and tries immediately * Manually trigger reconnection - resets attempt counter and tries immediately
* Also handles authentication on existing connection (e.g., when user logs in while connected as guest)
*/ */
async manualReconnect() { async manualReconnect() {
console.log('Manual reconnect triggered'); console.log('Manual reconnect triggered');
@ -440,25 +460,28 @@ class ChatWebSocket {
} }
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
// Refresh token before reconnecting if we have one // Get fresh token from localStorage (may have been set by login)
if (this.token && this.realmId) { const freshToken =
try { typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null;
const response = await fetch('/api/auth/refresh', {
method: 'POST', // If we have a token and connection is open, just send auth message
credentials: 'include' // This handles the case where user logs in while already connected as guest
}); if (freshToken && this.ws && this.ws.readyState === WebSocket.OPEN) {
if (response.ok) { console.log('[ChatWebSocket] Sending auth message on existing connection');
const data = await response.json(); this.token = freshToken;
if (data.token) { this.ws.send(JSON.stringify({ type: 'auth', token: freshToken }));
this.token = data.token; return;
}
}
} catch (e) {
console.warn('Token refresh error during manual reconnect:', e);
}
} }
// Otherwise, do full reconnect
this.token = freshToken;
if (this.realmId) { if (this.realmId) {
// Close existing connection to force fresh connect
if (this.ws) {
this.ws.onclose = null; // Prevent auto-reconnect
this.ws.close();
this.ws = null;
}
this.connect(this.realmId, this.token); this.connect(this.realmId, this.token);
} }
} }

View file

@ -530,9 +530,11 @@
} }
if (result === '1-0') { if (result === '1-0') {
return myColor === 'w' ? 'You win!' : 'You lose'; const winText = myColor === 'w' ? 'You win!' : 'You lose';
return reason ? `${winText} (${reason})` : winText;
} else if (result === '0-1') { } else if (result === '0-1') {
return myColor === 'b' ? 'You win!' : 'You lose'; const winText = myColor === 'b' ? 'You win!' : 'You lose';
return reason ? `${winText} (${reason})` : winText;
} else if (result === 'timeout') { } else if (result === 'timeout') {
return reason || 'Match timed out'; return reason || 'Match timed out';
} else if (result === 'cancelled') { } else if (result === 'cancelled') {

View file

@ -312,9 +312,35 @@
startDurationTimer(); startDurationTimer();
} }
// Only update isStreaming from stats if player isn't actively playing // Handle stream going OFFLINE - force states to show offline overlay
// This prevents stats from overriding the player's live state during connection if (!stats.isLive && wasLive) {
if (!playerPlaying) { console.log('Stream went offline - resetting player states');
playerPlaying = false;
playerBuffering = false;
isStreaming = false;
// Stop the player to release resources and clear stale buffers
if (player) {
try {
player.stop();
} catch (e) {
console.error('Error stopping player:', e);
}
}
}
// Handle stream coming back ONLINE - reinitialize player
else if (stats.isLive && !wasLive) {
console.log('Stream came back online - reinitializing player');
// Reset retry counters for fresh attempt
llhlsRetryAttempts = 0;
playerReconnectAttempts = 0;
playerInitializing = true;
isStreaming = true;
// Reinitialize player with fresh token and sources
reinitializePlayerForReconnect();
}
// Normal update - only update isStreaming if player isn't actively playing
else if (!playerPlaying) {
isStreaming = stats.isLive; isStreaming = stats.isLive;
} }
@ -323,6 +349,31 @@
realm.viewerCount = stats.connections; realm.viewerCount = stats.connections;
} }
} }
async function reinitializePlayerForReconnect() {
// Remove old player instance
if (player) {
try {
player.remove();
} catch (e) {
console.error('Error removing player:', e);
}
player = null;
}
// Refresh viewer token (may have expired during offline period)
const tokenSuccess = await getViewerToken();
if (!tokenSuccess) {
console.error('Failed to refresh viewer token for reconnect');
playerInitializing = false;
return;
}
// Small delay to ensure stream is fully available
setTimeout(() => {
initializePlayer();
}, 1000);
}
function initializePlayer() { function initializePlayer() {
const playerElement = document.getElementById('player'); const playerElement = document.getElementById('player');
@ -662,9 +713,7 @@
.player-wrapper { .player-wrapper {
background: #000; background: #000;
border-radius: 12px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
margin-bottom: 1rem; margin-bottom: 1rem;
position: relative; position: relative;
} }
@ -695,27 +744,13 @@
} }
.stream-info-section { .stream-info-section {
background: #111; background: #000;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem; padding: 1rem;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.stream-info-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--user-color, var(--primary));
opacity: 0.6;
}
.stream-header { .stream-header {
padding-left: 1rem;
} }
.header-top { .header-top {
@ -868,9 +903,7 @@
} }
.chat-section { .chat-section {
background: #111; background: #000;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden; overflow: hidden;
flex: 1 1 0; /* Grow and shrink to fill remaining space */ flex: 1 1 0; /* Grow and shrink to fill remaining space */
min-height: 0; /* Allow shrinking below content size */ min-height: 0; /* Allow shrinking below content size */

View file

@ -259,35 +259,19 @@
.player-wrapper { .player-wrapper {
background: #000; background: #000;
border-radius: 12px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
position: relative; position: relative;
} }
.room-info-section { .room-info-section {
background: #111; background: #000;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.room-info-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--user-color, var(--primary));
opacity: 0.6;
}
.room-header { .room-header {
padding-left: 1rem;
} }
.header-top { .header-top {
@ -403,9 +387,7 @@
} }
.chat-section { .chat-section {
background: #111; background: #000;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden; overflow: hidden;
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;

View file

@ -33,22 +33,23 @@ const (
// ChessState represents the match state // ChessState represents the match state
type ChessState struct { type ChessState struct {
PositionID int `json:"positionId"` PositionID int `json:"positionId"`
FEN string `json:"fen"` FEN string `json:"fen"`
PGN string `json:"pgn"` PGN string `json:"pgn"`
WhiteID string `json:"whiteId"` WhiteID string `json:"whiteId"`
BlackID string `json:"blackId"` BlackID string `json:"blackId"`
WhiteName string `json:"whiteName"` WhiteName string `json:"whiteName"`
BlackName string `json:"blackName"` BlackName string `json:"blackName"`
WhiteColor string `json:"whiteColor"` WhiteColor string `json:"whiteColor"`
BlackColor string `json:"blackColor"` BlackColor string `json:"blackColor"`
Turn string `json:"turn"` Turn string `json:"turn"`
GameOver bool `json:"gameOver"` GameOver bool `json:"gameOver"`
Result string `json:"result,omitempty"` Result string `json:"result,omitempty"`
MoveHistory []string `json:"moveHistory"` MoveHistory []string `json:"moveHistory"`
CreatedAt int64 `json:"createdAt"` CreatedAt int64 `json:"createdAt"`
Spectators []string `json:"spectators"` LastMoveAt int64 `json:"lastMoveAt"`
PendingCancel bool `json:"pendingCancel,omitempty"` Spectators []string `json:"spectators"`
PendingCancel bool `json:"pendingCancel,omitempty"`
} }
// AppJwtClaims from the main app // AppJwtClaims from the main app
@ -427,6 +428,7 @@ func (m *ChessMatch) MatchJoin(ctx context.Context, logger runtime.Logger, db *s
s.BlackID = userID s.BlackID = userID
s.BlackName = username s.BlackName = username
s.BlackColor = userColor s.BlackColor = userColor
s.LastMoveAt = time.Now().Unix() // Game starts - white has 3 days to move
logger.Info("%s joined as BLACK", username) logger.Info("%s joined as BLACK", username)
label := MatchLabel{ label := MatchLabel{
@ -521,6 +523,46 @@ func (m *ChessMatch) MatchLoop(ctx context.Context, logger runtime.Logger, db *s
} }
} }
// Check for move timeout (3 days without a move = forfeit)
if tick%60 == 0 && !s.GameOver && s.WhiteID != "" && s.BlackID != "" && s.LastMoveAt > 0 {
now := time.Now().Unix()
const moveTimeout = 3 * 24 * 60 * 60 // 3 days in seconds
if (now - s.LastMoveAt) > moveTimeout {
// Current player (whose turn it is) forfeits
if s.Turn == "w" {
logger.Info("White player forfeit due to move timeout (3 days)")
s.GameOver = true
s.Result = "0-1"
updateElo(ctx, nk, logger, s.BlackID, s.BlackName, s.WhiteID, s.WhiteName, false)
} else {
logger.Info("Black player forfeit due to move timeout (3 days)")
s.GameOver = true
s.Result = "1-0"
updateElo(ctx, nk, logger, s.WhiteID, s.WhiteName, s.BlackID, s.BlackName, false)
}
msg, _ := json.Marshal(map[string]interface{}{
"result": s.Result,
"reason": "timeout - no move in 3 days",
})
dispatcher.BroadcastMessage(OpCodeGameOver, msg, nil, nil, true)
label := MatchLabel{
Game: "chess960",
Status: "finished",
Result: s.Result,
White: s.WhiteName,
WhiteID: s.WhiteID,
Black: s.BlackName,
BlackID: s.BlackID,
PositionID: s.PositionID,
}
labelJSON, _ := json.Marshal(label)
dispatcher.MatchLabelUpdate(string(labelJSON))
}
}
for _, message := range messages { for _, message := range messages {
playerID := message.GetUserId() playerID := message.GetUserId()
@ -559,6 +601,7 @@ func (m *ChessMatch) MatchLoop(ctx context.Context, logger runtime.Logger, db *s
s.FEN = result.NewFEN s.FEN = result.NewFEN
s.Turn = result.Turn s.Turn = result.Turn
s.MoveHistory = append(s.MoveHistory, fmt.Sprintf("%s-%s", moveData.From, moveData.To)) s.MoveHistory = append(s.MoveHistory, fmt.Sprintf("%s-%s", moveData.From, moveData.To))
s.LastMoveAt = time.Now().Unix() // Reset move timer
// ALWAYS broadcast the move to all players/spectators first // ALWAYS broadcast the move to all players/spectators first
moveMsg, _ := json.Marshal(map[string]interface{}{ moveMsg, _ := json.Marshal(map[string]interface{}{
@ -662,60 +705,11 @@ func (m *ChessMatch) MatchLeave(ctx context.Context, logger runtime.Logger, db *
} }
} }
// Player leaving // Player leaving (disconnect/refresh)
if s.WhiteID == userID || s.BlackID == userID { if s.WhiteID == userID || s.BlackID == userID {
logger.Info("Player %s left the match", username) logger.Info("Player %s disconnected from match (can rejoin anytime)", username)
// Games persist indefinitely - player can rejoin anytime
// If waiting for opponent, don't terminate immediately // No forfeit on disconnect - only manual resign ends the game
if s.WhiteID != "" && s.BlackID == "" && userID == s.WhiteID {
logger.Info("White player disconnected from waiting challenge")
return s
}
// If game in progress, opponent wins by forfeit
if !s.GameOver && s.WhiteID != "" && s.BlackID != "" {
isWhiteLeaving := userID == s.WhiteID
s.GameOver = true
if isWhiteLeaving {
s.Result = "0-1"
} else {
s.Result = "1-0"
}
var winnerID, winnerName, loserName string
if isWhiteLeaving {
winnerID = s.BlackID
winnerName = s.BlackName
loserName = s.WhiteName
} else {
winnerID = s.WhiteID
winnerName = s.WhiteName
loserName = s.BlackName
}
updateElo(ctx, nk, logger, winnerID, winnerName, userID, loserName, false)
msg, _ := json.Marshal(map[string]interface{}{
"result": s.Result,
"reason": "forfeit",
})
dispatcher.BroadcastMessage(OpCodeGameOver, msg, nil, nil, true)
// Update match label to finished so it's not detected as active
label := MatchLabel{
Game: "chess960",
Status: "finished",
Result: s.Result,
White: s.WhiteName,
WhiteID: s.WhiteID,
Black: s.BlackName,
BlackID: s.BlackID,
PositionID: s.PositionID,
}
labelJSON, _ := json.Marshal(label)
dispatcher.MatchLabelUpdate(string(labelJSON))
logger.Info("Game forfeited: %s", s.Result)
}
} }
} }
@ -1054,17 +1048,21 @@ func listChessMatchesRpc(ctx context.Context, logger runtime.Logger, db *sql.DB,
} }
userID, _ := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string) userID, _ := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
logger.Info("listChessMatches: found %d total matches, filtering by status=%s", len(matches), params.Status)
var result []map[string]interface{} var result []map[string]interface{}
for _, match := range matches { for _, match := range matches {
var label MatchLabel var label MatchLabel
if err := json.Unmarshal([]byte(match.Label.Value), &label); err != nil { if err := json.Unmarshal([]byte(match.Label.Value), &label); err != nil {
logger.Warn("listChessMatches: failed to parse label for match %s", match.MatchId)
continue continue
} }
if label.Game != "chess960" { if label.Game != "chess960" {
continue continue
} }
logger.Info("listChessMatches: match %s has status=%s", match.MatchId, label.Status)
// Filter by status // Filter by status
if params.Status != "" && params.Status != "all" && label.Status != params.Status { if params.Status != "" && params.Status != "all" && label.Status != params.Status {
continue continue

View file

@ -36,7 +36,7 @@ local function get_stream_key_for_realm(realm_name)
local http = require "resty.http" local http = require "resty.http"
local httpc = http.new() local httpc = http.new()
local res, err = httpc:request_uri("http://backend:3000/internal/realm-stream-key/" .. ngx.escape_uri(realm_name), { local res, err = httpc:request_uri("http://drogon-backend:8080/internal/realm-stream-key/" .. ngx.escape_uri(realm_name), {
method = "GET", method = "GET",
headers = { ["Content-Type"] = "application/json" } headers = { ["Content-Type"] = "application/json" }
}) })
@ -64,7 +64,7 @@ local function get_live_realms()
local http = require "resty.http" local http = require "resty.http"
local httpc = http.new() local httpc = http.new()
local res, err = httpc:request_uri("http://backend:3000/api/realms/live", { local res, err = httpc:request_uri("http://drogon-backend:8080/api/realms/live", {
method = "GET", method = "GET",
headers = { ["Content-Type"] = "application/json" } headers = { ["Content-Type"] = "application/json" }
}) })