using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Json; using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using SameBeat.Application.Interfejsy; namespace SameBeat.Infrastructure.LiveKit; /// /// Cienki klient LiveKit Room Service API (Twirp przez zwykle HTTP+JSON) -- jedyny sposob, zeby /// FIZYCZNIE odebrac/przyznac prawo publikacji audio/wideo uczestnikowi, ktory jest juz polaczony /// (jego token z SerwisTokenowLiveKit dalej dziala az do wygasniecia niezaleznie od tego, co zmieni /// sie w bazie). Dla mikrofonu (ETAP 5) i usuwania ze sceny (ETAP 7, patrz OdbierzUprawnieniaScenyAsync /// i SerwisSceny) to jest sedno funkcji "serwer autorytatywnie egzekwuje stan", nie tylko baza. /// /// Endpoint UpdateParticipant wymaga wlasnego tokenu admina (grant "roomAdmin": true, podpisanego /// tym samym ApiKey/ApiSecret co zwykle tokeny uczestnikow) -- generujemy go tutaj osobno, bo to /// zupelnie inny rodzaj uprawnienia niz VideoGrant z SerwisTokenowLiveKit (ten nigdy nie dostaje /// roomAdmin, zeby zwykly token uczestnika/widza nie mogl administrowac pokojem). /// public class SerwisAdministracjiLiveKit : ISerwisAdministracjiLiveKit { private readonly HttpClient _httpClient; private readonly UstawieniaLiveKit _ustawienia; private readonly ILogger _logger; public SerwisAdministracjiLiveKit(HttpClient httpClient, IOptions ustawienia, ILogger logger) { _httpClient = httpClient; _ustawienia = ustawienia.Value; _logger = logger; } public Task UstawUprawnieniaMikrofonuAsync(string identity, string nazwaPokoju, bool mikrofonWlaczony, CancellationToken token = default) { var zrodlaPublikacji = mikrofonWlaczony ? new[] { "camera", "microphone" } : new[] { "camera" }; return WyslijAktualizacjeUprawnienAsync(identity, nazwaPokoju, zrodlaPublikacji, "mikrofonu", token); } public Task OdbierzUprawnieniaScenyAsync(string identity, string nazwaPokoju, CancellationToken token = default) => WyslijAktualizacjeUprawnienAsync(identity, nazwaPokoju, Array.Empty(), "sceny", token); /// /// Wspolna logika wywolania RoomService.UpdateParticipant -- rozni sie miedzy wywolaniami /// wylacznie lista (canPublishSources). Pusta lista = uczestnik /// nie moze publikowac NICZEGO (twarde usuniecie ze sceny, ETAP 7); ["camera"]/["camera","microphone"] /// to zwykle sterowanie mikrofonem (ETAP 5). /// private async Task WyslijAktualizacjeUprawnienAsync(string identity, string nazwaPokoju, string[] zrodlaPublikacji, string opisOperacji, CancellationToken token) { if (string.IsNullOrWhiteSpace(_ustawienia.Url) || string.IsNullOrWhiteSpace(_ustawienia.ApiKey) || string.IsNullOrWhiteSpace(_ustawienia.ApiSecret)) { // Brak konfiguracji LiveKit to normalny stan na wczesnym etapie dev -- logujemy i wychodzimy, // zeby caller (SerwisMikrofonu/SerwisSceny) mogl zawsze bezpiecznie "await" to wywolanie. _logger.LogWarning("Pominieto wymuszenie uprawnien {OpisOperacji} w LiveKit -- serwer LiveKit nie jest jeszcze skonfigurowany.", opisOperacji); return; } try { var cialoZapytania = new Dictionary { ["room"] = nazwaPokoju, ["identity"] = identity, ["permission"] = new Dictionary { ["canSubscribe"] = true, ["canPublish"] = true, ["canPublishData"] = true, ["canPublishSources"] = zrodlaPublikacji } }; var bazowyUrlHttp = ZamienNaAdresHttp(_ustawienia.Url); using var zadanie = new HttpRequestMessage(HttpMethod.Post, $"{bazowyUrlHttp}/twirp/livekit.RoomService/UpdateParticipant") { Content = JsonContent.Create(cialoZapytania) }; zadanie.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", WygenerujTokenAdmina(nazwaPokoju)); using var odpowiedz = await _httpClient.SendAsync(zadanie, token); if (!odpowiedz.IsSuccessStatusCode) { var trescBledu = await odpowiedz.Content.ReadAsStringAsync(token); _logger.LogWarning( "LiveKit UpdateParticipant ({OpisOperacji}) zwrocil {StatusKod} dla uczestnika {Identity} w pokoju {Pokoj} -- " + "stan w bazie i tak jest juz poprawny, kolejny token uczestnika bedzie mial wlasciwe uprawnienia. Tresc: {Tresc}", opisOperacji, (int)odpowiedz.StatusCode, identity, nazwaPokoju, trescBledu); } } catch (Exception wyjatek) { // Best effort -- patrz komentarz w interfejsie ISerwisAdministracjiLiveKit. Serwer LiveKit // moze byc chwilowo niedostepny; nie wywalamy przez to calej operacji biznesowej, bo // autorytatywnym zrodlem prawdy jest baza + kolejne wydawane tokeny. _logger.LogWarning(wyjatek, "Nie udalo sie natychmiast wymusic uprawnien {OpisOperacji} w LiveKit dla uczestnika {Identity} w pokoju {Pokoj}.", opisOperacji, identity, nazwaPokoju); } } /// /// Token administracyjny do Room Service -- osobny od tokenow uczestnikow/widzow (SerwisTokenowLiveKit), /// bo niesie grant "roomAdmin", ktorego zwykly klient nigdy nie powinien dostac. Krotki czas zycia /// (60s) -- generowany na chwile tego jednego wywolania HTTP, nie trafia nigdzie indziej. /// private string WygenerujTokenAdmina(string nazwaPokoju) { var teraz = DateTime.UtcNow; var grantVideo = new Dictionary { ["roomAdmin"] = true, ["room"] = nazwaPokoju }; var payload = new JwtPayload { ["iss"] = _ustawienia.ApiKey, ["sub"] = "samebeat-admin", ["nbf"] = EpochTime.GetIntDate(teraz), ["exp"] = EpochTime.GetIntDate(teraz.AddSeconds(60)), ["jti"] = Guid.NewGuid().ToString(), ["video"] = grantVideo }; var kluczPodpisujacy = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_ustawienia.ApiSecret)); var poswiadczenia = new SigningCredentials(kluczPodpisujacy, SecurityAlgorithms.HmacSha256); var naglowek = new JwtHeader(poswiadczenia); return new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(naglowek, payload)); } /// LiveKit URL jest w formie ws(s):// (do polaczen WebRTC klienta) -- Room Service API to zwykle HTTP(S). private static string ZamienNaAdresHttp(string urlWebSocket) { if (urlWebSocket.StartsWith("wss://", StringComparison.OrdinalIgnoreCase)) { return "https://" + urlWebSocket["wss://".Length..]; } if (urlWebSocket.StartsWith("ws://", StringComparison.OrdinalIgnoreCase)) { return "http://" + urlWebSocket["ws://".Length..]; } return urlWebSocket; } }