1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-13 04:32:57 +08:00

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

@ -1 +1 @@
Subproject commit 3bbfe0137546497e767f7863cda66efc42b1686c
Subproject commit 79572e2da7d5f0467f6fd6ad277cfbade2e21b79

View File

@ -2,6 +2,7 @@
//Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using osu.Framework.Configuration;
using osu.Game.Online.API;
namespace osu.Game.Configuration
{
@ -12,6 +13,10 @@ namespace osu.Game.Configuration
Set(OsuConfig.Width, 1366);
Set(OsuConfig.Height, 768);
Set(OsuConfig.MouseSensitivity, 1.0);
Set(OsuConfig.Username, string.Empty);
Set(OsuConfig.Password, string.Empty);
Set(OsuConfig.Token, string.Empty);
}
}
@ -20,5 +25,8 @@ namespace osu.Game.Configuration
Width,
Height,
MouseSensitivity,
Username,
Password,
Token
}
}

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()
{
}
}
}

View File

@ -1,12 +1,16 @@
//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 System.Drawing;
using osu.Framework.Framework;
using osu.Game.Configuration;
using osu.Game.GameModes.Menu;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.Processing;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Social;
namespace osu.Game
{
@ -16,6 +20,8 @@ namespace osu.Game
protected override string MainResourceFile => @"osu.Game.Resources.dll";
internal APIAccess API;
public override void Load()
{
base.Load();
@ -23,6 +29,19 @@ namespace osu.Game
Window.Size = new Size(Config.Get<int>(OsuConfig.Width), Config.Get<int>(OsuConfig.Height));
Window.OnSizeChanged += window_OnSizeChanged;
API = new APIAccess()
{
Username = Config.Get<string>(OsuConfig.Username),
Password = Config.Get<string>(OsuConfig.Password),
Token = Config.Get<string>(OsuConfig.Token)
};
//var req = new ListChannelsRequest();
//req.Success += content =>
//{
//};
//API.Queue(req);
AddProcessingContainer(new RatioAdjust());
//Add(new FontTest());
@ -31,10 +50,18 @@ namespace osu.Game
Add(new CursorContainer());
}
protected override void Dispose(bool isDisposing)
{
//refresh token may have changed.
Config.Set(OsuConfig.Token, API.Token);
base.Dispose(isDisposing);
}
private void window_OnSizeChanged()
{
Config.Set<int>(OsuConfig.Width, Window.Size.Width);
Config.Set<int>(OsuConfig.Height, Window.Size.Height);
Config.Set(OsuConfig.Width, Window.Size.Width);
Config.Set(OsuConfig.Height, Window.Size.Height);
}
}
}

View File

@ -12,6 +12,8 @@
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<TargetFrameworkProfile />
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
@ -31,6 +33,10 @@
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="Newtonsoft.Json, Version=9.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.9.0.1\lib\net45\Newtonsoft.Json.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="OpenTK, Version=1.1.0.0, Culture=neutral, PublicKeyToken=bad199fe84eb3df4, processorArchitecture=MSIL">
<HintPath>..\packages\ppy.OpenTK.1.1.2225.2\lib\net20\OpenTK.dll</HintPath>
<Private>True</Private>
@ -51,6 +57,15 @@
<Compile Include="Graphics\Cursor\CursorContainer.cs" />
<Compile Include="Graphics\Processing\RatioAdjust.cs" />
<Compile Include="Graphics\TextAwesome.cs" />
<Compile Include="Online\API\APIAccess.cs" />
<Compile Include="Online\API\APIRequest.cs" />
<Compile Include="Online\API\OAuth.cs" />
<Compile Include="Online\API\OAuthToken.cs" />
<Compile Include="Online\API\Requests\GetMessagesRequest.cs" />
<Compile Include="Online\API\SecurePassword.cs" />
<Compile Include="Online\API\Requests\ListChannels.cs" />
<Compile Include="Online\Social\Channel.cs" />
<Compile Include="Online\Social\Message.cs" />
<Compile Include="OsuGame.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>

View File

@ -3,7 +3,7 @@
Copyright (c) 2007-2016 ppy Pty Ltd <contact@ppy.sh>.
Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
-->
<packages>
<package id="Newtonsoft.Json" version="9.0.1" targetFramework="net45" />
<package id="ppy.OpenTK" version="1.1.2225.2" targetFramework="net452" />
</packages>