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;
}
}