diff --git a/MareSynchronos/WebAPI/SignalR/ApiController.cs b/MareSynchronos/WebAPI/SignalR/ApiController.cs index 441079c..18febd3 100644 --- a/MareSynchronos/WebAPI/SignalR/ApiController.cs +++ b/MareSynchronos/WebAPI/SignalR/ApiController.cs @@ -177,7 +177,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IM if (token.IsCancellationRequested) break; - _mareHub = _hubFactory.GetOrCreate(token); + _mareHub = await _hubFactory.GetOrCreate(token); InitializeApiHooks(); await _mareHub.StartAsync(token).ConfigureAwait(false); diff --git a/MareSynchronos/WebAPI/SignalR/HubConnectionConfig.cs b/MareSynchronos/WebAPI/SignalR/HubConnectionConfig.cs new file mode 100644 index 0000000..dc5fabb --- /dev/null +++ b/MareSynchronos/WebAPI/SignalR/HubConnectionConfig.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http.Connections; +using System.Text.Json.Serialization; + +namespace MareSynchronos.WebAPI.SignalR; + +public record HubConnectionConfig +{ + [JsonPropertyName("hub_url")] + public string HubUrl { get; set; } = string.Empty; + + private readonly bool? _skipNegotiation; + + [JsonPropertyName("skip_negotiation")] + public bool SkipNegotiation + { + get => _skipNegotiation ?? true; + init => _skipNegotiation = value; + } + + [JsonPropertyName("transports")] + public string[]? Transports { get; set; } + + [JsonIgnore] + public HttpTransportType TransportType + { + get + { + if (Transports == null || Transports.Length == 0) + return HttpTransportType.WebSockets; + + HttpTransportType result = HttpTransportType.None; + + foreach (var transport in Transports) + { + result |= transport.ToLowerInvariant() switch + { + "websockets" => HttpTransportType.WebSockets, + "serversentevents" => HttpTransportType.ServerSentEvents, + "longpolling" => HttpTransportType.LongPolling, + _ => HttpTransportType.None + }; + }; + + return result; + } + } +} diff --git a/MareSynchronos/WebAPI/SignalR/HubFactory.cs b/MareSynchronos/WebAPI/SignalR/HubFactory.cs index f5d623f..773cfd5 100644 --- a/MareSynchronos/WebAPI/SignalR/HubFactory.cs +++ b/MareSynchronos/WebAPI/SignalR/HubFactory.cs @@ -8,6 +8,9 @@ using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text.Json; namespace MareSynchronos.WebAPI.SignalR; @@ -17,6 +20,8 @@ public class HubFactory : MediatorSubscriberBase private readonly ServerConfigurationManager _serverConfigurationManager; private readonly TokenProvider _tokenProvider; private HubConnection? _instance; + private string _cachedConfigFor = string.Empty; + private HubConnectionConfig? _cachedConfig; private bool _isDisposed = false; public HubFactory(ILogger logger, MareMediator mediator, @@ -48,23 +53,110 @@ public class HubFactory : MediatorSubscriberBase Logger.LogDebug("Current HubConnection disposed"); } - public HubConnection GetOrCreate(CancellationToken ct) + public async Task GetOrCreate(CancellationToken ct) { if (!_isDisposed && _instance != null) return _instance; - return BuildHubConnection(ct); + _cachedConfig = await ResolveHubConfig(); + _cachedConfigFor = _serverConfigurationManager.CurrentApiUrl; + + return BuildHubConnection(_cachedConfig, ct); } - private HubConnection BuildHubConnection(CancellationToken ct) + public async Task ResolveHubConfig() + { + var uri = new Uri(_serverConfigurationManager.CurrentApiUrl); + + var httpScheme = uri.Scheme.ToLowerInvariant() switch + { + "ws" => "http", + "wss" => "https", + _ => uri.Scheme + }; + + var wellKnownUrl = $"{httpScheme}://{uri.Host}/.well-known/loporrit/client"; + + using var httpClient = new HttpClient( + new HttpClientHandler + { + AllowAutoRedirect = true, + MaxAutomaticRedirections = 5 + } + ); + + var ver = Assembly.GetExecutingAssembly().GetName().Version; + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("MareSynchronos", ver!.Major + "." + ver!.Minor + "." + ver!.Build)); + + HubConnectionConfig defaultConfig; + + if (_cachedConfig != null && _serverConfigurationManager.CurrentApiUrl == _cachedConfigFor) + { + defaultConfig = _cachedConfig; + } + else + { + defaultConfig = new HubConnectionConfig + { + HubUrl = uri.AbsoluteUri.TrimEnd('/') + IMareHub.Path, + Transports = [] + }; + } + + try + { + // Make a GET request to the loporrit endpoint + var response = await httpClient.GetAsync(wellKnownUrl).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + return defaultConfig; + + var contentType = response.Content.Headers.ContentType?.MediaType; + + if (contentType == null || contentType != "application/json") + return defaultConfig; + + var jsonResponse = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var config = JsonSerializer.Deserialize( + jsonResponse, + new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + AllowTrailingCommas = true + }); + + if (config == null) + return defaultConfig; + + if (string.IsNullOrEmpty(config.HubUrl)) + config.HubUrl ??= defaultConfig.HubUrl; + + config.Transports ??= []; + + return config; + } + catch (HttpRequestException ex) + { + Logger.LogWarning(ex, "HTTP request failed for .well-known"); + return defaultConfig; + } + catch (JsonException ex) + { + Logger.LogWarning(ex, "Invalid JSON in .well-known response"); + return defaultConfig; + } + } + + private HubConnection BuildHubConnection(HubConnectionConfig hubConfig, CancellationToken ct) { Logger.LogDebug("Building new HubConnection"); _instance = new HubConnectionBuilder() - .WithUrl(_serverConfigurationManager.CurrentApiUrl + IMareHub.Path, options => + .WithUrl(hubConfig.HubUrl, options => { + var transports = hubConfig.TransportType; options.AccessTokenProvider = () => _tokenProvider.GetOrUpdateToken(ct); - options.SkipNegotiation = true; - options.Transports = HttpTransportType.WebSockets; + options.SkipNegotiation = hubConfig.SkipNegotiation && (transports == HttpTransportType.WebSockets); + options.Transports = transports; }) .AddMessagePackProtocol(opt => {