diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 06f02cc..9d4b6c7 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -1156,6 +1156,10 @@ public void UpdateSessionLobbyID(Int64 newLobbyID) // lobby id public Int64 currentLobbyID = -1; + + // Observer-chat rate gate: last time this session sent an observer chat message + // (Environment.TickCount64). Dies with the socket, so no cleanup is needed. + public long m_timeLastObserverChatSent = -1; } public class UserWebSocketInstance @@ -1399,6 +1403,14 @@ public enum EAccountType DevAccount = 3 } + // Per-user livestream privilege (users.user_priority). + public enum EUserPriority + { + None = 0, + Player = 1, // their matches are highlighted in the Watch Live browser + Viewer = 2 // skips the livestream password and broadcast-delay gates + } + public class PlayerStats { const int numGeneralsEntries = 15; @@ -2545,7 +2557,15 @@ public enum EWebSocketMessageID AC_REGISTER_PLAYER = 40, AC_DEREGISTER_PLAYER = 41, WS_KEEPALIVE = 42, - WS_KEEPALIVE_CLIENT = 43 + WS_KEEPALIVE_CLIENT = 43, + LOBBY_OBSERVER_SUBSCRIBE = 44, + LOBBY_OBSERVER_UNSUBSCRIBE = 45, + LOBBY_OBSERVER_LOBBY_CHANGED = 46, + LOBBY_OBSERVER_GAME_STARTING = 47, + LOBBY_OBSERVER_STREAM_LIVE = 48, + LOBBY_OBSERVER_GAME_STARTED = 49, + LOBBY_OBSERVER_CHAT_FROM_CLIENT = 50, + LOBBY_OBSERVER_LIST_REQUEST = 51 }; public static class UserPresence @@ -2643,6 +2663,24 @@ public class WebSocketMessage_StartMatch : WebSocketMessage public string screenshot_url { get; set; } = String.Empty; } + // All six observer events share one shape; msg_id distinguishes them. Only GAME_STARTED sets + // delay_seconds, which is what a waiting observer times its watch-key request against. + public class WebSocketMessage_LobbyObserverEvent : WebSocketMessage + { + public Int64 lobby_id { get; set; } = -1; + public int? delay_seconds { get; set; } = null; + } + + // Observer chat into a pre-game lobby. Deliberately no action / announcement / + // show_announcement_to_host fields: the member path trusts those verbatim (letting a member + // post an unprefixed line that looks like a system message); this path has the server decide + // the formatting instead. + public class WebSocketMessage_LobbyObserverChatInbound : WebSocketMessage + { + public Int64 lobby_id { get; set; } = -1; + public string? message { get; set; } + } + public abstract class WebSocketMessage { public int msg_id { get; set; } diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index d40e629..0ba6564 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -155,6 +155,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr } bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); + EUserPriority userPriority = await Database.Users.GetUserPriority(db, user_id); #else EPendingLoginState? loginState = await Database.PendingLogins.GetPendingLoginState(db, gameCode.ToUpper()); @@ -166,6 +167,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr string strDisplayName = await Database.Users.GetDisplayName(db, user_id); bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); + EUserPriority userPriority = await Database.Users.GetUserPriority(db, user_id); #endif if (state == EPendingLoginState.Waiting) @@ -207,8 +209,8 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr string exe_crc = data.ContainsKey("exe_crc") ? data["exe_crc"].ToString() : "NONE"; Helpers.RegisterInitialPlayerExeCRC(user_id, exe_crc); - var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, knownClientID, sessionType, bIsAdmin); - var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, knownClientID, sessionType, false, out string refreshJti); + var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, knownClientID, sessionType, bIsAdmin, userPriority); + var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, knownClientID, sessionType, false, EUserPriority.None, out string refreshJti); // rotation: only this refresh token is accepted from now on await TokenRevocationManager.OnTokensIssued(user_id, sessionType, refreshJti); diff --git a/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs new file mode 100644 index 0000000..e1e1e56 --- /dev/null +++ b/GenOnlineService/Controllers/Livestreams/LivestreamsController.cs @@ -0,0 +1,553 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using Database; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace GenOnlineService.Controllers +{ + public class GET_Livestreams_Result : APIResult + { + public override Type GetReturnType() + { + return this.GetType(); + } + + public List livestreams { get; set; } = new(); + } + + public class GET_Livestreams_LivestreamEntry + { + public Int64 lobby_id { get; set; } = -1; + public string name { get; set; } = String.Empty; + public string map_name { get; set; } = String.Empty; + public List players { get; set; } = new(); + public int? delay_seconds { get; set; } = null; + public int observer_count { get; set; } = 0; + public int? age_seconds { get; set; } = null; + public int state { get; set; } = 1; + public bool passworded { get; set; } = false; + public int pending_observer_count { get; set; } = 0; + + // Highest EUserPriority among the lobby's human members (0/1/2). The client + // highlights rows with value 1 (a priority Player is in the match). + public int user_priority { get; set; } = 0; + + // Priority-player match (lobby latched when a users.user_priority = Player creates or + // joins): sorts the row to the top of the Watch Live browser. + public bool priority { get; set; } = false; + + // Per-viewer directive: 0 observe (pre-game, enter the lobby view), 1 wait (not live + // yet, or held behind the delay - enter the lobby view and wait), 2 join (connect now). + public int watch_action { get; set; } = 2; + + // Remaining broadcast-delay hold in seconds for this viewer (null when not held). + public int? delay_remaining_seconds { get; set; } = null; + } + + public class POST_Livestreams_Register_Result : APIResult + { + public override Type GetReturnType() + { + return this.GetType(); + } + + public bool success { get; set; } = false; + public string url { get; set; } = String.Empty; + public string detail { get; set; } = String.Empty; + } + + public class POST_Livestreams_Observe_Result : APIResult + { + public override Type GetReturnType() + { + return this.GetType(); + } + + public string url { get; set; } = String.Empty; + public string detail { get; set; } = String.Empty; + + // 423 (delay gate): seconds left before this viewer's ticket may be minted. + public int? delay_remaining_seconds { get; set; } = null; + + // 200: GO owns the broadcast delay (the ticket was only minted after it elapsed), so + // the client must not hold playback itself. + public bool server_held { get; set; } = false; + } + + public class POST_Livestreams_Observers_Result : APIResult + { + public override Type GetReturnType() + { + return this.GetType(); + } + + public bool received { get; set; } = false; + public string detail { get; set; } = String.Empty; + } + + public class POST_Livestreams_Observers_Entry + { + public string lobby_id { get; set; } = String.Empty; + public int observer_count { get; set; } = 0; + public bool is_live { get; set; } = true; + } + + // Per-endpoint rather than class-wide: an unconfigured relay must refuse the POSTs, but GET + // /livestreams should still answer with its ordinary empty list. + public class RequireRelayAttribute : ActionFilterAttribute + { + public override void OnActionExecuting(ActionExecutingContext context) + { + if (!RelayClient.IsEnabled()) + { + context.Result = new ObjectResult(new { detail = "Live streaming is not enabled on this server." }) + { + StatusCode = (int)HttpStatusCode.ServiceUnavailable + }; + } + } + } + + [ApiController] + [Authorize(Roles = "GameClient")] + [Route("env/{environment}/contract/{contract_version}/[controller]")] + public class LivestreamsController : ControllerBase + { + private readonly LobbyManager _lobbyManager; + private readonly IDbContextFactory _dbFactory; + + // How long a started-but-never-streamed game stays listed before Watch Live drops it - + // streamers register within seconds of match start, so 60s is generous. + private const int NeverStreamedGraceSeconds = 60; + + public LivestreamsController(LobbyManager lobbyManager, IDbContextFactory dbFactory) + { + _lobbyManager = lobbyManager; + _dbFactory = dbFactory; + } + + [HttpGet(Name = "GetLivestreams")] + public async Task Get() + { + GET_Livestreams_Result result = new GET_Livestreams_Result(); + + // Relay not configured -> no livestreams exist to list. An empty list is the right + // shape here: the observer menu just shows nothing, exactly as if nobody is + // streaming. No 5xx - there is no error, the feature is simply not deployed. + if (!RelayClient.IsEnabled()) + { + return result; + } + + Int64 user_id = TokenHelper.GetUserID(this); + // A priority viewer (admin, or user_priority = Viewer re-read live from the DB + // like Observe does) is never held behind the broadcast delay below. + bool isPriority = TokenHelper.IsAdmin(this) || TokenHelper.GetUserPriority(this) == EUserPriority.Viewer; + if (!isPriority) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + if (await Database.Users.GetUserPriority(db, user_id) == EUserPriority.Viewer) + { + isPriority = true; + } + } + + foreach (Lobby lobby in _lobbyManager.GetAllLobbies()) + { + // AllowObservers governs in-game observer slots, a different feature - a + // livestream is gated only by IsStreaming (did the host start one). + bool isPregame = lobby.State == ELobbyState.GAME_SETUP; + bool isLive = lobby.State == ELobbyState.INGAME && lobby.IsStreaming; + bool isWaiting = lobby.State == ELobbyState.INGAME && !lobby.IsStreaming; + // A pre-game lobby is only watchable when the host allowed streamers at + // creation: without that, no stream can ever come, and parking observers on + // it would only lead to an endless wait. Live rows are already streaming, so + // they are always listed. + if (!isLive && !lobby.AllowStreamers) + { + continue; + } + if (!isPregame && !isLive && !isWaiting) + { + continue; + } + + // A started game with no stream is only listed for a grace period: after + // that the stream is simply not coming (or has ended), so the row would + // only strand observers on a wait that can never end. The browser picks + // the drop up on its next 5s refresh. + if (isWaiting && lobby.TimeMatchStarted != null && + (DateTime.UtcNow - lobby.TimeMatchStarted.Value).TotalSeconds > NeverStreamedGraceSeconds) + { + continue; + } + + // 0 = observe (pre-game), 1 = wait (stream not live yet, or this viewer is + // held behind the broadcast delay), 2 = join (stream live, ticket mints now). + int watchAction = isPregame ? 0 : 1; + int? delayRemainingSeconds = null; + // The hold clock only means something for lobbies that actually have a + // stream: a never-streamed game would otherwise show a countdown for a + // hold that can never expire. + if (lobby.State == ELobbyState.INGAME && lobby.IsStreaming && + lobby.TimeMatchStarted != null && lobby.StreamDelaySeconds > 0) + { + delayRemainingSeconds = Math.Max(0, lobby.StreamDelaySeconds.Value - + (int)(DateTime.UtcNow - lobby.TimeMatchStarted.Value).TotalSeconds); + } + if (isLive && (isPriority || delayRemainingSeconds == null || delayRemainingSeconds == 0)) + { + watchAction = 2; + } + + GET_Livestreams_LivestreamEntry entry = new GET_Livestreams_LivestreamEntry(); + entry.lobby_id = lobby.LobbyID; + entry.name = lobby.Name; + entry.map_name = lobby.MapName; + entry.players = lobby.Members.Where(member => member.IsHuman()).Select(member => member.DisplayName).ToList(); + entry.delay_seconds = lobby.StreamDelaySeconds; + entry.observer_count = lobby.ObserverCount; + entry.age_seconds = Math.Max(0, (int)(DateTime.UtcNow - lobby.TimeCreated).TotalSeconds); + entry.state = isLive ? 1 : 0; + entry.passworded = lobby.IsPassworded; + entry.pending_observer_count = lobby.PendingObserverCount; + entry.priority = lobby.IsPriority; + entry.watch_action = watchAction; + entry.delay_remaining_seconds = delayRemainingSeconds; + + result.livestreams.Add(entry); + } + + // Watch Live order: priority-player matches first, then join -> wait -> pre-game. + // Stable, so equal rows keep their insertion order. + result.livestreams = result.livestreams + .OrderByDescending(e => e.priority) + .ThenByDescending(e => e.watch_action) + .ToList(); + + return result; + } + + [HttpPost("register", Name = "RegisterLivestream")] + [RequireRelay] + public async Task Register() + { + POST_Livestreams_Register_Result result = new POST_Livestreams_Register_Result(); + + Int64 user_id = TokenHelper.GetUserID(this); + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + + if (user_id == -1 || !SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + result.detail = "Invalid or missing game client session."; + return result; + } + + Lobby? lobby = _lobbyManager.GetPlayerParticipantLobby(user_id); + if (lobby == null || lobby.State != ELobbyState.INGAME) + { + Response.StatusCode = (int)HttpStatusCode.NotFound; + result.detail = "You are not in an in-progress match."; + return result; + } + + if (lobby.GetMemberFromUserID(user_id) == null && lobby.Owner != user_id) + { + Response.StatusCode = (int)HttpStatusCode.Forbidden; + result.detail = "You are not a member of this lobby."; + return result; + } + + // Runs once per streaming client, not once per lobby: each gets its own single-use + // relay token, and minting one for a member who hasn't asked for it just burns it. + bool isHost = lobby.Owner == user_id; + + // Only the host may set the delay (their spoiler window), but it's a lobby + // property - if a member registers first (host's own streaming still off), the + // session still needs the host's already-chosen delay, not the relay default. + int? delaySeconds = lobby.StreamDelaySeconds; + if (isHost) + { + using (var reader = new StreamReader(HttpContext.Request.Body)) + { + string jsonData = await reader.ReadToEndAsync(); + if (!String.IsNullOrEmpty(jsonData)) + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var data = JsonSerializer.Deserialize>(jsonData, options); + if (data != null && data.TryGetValue("delay_seconds", out JsonElement delayEl) && + delayEl.ValueKind == JsonValueKind.Number && + delayEl.TryGetInt32(out int parsedDelay)) + { + delaySeconds = Math.Max(0, parsedDelay); + } + } + } + } + + // lobby.Owner, not the caller - any member may register first, but the relay must + // record the same owner either way. + RelayLivestreamResponse? livestream = await RelayClient.CreateLivestreamAsync(lobby.LobbyID, lobby.Owner, delaySeconds); + if (livestream == null || String.IsNullOrEmpty(livestream.base_url)) + { + Response.StatusCode = (int)HttpStatusCode.BadGateway; + result.detail = "Relay failed to create a livestream session."; + return result; + } + + RelayTokenResponse? tokenResponse = await RelayClient.CreateStreamTokenAsync(lobby.LobbyID, user_id); + if (tokenResponse == null || String.IsNullOrEmpty(tokenResponse.url)) + { + Console.WriteLine($"[ERROR] Relay stream token mint failed for lobby {lobby.LobbyID} user {user_id}"); + Response.StatusCode = (int)HttpStatusCode.BadGateway; + result.detail = "Relay failed to mint a stream token for you."; + return result; + } + + // Registering does not make the lobby live - nothing has streamed into the relay + // session yet. Observers() below flips IsStreaming once the relay reports is_live. + lobby.SetStreamDelay(delaySeconds); + + result.success = true; + result.url = tokenResponse.url; + return result; + } + + [HttpPost("observe/{lobby_id}", Name = "ObserveLivestream")] + [RequireRelay] + public async Task Observe(Int64 lobby_id) + { + POST_Livestreams_Observe_Result result = new POST_Livestreams_Observe_Result(); + + Int64 user_id = TokenHelper.GetUserID(this); + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + + if (user_id == -1 || !SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.ServerListReadOnly)) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + result.detail = "Invalid or missing game client session."; + return result; + } + + // As in Get: watching a livestream is not the same as taking an in-game observer + // slot, so AllowObservers has no say here. IsStreaming below is the gate. + Lobby? lobby = _lobbyManager.GetLobby(lobby_id); + if (lobby == null || lobby.State != ELobbyState.INGAME) + { + Response.StatusCode = (int)HttpStatusCode.NotFound; + result.detail = "No watchable live game found for that lobby_id."; + return result; + } + + // No active relay stream for this lobby - reject before another relay round-trip. + if (!lobby.IsStreaming) + { + Response.StatusCode = (int)HttpStatusCode.NotFound; + result.detail = "That livestream has ended."; + return result; + } + + // Admin or user_priority = Viewer skips the password and broadcast-delay gates + // below - ticket mints instantly. + bool isPriority = TokenHelper.IsAdmin(this) || TokenHelper.GetUserPriority(this) == EUserPriority.Viewer; + if (!isPriority) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + + // Re-read live rather than trusting only the login-time claim: a mid-session + // grant (bot timed grant, Discord !setpriority) must not wait for a re-login. + if (await Database.Users.GetUserPriority(db, user_id) == EUserPriority.Viewer) + { + isPriority = true; + } + } + + // A livestream inherits its lobby's password; the read-only pre-game lobby view + // itself stays password-free. Checked before the ticket mint so a bad password + // never burns a relay ticket. + string? strProvidedPassword = null; + using (var reader = new StreamReader(HttpContext.Request.Body)) + { + string jsonData = await reader.ReadToEndAsync(); + if (!String.IsNullOrWhiteSpace(jsonData)) + { + try + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var data = JsonSerializer.Deserialize>(jsonData, options); + if (data != null && data.ContainsKey("password") && data["password"].ValueKind == JsonValueKind.String) + strProvidedPassword = data["password"].GetString(); + } + catch { /* unreadable body = no password supplied; the gate below decides */ } + } + } + + if (!isPriority && lobby.IsPassworded && strProvidedPassword != lobby.Password) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + result.detail = "This livestream is password protected."; + return result; + } + + // A normal viewer is held until the match has run for the host's delay, clocked + // from TimeMatchStarted (GO's own state, not the relay's liveness report). + if (!isPriority && lobby.StreamDelaySeconds > 0 && lobby.TimeMatchStarted != null) + { + int remainingSeconds = lobby.StreamDelaySeconds.Value - + (int)(DateTime.UtcNow - lobby.TimeMatchStarted.Value).TotalSeconds; + if (remainingSeconds > 0) + { + // 423 so the client's retry keeps working - nothing was minted, so there's + // no ticket to burn. + Response.StatusCode = (int)HttpStatusCode.Locked; + result.detail = "This stream starts after its broadcast delay."; + result.delay_remaining_seconds = remainingSeconds; + return result; + } + } + + RelayWatchTicketResult ticket = await RelayClient.CreateWatchTicketAsync(lobby.LobbyID, user_id, isPriority); + if (ticket.Status == RelayWatchTicketStatus.StreamEnded) + { + // The relay session for this lobby is gone (all sources left / reaped): the + // stream is over even though GO's lobby is still INGAME. Deregister it so the + // lobby drops out of /livestreams on the next refresh, and tell the client + // "stream ended" (404) rather than a confusing 502. + lobby.SetStreaming(false); + Response.StatusCode = (int)HttpStatusCode.NotFound; + result.detail = "That livestream has ended."; + return result; + } + + if (ticket.Status != RelayWatchTicketStatus.Ok || + ticket.Token == null || String.IsNullOrEmpty(ticket.Token.url)) + { + Response.StatusCode = (int)HttpStatusCode.BadGateway; + result.detail = "Relay failed to issue a watch ticket."; + return result; + } + + result.url = ticket.Token.url; + result.server_held = true; + return result; + } + + [HttpPost("observers", Name = "LivestreamObservers")] + [RequireRelay] + // The relay calls this (not a game client) with its own credential + // (Relay.ingress_api_key), so it must bypass the class-level GameClient JWT requirement. + [AllowAnonymous] + public async Task Observers([FromHeader(Name = "X-Relay-Key")] string? relayKey) + { + POST_Livestreams_Observers_Result result = new POST_Livestreams_Observers_Result(); + + if (!RelayClient.ValidateIngressKey(relayKey)) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + result.detail = "Invalid or missing relay key."; + return result; + } + + // One request batches every lobby whose state changed: array of + // {lobby_id, observer_count, is_live}, at least one entry. + List? updates = null; + using (var reader = new StreamReader(HttpContext.Request.Body)) + { + string jsonData = await reader.ReadToEndAsync(); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + updates = JsonSerializer.Deserialize>(jsonData, options); + } + + if (updates == null || updates.Count == 0) + { + Response.StatusCode = (int)HttpStatusCode.BadRequest; + result.detail = "livestream state updates must be an array."; + return result; + } + + // The relay is the authority on stream liveness, not GO's own lobby state - a match + // can stay INGAME with nobody streaming it, so is_live=false here still deregisters. + foreach (POST_Livestreams_Observers_Entry update in updates) + { + if (!Int64.TryParse(update.lobby_id, out Int64 entryLobby)) + { + continue; + } + + Lobby? observedLobby = _lobbyManager.GetLobby(entryLobby); + if (observedLobby == null) + { + continue; + } + + if (update.is_live) + { + bool wasStreaming = observedLobby.IsStreaming; + + // On the first transition, seed the observer count with the pre-game + // watchers who were parked in the lobby view: they join within moments, so + // without the seed the count would dip to ~0 then climb. The relay's own + // periodic reporting takes over from here. + int seededObserverCount = Math.Max(0, update.observer_count) + observedLobby.PendingObserverCount; + observedLobby.SetStreaming(true, observerCount: seededObserverCount); + + if (!wasStreaming) + { + // The stream just became watchable. Tell the read-only observers so + // they can fetch a watch ticket and join immediately. + if (observedLobby.PendingObservers.Count > 0) + { + WebSocketMessage_LobbyObserverEvent observerEvent = new WebSocketMessage_LobbyObserverEvent(); + observerEvent.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_STREAM_LIVE; + observerEvent.lobby_id = observedLobby.LobbyID; + byte[] observerBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(observerEvent)); + + foreach (UserSession sess in observedLobby.PendingObservers.Keys) + { + sess.QueueWebsocketSend(observerBytes); + } + } + + // Refresh the lobby lists in the network room so the in-progress game + // appears in the livestreams menu. + await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(observedLobby.NetworkRoomID); + } + } + else + { + observedLobby.SetStreaming(false, observerCount: 0); + } + } + + result.received = true; + return result; + } + } +} diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 60b165b..2184853 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -330,6 +330,10 @@ public async Task Put() bool bPassworded = data["passworded"].GetBoolean(); string? strPassword = data["password"].GetString(); bool bAllowObservers = data["allow_observers"].GetBoolean(); + // Optional: an older client that does not send it opts out of being + // watchable, which is also the server default. + bool bAllowStreamers = data.TryGetValue("allow_streamers", out var allowStreamersEl) + ? allowStreamersEl.GetBoolean() : false; UInt16 maxCamHeight = Convert.ToUInt16(data["max_cam_height"].GetDouble()); // client sends this as a float... UInt32 exe_crc = data["exe_crc"].GetUInt32(); UInt32 ini_crc = data["ini_crc"].GetUInt32(); @@ -381,13 +385,22 @@ public async Task Put() string strDisplayName = await Database.Users.GetDisplayName(db, user_id); Int64 newLobbyID = await _lobbyManager.CreateLobby(db, playerSession, strDisplayName, strName, strMapName, strMapPath, bMapOfficial, maxPlayers, strIPAddr, - hostPreferredPort, bVanillaTeamsOnly, bTrackStats, starting_cash, bPassworded, strPassword, playerSession.networkRoomID, bAllowObservers, maxCamHeight, exe_crc, ini_crc, ELobbyType.CustomGame, anticheatID); + hostPreferredPort, bVanillaTeamsOnly, bTrackStats, starting_cash, bPassworded, strPassword, playerSession.networkRoomID, bAllowObservers, bAllowStreamers, maxCamHeight, exe_crc, ini_crc, ELobbyType.CustomGame, anticheatID); if (newLobbyID >= 0) { result.result = 1; result.lobby_id = newLobbyID; + // A priority Player hosting marks the lobby for Watch Live. + EUserPriority userPriority = TokenHelper.GetUserPriority(this); + Lobby? newLobby = _lobbyManager.GetLobby(newLobbyID); + newLobby?.GetMemberFromUserID(user_id)?.SetPriority(userPriority); + if (userPriority == EUserPriority.Player) + { + newLobby?.SetPriority(true); + } + // mark lobby list as dirty await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(playerSession.networkRoomID); diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index b0b5b0f..98b29ed 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -134,7 +134,9 @@ enum ELobbyUpdateField AI_START_POS = 16, MAX_CAMERA_HEIGHT = 17, JOINABILITY = 18, - HOST_ACTION_BULK_SLOT_UPDATE = 19 + HOST_ACTION_BULK_SLOT_UPDATE = 19, + LOBBY_STREAM_DELAY = 20, + LOBBY_ALLOW_OBSERVER_CHAT = 21 }; public class RouteHandler_PUT_Lobby_Result : APIResult @@ -389,7 +391,9 @@ enum ELobbyUpdatePermissions [ELobbyUpdateField.AI_START_POS] = ELobbyUpdatePermissions.LobbyOwner, [ELobbyUpdateField.MAX_CAMERA_HEIGHT] = ELobbyUpdatePermissions.LobbyOwner, [ELobbyUpdateField.JOINABILITY] = ELobbyUpdatePermissions.LobbyOwner, - [ELobbyUpdateField.HOST_ACTION_BULK_SLOT_UPDATE] = ELobbyUpdatePermissions.LobbyOwner + [ELobbyUpdateField.HOST_ACTION_BULK_SLOT_UPDATE] = ELobbyUpdatePermissions.LobbyOwner, + [ELobbyUpdateField.LOBBY_STREAM_DELAY] = ELobbyUpdatePermissions.LobbyOwner, + [ELobbyUpdateField.LOBBY_ALLOW_OBSERVER_CHAT] = ELobbyUpdatePermissions.LobbyOwner }; @@ -537,6 +541,32 @@ public async Task Post(Int64 lobbyID) await lobby.UpdateLimitSuperweapons(db, bLimitSuperweapons); } } + else if (field == ELobbyUpdateField.LOBBY_STREAM_DELAY) + { + // Members see it read-only; reported to the relay at + // registration via StreamDelaySeconds. + if (data.ContainsKey("delay_seconds")) + { + int delaySeconds = data["delay_seconds"].GetInt32(); + delaySeconds = Math.Clamp(delaySeconds, 0, 600); + lobby.SetStreamDelay(delaySeconds); + lobby.DirtyRetransmit(); + } + } + else if (field == ELobbyUpdateField.LOBBY_ALLOW_OBSERVER_CHAT) + { + // Host-only kill switch for pre-game observer chat; on by + // default. The setter self-dirties, so no explicit + // DirtyRetransmit here. + if (data.ContainsKey("allow_observer_chat")) + { + bool bAllowObserverChat = data["allow_observer_chat"].GetBoolean(); + lobby.SetAllowObserverChat(bAllowObserverChat); + lobby.BroadcastSystemChatToMembers( + String.Format("The host has {0} observer chat.", bAllowObserverChat ? "enabled" : "disabled"), + includeObservers: true); + } + } else if (field == ELobbyUpdateField.HOST_ACTION_FORCE_START) { // dummy action... just force everyone ready @@ -724,6 +754,13 @@ public async Task Post(Int64 lobbyID) lobby.DirtyRetransmit(); } } + + // Unknown fields throw on the permission-table lookup above and + // never reach here, so this only fires for a field actually stored. + result.success = true; + + // Any field update cancels the host's countdown client-side. + lobby.SetCountdownStarted(false); } } @@ -826,6 +863,17 @@ public async Task Put(Int64 lobbyID) string strDisplayName = await Database.Users.GetDisplayName(db, user_id); bool bJoinedSuccessfully = await _lobbyManager.JoinLobby(db, lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); + // A priority Player joining marks the lobby for Watch Live. + if (bJoinedSuccessfully) + { + EUserPriority userPriority = TokenHelper.GetUserPriority(this); + lobby.GetMemberFromUserID(user_id)?.SetPriority(userPriority); + if (userPriority == EUserPriority.Player) + { + lobby.SetPriority(true); + } + } + result.success = bJoinedSuccessfully; if (!bJoinedSuccessfully) // this basically means full, didnt find a slot in correct state diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index e20d2c1..cb7bfc0 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -147,13 +147,14 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr await SessionHelpers.SetUsedLoggedIn(user_id, clientID, sessionType); bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); + EUserPriority userPriority = await Database.Users.GetUserPriority(db, user_id); result.result = EPendingLoginState.LoginSuccess; // extend token // TODO_TODAY_JWT: just get clientID from token - var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, clientID, sessionType, bIsAdmin); - var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, clientID, sessionType, false, out string refreshJti); + var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, clientID, sessionType, bIsAdmin, userPriority); + var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, clientID, sessionType, false, EUserPriority.None, out string refreshJti); // rotation: only this refresh token is accepted from now on await TokenRevocationManager.OnTokensIssued(user_id, sessionType, refreshJti); diff --git a/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs b/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs index 8ce557f..a06009d 100644 --- a/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs +++ b/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs @@ -110,9 +110,10 @@ public async Task Post_InternalHandler(string ipAddr) string strDisplayName = await Database.Users.GetDisplayName(db, user_id); bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); + EUserPriority userPriority = await Database.Users.GetUserPriority(db, user_id); - var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, clientID, sessionType, bIsAdmin); - var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, clientID, sessionType, false, out string refreshJti); + var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, clientID, sessionType, bIsAdmin, userPriority); + var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, clientID, sessionType, false, EUserPriority.None, out string refreshJti); // rotation: only this refresh token is accepted from now on await TokenRevocationManager.OnTokensIssued(user_id, sessionType, refreshJti); diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index cd56f79..e849461 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -252,6 +252,14 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) // close the session if (wsSess != null) { + // A closed websocket stopped watching - sweep its pending-observer subscriptions + // so the count doesn't keep counting it. + UserSession? closingSession = WebSocketManager.GetSessionFromUser(user_id, wsSess.m_SessionType); + if (closingSession != null) + { + _lobbyManager.RemovePendingObserver(closingSession); + } + await WebSocketManager.DeleteSession(user_id, wsSess.m_SessionType, wsSess, false); } @@ -740,9 +748,216 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession } } } + + // Read-only watchers in the pre-game lobby view get the same bytes. + // They are not members, so the announcement rules above cannot apply - + // an observer reads the lobby chat exactly as the members see it. + foreach (UserSession observerSess in playerLobby.PendingObservers.Keys) + { + observerSess.QueueWebsocketSend(bytesJSON); + } } } } + else if (msgID == EWebSocketMessageID.LOBBY_OBSERVER_SUBSCRIBE) + { + // Read-only registration: no membership, password, or lobby-state check. + WebSocketMessage_LobbyObserverEvent? subscribeMsg = + JsonSerializer.Deserialize(payload, JsonOpts); + + if (subscribeMsg != null) + { + Lobby? observerLobby = _lobbyManager.GetLobby(subscribeMsg.lobby_id); + if (observerLobby != null && observerLobby.PendingObservers.TryAdd(sourceUserSession, 0)) + { + Console.WriteLine($"[OBSERVER] User {sourceUserSession.m_UserID} subscribed to pre-game lobby {observerLobby.LobbyID}"); + // The members can be read by this watcher from here on, so say so by + // name: the count alone does not tell them who is listening. Other + // observers in the same lobby see the line too - except the one who + // just joined, who knows. + observerLobby.BroadcastSystemChatToMembers( + String.Format("Observer {0} joined the lobby", sourceUserData.m_strDisplayName), + includeObservers: true, excludeObserverSession: sourceUserSession); + + // Tell the joining observer what they are in for: the broadcast delay + // they will hold, or a straight join at match start. Priority viewers + // skip the delay, and a lobby without a configured delay holds nobody. + // The priority flag never reaches the client, so the server must pick + // the message - same authoritative live re-read as the Observe endpoint. + await using (var db = await _dbFactory.CreateDbContextAsync()) + { + bool bIsPriority = await Database.Users.GetUserPriority(db, sourceUserSession.m_UserID) == EUserPriority.Viewer; + + string strJoinMessage; + if (bIsPriority || observerLobby.StreamDelaySeconds == null || observerLobby.StreamDelaySeconds <= 0) + { + strJoinMessage = "Joining on match start."; + } + else + { + strJoinMessage = String.Format("Broadcast delay: {0}s - joining automatically when it ends", observerLobby.StreamDelaySeconds); + } + + WebSocketMessage_LobbyChatMessageOutbound joinMsg = new WebSocketMessage_LobbyChatMessageOutbound(); + joinMsg.msg_id = (int)EWebSocketMessageID.LOBBY_CHAT_FROM_SERVER; + joinMsg.user_id = -2; + joinMsg.message = strJoinMessage; + joinMsg.action = true; + sourceUserSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(joinMsg))); + } + + // Retransmit so members see the pending-observer count change too. + observerLobby.DirtyRetransmit(); + } + } + } + else if (msgID == EWebSocketMessageID.LOBBY_OBSERVER_UNSUBSCRIBE) + { + WebSocketMessage_LobbyObserverEvent? unsubscribeMsg = + JsonSerializer.Deserialize(payload, JsonOpts); + + if (unsubscribeMsg != null) + { + Lobby? observerLobby = _lobbyManager.GetLobby(unsubscribeMsg.lobby_id); + if (observerLobby != null && observerLobby.PendingObservers.TryRemove(sourceUserSession, out _)) + { + Console.WriteLine($"[OBSERVER] User {sourceUserSession.m_UserID} unsubscribed from pre-game lobby {observerLobby.LobbyID}"); + // The leaving session is already out of PendingObservers, so the + // remaining observers and the members all get the line. + observerLobby.BroadcastSystemChatToMembers( + String.Format("Observer {0} left the lobby", sourceUserData.m_strDisplayName), + includeObservers: true, excludeObserverSession: sourceUserSession); + observerLobby.DirtyRetransmit(); + } + } + } + else if (msgID == EWebSocketMessageID.LOBBY_OBSERVER_CHAT_FROM_CLIENT) + { + // Observer sends chat into a pre-game lobby. Own lane because the member + // path is gated on currentLobbyID != -1 and an observer is not in a lobby. + WebSocketMessage_LobbyObserverChatInbound? observerChatMsg = + JsonSerializer.Deserialize(payload, JsonOpts); + + if (observerChatMsg == null) + { + return; + } + + Lobby? observerChatLobby = _lobbyManager.GetLobby(observerChatMsg.lobby_id); + if (observerChatLobby == null) + { + return; + } + + // Authorization: only sessions actually observing this lobby may post into + // it. This is what stops someone from writing into an arbitrary lobby by + // guessing an id; lobby_id selects which observed lobby this is for. + if (!observerChatLobby.PendingObservers.ContainsKey(sourceUserSession)) + { + return; + } + + // Host kill switch. A stale client that still shows an enabled entry gets + // an explanation rather than silence. + if (!observerChatLobby.AllowObserverChat) + { + WebSocketMessage_LobbyChatMessageOutbound refusalMsg = new WebSocketMessage_LobbyChatMessageOutbound(); + refusalMsg.msg_id = (int)EWebSocketMessageID.LOBBY_CHAT_FROM_SERVER; + refusalMsg.user_id = -2; + refusalMsg.message = "Observer chat is disabled by the host."; + refusalMsg.action = true; + sourceUserSession.QueueWebsocketSend( + Encoding.UTF8.GetBytes(JsonSerializer.Serialize(refusalMsg))); + return; + } + + string strText = observerChatMsg.message ?? String.Empty; + strText = strText.Trim(); + if (strText.Length == 0) + { + return; + } + if (strText.Length > 200) + { + strText = strText.Substring(0, 200); + } + + // Server-side rate gate (3000 ms, matching the client's network-room + // slowmode): the client-side slowmode is only courtesy, a modded client + // ignores it. The timestamp lives on the session and dies with the socket. + long timeNow = Environment.TickCount64; + if (sourceUserSession.m_timeLastObserverChatSent != -1 && + timeNow - sourceUserSession.m_timeLastObserverChatSent < 3000) + { + return; + } + sourceUserSession.m_timeLastObserverChatSent = timeNow; + + // Server-controlled formatting: exactly the member path's [Name] message, + // but with the flags fixed so a client can never smuggle in an action or + // announcement line through this lane. + WebSocketMessage_LobbyChatMessageOutbound outboundMsg = new WebSocketMessage_LobbyChatMessageOutbound(); + outboundMsg.msg_id = (int)EWebSocketMessageID.LOBBY_CHAT_FROM_SERVER; + outboundMsg.user_id = sourceUserSession.m_UserID; + outboundMsg.message = String.Format("[{0}] {1}", sourceUserData.m_strDisplayName, strText); + outboundMsg.action = false; + outboundMsg.announcement = false; + outboundMsg.show_announcement_to_host = false; + + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); + + foreach (LobbyMember lobbyMember in observerChatLobby.Members) + { + if (lobbyMember != null && lobbyMember.GetSession().TryGetTarget(out UserSession? sess) && sess != null) + { + sess.QueueWebsocketSend(bytesJSON); + } + } + + // Observers get the same bytes; the sender's own copy comes back this way, + // so no local echo is needed. + foreach (UserSession observerSess in observerChatLobby.PendingObservers.Keys) + { + observerSess.QueueWebsocketSend(bytesJSON); + } + } + else if (msgID == EWebSocketMessageID.LOBBY_OBSERVER_LIST_REQUEST) + { + // Host-only: the players already see join/leave lines as they happen, but + // the host can ask for the current roster by name. The list is announced + // into the lobby chat, so the whole game sees it. + WebSocketMessage_LobbyObserverEvent? listMsg = + JsonSerializer.Deserialize(payload, JsonOpts); + + if (listMsg == null) + { + return; + } + + Lobby? observerListLobby = _lobbyManager.GetLobby(listMsg.lobby_id); + if (observerListLobby == null) + { + return; + } + + if (observerListLobby.Owner != sourceUserSession.m_UserID) + { + return; + } + + List lstObserverNames = new List(); + foreach (UserSession observerSession in observerListLobby.PendingObservers.Keys) + { + SharedUserData? observerData = WebSocketManager.GetSharedDataForUser(observerSession.m_UserID); + lstObserverNames.Add(observerData != null ? observerData.m_strDisplayName : "Unknown"); + } + + string strMessage = lstObserverNames.Count == 0 + ? "No observers are watching this lobby." + : String.Format("Observers watching: {0}", String.Join(", ", lstObserverNames)); + + observerListLobby.BroadcastSystemChatToMembers(strMessage); + } else if (msgID == EWebSocketMessageID.START_GAME_COUNTDOWN_STARTED) { // must be in a lobby @@ -765,6 +980,26 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // lock slots lobbyInfo.CloseOpenSlots(); + + // Observers mirror this through the ordinary lobby-changed refetch; the + // eager push below is just the instant cue. + lobbyInfo.SetCountdownStarted(true); + + // Push it now so observers' countdown starts in sync with the lobby's, + // rather than only on the START_GAME forward below (kept as a fallback + // for observers that subscribed too late to catch this one). + if (lobbyInfo.PendingObservers.Count > 0) + { + WebSocketMessage_LobbyObserverEvent observerEvent = new WebSocketMessage_LobbyObserverEvent(); + observerEvent.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_GAME_STARTING; + observerEvent.lobby_id = lobbyInfo.LobbyID; + byte[] observerBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(observerEvent)); + + foreach (UserSession sess in lobbyInfo.PendingObservers.Keys) + { + sess.QueueWebsocketSend(observerBytes); + } + } } else if (msgID == EWebSocketMessageID.START_GAME) { @@ -789,6 +1024,10 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // start match + create placeholder match await lobbyInfo.UpdateState(ELobbyState.INGAME); + // Not a cancel: UpdateState already flipped State to INGAME above, so + // observers' next refetch reads "started", not "countdown cleared". + lobbyInfo.SetCountdownStarted(false); + // simple websocket msg, has no data, so dont even read anything @@ -813,6 +1052,22 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession } } } + + // Tell pending observers to queue their join now - the delay rides along so + // the client can show the broadcast-delay wait from the start. + if (lobbyInfo.PendingObservers.Count > 0) + { + WebSocketMessage_LobbyObserverEvent observerEvent = new WebSocketMessage_LobbyObserverEvent(); + observerEvent.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_GAME_STARTED; + observerEvent.lobby_id = lobbyInfo.LobbyID; + observerEvent.delay_seconds = lobbyInfo.StreamDelaySeconds; + byte[] observerBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(observerEvent)); + + foreach (UserSession sess in lobbyInfo.PendingObservers.Keys) + { + sess.QueueWebsocketSend(observerBytes); + } + } } else if (msgID == EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_HOST_REQUESTS_BEGIN) { diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 1e796e8..98df162 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -77,6 +77,10 @@ public class User public bool IsAdmin { get; set; } = false; public bool IsBanned { get; set; } = false; + // Livestream privilege: 0 none / 1 player (highlight their matches) / 2 viewer (skip + // the password + broadcast-delay gates). See EUserPriority. + public int UserPriority { get; set; } = 0; + // ELO public int EloRating { get; set; } = EloConfig.BaseRating; public int MonthlyEloRating { get; set; } = EloConfig.BaseRating; @@ -162,6 +166,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.LimitSuperweapons).HasColumnName("favorite_limit_superweapons"); builder.Property(e => e.IsAdmin).HasColumnName("admin"); builder.Property(e => e.IsBanned).HasColumnName("banned"); + builder.Property(e => e.UserPriority).HasColumnName("user_priority"); builder.Property(e => e.EloRating).HasColumnName("elo_rating"); builder.Property(e => e.MonthlyEloRating).HasColumnName("monthly_elo_rating"); builder.Property(e => e.EloNumberOfMatches).HasColumnName("elo_num_matches"); @@ -502,6 +507,105 @@ public static async Task IsUserAdmin(AppDbContext db, long userId) } } + private static readonly Func> _getUserPriorityQuery = + EF.CompileAsyncQuery((AppDbContext db, long userId) => + db.Users + .AsNoTracking() + .Where(u => u.ID == userId) + .Select(u => u.UserPriority) + .FirstOrDefault()); + + public static async Task GetUserPriority(AppDbContext db, long userId) + { + try + { + return (EUserPriority)await _getUserPriorityQuery(db, userId); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUserPriority failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return EUserPriority.None; + } + } + + public static async Task SetUserPriority(AppDbContext db, long userId, int priority) + { + try + { + // Reject out-of-range values. + if (priority < (int)EUserPriority.None || priority > (int)EUserPriority.Viewer) + { + return false; + } + + int updated = await db.Users + .Where(u => u.ID == userId) + .ExecuteUpdateAsync(setters => setters.SetProperty(u => u.UserPriority, priority)); + // False here means "no such user id" - distinct from the range check above. + return updated > 0; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] SetUserPriority failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return false; + } + } + + // Exact match - display names are unique (SetDisplayName enforces it), and the MySQL + // collation makes it case-insensitive. + public static async Task GetUserByDisplayName(AppDbContext db, string displayName) + { + try + { + return await db.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.DisplayName == displayName); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUserByDisplayName failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return null; + } + } + + // Partial display-name search for Discord !searchuserid: every part must match (AND). + // Parameterised through EF Core, so arbitrary input cannot reach SQL. + public static async Task> SearchUsersByDisplayName(AppDbContext db, List nameParts, int limit) + { + try + { + IQueryable query = db.Users.AsNoTracking(); + foreach (string part in nameParts) + { + query = query.Where(u => u.DisplayName != null && u.DisplayName.Contains(part)); + } + + return await query.Take(limit).ToListAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] SearchUsersByDisplayName failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return new List(); + } + } + + public static async Task GetUserById(AppDbContext db, long userId) + { + try + { + return await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.ID == userId); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUserById failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return null; + } + } + public static async Task> GetDisplayNameBulk(AppDbContext db, List lstUserIDs) { var dict = new Dictionary(lstUserIDs.Count); diff --git a/GenOnlineService/Database_Structure/structure.sql b/GenOnlineService/Database_Structure/structure.sql index 57199e3..4ed3e59 100644 --- a/GenOnlineService/Database_Structure/structure.sql +++ b/GenOnlineService/Database_Structure/structure.sql @@ -211,6 +211,7 @@ CREATE TABLE IF NOT EXISTS `users` ( `favorite_limit_superweapons` int(11) NOT NULL DEFAULT -1, `admin` tinyint(4) NOT NULL DEFAULT 0, `banned` tinyint(4) NOT NULL DEFAULT 0, + `user_priority` tinyint(4) NOT NULL DEFAULT 0, `elo_rating` int(11) NOT NULL DEFAULT 1000, `monthly_elo_rating` int(11) NOT NULL DEFAULT 1000, `elo_num_matches` int(11) NOT NULL DEFAULT 0, diff --git a/GenOnlineService/Discord.cs b/GenOnlineService/Discord.cs index 6e9c598..c2fdb54 100644 --- a/GenOnlineService/Discord.cs +++ b/GenOnlineService/Discord.cs @@ -23,6 +23,7 @@ using Discord.Rest; using Discord.WebSocket; using GenOnlineService; +using Microsoft.EntityFrameworkCore; using MySqlX.XDevAPI; using System; using System.Collections.Generic; @@ -111,7 +112,7 @@ enum EBotAction public DiscordBot() { -#if !DEBUG +#if !DEBUG || USE_DISCORD_IN_DEBUG _ = InitAsync().ContinueWith(t => { if (t.IsFaulted) @@ -274,6 +275,35 @@ public bool IsChannelIDDefined(ulong channelID, EDiscordChannelIDs discordChanne return false; } + private bool IsDiscordAdmin(UInt64 userId) + { + try + { + if (Program.g_Config == null) + { + return false; + } + + IConfiguration? discordSettings = Program.g_Config.GetSection("Discord"); + if (discordSettings == null) + { + return false; + } + + List? discord_admins = discordSettings.GetSection("discord_admins").Get>(); + if (discord_admins == null) + { + return false; + } + + return discord_admins.Contains(userId); + } + catch + { + return false; + } + } + private uint g_cooldownLengthSeconds = 20; private Dictionary m_dictCooldowns = new Dictionary(); @@ -680,6 +710,48 @@ private async Task OnMessageReceived(SocketMessage message) } } } + else if (message.Content.ToLower().StartsWith("!setpriority") || message.Content.ToLower().StartsWith("!user_setpriority")) + { + if (message.Channel.Id == g_dictChannelIDs[EDiscordChannelIDs.AdminCommands]) + { + if (IsDiscordAdmin(message.Author.Id)) + { + await HandleSetPriorityCommand(message); + } + else + { + PushDM(message.Author, "You don't have access to staff commands."); + } + } + } + else if (message.Content.ToLower().StartsWith("!getuserid")) + { + if (message.Channel.Id == g_dictChannelIDs[EDiscordChannelIDs.AdminCommands]) + { + if (IsDiscordAdmin(message.Author.Id)) + { + await HandleGetUserIdCommand(message); + } + else + { + PushDM(message.Author, "You don't have access to staff commands."); + } + } + } + else if (message.Content.ToLower().StartsWith("!searchuserid")) + { + if (message.Channel.Id == g_dictChannelIDs[EDiscordChannelIDs.AdminCommands]) + { + if (IsDiscordAdmin(message.Author.Id)) + { + await HandleSearchUserIdCommand(message); + } + else + { + PushDM(message.Author, "You don't have access to staff commands."); + } + } + } //JSONRequest_PushCommand requestToSend = new JSONRequest_PushCommand(new DiscordUser(message.Author.Id, message.Author.Username), message.Content, enumChannelID); @@ -718,6 +790,99 @@ private async Task OnMessageReceived(SocketMessage message) } } + // ---- Staff commands: user priority + user lookup -------------------------------------- + + private async Task HandleSetPriorityCommand(SocketMessage message) + { + string[] strComponents = message.Content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (strComponents.Length == 2 && strComponents[1].ToLower() == "help") + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, + "!setpriority \nPriority types:\n0 = none\n1 = player (their matches are highlighted in the Watch Live browser)\n2 = viewer (skips the livestream password + broadcast-delay gates)\nExample: !setpriority 12345 2"); + return; + } + + if (strComponents.Length != 3 || + !Int64.TryParse(strComponents[1], out Int64 targetUserId) || + !Int32.TryParse(strComponents[2], out Int32 priority)) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !setpriority <0|1|2> (e.g. !setpriority 12345 2). Use !setpriority help for the priority types."); + return; + } + + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + + User? user = await Database.Users.GetUserById(db, targetUserId); + if (user == null) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {targetUserId} does not exist."); + return; + } + + if (await Database.Users.SetUserPriority(db, targetUserId, priority)) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {targetUserId} ({user.DisplayName}) priority set to {priority}."); + } + else + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"Invalid priority type {priority}. Use 0, 1 or 2 (see !setpriority help)."); + } + } + + private async Task HandleGetUserIdCommand(SocketMessage message) + { + string[] strComponents = message.Content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (strComponents.Length < 2) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !getuserid (e.g. !getuserid x64)"); + return; + } + + string strName = string.Join(' ', strComponents.Skip(1)); + + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + + User? user = await Database.Users.GetUserByDisplayName(db, strName); + if (user == null) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"No user found with display name '{strName}'."); + return; + } + + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"{user.DisplayName} is user ID {user.ID} (priority {user.UserPriority}, account type {user.AccountType})."); + } + + private async Task HandleSearchUserIdCommand(SocketMessage message) + { + string[] strComponents = message.Content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (strComponents.Length < 2) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !searchuserid [part2 ...] (e.g. !searchuserid bob x64)"); + return; + } + + List parts = strComponents.Skip(1).ToList(); + + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + + List users = await Database.Users.SearchUsersByDisplayName(db, parts, 10); + if (users.Count == 0) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"No users match '{String.Join(" ", parts)}'."); + return; + } + + string strResults = String.Join("\n", users.Select(u => $"`{u.ID}` {u.DisplayName} (priority {u.UserPriority})")); + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"Search results ({users.Count} shown):\n{strResults}"); + } + private static Task LogAsync(LogMessage log) { Console.WriteLine(log.ToString()); diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 61e5b79..11c0906 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -359,6 +359,108 @@ public int MaxPlayers public string Password { get; private set; } = String.Empty; public bool AllowObservers { get; private set; } = false; + + // Host decision at creation: may this game be watched at all, regardless of any + // individual player's own streamer role? When off, hidden from Watch Live entirely. + public bool AllowStreamers { get; private set; } = false; + + public void SetAllowStreamers(bool allowed) + { + if (AllowStreamers == allowed) + return; + AllowStreamers = allowed; + DirtyRetransmit(); + } + + // Host decision: may pre-game observers send chat into the lobby? On by default; the host + // opts out. Public so it lands in GET /Lobby/{id} (which returns the live object). + public bool AllowObserverChat { get; private set; } = true; + + public void SetAllowObserverChat(bool allowed) + { + if (AllowObserverChat == allowed) + return; + AllowObserverChat = allowed; + DirtyRetransmit(); + } + + // Livestream state, owned by the relay session. + public bool IsStreaming { get; private set; } = false; + public int? StreamDelaySeconds { get; private set; } = null; + public int ObserverCount { get; private set; } = 0; + + // Clock for the broadcast-delay gate (TimeMatchStarted + StreamDelaySeconds), taken from GO's + // own INGAME transition rather than the relay's liveness report. + public DateTime? TimeMatchStarted { get; private set; } = null; + + // Latched TRUE when a user_priority = Player creates or joins. Not in the lobby JSON: + // LivestreamsController copies it into the Watch Live entry itself, for sorting. + [JsonIgnore] + public bool IsPriority { get; private set; } = false; + + public void SetPriority(bool priority) + { + IsPriority = priority; + } + + // True while the host's match-start countdown is running. Rides the lobby JSON so + // members/observers mirror it via the ordinary refetch - no separate countdown message. + public bool CountdownStarted { get; private set; } = false; + + public void SetCountdownStarted(bool started) + { + if (CountdownStarted == started) + return; + CountdownStarted = started; + DirtyRetransmit(); + } + + // Pre-game watchers in the read-only lobby view, distinct from ObserverCount (live watchers, + // reported by the relay). Keyed by UserSession so one closed socket sweeps every lobby. + [JsonIgnore] + public ConcurrentDictionary PendingObservers { get; } = new(); + public int PendingObserverCount => PendingObservers.Count; + + // A system line in the lobby's chat box, sent as an ordinary LOBBY_CHAT_FROM_SERVER so + // every existing client renders it with no change at all. user_id -2 is the established + // "not a player" sender id (see the admin announcement in Discord.cs); it matches no slot, + // so clients colour it as a generic action line rather than in some player's colour. + public void BroadcastSystemChatToMembers(string message, bool includeObservers = false, UserSession? excludeObserverSession = null) + { + WebSocketMessage_LobbyChatMessageOutbound outboundMsg = new WebSocketMessage_LobbyChatMessageOutbound(); + outboundMsg.msg_id = (int)EWebSocketMessageID.LOBBY_CHAT_FROM_SERVER; + outboundMsg.user_id = -2; + outboundMsg.message = message; + outboundMsg.action = true; + + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); + + foreach (LobbyMember lobbyMember in Members) + { + if (lobbyMember == null) + { + continue; + } + + if (lobbyMember.GetSession().TryGetTarget(out UserSession? sess) && sess != null) + { + sess.QueueWebsocketSend(bytesJSON); + } + } + + if (includeObservers) + { + foreach (UserSession observerSession in PendingObservers.Keys) + { + if (observerSession == excludeObserverSession) + { + continue; + } + observerSession.QueueWebsocketSend(bytesJSON); + } + } + } + public UInt32 ExeCRC { get; private set; } = 0; public UInt32 IniCRC { get; private set; } = 0; @@ -385,20 +487,12 @@ public int MaxPlayers [JsonIgnore] public ConcurrentDictionary TimeMemberLeft { get; private set; } = new(); - // Records the first time each player's in-game WebSocket connection dropped (i.e., when they first "quit" - // while the match was in progress). Only the first disconnect is stored � reconnects do not reset it. - // Used by DetermineLobbyWinnerIfNotPresent to find who abandoned first (= loser) vs last (= winner). - // NOTE: These are mutated from HTTP/websocket threads and the lobby tick loop at the same time, so they must - // be concurrent collections. + // First in-game disconnect per player; DetermineLobbyWinnerIfNotPresent reads it to tell who quit first. + // Concurrent because HTTP/websocket threads and the lobby tick loop mutate it at the same time. [JsonIgnore] public ConcurrentDictionary TimePlayerAbandonedIngame { get; private set; } = new(); - /// - /// Records the moment a player's WebSocket dropped while the lobby was in INGAME state. - /// Only the FIRST disconnect is stored; subsequent reconnect/disconnect cycles are ignored - /// so that a player who briefly loses connection is not penalised more than the player who - /// intentionally killed the game first. - /// + // Only the first disconnect is kept, so a brief drop is not penalised over the player who quit first. public void RecordPlayerIngameAbandon(Int64 userId) { DateTime abandonTime = DateTime.UtcNow; @@ -408,10 +502,7 @@ public void RecordPlayerIngameAbandon(Int64 userId) } } - /// - /// Removes the in-game abandon timestamp for a player who successfully reconnected. - /// This ensures a future disconnect records the correct (later) quit time. - /// + // Cleared on reconnect so a later disconnect records the real quit time. public void ClearPlayerIngameAbandon(Int64 userId) { if (TimePlayerAbandonedIngame.TryRemove(userId, out _)) @@ -707,6 +798,21 @@ public async Task Tick() } } + // Ping pending observers too - they are not lobby members, so they get no + // LOBBY_CURRENT_LOBBY_UPDATE, and they refetch GET /Lobby/{id} on the ping. + if (PendingObservers.Count > 0) + { + WebSocketMessage_LobbyObserverEvent observerPing = new WebSocketMessage_LobbyObserverEvent(); + observerPing.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_LOBBY_CHANGED; + observerPing.lobby_id = LobbyID; + byte[] observerBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(observerPing)); + + foreach (UserSession sess in PendingObservers.Keys) + { + sess.QueueWebsocketSend(observerBytes); + } + } + // transmit to those in network room //WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(NetworkRoomID); @@ -968,6 +1074,30 @@ public async Task RemoveMember(LobbyMember member) await OnAfterPlayerLeft(UserID); + // Any departure cancels the host's countdown client-side - follow suit or + // observers keep waiting for a match that isn't starting. + CountdownStarted = false; + + // Demote the lobby if the leaver was the last priority Player - in-memory scan, + // no DB lookup, since each member already carries its grant from the JWT. + if (IsPriority) + { + bool stillHasPriorityPlayer = false; + foreach (LobbyMember memberEntry in Members) + { + if (memberEntry.IsHuman() && memberEntry.Priority == EUserPriority.Player) + { + stillHasPriorityPlayer = true; + break; + } + } + + if (!stillHasPriorityPlayer) + { + IsPriority = false; + } + } + DirtyRetransmit(); } @@ -1159,8 +1289,16 @@ public bool HadAIAtStart() public async Task UpdateState(ELobbyState state) { + bool wasIngame = State == ELobbyState.INGAME; State = state; + // One-shot latch: every start path (host START_GAME, quickmatch) lands here, and a + // repeated INGAME update must not reset it. + if (state == ELobbyState.INGAME && !wasIngame) + { + TimeMatchStarted = DateTime.UtcNow; + } + // if start, init our AC probe if (state == ELobbyState.INGAME) { @@ -1209,6 +1347,37 @@ public void UpdateJoinability(ELobbyJoinability newJoinability) } } + // Liveness and observer count both come from the relay, which is the only party that can + // see whether a stream has data and who is watching it. + public void SetStreaming(bool isStreaming, int? observerCount = null) + { + // Retransmit on the live/not-live edge only: the observer lobby view mirrors + // IsStreaming, but the relay reports counts continuously and those must not each + // cost a lobby transmit. + bool bStateChanged = IsStreaming != isStreaming; + + IsStreaming = isStreaming; + if (observerCount.HasValue) + { + ObserverCount = observerCount.Value; + } + + if (bStateChanged) + { + DirtyRetransmit(); + } + } + + // The delay is set separately, at registration, because it is known before the stream is + // live and is reported by the host rather than observed by the relay. + public void SetStreamDelay(int? delaySeconds) + { + if (delaySeconds.HasValue) + { + StreamDelaySeconds = delaySeconds; + } + } + public void UpdateMaxCameraHeight(UInt16 maxCamHeight) { if (maxCamHeight >= 210 && maxCamHeight <= 1000) @@ -1276,6 +1445,15 @@ public void UpdateSlotIndex(UInt16 index) public string Region { get; private set; } = "Unknown"; public string MiddlewareUserID { get; private set; } = String.Empty; + // Livestream privilege grant, carried from the member's JWT at create/join - lets the + // lobby's priority latch be recomputed on leave without a DB lookup. + public EUserPriority Priority { get; private set; } = EUserPriority.None; + + public void SetPriority(EUserPriority priority) + { + Priority = priority; + } + [JsonIgnore] // cant serialize refs private WeakReference CurrentLobby = new(null); @@ -1442,9 +1620,8 @@ public async Task Cleanup() } } - // Lobbies that asked to be destroyed from a synchronous event callback. They are deleted from the tick loop so - // the delete is properly awaited and its exceptions are observed (an `async void` handler would let a failure - // escape unobserved and leave the lobby leaked in m_dictLobbies forever). + // Destroyed from the tick loop so the delete is awaited and its exceptions observed; an async void + // handler would let a failure escape and leak the lobby in m_dictLobbies. private readonly ConcurrentQueue m_queueLobbiesNeedingDestroyed = new(); private void HandleLobbyNeedsDestroyed(Lobby lobby) @@ -1474,7 +1651,7 @@ public async Task ProcessLobbiesNeedingDestroyed() } public async Task CreateLobby(AppDbContext _db, UserSession owningSession, string strOwnerDisplayName, string strName, string strMapName, string strMapPath, bool bMapOfficial, int maxPlayers, string HostIPAddr, - UInt16 hostPreferredPort, bool bVanillaTeams, bool bTrackStats, UInt32 default_starting_cash, bool bPassworded, String strPassword, Int16 parentNetworkRoom, bool bAllowObservers, + UInt16 hostPreferredPort, bool bVanillaTeams, bool bTrackStats, UInt32 default_starting_cash, bool bPassworded, String strPassword, Int16 parentNetworkRoom, bool bAllowObservers, bool bAllowStreamers, UInt16 maxCamHeight, UInt32 exe_crc, UInt32 ini_crc, ELobbyType lobbyType, EKnownAnticheatID anticheatID) { Console.WriteLine("Created lobby"); @@ -1505,6 +1682,7 @@ public async Task CreateLobby(AppDbContext _db, UserSession owningSession } Lobby newLobby = new Lobby(newLobbyID, owningSession, strName, ELobbyState.GAME_SETUP, strMapName, strMapPath, bVanillaTeams, starting_cash, bLimitSuperweapons, bTrackStats, bPassworded, strPassword, bMapOfficial, rng_seed, parentNetworkRoom, bAllowObservers, maxCamHeight, exe_crc, ini_crc, maxPlayers, lobbyType, anticheatID); + newLobby.SetAllowStreamers(bAllowStreamers); m_dictLobbies[newLobbyID] = newLobby; @@ -1611,6 +1789,11 @@ public List GetAllLobbies(Int16 networkRoomID, bool bIncludePassword, boo return listLobbies; } + public List GetAllLobbies() + { + return m_dictLobbies.Values.ToList(); + } + public Lobby? GetLobby(Int64 lobbyID) { if (m_dictLobbies.TryGetValue(lobbyID, out Lobby? lobby)) @@ -1716,6 +1899,31 @@ public async Task LeaveAnyLobby(Int64 userID) } } + // A closed websocket means the client is gone (or reconnecting elsewhere) - its + // read-only observer subscriptions are dead too. Called from the ws disconnect path. + public void RemovePendingObserver(UserSession session) + { + SharedUserData? observerData = WebSocketManager.GetSharedDataForUser(session.m_UserID); + string strObserverName = (observerData != null) ? observerData.m_strDisplayName : "An observer"; + + foreach (Lobby lobbyInst in m_dictLobbies.Values) + { + if (!lobbyInst.PendingObservers.TryRemove(session, out _)) + { + continue; + } + +// Same courtesy as an explicit unsubscribe: the members were told this observer + // arrived, so tell them it is gone, and the remaining observers in the same lobby + // see it too. DirtyRetransmit keeps the "N observers waiting" count honest, which + // the subscribe/unsubscribe paths already do. The swept session is already out of + // PendingObservers, so the exclusion is moot but keeps the shape uniform. + lobbyInst.BroadcastSystemChatToMembers(String.Format("Observer {0} left the lobby", strObserverName), + includeObservers: true, excludeObserverSession: session); + lobbyInst.DirtyRetransmit(); + } + } + public async Task DeleteLobby(Lobby lobby) { try @@ -1733,6 +1941,23 @@ public async Task DeleteLobby(Lobby lobby) bool bRemoved = m_dictLobbies.Remove(lobby.LobbyID, out _); await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(lobby.NetworkRoomID); + // Pending observers get one last lobby-changed ping; their next GET /Lobby/{id} + // refetch 404s and they leave the read-only lobby view cleanly. + if (lobby.PendingObservers.Count > 0) + { + WebSocketMessage_LobbyObserverEvent observerEvent = new WebSocketMessage_LobbyObserverEvent(); + observerEvent.msg_id = (int)EWebSocketMessageID.LOBBY_OBSERVER_LOBBY_CHANGED; + observerEvent.lobby_id = lobby.LobbyID; + byte[] observerBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(observerEvent)); + + foreach (UserSession sess in lobby.PendingObservers.Keys) + { + sess.QueueWebsocketSend(observerBytes); + } + + lobby.PendingObservers.Clear(); + } + // only do this once if (bRemoved) { diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index 72c4cab..1cc8613 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -907,7 +907,7 @@ await SendMatchmakingMessage(memberSession, await using var db = await factory.CreateDbContextAsync(); m_LobbyID = await lobbyManager.CreateLobby(db, dummyHostUser, dummyHostUserData.m_strDisplayName, "Quickmatch Lobby", strMapName, strMapPath + ".map", - true, playlist.DesiredPlayers, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, dummyHostUser.ExeCRC, dummyHostUser.IniCRC, ELobbyType.QuickMatch, + true, playlist.DesiredPlayers, "", 12345, false, true, 10000, false, String.Empty, -5, false, false, Constants.g_DefaultCameraMaxHeight, dummyHostUser.ExeCRC, dummyHostUser.IniCRC, ELobbyType.QuickMatch, dummyHostUser.AnticheatID); // tell both to join our lobby diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index b9b823d..c51bb87 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -144,6 +144,7 @@ public static bool ValidateKey(string strKey) return bMatched; } } + public static class CertHelpers { public static X509Certificate2 LoadPemWithPrivateKey(string certPath, string keyPath) @@ -313,6 +314,18 @@ public static bool IsAdmin(ControllerBase controller) return controller.User.IsInRole("Admin"); } + // Signed at login from users.user_priority - a client cannot forge it. + public static EUserPriority GetUserPriority(ControllerBase controller) + { + var claim = controller.User.FindFirst("priority"); + if (claim != null && Int32.TryParse(claim.Value, out int value)) + { + return (EUserPriority)value; + } + + return EUserPriority.None; + } + public static KnownClients.EKnownClients GetClientID(ControllerBase controller) { var first = controller.User.FindFirst("client_id"); @@ -678,12 +691,12 @@ public enum ETokenType public const string TokenGenerationClaim = "tgen"; - public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETokenType tokenType, KnownClients.EKnownClients knownClientID, EUserSessionType sessionType, bool bIsAdmin) + public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETokenType tokenType, KnownClients.EKnownClients knownClientID, EUserSessionType sessionType, bool bIsAdmin, EUserPriority userPriority = EUserPriority.None) { - return GenerateToken(displayname, userID, ipAddr, tokenType, knownClientID, sessionType, bIsAdmin, out _); + return GenerateToken(displayname, userID, ipAddr, tokenType, knownClientID, sessionType, bIsAdmin, userPriority, out _); } - public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETokenType tokenType, KnownClients.EKnownClients knownClientID, EUserSessionType sessionType, bool bIsAdmin, out string jti) + public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETokenType tokenType, KnownClients.EKnownClients knownClientID, EUserSessionType sessionType, bool bIsAdmin, EUserPriority userPriority, out string jti) { var jwtSettings = _configuration.GetSection("JwtSettings"); @@ -743,6 +756,10 @@ public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETo claims.Add(new Claim(ClaimTypes.Role, "Admin")); } + // Signed like every other claim - a client cannot alter it without breaking + // the HMAC signature. + claims.Add(new Claim("priority", ((int)userPriority).ToString())); + var token = new JwtSecurityToken( issuer: jwtSettings["Issuer"], @@ -862,6 +879,21 @@ public static async Task Main(string[] args) g_Discord = new DiscordBot(); } + // A misconfigured optional integration must not take the server down - warn and + // continue, same as Discord's own startup check. + var relaySettings = Program.g_Config.GetSection("Relay"); + if (relaySettings.GetValue("enabled")) + { + string? relayBaseUrl = relaySettings.GetValue("base_url"); + string? relayApiKey = relaySettings.GetValue("api_key"); + string? relayIngressKey = relaySettings.GetValue("ingress_api_key"); + if (string.IsNullOrEmpty(relayBaseUrl) || string.IsNullOrEmpty(relayApiKey) || string.IsNullOrEmpty(relayIngressKey)) + { + Console.WriteLine($"[WARNING] Relay is enabled in config but base_url, api_key and/or ingress_api_key are missing - " + + $"livestream endpoints will return 503 until the Relay section is completed."); + } + } + builder.Services.AddSingleton(); var rateLimitingSettings = Program.g_Config.GetSection("RateLimiting"); diff --git a/GenOnlineService/RelayClient.cs b/GenOnlineService/RelayClient.cs new file mode 100644 index 0000000..a7b3d6e --- /dev/null +++ b/GenOnlineService/RelayClient.cs @@ -0,0 +1,326 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Polly; + +namespace GenOnlineService +{ + public class RelayLivestreamResponse + { + public string base_url { get; set; } = String.Empty; + } + + public class RelayTokenResponse + { + public string url { get; set; } = String.Empty; + } + + public enum RelayWatchTicketStatus + { + Ok, + // Relay session for that lobby is gone (all sources left / reaped): the stream ended. + StreamEnded, + // Relay unreachable or returned an error. + Failure + } + + public class RelayWatchTicketResult + { + public RelayWatchTicketStatus Status { get; set; } + public RelayTokenResponse? Token { get; set; } + } + + public static class RelayClient + { + // Tight on purpose: every call sits inside a request a player is waiting on. + private static readonly HttpClient s_httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(5) + }; + + // Off unless enabled:true and a base_url are configured, so an unconfigured deployment makes no relay call. + public static bool IsEnabled() + { + if (Program.g_Config == null) + { + return false; + } + + IConfigurationSection? configSection = Program.g_Config.GetSection("Relay"); + if (configSection == null) + { + return false; + } + + // `enabled` is the master switch (mirrors Discord's enable_discord / Sentry's + // enabled). A missing value means off - the feature ships inert. + if (!configSection.GetValue("enabled")) + { + return false; + } + + string? sectionBaseUrl = configSection.GetValue("base_url"); + string? sectionApiKey = configSection.GetValue("api_key"); + string? sectionIngressKey = configSection.GetValue("ingress_api_key"); + + return !string.IsNullOrEmpty(sectionBaseUrl) && + !string.IsNullOrEmpty(sectionApiKey) && + !string.IsNullOrEmpty(sectionIngressKey); + } + + // Relay.ingress_api_key is the relay's credential for calls into GO, distinct from the api_key GO sends out. + public static bool ValidateIngressKey(string? suppliedKey) + { + if (string.IsNullOrEmpty(suppliedKey) || Program.g_Config == null) + { + return false; + } + + IConfigurationSection configSection = Program.g_Config.GetSection("Relay"); + string? expectedKey = configSection.GetValue("ingress_api_key"); + + if (string.IsNullOrEmpty(expectedKey)) + { + return false; + } + + // Fixed-time compare: this endpoint is reachable by anyone, and an ordinary string + // comparison leaks how many leading bytes of the key were right. The relay does the + // same on its side with hmac.compare_digest. + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(suppliedKey), + Encoding.UTF8.GetBytes(expectedKey)); + } + + private static void GetRelayConfig(out string baseUrl, out string apiKey) + { + baseUrl = String.Empty; + apiKey = String.Empty; + + if (Program.g_Config == null) + { + throw new Exception("Config not loaded"); + } + + IConfigurationSection configSection = Program.g_Config.GetSection("Relay"); + + string? sectionBaseUrl = configSection.GetValue("base_url"); + string? sectionApiKey = configSection.GetValue("api_key"); + + if (string.IsNullOrEmpty(sectionBaseUrl)) + { + throw new Exception("Relay base_url missing in config"); + } + + if (string.IsNullOrEmpty(sectionApiKey)) + { + throw new Exception("Relay api_key missing in config"); + } + + baseUrl = sectionBaseUrl.TrimEnd('/'); + apiKey = sectionApiKey; + } + + private static Polly.Retry.AsyncRetryPolicy BuildRetryPolicy(string description) + { + // Two retries at 400ms/800ms - covers a dropped connection or relay restart + // without turning a sick relay into a minute-long hang. + return Policy + .Handle() + .Or() + .Or() + .WaitAndRetryAsync(2, retryAttempt => TimeSpan.FromMilliseconds(200 * Math.Pow(2, retryAttempt)), (exception, timeSpan, retryCount, context) => + { + Console.WriteLine($"[WARNING] Relay {description} call failed (attempt {retryCount}). Retrying in {timeSpan.TotalMilliseconds}ms. Error: {exception.Message}"); + }); + } + + // throwOnError false returns non-success as-is, because a relay 404 (stream ended) is an answer, not a failure. + private static async Task SendAsync(HttpMethod method, string path, string? payloadJson, string description, bool throwOnError) + { + GetRelayConfig(out string baseUrl, out string apiKey); + + string requestUrl = baseUrl + path; + + var retryPolicy = BuildRetryPolicy(description); + + HttpResponseMessage? response = null; + + try + { + await retryPolicy.ExecuteAsync(async () => + { + using (var request = new HttpRequestMessage(method, requestUrl)) + { + request.Headers.TryAddWithoutValidation("X-Relay-Key", apiKey); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (payloadJson != null) + { + request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); + } + + // A retried attempt overwrites this local; dispose the previous + // attempt's response first or it leaks (HttpResponseMessage is + // IDisposable and holds the response stream). + response?.Dispose(); + response = await s_httpClient.SendAsync(request); + + // Explicitly verify response success inside execution block so the retry + // policy also triggers on HTTP error statuses. + if (throwOnError) + { + response.EnsureSuccessStatusCode(); + } + } + }); + } + catch (Exception ex) + { + // The last attempt's response (if any) is a failure Polly gave up retrying - + // still needs disposing. + response?.Dispose(); + response = null; + Console.WriteLine($"[ERROR] Relay {description} call failed: {ex.Message}"); + return null; + } + + return response; + } + + public static async Task CreateLivestreamAsync(long lobbyId, long ownerUserId, int? delaySeconds = null) + { + try + { + string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, owner_user_id = ownerUserId, delay_seconds = delaySeconds }); + + using (var response = await SendAsync(HttpMethod.Post, "/internal/livestreams", payloadJson, "CreateLivestream", true)) + { + if (response == null) + { + return null; + } + + string responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Relay CreateLivestream failed for lobby {lobbyId}: {ex.Message}"); + return null; + } + } + + public static async Task CreateStreamTokenAsync(long lobbyId, long userId) + { + try + { + string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, user_id = userId }); + + using (var response = await SendAsync(HttpMethod.Post, "/internal/stream_tokens", payloadJson, "CreateStreamToken", true)) + { + if (response == null) + { + return null; + } + + string responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Relay CreateStreamToken failed for lobby {lobbyId} user {userId}: {ex.Message}"); + return null; + } + } + + // The relay answers "no such live session" with {"detail": {"code": "stream_ended", ...}}. + // Anything else 404ing on that path is a routing problem, not an ended stream. + private static bool IsStreamEndedBody(string responseBody) + { + if (String.IsNullOrEmpty(responseBody)) + { + return false; + } + + try + { + using (JsonDocument document = JsonDocument.Parse(responseBody)) + { + if (document.RootElement.ValueKind == JsonValueKind.Object && + document.RootElement.TryGetProperty("detail", out JsonElement detail) && + detail.ValueKind == JsonValueKind.Object && + detail.TryGetProperty("code", out JsonElement code) && + code.ValueKind == JsonValueKind.String) + { + return code.GetString() == "stream_ended"; + } + } + } + catch (JsonException) + { + } + + return false; + } + + // A priority ticket lets the relay bypass its byte-level broadcast-delay hold for that watcher. + public static async Task CreateWatchTicketAsync(long lobbyId, long userId, bool priority = false) + { + try + { + string payloadJson = JsonSerializer.Serialize(new { lobby_id = lobbyId, user_id = userId, priority = priority }); + + using (var response = await SendAsync(HttpMethod.Post, "/internal/watch_tickets", payloadJson, "CreateWatchTicket", false)) + { + if (response == null) + { + return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.Failure }; + } + + // Only a 404 carrying the relay's own marker means the stream ended - a bare + // 404 is a routing problem (wrong base_url, a mishandled proxy prefix) and + // must not be reported to the player as the stream having ended. + if ((int)response.StatusCode == 404) + { + string notFoundBody = await response.Content.ReadAsStringAsync(); + if (IsStreamEndedBody(notFoundBody)) + { + return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.StreamEnded }; + } + + Console.WriteLine($"[ERROR] Relay CreateWatchTicket for lobby {lobbyId} got a 404 that did not come from the relay's livestream handler. " + + $"Check Relay.base_url and any reverse-proxy path prefix. Body: {notFoundBody}"); + return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.Failure }; + } + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"[ERROR] Relay CreateWatchTicket for lobby {lobbyId} user {userId} returned status {response.StatusCode}."); + return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.Failure }; + } + + string responseBody = await response.Content.ReadAsStringAsync(); + return new RelayWatchTicketResult + { + Status = RelayWatchTicketStatus.Ok, + Token = JsonSerializer.Deserialize(responseBody) + }; + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Relay CreateWatchTicket failed for lobby {lobbyId} user {userId}: {ex.Message}"); + return new RelayWatchTicketResult { Status = RelayWatchTicketStatus.Failure }; + } + } + } +} diff --git a/GenOnlineService/appsettings.json b/GenOnlineService/appsettings.json index 2243044..f55f4b2 100644 --- a/GenOnlineService/appsettings.json +++ b/GenOnlineService/appsettings.json @@ -85,5 +85,11 @@ "GetUrl": null, "GetToken": null, "PostToken": null + }, + "Relay": { + "enabled": false, + "base_url": "", + "api_key": "", + "ingress_api_key": "" } }