Add basic online API support.

This commit is contained in:
Dean Herbert
2016-08-31 19:49:34 +09:00
parent 6a588301ba
commit 8870935a4b
14 changed files with 819 additions and 4 deletions

View File

@ -0,0 +1,277 @@
//Copyright (c) 2007-2016 ppy Pty Ltd <contact@ppy.sh>.
//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using System.Collections.Concurrent;
using System.Net;
using System.Threading;
using osu.Framework.Logging;
using osu.Game.Online.API.Requests;
namespace osu.Game.Online.API
{
internal class APIAccess
{
private OAuth authentication;
internal string Endpoint = @"https://new.ppy.sh";
const string ClientId = @"daNBnfdv7SppRVc61z0XuOI13y6Hroiz";
const string ClientSecret = @"d6fgZuZeQ0eSXkEj5igdqQX6ztdtS6Ow";
ConcurrentQueue<APIRequest> queue = new ConcurrentQueue<APIRequest>();
public string Username;
private SecurePassword password;
public string Password
{
set
{
password = string.IsNullOrEmpty(value) ? null : new SecurePassword(value);
}
}
public string Token
{
get { return authentication.Token?.ToString(); }
set
{
if (string.IsNullOrEmpty(value))
authentication.Token = null;
else
authentication.Token = OAuthToken.Parse(value);
}
}
protected bool HasLogin => Token != null || (!string.IsNullOrEmpty(Username) && password != null);
private Thread thread;
Logger log;
internal APIAccess()
{
authentication = new OAuth(ClientId, ClientSecret, Endpoint);
log = Logger.GetLogger(LoggingTarget.Network);
thread = new Thread(run) { IsBackground = true };
thread.Start();
}
internal string AccessToken => authentication.RequestAccessToken();
/// <summary>
/// Number of consecutive requests which failed due to network issues.
/// </summary>
int failureCount = 0;
private void run()
{
while (true)
{
switch (State)
{
case APIState.Failing:
//todo: replace this with a ping request.
log.Add($@"In a failing state, waiting a bit before we try again...");
Thread.Sleep(5000);
if (queue.Count == 0)
{
log.Add($@"Queueing a ping request");
Queue(new ListChannelsRequest() { Timeout = 5000 });
}
break;
case APIState.Offline:
//work to restore a connection...
if (!HasLogin)
{
//OsuGame.Scheduler.Add(() => { OsuGame.ShowLogin(); });
State = APIState.Offline;
Thread.Sleep(500);
continue;
}
if (State < APIState.Connecting)
State = APIState.Connecting;
if (!authentication.HasValidAccessToken && !authentication.AuthenticateWithLogin(Username, password.Get(Representation.Raw)))
{
//todo: this fails even on network-related issues. we should probably handle those differently.
//NotificationManager.ShowMessage("Login failed!");
log.Add(@"Login failed!");
ClearCredentials();
continue;
}
//we're connected!
State = APIState.Online;
failureCount = 0;
break;
}
//hard bail if we can't get a valid access token.
if (authentication.RequestAccessToken() == null)
{
State = APIState.Offline;
continue;
}
//process the request queue.
APIRequest req;
while (queue.TryPeek(out req))
{
if (handleRequest(req))
{
//we have succeeded, so let's unqueue.
queue.TryDequeue(out req);
}
}
Thread.Sleep(1);
}
}
private void ClearCredentials()
{
Username = null;
password = null;
}
/// <summary>
/// Handle a single API request.
/// </summary>
/// <param name="req">The request.</param>
/// <returns>true if we should remove this request from the queue.</returns>
private bool handleRequest(APIRequest req)
{
try
{
req.Perform(this);
State = APIState.Online;
failureCount = 0;
return true;
}
catch (WebException we)
{
HttpStatusCode statusCode = (we.Response as HttpWebResponse)?.StatusCode ?? HttpStatusCode.RequestTimeout;
switch (statusCode)
{
case HttpStatusCode.Unauthorized:
State = APIState.Offline;
return true;
case HttpStatusCode.RequestTimeout:
failureCount++;
log.Add($@"API failure count is now {failureCount}");
if (failureCount < 3)
//we might try again at an api level.
return false;
State = APIState.Failing;
return true;
}
req.Fail(we);
return true;
}
catch (Exception e)
{
if (e is TimeoutException)
log.Add(@"API level timeout exception was hit");
req.Fail(e);
return true;
}
}
private APIState state;
public APIState State
{
get { return state; }
set
{
APIState oldState = state;
APIState newState = value;
state = value;
switch (state)
{
case APIState.Failing:
case APIState.Offline:
flushQueue();
break;
}
if (oldState != newState)
{
//OsuGame.Scheduler.Add(delegate
{
//NotificationManager.ShowMessage($@"We just went {newState}!", newState == APIState.Online ? Color4.YellowGreen : Color4.OrangeRed, 5000);
log.Add($@"We just went {newState}!");
OnStateChange?.Invoke(oldState, newState);
}
}
}
}
internal void Queue(APIRequest request)
{
queue.Enqueue(request);
}
internal event StateChangeDelegate OnStateChange;
internal delegate void StateChangeDelegate(APIState oldState, APIState newState);
internal enum APIState
{
/// <summary>
/// We cannot login (not enough credentials).
/// </summary>
Offline,
/// <summary>
/// We are having connectivity issues.
/// </summary>
Failing,
/// <summary>
/// We are in the process of (re-)connecting.
/// </summary>
Connecting,
/// <summary>
/// We are online.
/// </summary>
Online
}
private void flushQueue(bool failOldRequests = true)
{
var oldQueue = queue;
//flush the queue.
queue = new ConcurrentQueue<APIRequest>();
if (failOldRequests)
{
APIRequest req;
while (queue.TryDequeue(out req))
req.Fail(new Exception(@"Disconnected from server"));
}
}
internal void Logout()
{
authentication.Clear();
State = APIState.Offline;
}
}
}

View File

@ -0,0 +1,93 @@
//Copyright (c) 2007-2016 ppy Pty Ltd <contact@ppy.sh>.
//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API
{
/// <summary>
/// An API request with a well-defined response type.
/// </summary>
/// <typeparam name="T">Type of the response (used for deserialisation).</typeparam>
internal class APIRequest<T> : APIRequest
{
protected override WebRequest CreateWebRequest() => new JsonWebRequest<T>(Uri);
public APIRequest()
{
base.Success += onSuccess;
}
private void onSuccess()
{
Success?.Invoke((WebRequest as JsonWebRequest<T>).ResponseObject);
}
public new event APISuccessHandler<T> Success;
}
/// <summary>
/// AN API request with no specified response type.
/// </summary>
public class APIRequest
{
/// <summary>
/// The maximum amount of time before this request will fail.
/// </summary>
internal int Timeout = WebRequest.DEFAULT_TIMEOUT;
protected virtual string Target => string.Empty;
protected virtual WebRequest CreateWebRequest() => new WebRequest(Uri);
protected virtual string Uri => $@"{api.Endpoint}/api/v2/{Target}";
private double remainingTime => Math.Max(0, Timeout - (DateTime.Now.TotalMilliseconds() - (startTime ?? 0)));
internal bool ExceededTimeout => remainingTime == 0;
private double? startTime;
internal double StartTime => startTime ?? -1;
private APIAccess api;
protected WebRequest WebRequest;
public event APISuccessHandler Success;
public event APIFailureHandler Failure;
internal void Perform(APIAccess api)
{
if (startTime == null)
startTime = DateTime.Now.TotalMilliseconds();
this.api = api;
if (remainingTime <= 0)
throw new TimeoutException(@"API request timeout hit");
WebRequest = CreateWebRequest();
WebRequest.RetryCount = 0;
WebRequest.Headers[@"Authorization"] = $@"Bearer {api.AccessToken}";
WebRequest.BlockingPerform();
//OsuGame.Scheduler.Add(delegate {
Success?.Invoke();
//});
}
internal void Fail(Exception e)
{
WebRequest?.Abort();
//OsuGame.Scheduler.Add(delegate {
Failure?.Invoke(e);
//});
}
}
public delegate void APIFailureHandler(Exception e);
public delegate void APISuccessHandler();
public delegate void APISuccessHandler<T>(T content);
}

View File

@ -0,0 +1,162 @@
//Copyright (c) 2007-2016 ppy Pty Ltd <contact@ppy.sh>.
//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using System.Diagnostics;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API
{
internal class OAuth
{
private readonly string clientId;
private readonly string clientSecret;
private readonly string endpoint;
public OAuthToken Token;
internal OAuth(string clientId, string clientSecret, string endpoint)
{
Debug.Assert(clientId != null);
Debug.Assert(clientSecret != null);
Debug.Assert(endpoint != null);
this.clientId = clientId;
this.clientSecret = clientSecret;
this.endpoint = endpoint;
}
internal bool AuthenticateWithLogin(string username, string password)
{
var req = new AccessTokenRequestPassword(username, password)
{
Url = $@"{endpoint}/oauth/access_token",
Method = HttpMethod.POST,
ClientId = clientId,
ClientSecret = clientSecret
};
try
{
req.BlockingPerform();
}
catch
{
return false;
}
Token = req.ResponseObject;
return true;
}
internal bool AuthenticateWithRefresh(string refresh)
{
try
{
var req = new AccessTokenRequestRefresh(refresh)
{
Url = $@"{endpoint}/oauth/access_token",
Method = HttpMethod.POST,
ClientId = clientId,
ClientSecret = clientSecret
};
req.BlockingPerform();
Token = req.ResponseObject;
return true;
}
catch (Exception e)
{
//todo: potentially only kill the refresh token on certain exception types.
Token = null;
return false;
}
}
/// <summary>
/// Should be run before any API request to make sure we have a valid key.
/// </summary>
private bool ensureAccessToken()
{
//todo: we need to mutex this to ensure only one authentication request is running at a time.
//If we already have a valid access token, let's use it.
if (accessTokenValid) return true;
//If not, let's try using our refresh token to request a new access token.
if (!string.IsNullOrEmpty(Token?.RefreshToken))
AuthenticateWithRefresh(Token.RefreshToken);
return accessTokenValid;
}
private bool accessTokenValid => Token?.IsValid ?? false;
internal bool HasValidAccessToken => RequestAccessToken() != null;
internal string RequestAccessToken()
{
if (!ensureAccessToken()) return null;
return Token.AccessToken;
}
internal void Clear()
{
Token = null;
}
private class AccessTokenRequestRefresh : AccessTokenRequest
{
internal readonly string RefreshToken;
internal AccessTokenRequestRefresh(string refreshToken)
{
RefreshToken = refreshToken;
GrantType = @"refresh_token";
}
protected override void PrePerform()
{
Parameters[@"refresh_token"] = RefreshToken;
base.PrePerform();
}
}
private class AccessTokenRequestPassword : AccessTokenRequest
{
internal readonly string Username;
internal readonly string Password;
internal AccessTokenRequestPassword(string username, string password)
{
Username = username;
Password = password;
GrantType = @"password";
}
protected override void PrePerform()
{
Parameters[@"username"] = Username;
Parameters[@"password"] = Password;
base.PrePerform();
}
}
private class AccessTokenRequest : JsonWebRequest<OAuthToken>
{
protected string GrantType;
internal string ClientId;
internal string ClientSecret;
protected override void PrePerform()
{
Parameters[@"grant_type"] = GrantType;
Parameters[@"client_id"] = ClientId;
Parameters[@"client_secret"] = ClientSecret;
base.PrePerform();
}
}
}
}

View File

@ -0,0 +1,65 @@
//Copyright (c) 2007-2016 ppy Pty Ltd <contact@ppy.sh>.
//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using System.Globalization;
using Newtonsoft.Json;
namespace osu.Game.Online.API
{
[Serializable]
internal class OAuthToken
{
/// <summary>
/// OAuth 2.0 access token.
/// </summary>
[JsonProperty(@"access_token")]
public string AccessToken;
[JsonProperty(@"expires_in")]
public long ExpiresIn
{
get
{
return AccessTokenExpiry - DateTime.Now.ToUnixTimestamp();
}
set
{
AccessTokenExpiry = DateTime.Now.AddSeconds(value).ToUnixTimestamp();
}
}
public bool IsValid => !string.IsNullOrEmpty(AccessToken) && ExpiresIn > 30;
public long AccessTokenExpiry;
/// <summary>
/// OAuth 2.0 refresh token.
/// </summary>
[JsonProperty(@"refresh_token")]
public string RefreshToken;
public override string ToString() => $@"{AccessToken}/{AccessTokenExpiry.ToString(NumberFormatInfo.InvariantInfo)}/{RefreshToken}";
public static OAuthToken Parse(string value)
{
try
{
string[] parts = value.Split('/');
return new OAuthToken()
{
AccessToken = parts[0],
AccessTokenExpiry = long.Parse(parts[1], NumberFormatInfo.InvariantInfo),
RefreshToken = parts[2]
};
}
catch
{
}
return null;
}
}
}

View File

@ -0,0 +1,37 @@
//Copyright (c) 2007-2016 ppy Pty Ltd <contact@ppy.sh>.
//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System.Collections.Generic;
using osu.Framework.IO.Network;
using osu.Game.Online.Social;
namespace osu.Game.Online.API.Requests
{
internal class GetMessagesRequest : APIRequest<List<Message>>
{
List<Channel> channels;
long? since;
public GetMessagesRequest(List<Channel> channels, long? sinceId)
{
this.channels = channels;
this.since = sinceId;
}
protected override WebRequest CreateWebRequest()
{
string channelString = string.Empty;
foreach (Channel c in channels)
channelString += c.Id + ",";
channelString = channelString.TrimEnd(',');
var req = base.CreateWebRequest();
req.AddParameter(@"channels", channelString);
if (since.HasValue) req.AddParameter(@"since", since.Value.ToString());
return req;
}
protected override string Target => @"chat/messages";
}
}

View File

@ -0,0 +1,13 @@
//Copyright (c) 2007-2016 ppy Pty Ltd <contact@ppy.sh>.
//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System.Collections.Generic;
using osu.Game.Online.Social;
namespace osu.Game.Online.API.Requests
{
internal class ListChannelsRequest : APIRequest<List<Channel>>
{
protected override string Target => @"chat/channels";
}
}

View File

@ -0,0 +1,52 @@
//Copyright (c) 2007-2016 ppy Pty Ltd <contact@ppy.sh>.
//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using System.Security;
namespace osu.Game.Online.API
{
internal class SecurePassword
{
private readonly SecureString storage = new SecureString();
private readonly Representation representation;
//todo: move this to a central constants file.
private const string password_entropy = @"cu24180ncjeiu0ci1nwui";
public SecurePassword(string input, bool encrypted = false)
{
//if (encrypted)
//{
// string rep;
// input = DPAPI.Decrypt(input, password_entropy, out rep);
// Enum.TryParse(rep, out representation);
//}
//else
{
representation = Representation.Raw;
}
foreach (char c in input)
storage.AppendChar(c);
storage.MakeReadOnly();
}
internal string Get(Representation request = Representation.Raw)
{
switch (request)
{
default:
return storage.UnsecureRepresentation();
//case Representation.Encrypted:
// return DPAPI.Encrypt(DPAPI.KeyType.UserKey, storage.UnsecureRepresentation(), password_entropy, representation.ToString());
}
}
}
enum Representation
{
Raw,
Encrypted
}
}

View File

@ -0,0 +1,32 @@
//Copyright (c) 2007-2016 ppy Pty Ltd <contact@ppy.sh>.
//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System.Collections.Generic;
using Newtonsoft.Json;
namespace osu.Game.Online.Chat
{
public class Channel
{
[JsonProperty(@"name")]
public string Name;
[JsonProperty(@"description")]
public string Topic;
[JsonProperty(@"type")]
public string Type;
[JsonProperty(@"channel_id")]
public int Id;
public List<Message> Messages = new List<Message>();
internal bool Joined;
[JsonConstructor]
public Channel()
{
}
}
}

View File

@ -0,0 +1,34 @@
//Copyright (c) 2007-2016 ppy Pty Ltd <contact@ppy.sh>.
//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using Newtonsoft.Json;
namespace osu.Game.Online.Chat
{
public class Message
{
[JsonProperty(@"message_id")]
public long Id;
[JsonProperty(@"user_id")]
public string UserId;
[JsonProperty(@"channel_id")]
public string ChannelId;
[JsonProperty(@"timestamp")]
public DateTime Timestamp;
[JsonProperty(@"content")]
internal string Content;
[JsonProperty(@"sender")]
internal string User;
[JsonConstructor]
public Message()
{
}
}
}