286 lines
9.3 KiB
C#
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);
|
|
}
|
|
}
|