-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
50 changed files
with
1,343 additions
and
57 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
using System.Collections.Concurrent; | ||
|
||
namespace BTCPayApp.Core; | ||
|
||
///from https://stackoverflow.com/a/31194647/275504 | ||
public sealed class AsyncDuplicateLock | ||
{ | ||
private sealed class RefCounted<T> | ||
{ | ||
public RefCounted(T value) | ||
{ | ||
RefCount = 1; | ||
Value = value; | ||
} | ||
|
||
public int RefCount { get; set; } | ||
public T Value { get; private set; } | ||
} | ||
|
||
private readonly ConcurrentDictionary<object, RefCounted<SemaphoreSlim>?> _semaphoreSlims = new(); | ||
|
||
private SemaphoreSlim GetOrCreate(object key) | ||
{ | ||
RefCounted<SemaphoreSlim>? item; | ||
lock (_semaphoreSlims) | ||
{ | ||
if (_semaphoreSlims.TryGetValue(key, out item) && item is { }) | ||
{ | ||
++item.RefCount; | ||
} | ||
else | ||
{ | ||
item = new RefCounted<SemaphoreSlim>(new SemaphoreSlim(1, 1)); | ||
_semaphoreSlims[key] = item; | ||
} | ||
} | ||
return item.Value; | ||
} | ||
|
||
// get a lock for a specific key, and wait until it is available | ||
public async Task<IDisposable> LockAsync(object key, CancellationToken cancellationToken = default) | ||
{ | ||
await GetOrCreate(key).WaitAsync(cancellationToken).ConfigureAwait(false); | ||
return new Releaser(_semaphoreSlims, key); | ||
} | ||
|
||
// get a lock for a specific key if it is available, or return null if it is currently locked | ||
public async Task<IDisposable?> LockOrBustAsync(object key, CancellationToken cancellationToken = default) | ||
{ | ||
var semaphore = GetOrCreate(key); | ||
if (semaphore.CurrentCount == 0) | ||
return null; | ||
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); | ||
return new Releaser(_semaphoreSlims, key); | ||
} | ||
private sealed class Releaser : IDisposable | ||
{ | ||
private readonly ConcurrentDictionary<object, RefCounted<SemaphoreSlim>?> _semaphoreSlims; | ||
|
||
public Releaser(ConcurrentDictionary<object, RefCounted<SemaphoreSlim>?> semaphoreSlims, object key) | ||
{ | ||
_semaphoreSlims = semaphoreSlims; | ||
Key = key; | ||
} | ||
|
||
private object Key { get; set; } | ||
|
||
public void Dispose() | ||
{ | ||
RefCounted<SemaphoreSlim>? item; | ||
lock (_semaphoreSlims) | ||
{ | ||
if (_semaphoreSlims.TryGetValue(Key, out item) && item is { }) | ||
{ | ||
--item.RefCount; | ||
if (item.RefCount == 0) | ||
_semaphoreSlims.TryRemove(Key, out _); | ||
} | ||
} | ||
item?.Value.Release(); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
using System.Text.Json.Serialization; | ||
|
||
namespace BTCPayApp.Core; | ||
|
||
public class BTCPayServerAccount(string baseUri, string email) | ||
{ | ||
public Uri BaseUri { get; init; } = new (WithTrailingSlash(baseUri)); | ||
public string Email { get; init; } = email; | ||
public string? AccessToken { get; set; } | ||
public string? RefreshToken { get; set; } | ||
public DateTimeOffset? AccessExpiry { get; set; } | ||
|
||
[JsonConstructor] | ||
public BTCPayServerAccount() : this(string.Empty, string.Empty) {} | ||
|
||
public void SetAccess(string accessToken, string refreshToken, DateTimeOffset expiry) | ||
{ | ||
AccessToken = accessToken; | ||
RefreshToken = refreshToken; | ||
AccessExpiry = expiry; | ||
} | ||
|
||
public void ClearAccess() | ||
{ | ||
AccessToken = null; | ||
RefreshToken = null; | ||
AccessExpiry = null; | ||
} | ||
|
||
private static string WithTrailingSlash(string str) => | ||
str.EndsWith("/", StringComparison.InvariantCulture) ? str : str + "/"; | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,166 @@ | ||
using System.Net; | ||
using System.Net.Http.Headers; | ||
using System.Net.Http.Json; | ||
using BTCPayApp.Core.Contracts; | ||
using BTCPayApp.Core.Models; | ||
using Microsoft.AspNetCore.Authentication.BearerToken; | ||
using Microsoft.AspNetCore.Identity.Data; | ||
using Microsoft.AspNetCore.Mvc; | ||
|
||
namespace BTCPayApp.Core; | ||
|
||
public class BTCPayServerAppApiClient(IHttpClientFactory clientFactory, ISecureConfigProvider secureConfigProvider) | ||
{ | ||
private readonly HttpClient _httpClient = clientFactory.CreateClient(); | ||
private readonly string[] _unauthenticatedPaths = ["login", "forgot-password", "reset-password"]; | ||
|
||
private BTCPayServerAccount? Account { get; set; } | ||
|
||
public async Task<(AccessTokenResult? success, string? errorCode)> Login(BTCPayServerAccount account, string password, string? otp = null, CancellationToken? cancellation = default) | ||
{ | ||
Account = account; | ||
var payload = new LoginRequest | ||
{ | ||
Email = Account.Email, | ||
Password = password, | ||
TwoFactorCode = otp | ||
}; | ||
try | ||
{ | ||
var now = DateTimeOffset.Now; | ||
var response = await Post<LoginRequest, AccessTokenResponse>("login", payload, cancellation.GetValueOrDefault()); | ||
var res = await HandleAccessTokenResponse(response!, now); | ||
return (res, null); | ||
} | ||
catch (BTCPayServerClientException e) | ||
{ | ||
return (null, e.Message); | ||
} | ||
} | ||
|
||
private async Task<(AccessTokenResult? success, string? errorCode)> Refresh(BTCPayServerAccount account, CancellationToken? cancellation = default) | ||
{ | ||
if (string.IsNullOrEmpty(account.RefreshToken)) throw new BTCPayServerClientException(422, "Account or Refresh Token missing"); | ||
|
||
Account = account; | ||
var payload = new RefreshRequest | ||
{ | ||
RefreshToken = Account.RefreshToken | ||
}; | ||
try | ||
{ | ||
var now = DateTimeOffset.Now; | ||
var response = await Post<RefreshRequest, AccessTokenResponse>("refresh", payload, cancellation.GetValueOrDefault()); | ||
var res = await HandleAccessTokenResponse(response!, now); | ||
return (res, null); | ||
} | ||
catch (BTCPayServerClientException e) | ||
{ | ||
return (null, e.Message); | ||
} | ||
} | ||
|
||
public async Task<AppUserInfoResult?> GetUserInfo(BTCPayServerAccount account, CancellationToken? cancellation = default) | ||
{ | ||
Account = account; | ||
return await Get<AppUserInfoResult>("info", cancellation.GetValueOrDefault()); | ||
} | ||
|
||
public async Task<(bool success, string? errorCode)> ResetPassword(BTCPayServerAccount account, string? resetCode = null, string? newPassword = null, CancellationToken? cancellation = default) | ||
{ | ||
Account = account; | ||
var payload = new ResetPasswordRequest | ||
{ | ||
Email = Account.Email, | ||
ResetCode = resetCode ?? string.Empty, | ||
NewPassword = newPassword ?? string.Empty | ||
}; | ||
try | ||
{ | ||
var path = string.IsNullOrEmpty(payload.ResetCode) && string.IsNullOrEmpty(payload.NewPassword) | ||
? "forgot-password" | ||
: "reset-password"; | ||
await Post<ResetPasswordRequest, EmptyResponseModel>(path, payload, cancellation.GetValueOrDefault()); | ||
return (true, null); | ||
} | ||
catch (BTCPayServerClientException e) | ||
{ | ||
return (false, e.Message); | ||
} | ||
} | ||
|
||
public void Logout() | ||
{ | ||
_httpClient.DefaultRequestHeaders.Authorization = null; | ||
} | ||
|
||
private async Task<TResponse?> Get<TResponse>(string path, CancellationToken cancellation = default) | ||
{ | ||
return await Send<EmptyRequestModel, TResponse>(HttpMethod.Get, path, null, cancellation); | ||
} | ||
|
||
private async Task<TResponse?> Post<TRequest, TResponse>(string path, TRequest payload, CancellationToken cancellation = default) | ||
{ | ||
return await Send<TRequest, TResponse>(HttpMethod.Post, path, payload, cancellation); | ||
} | ||
|
||
private async Task<TResponse?> Send<TRequest, TResponse>(HttpMethod method, string path, TRequest? payload, CancellationToken cancellation, bool isRetry = false) | ||
{ | ||
if (string.IsNullOrEmpty(Account?.BaseUri.ToString())) throw new BTCPayServerClientException(422, "Account or Server URL missing"); | ||
|
||
var req = new HttpRequestMessage | ||
{ | ||
RequestUri = new Uri($"{Account.BaseUri}btcpayapp/{path}"), | ||
Method = method, | ||
Content = payload == null ? null : JsonContent.Create(payload) | ||
}; | ||
req.Headers.Accept.Clear(); | ||
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); | ||
req.Headers.Add("User-Agent", "BTCPayServerAppApiClient"); | ||
|
||
if (!_unauthenticatedPaths.Contains(path) && !string.IsNullOrEmpty(Account.AccessToken)) | ||
{ | ||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Account.AccessToken); | ||
} | ||
|
||
var res = await _httpClient.SendAsync(req, cancellation); | ||
if (!res.IsSuccessStatusCode) | ||
{ | ||
// try refresh and recurse if the token is expired | ||
if (res.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrEmpty(Account.RefreshToken) && !isRetry) | ||
{ | ||
var (refresh, _) = await Refresh(Account, cancellation); | ||
if (refresh != null) return await Send<TRequest, TResponse>(method, path, payload, cancellation); | ||
} | ||
// otherwise handle the error response | ||
var problem = await res.Content.ReadFromJsonAsync<ProblemDetails>(cancellationToken: cancellation); | ||
var statusCode = problem?.Status ?? (int)res.StatusCode; | ||
var message = problem?.Detail ?? res.ReasonPhrase; | ||
throw new BTCPayServerClientException(statusCode, message ?? "Request failed"); | ||
} | ||
|
||
if (typeof(TResponse) == typeof(EmptyResponseModel)) | ||
{ | ||
return (TResponse)(object)new EmptyResponseModel(); | ||
} | ||
return await res.Content.ReadFromJsonAsync<TResponse>(cancellationToken: cancellation); | ||
} | ||
|
||
private class EmptyRequestModel; | ||
private class EmptyResponseModel; | ||
|
||
private class BTCPayServerClientException(int statusCode, string message) : Exception | ||
{ | ||
public int StatusCode { get; init; } = statusCode; | ||
public override string Message => message; | ||
} | ||
|
||
private async Task<AccessTokenResult?> HandleAccessTokenResponse(AccessTokenResponse response, DateTimeOffset expiryOffset) | ||
{ | ||
var expiry = expiryOffset + TimeSpan.FromSeconds(response.ExpiresIn); | ||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(response.TokenType, response.AccessToken); | ||
Account!.SetAccess(response.AccessToken, response.RefreshToken, expiry); | ||
await secureConfigProvider.Set("account", Account); | ||
return new AccessTokenResult(response.AccessToken, response.RefreshToken, expiry); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,3 @@ | ||
namespace BTCPayApp.Core.Contracts; | ||
|
||
public interface ISecureConfigProvider : IConfigProvider | ||
{ | ||
} | ||
public interface ISecureConfigProvider : IConfigProvider; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
namespace BTCPayApp.Core.Models; | ||
|
||
public class AccessTokenResult(string accessToken, string refreshToken, DateTimeOffset expiry) | ||
{ | ||
public string AccessToken { get; init; } = accessToken; | ||
public string RefreshToken { get; init; } = refreshToken; | ||
public DateTimeOffset Expiry { get; init; } = expiry; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
namespace BTCPayApp.Core.Models; | ||
|
||
public class AppUserInfoResult | ||
{ | ||
public string UserId { get; set; } | ||
public string Email { get; set; } | ||
public IEnumerable<string> Roles { get; set; } | ||
public IEnumerable<AppUserStoreInfo> Stores { get; set; } | ||
} | ||
|
||
public class AppUserStoreInfo | ||
{ | ||
public string Id { get; set; } | ||
public string Name { get; set; } | ||
public string RoleId { get; set; } | ||
public bool Archived { get; set; } | ||
public IEnumerable<string> Permissions { get; set; } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.