using System; using System.Diagnostics; using System.Globalization; using System.IO; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using game.Account; namespace game.KV { public enum KVLoginPrepareError { None, Validation, Cancelled, Stale, AuthenticationFailed, OwnerRejected, NotReady, Unexpected, CloseFailed } internal sealed class KvAccountDescriptor { internal string PlayerId { get; } internal long Epoch { get; } internal uint InternalId { get; } internal bool NewlyCreated { get; } internal DateTime CreateTimeUtc { get; } internal DateTime ServerTimeUtc { get; } internal KvAccountDescriptor(string playerId, long epoch, uint internalId, bool newlyCreated, DateTime createTimeUtc, DateTime serverTimeUtc) { PlayerId = playerId; Epoch = epoch; InternalId = internalId; NewlyCreated = newlyCreated; CreateTimeUtc = createTimeUtc; ServerTimeUtc = serverTimeUtc; } } internal sealed class KvAccountResponse { internal bool IsCurrent { get; } internal int? StatusCode { get; } internal string Body { get; } internal KvAccountResponse(bool isCurrent, int? statusCode = null, string body = null) { IsCurrent = isCurrent; StatusCode = isCurrent ? statusCode : null; Body = isCurrent ? body : null; } } /// A context-owned account transport. Close drains before disposing its client. internal sealed class KvAccountLease { private readonly object gate = new object(); private readonly HttpClient client; private readonly CookieContainer cookies; private readonly Func current; private readonly Func admission; private readonly CancellationTokenSource lifetime = new CancellationTokenSource(); private TaskCompletionSource drained; private TaskCompletionSource closing; private int active; internal Uri ApiBaseUri { get; } internal long Epoch { get; } internal CookieContainer FormalCookies => IsCurrent ? cookies : null; internal bool IsCurrent { get { lock (gate) if (closing != null) { return false; } return current(); } } internal KvAccountLease(Uri api, long epoch, CookieContainer cookies, Func capability, Func current, Func admission) { if (capability == null) { throw new ArgumentNullException(nameof(capability)); } ApiBaseUri = api; Epoch = epoch; this.cookies = cookies; this.current = current; this.admission = admission; client = new HttpClient(capability(cookies)) { BaseAddress = api }; client.Timeout = TimeSpan.FromSeconds(15); } internal async Task SendAsync(HttpMethod method, string route, string body = null, string contentType = "application/json", CancellationToken token = default) { if (!IsCanonicalBusinessRoute(route)) { throw new ArgumentException("Only canonical relative business routes are allowed.", nameof(route)); } if (method != HttpMethod.Get && method != HttpMethod.Post) { throw new ArgumentException("Unsupported lease method.", nameof(method)); } if (!IsCurrent) { return new KvAccountResponse(false); } lock (gate) { if (closing != null || !admission()) { return new KvAccountResponse(false); } active++; } try { using (var linked = CancellationTokenSource.CreateLinkedTokenSource(token, lifetime.Token)) using (var request = new HttpRequestMessage(method, route)) { if (body != null) { request.Content = new StringContent(body, System.Text.Encoding.UTF8, contentType); } using (var response = await client.SendAsync(request, linked.Token).ConfigureAwait(false)) { if (!IsCurrent || linked.IsCancellationRequested) { return new KvAccountResponse(false); } var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false); // Do not follow redirects from the authenticated business endpoint. if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400) { throw new InvalidOperationException("Redirects are not accepted."); } return IsCurrent && !linked.IsCancellationRequested ? new KvAccountResponse(true, (int)response.StatusCode, text) : new KvAccountResponse(false); } } } catch (OperationCanceledException) when (!IsCurrent || token.IsCancellationRequested || lifetime.IsCancellationRequested) { return new KvAccountResponse(false); } finally { lock (gate) { active--; if (active == 0) { drained?.TrySetResult(true); } } } } private static bool IsCanonicalBusinessRoute(string route) { if (string.IsNullOrEmpty(route) || route[0] == '/' || route[0] == '\\' || Uri.TryCreate(route, UriKind.Absolute, out _) || route.IndexOf("//", StringComparison.Ordinal) >= 0 || route.IndexOfAny(new[] { '?', '#', '%', '\\' }) >= 0) { return false; } var segments = route.Split('/'); foreach (var segment in segments) { if (segment.Length == 0 || segment == "." || segment == "..") { return false; } foreach (var character in segment) if (char.IsControl(character)) { return false; } } return !route.Equals("account/Login", StringComparison.OrdinalIgnoreCase) && !route.Equals("playerdata", StringComparison.OrdinalIgnoreCase) && !route.StartsWith("playerdata/", StringComparison.OrdinalIgnoreCase); } internal Task CloseAsync() { TaskCompletionSource completion; lock (gate) { if (closing != null) { return closing.Task; } completion = closing = Signal(); drained = Signal(); if (active == 0) { drained.TrySetResult(true); } } _ = completion.Task.ContinueWith(t => { _ = t.Exception; }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); _ = Task.Run(async () => { try { Exception cancellationFailure = null; try { lifetime.Cancel(); } catch (Exception ex) { cancellationFailure = ex; } await drained.Task.ConfigureAwait(false); client.Dispose(); lifetime.Dispose(); if (cancellationFailure != null) { throw cancellationFailure; } completion.TrySetResult(true); } catch (Exception ex) { completion.TrySetException(ex); } }); return completion.Task; } private static TaskCompletionSource Signal() => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } }