Files
ft/Client/Assets/Scripts/Services/PlayerData/KvAccountLease.cs

286 lines
9.3 KiB
C#

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;
}
}
/// <summary>A context-owned account transport. Close drains before disposing its client.</summary>
internal sealed class KvAccountLease
{
private readonly object gate = new object();
private readonly HttpClient client;
private readonly CookieContainer cookies;
private readonly Func<bool> current;
private readonly Func<bool> admission;
private readonly CancellationTokenSource lifetime = new CancellationTokenSource();
private TaskCompletionSource<bool> drained;
private TaskCompletionSource<bool> 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<CookieContainer, HttpMessageHandler> capability, Func<bool> current, Func<bool> 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<KvAccountResponse> 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<bool> 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<bool> Signal() =>
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
}
}