Skip to content

Commit 3fc71fc

Browse files
Add Trovo provider (#645)
* Trovo OAuth provider * Added documentation * Added DisablePackageBaselineValidation attribute * Fix PR issues * Add Trovo to README Add the Trovo provider to the table in the README. Co-authored-by: Martin Costello <[email protected]>
1 parent c152a7d commit 3fc71fc

12 files changed

+474
-0
lines changed

AspNet.Security.OAuth.Providers.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.Ebay"
267267
EndProject
268268
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.ServiceChannel", "src\AspNet.Security.OAuth.ServiceChannel\AspNet.Security.OAuth.ServiceChannel.csproj", "{57633BE6-C7AD-4197-A75A-F38A2312A4D9}"
269269
EndProject
270+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Trovo", "src\AspNet.Security.OAuth.Trovo\AspNet.Security.OAuth.Trovo.csproj", "{DC804C6D-3774-4FA7-8EA6-8C8198995BD6}"
271+
EndProject
270272
Global
271273
GlobalSection(SolutionConfigurationPlatforms) = preSolution
272274
Debug|Any CPU = Debug|Any CPU
@@ -605,6 +607,10 @@ Global
605607
{57633BE6-C7AD-4197-A75A-F38A2312A4D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
606608
{57633BE6-C7AD-4197-A75A-F38A2312A4D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
607609
{57633BE6-C7AD-4197-A75A-F38A2312A4D9}.Release|Any CPU.Build.0 = Release|Any CPU
610+
{DC804C6D-3774-4FA7-8EA6-8C8198995BD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
611+
{DC804C6D-3774-4FA7-8EA6-8C8198995BD6}.Debug|Any CPU.Build.0 = Debug|Any CPU
612+
{DC804C6D-3774-4FA7-8EA6-8C8198995BD6}.Release|Any CPU.ActiveCfg = Release|Any CPU
613+
{DC804C6D-3774-4FA7-8EA6-8C8198995BD6}.Release|Any CPU.Build.0 = Release|Any CPU
608614
EndGlobalSection
609615
GlobalSection(SolutionProperties) = preSolution
610616
HideSolutionNode = FALSE
@@ -699,6 +705,7 @@ Global
699705
{815DA59A-E884-4BAD-B16C-D0B550B40A8D} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
700706
{574A52D9-E7A5-4E11-8F36-5C8AA7033287} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
701707
{57633BE6-C7AD-4197-A75A-F38A2312A4D9} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
708+
{DC804C6D-3774-4FA7-8EA6-8C8198995BD6} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
702709
EndGlobalSection
703710
GlobalSection(ExtensibilityGlobals) = postSolution
704711
SolutionGuid = {C7B54DE2-6407-4802-AD9C-CE54BF414C8C}

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ If a provider you're looking for does not exist, consider making a PR to add one
182182
| Streamlabs | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Streamlabs?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Streamlabs/ "Download AspNet.Security.OAuth.Streamlabs from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Streamlabs?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Streamlabs "Download AspNet.Security.OAuth.Streamlabs from MyGet.org") | [Documentation](https://dev.streamlabs.com/reference#authorize "Streamlabs developer documentation") |
183183
| SuperOffice | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.SuperOffice?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.SuperOffice/ "Download AspNet.Security.OAuth.SuperOffice from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.SuperOffice?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.SuperOffice "Download AspNet.Security.OAuth.SuperOffice from MyGet.org") | [Documentation](https://community.superoffice.com/en/developer/create-apps/concepts/authentication/ "SuperOffice developer documentation") |
184184
| Trakt | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Trakt?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Trakt/ "Download AspNet.Security.OAuth.Trakt from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Trakt?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Trakt "Download AspNet.Security.OAuth.Trakt from MyGet.org") | [Documentation](https://trakt.docs.apiary.io/ "Trakt developer documentation") |
185+
| Trovo | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Trovo?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Trovo/ "Download AspNet.Security.OAuth.Trovo from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Trovo?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Trovo "Download AspNet.Security.OAuth.Trovo from MyGet.org") | [Documentation](https://developer.trovo.live/docs/APIs.html "Trovo developer documentation") |
185186
| Twitch | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Twitch?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Twitch/ "Download AspNet.Security.OAuth.Twitch from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Twitch?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Twitch "Download AspNet.Security.OAuth.Twitch from MyGet.org") | [Documentation](https://dev.twitch.tv/docs/authentication/ "Twitch developer documentation") |
186187
| Untappd | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Untappd?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Untappd/ "Download AspNet.Security.OAuth.Untappd from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Untappd?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Untappd "Download AspNet.Security.OAuth.Untappd from MyGet.org") | [Documentation](https://untappd.com/api/docs#authentication "Untappd developer documentation") |
187188
| Vimeo | [![NuGet](https://buildstats.info/nuget/AspNet.Security.OAuth.Vimeo?includePreReleases=false)](https://www.nuget.org/packages/AspNet.Security.OAuth.Vimeo/ "Download AspNet.Security.OAuth.Vimeo from NuGet.org") | [![MyGet](https://buildstats.info/myget/aspnet-contrib/AspNet.Security.OAuth.Vimeo?includePreReleases=true)](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Vimeo "Download AspNet.Security.OAuth.Vimeo from MyGet.org") | [Documentation](https://developer.vimeo.com/api/authentication "Vimeo developer documentation") |

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ covered by the section above.
6565
| StackExchange | _Optional_ | [Documentation](stackexchange.md "StackExchange provider documentation") |
6666
| SuperOffice | **Required** | [Documentation](superoffice.md "SuperOffice provider documentation") |
6767
| Trakt | _Optional_ | [Documentation](trakt.md "Trakt provider documentation") |
68+
| Trovo | _Optional_ | [Documentation](trovo.md "Trovo provider documentation") |
6869
| Twitch | _Optional_ | [Documentation](twitch.md "Twitch provider documentation") |
6970
| Vkontakte | _Optional_ | [Documentation](vkontakte.md "Vkontakte provider documentation") |
7071
| Weibo | _Optional_ | [Documentation](weibo.md "Weibo provider documentation") |

docs/trovo.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Integrating the Trovo Provider
2+
3+
## Example
4+
5+
```csharp
6+
services.AddAuthentication(options => /* Auth configuration */)
7+
.AddTrovo(options =>
8+
{
9+
options.ClientId = "my-client-id";
10+
options.ClientSecret = "my-client-secret";
11+
});
12+
```
13+
14+
## Required Additional Settings
15+
16+
_None._
17+
18+
## Optional Settings
19+
20+
_None._
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<PackageValidationBaselineVersion>6.0.3</PackageValidationBaselineVersion>
5+
<TargetFrameworks>$(DefaultNetCoreTargetFramework)</TargetFrameworks>
6+
</PropertyGroup>
7+
8+
<PropertyGroup>
9+
<Description>ASP.NET Core security middleware enabling Trovo authentication.</Description>
10+
<Authors>Albert Zakiev;Chino Chang</Authors>
11+
<PackageTags>aspnetcore;authentication;oauth;security;trovo</PackageTags>
12+
</PropertyGroup>
13+
14+
<!-- TODO Remove once published to NuGet.org -->
15+
<PropertyGroup>
16+
<DisablePackageBaselineValidation>true</DisablePackageBaselineValidation>
17+
</PropertyGroup>
18+
19+
<ItemGroup>
20+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
21+
<PackageReference Include="JetBrains.Annotations" PrivateAssets="All" />
22+
</ItemGroup>
23+
24+
</Project>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
4+
* for more information concerning the license and the contributors participating to this project.
5+
*/
6+
7+
namespace AspNet.Security.OAuth.Trovo;
8+
9+
/// <summary>
10+
/// Contains constants specific to the <see cref="TrovoAuthenticationHandler"/>.
11+
/// </summary>
12+
public class TrovoAuthenticationConstants
13+
{
14+
public static class Claims
15+
{
16+
public const string ChannelId = "urn:trovo:channelid";
17+
public const string ProfilePic = "urn:trovo:profilepic";
18+
}
19+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
4+
* for more information concerning the license and the contributors participating to this project.
5+
*/
6+
7+
namespace AspNet.Security.OAuth.Trovo;
8+
9+
/// <summary>
10+
/// Default values used by the Trovo authentication middleware.
11+
/// </summary>
12+
public static class TrovoAuthenticationDefaults
13+
{
14+
/// <summary>
15+
/// Default value for <see cref="AuthenticationScheme.Name"/>.
16+
/// </summary>
17+
public const string AuthenticationScheme = "Trovo";
18+
19+
/// <summary>
20+
/// Default value for <see cref="AuthenticationScheme.DisplayName"/>.
21+
/// </summary>
22+
public static readonly string DisplayName = "Trovo";
23+
24+
/// <summary>
25+
/// Default value for <see cref="AuthenticationSchemeOptions.ClaimsIssuer"/>.
26+
/// </summary>
27+
public static readonly string Issuer = "Trovo";
28+
29+
/// <summary>
30+
/// Default value for <see cref="RemoteAuthenticationOptions.CallbackPath"/>.
31+
/// </summary>
32+
public static readonly string CallbackPath = "/signin-trovo";
33+
34+
/// <summary>
35+
/// Default value for <see cref="OAuthOptions.AuthorizationEndpoint"/>.
36+
/// </summary>
37+
public static readonly string AuthorizationEndpoint = "https://open.trovo.live/page/login.html";
38+
39+
/// <summary>
40+
/// Default value for <see cref="OAuthOptions.TokenEndpoint"/>.
41+
/// </summary>
42+
public static readonly string TokenEndpoint = "https://open-api.trovo.live/openplatform/exchangetoken";
43+
44+
/// <summary>
45+
/// Default value for <see cref="OAuthOptions.UserInformationEndpoint"/>.
46+
/// </summary>
47+
public static readonly string UserInformationEndpoint = "https://open-api.trovo.live/openplatform/getuserinfo";
48+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
4+
* for more information concerning the license and the contributors participating to this project.
5+
*/
6+
7+
using AspNet.Security.OAuth.Trovo;
8+
9+
namespace Microsoft.Extensions.DependencyInjection;
10+
11+
/// <summary>
12+
/// Extension methods to add Trovo authentication capabilities to an HTTP application pipeline.
13+
/// </summary>
14+
public static class TrovoAuthenticationExtensions
15+
{
16+
/// <summary>
17+
/// Adds <see cref="TrovoAuthenticationHandler"/> to the specified
18+
/// <see cref="AuthenticationBuilder"/>, which enables Trovo authentication capabilities.
19+
/// </summary>
20+
/// <param name="builder">The authentication builder.</param>
21+
/// <returns>The <see cref="AuthenticationBuilder"/>.</returns>
22+
public static AuthenticationBuilder AddTrovo([NotNull] this AuthenticationBuilder builder)
23+
{
24+
return builder.AddTrovo(TrovoAuthenticationDefaults.AuthenticationScheme, options => { });
25+
}
26+
27+
/// <summary>
28+
/// Adds <see cref="TrovoAuthenticationHandler"/> to the specified
29+
/// <see cref="AuthenticationBuilder"/>, which enables Trovo authentication capabilities.
30+
/// </summary>
31+
/// <param name="builder">The authentication builder.</param>
32+
/// <param name="configuration">The delegate used to configure the OpenID 2.0 options.</param>
33+
/// <returns>The <see cref="AuthenticationBuilder"/>.</returns>
34+
public static AuthenticationBuilder AddTrovo(
35+
[NotNull] this AuthenticationBuilder builder,
36+
[NotNull] Action<TrovoAuthenticationOptions> configuration)
37+
{
38+
return builder.AddTrovo(TrovoAuthenticationDefaults.AuthenticationScheme, configuration);
39+
}
40+
41+
/// <summary>
42+
/// Adds <see cref="TrovoAuthenticationHandler"/> to the specified
43+
/// <see cref="AuthenticationBuilder"/>, which enables Trovo authentication capabilities.
44+
/// </summary>
45+
/// <param name="builder">The authentication builder.</param>
46+
/// <param name="scheme">The authentication scheme associated with this instance.</param>
47+
/// <param name="configuration">The delegate used to configure the Trovo options.</param>
48+
/// <returns>The <see cref="AuthenticationBuilder"/>.</returns>
49+
public static AuthenticationBuilder AddTrovo(
50+
[NotNull] this AuthenticationBuilder builder,
51+
[NotNull] string scheme,
52+
[NotNull] Action<TrovoAuthenticationOptions> configuration)
53+
{
54+
return builder.AddTrovo(scheme, TrovoAuthenticationDefaults.DisplayName, configuration);
55+
}
56+
57+
/// <summary>
58+
/// Adds <see cref="TrovoAuthenticationHandler"/> to the specified
59+
/// <see cref="AuthenticationBuilder"/>, which enables Trovo authentication capabilities.
60+
/// </summary>
61+
/// <param name="builder">The authentication builder.</param>
62+
/// <param name="scheme">The authentication scheme associated with this instance.</param>
63+
/// <param name="caption">The optional display name associated with this instance.</param>
64+
/// <param name="configuration">The delegate used to configure the Trovo options.</param>
65+
/// <returns>The <see cref="AuthenticationBuilder"/>.</returns>
66+
public static AuthenticationBuilder AddTrovo(
67+
[NotNull] this AuthenticationBuilder builder,
68+
[NotNull] string scheme,
69+
[CanBeNull] string caption,
70+
[NotNull] Action<TrovoAuthenticationOptions> configuration)
71+
{
72+
return builder.AddOAuth<TrovoAuthenticationOptions, TrovoAuthenticationHandler>(scheme, caption, configuration);
73+
}
74+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
4+
* for more information concerning the license and the contributors participating to this project.
5+
*/
6+
7+
using System.Net;
8+
using System.Net.Http.Headers;
9+
using System.Net.Mime;
10+
using System.Security.Claims;
11+
using System.Text;
12+
using System.Text.Encodings.Web;
13+
using System.Text.Json;
14+
using Microsoft.Extensions.Logging;
15+
using Microsoft.Extensions.Options;
16+
17+
namespace AspNet.Security.OAuth.Trovo;
18+
19+
public partial class TrovoAuthenticationHandler : OAuthHandler<TrovoAuthenticationOptions>
20+
{
21+
private const string ClientIdHeaderName = "client-id";
22+
23+
public TrovoAuthenticationHandler(
24+
[NotNull] IOptionsMonitor<TrovoAuthenticationOptions> options,
25+
[NotNull] ILoggerFactory logger,
26+
[NotNull] UrlEncoder encoder,
27+
[NotNull] ISystemClock clock)
28+
: base(options, logger, encoder, clock)
29+
{
30+
}
31+
32+
protected override async Task<AuthenticationTicket> CreateTicketAsync(
33+
[NotNull] ClaimsIdentity identity,
34+
[NotNull] AuthenticationProperties properties,
35+
[NotNull] OAuthTokenResponse tokens)
36+
{
37+
using var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint);
38+
request.Headers.Add(ClientIdHeaderName, Options.ClientId);
39+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
40+
request.Headers.Authorization = new AuthenticationHeaderValue("OAuth", tokens.AccessToken);
41+
42+
using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted);
43+
if (!response.IsSuccessStatusCode)
44+
{
45+
await Log.UserProfileErrorAsync(Logger, response, Context.RequestAborted);
46+
throw new HttpRequestException("An error occurred while retrieving the user profile.");
47+
}
48+
49+
var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted));
50+
51+
var principal = new ClaimsPrincipal(identity);
52+
var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload.RootElement);
53+
context.RunClaimActions();
54+
55+
await Events.CreatingTicket(context);
56+
return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name);
57+
}
58+
59+
protected override async Task<OAuthTokenResponse> ExchangeCodeAsync([NotNull] OAuthCodeExchangeContext context)
60+
{
61+
var tokenRequestParameters = new Dictionary<string, string>
62+
{
63+
["redirect_uri"] = context.RedirectUri,
64+
["code"] = context.Code,
65+
["client_secret"] = Options.ClientSecret,
66+
["grant_type"] = "authorization_code"
67+
};
68+
69+
// PKCE https://tools.ietf.org/html/rfc7636#section-4.5, see BuildChallengeUrl
70+
if (context.Properties.Items.TryGetValue(OAuthConstants.CodeVerifierKey, out var codeVerifier))
71+
{
72+
tokenRequestParameters.Add(OAuthConstants.CodeVerifierKey, codeVerifier!);
73+
context.Properties.Items.Remove(OAuthConstants.CodeVerifierKey);
74+
}
75+
76+
using var request = new HttpRequestMessage(HttpMethod.Post, Options.TokenEndpoint);
77+
78+
request.Headers.Add(ClientIdHeaderName, Options.ClientId);
79+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
80+
request.Content = new StringContent(JsonSerializer.Serialize(tokenRequestParameters), Encoding.UTF8, MediaTypeNames.Application.Json);
81+
82+
using var response = await Backchannel.SendAsync(request, Context.RequestAborted);
83+
84+
if (!response.IsSuccessStatusCode)
85+
{
86+
await Log.ExchangeCodeErrorAsync(Logger, response, Context.RequestAborted);
87+
return OAuthTokenResponse.Failed(new Exception("An error occurred while retrieving an access token."));
88+
}
89+
90+
var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Context.RequestAborted));
91+
92+
return OAuthTokenResponse.Success(payload);
93+
}
94+
95+
private static partial class Log
96+
{
97+
internal static async Task UserProfileErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken)
98+
{
99+
UserProfileError(
100+
logger,
101+
response.StatusCode,
102+
response.Headers.ToString(),
103+
await response.Content.ReadAsStringAsync(cancellationToken));
104+
}
105+
106+
internal static async Task ExchangeCodeErrorAsync(ILogger logger, HttpResponseMessage response, CancellationToken cancellationToken)
107+
{
108+
ExchangeCodeError(
109+
logger,
110+
response.StatusCode,
111+
response.Headers.ToString(),
112+
await response.Content.ReadAsStringAsync(cancellationToken));
113+
}
114+
115+
[LoggerMessage(1, LogLevel.Error, "An error occurred while retrieving the user profile: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")]
116+
private static partial void UserProfileError(
117+
ILogger logger,
118+
HttpStatusCode status,
119+
string headers,
120+
string body);
121+
122+
[LoggerMessage(2, LogLevel.Error, "An error occurred while retrieving an access token: the remote server returned a {Status} response with the following payload: {Headers} {Body}.")]
123+
private static partial void ExchangeCodeError(
124+
ILogger logger,
125+
HttpStatusCode status,
126+
string headers,
127+
string body);
128+
}
129+
}

0 commit comments

Comments
 (0)