using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mime; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Web; using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Client.Extensions; using Tgstation.Server.Common.Extensions; using Tgstation.Server.Common.Http; namespace Tgstation.Server.Client { /// class ApiClient : IApiClient { /// /// PATCH . /// /// HOW IS THIS NOT INCLUDED IN THE FRAMEWORK??!?!? static readonly HttpMethod HttpPatch = new("PATCH"); /// public Uri Url { get; } /// public ApiHeaders Headers { get => headers; set => headers = value ?? throw new InvalidOperationException("Cannot set null headers!"); } /// public TimeSpan Timeout { get => httpClient.Timeout; set => httpClient.Timeout = value; } /// /// The to use. /// static readonly JsonSerializerSettings SerializerSettings = new() { ContractResolver = new CamelCasePropertyNamesContractResolver(), Converters = new[] { new VersionConverter(), }, }; /// /// The for the . /// readonly HttpClient httpClient; /// /// The s used by the . /// readonly List requestLoggers; /// /// List of s created by the . /// readonly List hubConnections; /// /// Backing field for . /// readonly ApiHeaders? tokenRefreshHeaders; /// /// The for refreshes. /// readonly SemaphoreSlim semaphoreSlim; /// /// If the authentication header should be stripped from requests. /// readonly bool authless; /// /// Backing field for . /// ApiHeaders headers; /// /// If the is disposed. /// bool disposed; /// /// Handle a bad HTTP . /// /// The . /// The JSON if any. static void HandleBadResponse(HttpResponseMessage response, string json) { ErrorMessageResponse? errorMessage = null; try { // check if json serializes to an error message errorMessage = JsonConvert.DeserializeObject(json, SerializerSettings); } catch (JsonException) { } #pragma warning disable IDE0010 // Add missing cases switch (response.StatusCode) #pragma warning restore IDE0010 // Add missing cases { case HttpStatusCode.Unauthorized: throw new UnauthorizedException(errorMessage, response); case HttpStatusCode.InternalServerError: throw new ServerErrorException(errorMessage, response); case HttpStatusCode.NotImplemented: // unprocessable entity case (HttpStatusCode)422: throw new MethodNotSupportedException(errorMessage, response); case HttpStatusCode.NotFound: case HttpStatusCode.Gone: case HttpStatusCode.Conflict: throw new ConflictException(errorMessage, response); case HttpStatusCode.Forbidden: throw new InsufficientPermissionsException(response); case HttpStatusCode.ServiceUnavailable: throw new ServiceUnavailableException(response); case HttpStatusCode.RequestTimeout: throw new RequestTimeoutException(response); case (HttpStatusCode)429: throw new RateLimitException(errorMessage, response); default: if (errorMessage?.ErrorCode == ErrorCode.ApiMismatch) throw new VersionMismatchException(errorMessage, response); throw new ApiConflictException(errorMessage, response); } } /// /// Initializes a new instance of the class. /// /// The value of . /// The value of . /// The value of . /// The value of . /// The value of . public ApiClient( HttpClient httpClient, Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders, bool authless) { this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); Url = url ?? throw new ArgumentNullException(nameof(url)); headers = apiHeaders ?? throw new ArgumentNullException(nameof(apiHeaders)); this.tokenRefreshHeaders = tokenRefreshHeaders; this.authless = authless; requestLoggers = new List(); hubConnections = new List(); semaphoreSlim = new SemaphoreSlim(1); } /// public async ValueTask DisposeAsync() { List localHubConnections; lock (hubConnections) { if (disposed) return; disposed = true; localHubConnections = [.. hubConnections]; hubConnections.Clear(); } await ValueTaskExtensions.WhenAll(hubConnections.Select(connection => connection.DisposeAsync())); httpClient.Dispose(); semaphoreSlim.Dispose(); } /// public ValueTask Create(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, null, false, cancellationToken); /// public ValueTask Read(string route, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Get, null, false, cancellationToken); /// public ValueTask Update(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Post, null, false, cancellationToken); /// public ValueTask Update(string route, TBody body, CancellationToken cancellationToken) where TBody : class => RunRequest(route, body, HttpMethod.Post, null, false, cancellationToken); /// public ValueTask Patch(string route, CancellationToken cancellationToken) => RunRequest(route, HttpPatch, null, false, cancellationToken); /// public ValueTask Update(string route, TBody body, CancellationToken cancellationToken) where TBody : class => RunResultlessRequest(route, body, HttpMethod.Post, null, false, cancellationToken); /// public ValueTask Create(string route, TBody body, CancellationToken cancellationToken) where TBody : class => RunRequest(route, body, HttpMethod.Put, null, false, cancellationToken); /// public ValueTask Delete(string route, CancellationToken cancellationToken) => RunRequest(route, HttpMethod.Delete, null, false, cancellationToken); /// public ValueTask Create(string route, TBody body, long instanceId, CancellationToken cancellationToken) where TBody : class => RunRequest(route, body, HttpMethod.Put, instanceId, false, cancellationToken); /// public ValueTask Read(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Get, instanceId, false, cancellationToken); /// public ValueTask Update(string route, TBody body, long instanceId, CancellationToken cancellationToken) where TBody : class => RunRequest(route, body, HttpMethod.Post, instanceId, false, cancellationToken); /// public ValueTask Delete(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, HttpMethod.Delete, instanceId, false, cancellationToken); /// public ValueTask Delete(string route, TBody body, long instanceId, CancellationToken cancellationToken) where TBody : class => RunResultlessRequest(route, body, HttpMethod.Delete, instanceId, false, cancellationToken); /// public ValueTask Delete(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, instanceId, false, cancellationToken); /// public ValueTask Delete(string route, TBody body, long instanceId, CancellationToken cancellationToken) where TBody : class => RunRequest(route, body, HttpMethod.Delete, instanceId, false, cancellationToken); /// public ValueTask Create(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, instanceId, false, cancellationToken); /// public ValueTask Patch(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpPatch, instanceId, false, cancellationToken); /// public void AddRequestLogger(IRequestLogger requestLogger) => requestLoggers.Add(requestLogger ?? throw new ArgumentNullException(nameof(requestLogger))); /// public ValueTask Download(FileTicketResponse ticket, CancellationToken cancellationToken) { if (ticket == null) throw new ArgumentNullException(nameof(ticket)); return RunRequest( $"{Routes.Transfer}?ticket={HttpUtility.UrlEncode(ticket.FileTicket)}", null, HttpMethod.Get, null, false, cancellationToken); } /// public async ValueTask Upload(FileTicketResponse ticket, Stream? uploadStream, CancellationToken cancellationToken) { if (ticket == null) throw new ArgumentNullException(nameof(ticket)); MemoryStream? memoryStream = null; if (uploadStream == null) memoryStream = new MemoryStream(); using (memoryStream) { var streamContent = new StreamContent(uploadStream ?? memoryStream); try { await RunRequest( $"{Routes.Transfer}?ticket={HttpUtility.UrlEncode(ticket.FileTicket)}", streamContent, HttpMethod.Put, null, false, cancellationToken) .ConfigureAwait(false); streamContent = null; } finally { streamContent?.Dispose(); } } } /// /// Attempt to refresh the stored Bearer token in . /// /// The for the operation. /// A resulting in if the refresh was successful, if a refresh is unable to be performed. public async ValueTask RefreshToken(CancellationToken cancellationToken) { if (tokenRefreshHeaders == null) return false; var startingToken = headers.Token; await semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (startingToken != headers.Token) return true; var token = await RunRequest(Routes.ApiRoot, new object(), HttpMethod.Post, null, true, cancellationToken).ConfigureAwait(false); headers = new ApiHeaders(headers.UserAgent!, token); } finally { semaphoreSlim.Release(); } return true; } /// public async ValueTask CreateHubConnection( THubImplementation hubImplementation, IRetryPolicy? retryPolicy, Action? loggingConfigureAction, CancellationToken cancellationToken) where THubImplementation : class { if (hubImplementation == null) throw new ArgumentNullException(nameof(hubImplementation)); retryPolicy ??= new InfiniteThirtySecondMaxRetryPolicy(); var wrappedPolicy = new ApiClientTokenRefreshRetryPolicy(this, retryPolicy); HubConnection? hubConnection = null; var hubConnectionBuilder = new HubConnectionBuilder() .AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings = SerializerSettings; }) .WithAutomaticReconnect(wrappedPolicy) .WithUrl( new Uri(Url, Routes.JobsHub), HttpTransportType.ServerSentEvents, options => { options.AccessTokenProvider = async () => { // DCT: None available. if (Headers.Token == null || (Headers.Token.ParseJwt().ValidTo <= DateTime.UtcNow && !await RefreshToken(CancellationToken.None))) { _ = hubConnection!.StopAsync(); // DCT: None available. return null; } return Headers.Token.Bearer; }; options.CloseTimeout = Timeout; Headers.SetHubConnectionHeaders(options.Headers); }); if (loggingConfigureAction != null) hubConnectionBuilder.ConfigureLogging(loggingConfigureAction); async ValueTask AttemptConnect() { hubConnection = hubConnectionBuilder.Build(); try { hubConnection.Closed += async (error) => { if (error is HttpRequestException httpRequestException) { // .StatusCode isn't in netstandard but fuck the police var property = error.GetType().GetProperty("StatusCode"); if (property != null) { var statusCode = (HttpStatusCode?)property.GetValue(error); if (statusCode == HttpStatusCode.Unauthorized && !await RefreshToken(CancellationToken.None)) _ = hubConnection!.StopAsync(); } } }; hubConnection.ProxyOn(hubImplementation); Task startTask; lock (hubConnections) { if (disposed) throw new ObjectDisposedException(nameof(ApiClient)); hubConnections.Add(hubConnection); startTask = hubConnection.StartAsync(cancellationToken); } await startTask; return hubConnection; } catch { bool needsDispose; lock (hubConnections) needsDispose = hubConnections.Remove(hubConnection); if (needsDispose) await hubConnection.DisposeAsync(); throw; } } return await WrapHubInitialConnectAuthRefresh(AttemptConnect, cancellationToken); } /// /// Main request method. /// /// The resulting POCO type. /// The route to run. /// The of the request if any. /// The method of the request. /// The optional instance for the request. /// If this is a token refresh operation. /// The for the operation. /// A resulting in the response on success. #pragma warning disable CA1506 // TODO: Decomplexify protected virtual async ValueTask RunRequest( string route, HttpContent? content, HttpMethod method, long? instanceId, bool tokenRefresh, CancellationToken cancellationToken) { if (route == null) throw new ArgumentNullException(nameof(route)); if (method == null) throw new ArgumentNullException(nameof(method)); if (content == null && (method == HttpMethod.Post || method == HttpMethod.Put)) throw new InvalidOperationException("content cannot be null for POST or PUT!"); if (disposed) throw new ObjectDisposedException(nameof(ApiClient)); HttpResponseMessage response; var fullUri = new Uri(Url, route); var serializerSettings = SerializerSettings; var fileDownload = typeof(TResult) == typeof(Stream); using (var request = new HttpRequestMessage(method, fullUri)) { if (content != null) request.Content = content; try { var headersToUse = tokenRefresh ? tokenRefreshHeaders! : headers; headersToUse.SetRequestHeaders(request.Headers, instanceId); if (authless) request.Headers.Remove(HeaderNames.Authorization); else { var bearer = headersToUse.Token?.Bearer; if (bearer != null) { var parsed = headersToUse.Token!.ParseJwt(); var nbf = parsed.ValidFrom; var now = DateTime.UtcNow; if (nbf >= now) { var delay = (nbf - now).Add(TimeSpan.FromMilliseconds(1)); await Task.Delay(delay, cancellationToken); } } } if (fileDownload) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Octet)); await ValueTaskExtensions.WhenAll(requestLoggers.Select(x => x.LogRequest(request, cancellationToken))).ConfigureAwait(false); response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); } finally { // prevent content param from getting disposed request.Content = null; } } try { await ValueTaskExtensions.WhenAll(requestLoggers.Select(x => x.LogResponse(response, cancellationToken))).ConfigureAwait(false); // just stream if (fileDownload && response.IsSuccessStatusCode) return (TResult)(object)await CachedResponseStream.Create(response).ConfigureAwait(false); } catch { response.Dispose(); throw; } using (response) { var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (!response.IsSuccessStatusCode) { if (!tokenRefresh && response.StatusCode == HttpStatusCode.Unauthorized && await RefreshToken(cancellationToken).ConfigureAwait(false)) return await RunRequest(route, content, method, instanceId, false, cancellationToken).ConfigureAwait(false); HandleBadResponse(response, json); } if (String.IsNullOrWhiteSpace(json)) json = JsonConvert.SerializeObject(new object()); try { var result = JsonConvert.DeserializeObject(json, serializerSettings); return result!; } catch (JsonException) { throw new UnrecognizedResponseException(response); } } } #pragma warning restore CA1506 /// /// Wrap a hub connection attempt via a with proper token refreshing. /// /// The . /// The for the operation. /// A resulting in the connected . async ValueTask WrapHubInitialConnectAuthRefresh(Func> connectFunc, CancellationToken cancellationToken) { try { return await connectFunc(); } catch (HttpRequestException ex) { // status code is not in netstandard var propertyInfo = ex.GetType().GetProperty("StatusCode"); if (propertyInfo != null) { var statusCode = (HttpStatusCode)propertyInfo.GetValue(ex); if (statusCode != HttpStatusCode.Unauthorized) throw; } await RefreshToken(cancellationToken); return await connectFunc(); } } /// /// Main request method. /// /// The body . /// The resulting POCO type. /// The route to run. /// The body of the request. /// The method of the request. /// The optional instance for the request. /// If this is a token refresh operation. /// The for the operation. /// A resulting in the response on success. async ValueTask RunRequest( string route, TBody? body, HttpMethod method, long? instanceId, bool tokenRefresh, CancellationToken cancellationToken) where TBody : class { HttpContent? content = null; if (body != null) content = new StringContent( JsonConvert.SerializeObject(body, typeof(TBody), Formatting.None, SerializerSettings), Encoding.UTF8, ApiHeaders.ApplicationJsonMime); using (content) return await RunRequest( route, content, method, instanceId, tokenRefresh, cancellationToken) .ConfigureAwait(false); } /// /// Main request method. /// /// The body . /// The route to run. /// The body of the request. /// The method of the request. /// The optional instance for the request. /// If this is a token refresh operation. /// The for the operation. /// A resulting in the response on success. async ValueTask RunResultlessRequest( string route, TBody? body, HttpMethod method, long? instanceId, bool tokenRefresh, CancellationToken cancellationToken) where TBody : class => await RunRequest( route, body, method, instanceId, tokenRefresh, cancellationToken); /// /// Main request method. /// /// The route to run. /// The method of the request. /// The optional instance for the request. /// If this is a token refresh operation. /// The for the operation. /// A resulting in the response on success. ValueTask RunRequest( string route, HttpMethod method, long? instanceId, bool tokenRefresh, CancellationToken cancellationToken) => RunResultlessRequest( route, null, method, instanceId, tokenRefresh, cancellationToken); } }