Add Greenfield API

This commit is contained in:
nicolas.dorier
2020-11-13 14:01:51 +09:00
parent f3611ac693
commit 94bcbeb604
19 changed files with 1050 additions and 124 deletions

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace BTCPayServer.Client
{

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client
{
public partial class BTCPayServerClient
{
public async Task<StoreWebhookData> CreateWebhook(string storeId, Client.Models.CreateStoreWebhookRequest create, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks", bodyPayload: create, method: HttpMethod.Post), token);
return await HandleResponse<StoreWebhookData>(response);
}
public async Task<StoreWebhookData> GetWebhook(string storeId, string webhookId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}"), token);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
return await HandleResponse<StoreWebhookData>(response);
}
public async Task<StoreWebhookData> UpdateWebhook(string storeId, string webhookId, Models.UpdateStoreWebhookRequest update, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}", bodyPayload: update, method: HttpMethod.Put), token);
return await HandleResponse<StoreWebhookData>(response);
}
public async Task<bool> DeleteWebhook(string storeId, string webhookId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}", method: HttpMethod.Delete), token);
return response.IsSuccessStatusCode;
}
public async Task<StoreWebhookData[]> GetWebhooks(string storeId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks"), token);
return await HandleResponse<StoreWebhookData[]>(response);
}
public async Task<WebhookDeliveryData[]> GetWebhookDeliveries(string storeId, string webhookId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries"), token);
return await HandleResponse<WebhookDeliveryData[]>(response);
}
public async Task<WebhookDeliveryData> GetWebhookDelivery(string storeId, string webhookId, string deliveryId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}"), token);
return await HandleResponse<WebhookDeliveryData>(response);
}
public async Task<string> RedeliverWebhook(string storeId, string webhookId, string deliveryId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver", null, HttpMethod.Post), token);
return await HandleResponse<string>(response);
}
public async Task<JObject> GetWebhookDeliveryRequest(string storeId, string webhookId, string deliveryId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request"), token);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
return await HandleResponse<JObject>(response);
}
}
}

View File

@@ -65,7 +65,8 @@ namespace BTCPayServer.Client
protected async Task<T> HandleResponse<T>(HttpResponseMessage message)
{
await HandleResponse(message);
return JsonConvert.DeserializeObject<T>(await message.Content.ReadAsStringAsync());
var str = await message.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<T>(str);
}
protected virtual HttpRequestMessage CreateHttpRequest(string path,

View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Text;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models
{
public class StoreWebhookBaseData
{
public class AuthorizedEventsData
{
public bool Everything { get; set; } = true;
[JsonProperty(ItemConverterType = typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public WebhookEventType[] SpecificEvents { get; set; } = Array.Empty<WebhookEventType>();
}
public bool Enabled { get; set; } = true;
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string Secret { get; set; }
public bool AutomaticRedelivery { get; set; } = true;
public string Url { get; set; }
public AuthorizedEventsData AuthorizedEvents { get; set; } = new AuthorizedEventsData();
}
public class UpdateStoreWebhookRequest : StoreWebhookBaseData
{
}
public class CreateStoreWebhookRequest : StoreWebhookBaseData
{
}
public class StoreWebhookData : StoreWebhookBaseData
{
public string Id { get; set; }
}
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Text;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models
{
public class WebhookDeliveryData
{
public string Id { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset Timestamp { get; set; }
public int? HttpCode { get; set; }
public string ErrorMessage { get; set; }
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public WebhookDeliveryStatus Status { get; set; }
}
}

View File

@@ -12,6 +12,7 @@ namespace BTCPayServer.Client
public const string CanUseLightningNodeInStore = "btcpay.store.canuselightningnode";
public const string CanModifyServerSettings = "btcpay.server.canmodifyserversettings";
public const string CanModifyStoreSettings = "btcpay.store.canmodifystoresettings";
public const string CanModifyStoreWebhooks = "btcpay.store.webhooks.canmodifywebhooks";
public const string CanModifyStoreSettingsUnscoped = "btcpay.store.canmodifystoresettings:";
public const string CanViewStoreSettings = "btcpay.store.canviewstoresettings";
public const string CanViewInvoices = "btcpay.store.canviewinvoices";
@@ -29,6 +30,7 @@ namespace BTCPayServer.Client
{
yield return CanViewInvoices;
yield return CanCreateInvoice;
yield return CanModifyStoreWebhooks;
yield return CanModifyServerSettings;
yield return CanModifyStoreSettings;
yield return CanViewStoreSettings;
@@ -156,6 +158,7 @@ namespace BTCPayServer.Client
switch (subpolicy)
{
case Policies.CanViewInvoices when this.Policy == Policies.CanModifyStoreSettings:
case Policies.CanModifyStoreWebhooks when this.Policy == Policies.CanModifyStoreSettings:
case Policies.CanViewInvoices when this.Policy == Policies.CanViewStoreSettings:
case Policies.CanViewStoreSettings when this.Policy == Policies.CanModifyStoreSettings:
case Policies.CanCreateInvoice when this.Policy == Policies.CanModifyStoreSettings:

View File

@@ -611,6 +611,98 @@ namespace BTCPayServer.Tests
}
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanUseWebhooks()
{
void AssertHook(FakeServer fakeServer, Client.Models.StoreWebhookData hook)
{
Assert.True(hook.Enabled);
Assert.True(hook.AuthorizedEvents.Everything);
Assert.True(hook.AutomaticRedelivery);
Assert.Equal(fakeServer.ServerUri.AbsoluteUri, hook.Url);
}
using var tester = ServerTester.Create();
using var fakeServer = new FakeServer();
await fakeServer.Start();
await tester.StartAsync();
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
var clientProfile = await user.CreateClient(Policies.CanModifyStoreWebhooks, Policies.CanCreateInvoice);
var hook = await clientProfile.CreateWebhook(user.StoreId, new CreateStoreWebhookRequest()
{
Url = fakeServer.ServerUri.AbsoluteUri,
AutomaticRedelivery = false
});
Assert.NotNull(hook.Secret);
AssertHook(fakeServer, hook);
hook = await clientProfile.GetWebhook(user.StoreId, hook.Id);
AssertHook(fakeServer, hook);
var hooks = await clientProfile.GetWebhooks(user.StoreId);
hook = Assert.Single(hooks);
AssertHook(fakeServer, hook);
await clientProfile.CreateInvoice(user.StoreId,
new CreateInvoiceRequest() { Currency = "USD", Amount = 100 });
var req = await fakeServer.GetNextRequest();
req.Response.StatusCode = 200;
fakeServer.Done();
hook = await clientProfile.UpdateWebhook(user.StoreId, hook.Id, new UpdateStoreWebhookRequest()
{
Url = hook.Url,
Secret = "lol",
AutomaticRedelivery = false
});
Assert.Null(hook.Secret);
AssertHook(fakeServer, hook);
var deliveries = await clientProfile.GetWebhookDeliveries(user.StoreId, hook.Id);
var delivery = Assert.Single(deliveries);
delivery = await clientProfile.GetWebhookDelivery(user.StoreId, hook.Id, delivery.Id);
Assert.NotNull(delivery);
Assert.Equal(WebhookDeliveryStatus.HttpSuccess, delivery.Status);
var newDeliveryId = await clientProfile.RedeliverWebhook(user.StoreId, hook.Id, delivery.Id);
req = await fakeServer.GetNextRequest();
req.Response.StatusCode = 404;
fakeServer.Done();
await TestUtils.EventuallyAsync(async () =>
{
var newDelivery = await clientProfile.GetWebhookDelivery(user.StoreId, hook.Id, newDeliveryId);
Assert.NotNull(newDelivery);
Assert.Equal(404, newDelivery.HttpCode);
Assert.Equal(WebhookDeliveryStatus.HttpError, newDelivery.Status);
});
deliveries = await clientProfile.GetWebhookDeliveries(user.StoreId, hook.Id);
Assert.Equal(2, deliveries.Length);
Assert.Equal(newDeliveryId, deliveries[0].Id);
var jObj = await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, newDeliveryId);
Assert.NotNull(jObj);
Logs.Tester.LogInformation("Should not be able to access webhook without proper auth");
var unauthorized = await user.CreateClient(Policies.CanCreateInvoice);
await AssertHttpError(403, async () =>
{
await unauthorized.GetWebhookDeliveryRequest(user.StoreId, hook.Id, newDeliveryId);
});
Logs.Tester.LogInformation("Can use btcpay.store.canmodifystoresettings to query webhooks");
clientProfile = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanCreateInvoice);
await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, newDeliveryId);
Logs.Tester.LogInformation("Testing corner cases");
Assert.Null(await clientProfile.GetWebhookDeliveryRequest(user.StoreId, "lol", newDeliveryId));
Assert.Null(await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, "lol"));
Assert.Null(await clientProfile.GetWebhookDeliveryRequest(user.StoreId, "lol", "lol"));
Assert.Null(await clientProfile.GetWebhook(user.StoreId, "lol"));
await AssertHttpError(404, async () =>
{
await clientProfile.UpdateWebhook(user.StoreId, "lol", new UpdateStoreWebhookRequest() { Url = hook.Url });
});
Assert.True(await clientProfile.DeleteWebhook(user.StoreId, hook.Id));
Assert.False(await clientProfile.DeleteWebhook(user.StoreId, hook.Id));
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task HealthControllerTests()

View File

@@ -1,89 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Tests
{
public class RawHttpServer : IDisposable
{
public class RawRequest
{
public RawRequest(TaskCompletionSource<bool> taskCompletion)
{
TaskCompletion = taskCompletion;
}
public HttpContext HttpContext { get; set; }
public TaskCompletionSource<bool> TaskCompletion { get; }
public void Complete()
{
TaskCompletion.SetResult(true);
}
}
readonly IWebHost _Host = null;
readonly CancellationTokenSource _Closed = new CancellationTokenSource();
readonly Channel<RawRequest> _Requests = Channel.CreateUnbounded<RawRequest>();
public RawHttpServer()
{
var port = Utils.FreeTcpPort();
_Host = new WebHostBuilder()
.Configure(app =>
{
app.Run(req =>
{
var cts = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_Requests.Writer.TryWrite(new RawRequest(cts)
{
HttpContext = req
});
return cts.Task;
});
})
.UseKestrel()
.UseUrls("http://127.0.0.1:" + port)
.Build();
_Host.Start();
}
public Uri GetUri()
{
return new Uri(_Host.ServerFeatures.Get<IServerAddressesFeature>().Addresses.First());
}
public async Task<RawRequest> GetNextRequest()
{
using (CancellationTokenSource cancellation = new CancellationTokenSource(20 * 1000))
{
try
{
RawRequest req = null;
while (!await _Requests.Reader.WaitToReadAsync(cancellation.Token) ||
!_Requests.Reader.TryRead(out req))
{
}
return req;
}
catch (TaskCanceledException)
{
throw new Xunit.Sdk.XunitException("Callback to the webserver was expected, check if the callback url is accessible from internet");
}
}
}
public void Dispose()
{
_Closed.Cancel();
_Host.Dispose();
}
}
}

View File

@@ -639,10 +639,10 @@ namespace BTCPayServer.Tests
Logs.Tester.LogInformation("Let's try to update one of them");
s.Driver.FindElement(By.LinkText("Modify")).Click();
using RawHttpServer server = new RawHttpServer();
using FakeServer server = new FakeServer();
await server.Start();
s.Driver.FindElement(By.Name("PayloadUrl")).Clear();
s.Driver.FindElement(By.Name("PayloadUrl")).SendKeys(server.GetUri().AbsoluteUri);
s.Driver.FindElement(By.Name("PayloadUrl")).SendKeys(server.ServerUri.AbsoluteUri);
s.Driver.FindElement(By.Name("Secret")).Clear();
s.Driver.FindElement(By.Name("Secret")).SendKeys("HelloWorld");
s.Driver.FindElement(By.Name("update")).Click();
@@ -663,26 +663,26 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Name("update")).Click();
s.AssertHappyMessage();
Assert.Contains(server.GetUri().AbsoluteUri, s.Driver.PageSource);
Assert.Contains(server.ServerUri.AbsoluteUri, s.Driver.PageSource);
Logs.Tester.LogInformation("Let's see if we can generate an event");
s.GoToStore(store.storeId);
s.AddDerivationScheme();
s.CreateInvoice(store.storeName);
var request = await server.GetNextRequest();
var headers = request.HttpContext.Request.Headers;
var headers = request.Request.Headers;
var actualSig = headers["BTCPay-Sig"].First();
var bytes = await request.HttpContext.Request.Body.ReadBytesAsync((int)headers.ContentLength.Value);
var bytes = await request.Request.Body.ReadBytesAsync((int)headers.ContentLength.Value);
var expectedSig = $"sha256={Encoders.Hex.EncodeData(new HMACSHA256(Encoding.UTF8.GetBytes("HelloWorld")).ComputeHash(bytes))}";
Assert.Equal(expectedSig, actualSig);
request.HttpContext.Response.StatusCode = 200;
request.Complete();
request.Response.StatusCode = 200;
server.Done();
Logs.Tester.LogInformation("Let's make a failed event");
s.CreateInvoice(store.storeName);
request = await server.GetNextRequest();
request.HttpContext.Response.StatusCode = 404;
request.Complete();
request.Response.StatusCode = 404;
server.Done();
// The delivery is done asynchronously, so small wait here
await Task.Delay(500);
@@ -695,8 +695,8 @@ namespace BTCPayServer.Tests
elements[0].Click();
s.AssertHappyMessage();
request = await server.GetNextRequest();
request.HttpContext.Response.StatusCode = 404;
request.Complete();
request.Response.StatusCode = 404;
server.Done();
Logs.Tester.LogInformation("Can we browse the json content?");
CanBrowseContent(s);
@@ -708,8 +708,8 @@ namespace BTCPayServer.Tests
element.Click();
s.AssertHappyMessage();
request = await server.GetNextRequest();
request.HttpContext.Response.StatusCode = 404;
request.Complete();
request.Response.StatusCode = 404;
server.Done();
Logs.Tester.LogInformation("Let's see if we can delete store with some webhooks inside");
s.GoToStore(store.storeId);

View File

@@ -251,5 +251,5 @@
<_ContentIncludedByDefault Remove="Views\Components\NotificationsDropdown\Default.cshtml" />
</ItemGroup>
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1invoices_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1invoices_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1webhooks_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
</Project>

View File

@@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Amazon.Runtime;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Security;
using BTCPayServer.Services.Stores;
using Google.Apis.Auth.OAuth2;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using Org.BouncyCastle.Bcpg.OpenPgp;
namespace BTCPayServer.Controllers.GreenField
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield,
Policy = Policies.CanModifyStoreWebhooks)]
[EnableCors(CorsPolicies.All)]
public class StoreWebhooksController : ControllerBase
{
public StoreWebhooksController(StoreRepository storeRepository, WebhookNotificationManager webhookNotificationManager)
{
StoreRepository = storeRepository;
WebhookNotificationManager = webhookNotificationManager;
}
public StoreRepository StoreRepository { get; }
public WebhookNotificationManager WebhookNotificationManager { get; }
[HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId?}")]
public async Task<IActionResult> ListWebhooks(string storeId, string webhookId)
{
if (webhookId is null)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
return Ok((await StoreRepository.GetWebhooks(storeId))
.Select(o => FromModel(o, false))
.ToList());
}
else
{
var w = await StoreRepository.GetWebhook(storeId, webhookId);
if (w is null)
return NotFound();
return Ok(FromModel(w, false));
}
}
[HttpPost("~/api/v1/stores/{storeId}/webhooks")]
public async Task<IActionResult> CreateWebhook(string storeId, Client.Models.CreateStoreWebhookRequest create)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
ValidateWebhookRequest(create);
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var webhookId = await StoreRepository.CreateWebhook(storeId, ToModel(create));
var w = await StoreRepository.GetWebhook(storeId, webhookId);
if (w is null)
return NotFound();
return Ok(FromModel(w, true));
}
private void ValidateWebhookRequest(StoreWebhookBaseData create)
{
if (!Uri.TryCreate(create?.Url, UriKind.Absolute, out var uri))
ModelState.AddModelError(nameof(Url), "Invalid Url");
}
[HttpPut("~/api/v1/stores/{storeId}/webhooks/{webhookId}")]
public async Task<IActionResult> UpdateWebhook(string storeId, string webhookId, Client.Models.UpdateStoreWebhookRequest update)
{
ValidateWebhookRequest(update);
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var w = await StoreRepository.GetWebhook(storeId, webhookId);
if (w is null)
return NotFound();
await StoreRepository.UpdateWebhook(storeId, webhookId, ToModel(update));
return await ListWebhooks(storeId, webhookId);
}
[HttpDelete("~/api/v1/stores/{storeId}/webhooks/{webhookId}")]
public async Task<IActionResult> DeleteWebhook(string storeId, string webhookId)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var w = await StoreRepository.GetWebhook(storeId, webhookId);
if (w is null)
return NotFound();
await StoreRepository.DeleteWebhook(storeId, webhookId);
return Ok();
}
private WebhookBlob ToModel(StoreWebhookBaseData create)
{
return new WebhookBlob()
{
Active = create.Enabled,
Url = create.Url,
Secret = create.Secret,
AuthorizedEvents = create.AuthorizedEvents is Client.Models.StoreWebhookBaseData.AuthorizedEventsData aed ?
new AuthorizedWebhookEvents()
{
Everything = aed.Everything,
SpecificEvents = aed.SpecificEvents
}:
new AuthorizedWebhookEvents() { Everything = true },
AutomaticRedelivery = create.AutomaticRedelivery,
};
}
[HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId?}")]
public async Task<IActionResult> ListDeliveries(string storeId, string webhookId, string deliveryId, int? count = null)
{
if (deliveryId is null)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
return Ok((await StoreRepository.GetWebhookDeliveries(storeId, webhookId, count))
.Select(o => FromModel(o))
.ToList());
}
else
{
var delivery = await StoreRepository.GetWebhookDelivery(storeId, webhookId, deliveryId);
if (delivery is null)
return NotFound();
return Ok(FromModel(delivery));
}
}
[HttpPost("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver")]
public async Task<IActionResult> RedeliverWebhook(string storeId, string webhookId, string deliveryId)
{
var delivery = await StoreRepository.GetWebhookDelivery(HttpContext.GetStoreData().Id, webhookId, deliveryId);
if (delivery is null)
return NotFound();
return this.Ok(new JValue(await WebhookNotificationManager.Redeliver(deliveryId)));
}
[HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")]
public async Task<IActionResult> GetDeliveryRequest(string storeId, string webhookId, string deliveryId)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var delivery = await StoreRepository.GetWebhookDelivery(storeId, webhookId, deliveryId);
if (delivery is null)
return NotFound();
return File(delivery.GetBlob().Request, "application/json");
}
private Client.Models.WebhookDeliveryData FromModel(Data.WebhookDeliveryData data)
{
var b = data.GetBlob();
return new Client.Models.WebhookDeliveryData()
{
Id = data.Id,
Timestamp = data.Timestamp,
Status = b.Status,
ErrorMessage = b.ErrorMessage,
HttpCode = b.HttpCode
};
}
Client.Models.StoreWebhookData FromModel(Data.WebhookData data, bool includeSecret)
{
var b = data.GetBlob();
return new Client.Models.StoreWebhookData()
{
Id = data.Id,
Url = b.Url,
Enabled = b.Active,
Secret = includeSecret ? b.Secret : null,
AutomaticRedelivery = b.AutomaticRedelivery,
AuthorizedEvents = new Client.Models.StoreWebhookData.AuthorizedEventsData()
{
Everything = b.AuthorizedEvents.Everything,
SpecificEvents = b.AuthorizedEvents.SpecificEvents
}
};
}
}
}

View File

@@ -465,6 +465,8 @@ namespace BTCPayServer.Controllers
{BTCPayServer.Client.Policies.CanCreateUser, ("Create new users", "The app will be able to create new users on this server.")},
{BTCPayServer.Client.Policies.CanModifyStoreSettings, ("Modify your stores", "The app will be able to view, modify, delete and create new invoices on all your stores.")},
{$"{BTCPayServer.Client.Policies.CanModifyStoreSettings}:", ("Manage selected stores", "The app will be able to view, modify, delete and create new invoices on the selected stores.")},
{BTCPayServer.Client.Policies.CanModifyStoreWebhooks, ("Modify stores webhooks", "The app will be mofidy the webhooks of all your stores.")},
{$"{BTCPayServer.Client.Policies.CanModifyStoreWebhooks}:", ("Modify selected stores' webhooks", "The app will be mofidy the webhooks of the selected stores.")},
{BTCPayServer.Client.Policies.CanViewStoreSettings, ("View your stores", "The app will be able to view stores settings.")},
{$"{BTCPayServer.Client.Policies.CanViewStoreSettings}:", ("View your stores", "The app will be able to view the selected stores' settings.")},
{BTCPayServer.Client.Policies.CanModifyServerSettings, ("Manage your server", "The app will have total control on the server settings of your server")},

View File

@@ -57,10 +57,10 @@ namespace BTCPayServer.HostedServices
class WebhookDeliveryRequest
{
public WebhookEvent WebhookEvent;
public WebhookDeliveryData Delivery;
public Data.WebhookDeliveryData Delivery;
public WebhookBlob WebhookBlob;
public string WebhookId;
public WebhookDeliveryRequest(string webhookId, WebhookEvent webhookEvent, WebhookDeliveryData delivery, WebhookBlob webhookBlob)
public WebhookDeliveryRequest(string webhookId, WebhookEvent webhookEvent, Data.WebhookDeliveryData delivery, WebhookBlob webhookBlob)
{
WebhookId = webhookId;
WebhookEvent = webhookEvent;
@@ -130,7 +130,7 @@ namespace BTCPayServer.HostedServices
continue;
if (!ShouldDeliver(webhookEventType, webhookBlob))
continue;
WebhookDeliveryData delivery = NewDelivery();
Data.WebhookDeliveryData delivery = NewDelivery();
delivery.WebhookId = webhook.Id;
var webhookEvent = new WebhookInvoiceEvent();
webhookEvent.InvoiceId = invoiceEvent.InvoiceId;
@@ -293,9 +293,9 @@ namespace BTCPayServer.HostedServices
return bytes;
}
private static WebhookDeliveryData NewDelivery()
private static Data.WebhookDeliveryData NewDelivery()
{
var delivery = new WebhookDeliveryData();
var delivery = new Data.WebhookDeliveryData();
delivery.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16));
delivery.Timestamp = DateTimeOffset.UtcNow;
return delivery;

View File

@@ -15,7 +15,7 @@ namespace BTCPayServer.Models.StoreViewModels
{
}
public DeliveryViewModel(WebhookDeliveryData s)
public DeliveryViewModel(Data.WebhookDeliveryData s)
{
var blob = s.GetBlob();
Id = s.Id;

View File

@@ -19,6 +19,9 @@ namespace BTCPayServer.Payments.PayJoin
services.AddHttpClient(PayjoinClient.PayjoinOnionNamedClient)
.ConfigureHttpClient(h => h.DefaultRequestHeaders.ConnectionClose = true)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddHttpClient(WebhookNotificationManager.OnionNamedClient)
.ConfigureHttpClient(h => h.DefaultRequestHeaders.ConnectionClose = true)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
}
}
}

View File

@@ -61,7 +61,7 @@ retry:
_eventAggregator = eventAggregator;
}
public async Task<WebhookDeliveryData> GetWebhookDelivery(string invoiceId, string deliveryId)
public async Task<Data.WebhookDeliveryData> GetWebhookDelivery(string invoiceId, string deliveryId)
{
using var ctx = _ContextFactory.CreateContext();
return await ctx.InvoiceWebhookDeliveries
@@ -118,7 +118,7 @@ retry:
}
}
public async Task<List<WebhookDeliveryData>> GetWebhookDeliveries(string invoiceId)
public async Task<List<Data.WebhookDeliveryData>> GetWebhookDeliveries(string invoiceId)
{
using var ctx = _ContextFactory.CreateContext();
return await ctx.InvoiceWebhookDeliveries

View File

@@ -230,14 +230,16 @@ namespace BTCPayServer.Services.Stores
await ctx.SaveChangesAsync();
}
public async Task<WebhookDeliveryData[]> GetWebhookDeliveries(string storeId, string webhookId, int count)
public async Task<WebhookDeliveryData[]> GetWebhookDeliveries(string storeId, string webhookId, int? count)
{
using var ctx = _ContextFactory.CreateContext();
return await ctx.StoreWebhooks
IQueryable<WebhookDeliveryData> req = ctx.StoreWebhooks
.Where(s => s.StoreId == storeId && s.WebhookId == webhookId)
.SelectMany(s => s.Webhook.Deliveries)
.OrderByDescending(s => s.Timestamp)
.Take(count)
.OrderByDescending(s => s.Timestamp);
if (count is int c)
req = req.Take(c);
return await req
.ToArrayAsync();
}
@@ -246,6 +248,8 @@ namespace BTCPayServer.Services.Stores
using var ctx = _ContextFactory.CreateContext();
WebhookData data = new WebhookData();
data.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16));
if (string.IsNullOrEmpty(blob.Secret))
blob.Secret = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16));
data.SetBlob(blob);
StoreWebhookData storeWebhook = new StoreWebhookData();
storeWebhook.StoreId = storeId;

View File

@@ -53,7 +53,7 @@
"securitySchemes": {
"API Key": {
"type": "apiKey",
"description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions applies to the context of the user creating the API Key:\n * `unrestricted`: Allow unrestricted access to your account.\n * `btcpay.server.canmodifyserversettings`: Allow total control on the server settings. (only if user is administrator)\n * `btcpay.server.cancreateuser`: Allow the creation of new users on this server. (only if user is an administrator)\n * `btcpay.user.canviewprofile`: Allow view access to your user profile.\n * `btcpay.user.canmodifyprofile`: Allow view and modification access to your user profile.\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n * `btcpay.store.canviewstoresettings`: Allow view access to the stores settings. \n * `btcpay.store.canmodifystoresettings`: Allow view and modification access to the stores settings.\n * `btcpay.store.cancreateinvoice`: Allow invoice creation of the store.\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\n",
"description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions applies to the context of the user creating the API Key:\n * `unrestricted`: Allow unrestricted access to your account.\n * `btcpay.server.canmodifyserversettings`: Allow total control on the server settings. (only if user is administrator)\n * `btcpay.server.cancreateuser`: Allow the creation of new users on this server. (only if user is an administrator)\n * `btcpay.user.canviewprofile`: Allow view access to your user profile.\n * `btcpay.user.canmodifyprofile`: Allow view and modification access to your user profile.\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n * `btcpay.store.canviewstoresettings`: Allow view access to the stores settings. \n * `btcpay.store.webhooks.canmodifywebhooks`: Allow modifications of webhooks in the store. \n * `btcpay.store.canmodifystoresettings`: Allow view and modification access to the stores settings and webhooks.\n * `btcpay.store.cancreateinvoice`: Allow invoice creation of the store.\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\n",
"name": "Authorization",
"in": "header",
"scheme": "token"

View File

@@ -0,0 +1,593 @@
{
"paths": {
"/api/v1/stores/{storeId}/webhooks": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Webhooks"
],
"summary": "Get webhooks of a store",
"description": "View webhooks of a store",
"operationId": "Webhokks_GetWebhooks",
"responses": {
"200": {
"description": "List of webhooks",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDataList"
}
}
}
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
},
"post": {
"tags": [ "Webhooks" ],
"summary": "Create a new webhook",
"description": "Create a new webhook",
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDataCreate"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "Information about the new webhook",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDataCreate"
}
}
}
},
"400": {
"description": "A list of errors that occurred when creating the webhook",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/webhooks/{webhookId}": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
},
{
"name": "webhookId",
"in": "path",
"required": true,
"description": "The webhook id",
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Webhooks"
],
"summary": "Get a webhook of a store",
"description": "View webhook of a store",
"operationId": "Webhokks_GetWebhook",
"responses": {
"200": {
"description": "A webhook",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookData"
}
}
}
},
"404": {
"description": "The webhook has not been found"
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
},
"put": {
"tags": [ "Webhooks" ],
"summary": "Update a webhook",
"description": "Update a webhook",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDataBase"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "Information about the updated webhook",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookData"
}
}
}
},
"400": {
"description": "A list of errors that occurred when creating the webhook",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
},
"delete": {
"tags": [ "Webhooks" ],
"summary": "Delete a webhook",
"description": "Delete a webhook",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDataBase"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "The webhook has been deleted"
},
"404": {
"description": "The webhook does not exist"
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
},
{
"name": "webhookId",
"in": "path",
"required": true,
"description": "The webhook id",
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Webhooks"
],
"summary": "Get latest deliveries",
"description": "List the latest deliveries to the webhook, ordered from the most recent",
"parameters": [
{
"name": "count",
"in": "query",
"description": "The number of latest deliveries to fetch",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "List of deliveries",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDeliveryList"
}
}
}
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
},
{
"name": "webhookId",
"in": "path",
"required": true,
"description": "The webhook id",
"schema": {
"type": "string"
}
},
{
"name": "deliveryId",
"in": "path",
"required": true,
"description": "The id of the delivery",
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Webhooks"
],
"summary": "Get a webhook delivery",
"description": "Information about a webhook delivery",
"responses": {
"200": {
"description": "Information about a delivery",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDeliveryData"
}
}
}
},
"404": {
"description": "The delivery does not exists."
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
},
{
"name": "webhookId",
"in": "path",
"required": true,
"description": "The webhook id",
"schema": {
"type": "string"
}
},
{
"name": "deliveryId",
"in": "path",
"required": true,
"description": "The id of the delivery",
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Webhooks"
],
"summary": "Get the delivery's request",
"description": "The delivery's JSON request sent to the endpoint",
"responses": {
"200": {
"description": "The delivery's JSON Request",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"404": {
"description": "The delivery does not exists."
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
},
{
"name": "webhookId",
"in": "path",
"required": true,
"description": "The webhook id",
"schema": {
"type": "string"
}
},
{
"name": "deliveryId",
"in": "path",
"required": true,
"description": "The id of the delivery",
"schema": {
"type": "string"
}
}
],
"post": {
"tags": [
"Webhooks"
],
"summary": "Redeliver the delivery",
"description": "Redeliver the delivery",
"responses": {
"200": {
"description": "The new delivery id being broadcasted. (Broadcast happen asynchronously with this call)",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "The delivery does not exists."
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
}
},
"components": {
"schemas": {
"WebhookDeliveryList": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WebhookDeliveryData"
}
},
"WebhookDeliveryData": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The id of the delivery",
"nullable": false
},
"timestamp": {
"type": "number",
"format": "int64",
"nullable": false,
"description": "Timestamp of when the delivery got broadcasted"
},
"httpCode": {
"type": "number",
"description": "HTTP code received by the remote service, if any.",
"nullable": true
},
"errorMessage": {
"type": "string",
"description": "User friendly error message, if any."
},
"status": {
"type": "string",
"description": "Whether the delivery failed or not (possible values are: `Failed`, `HttpError`, `HttpSuccess`)"
}
}
},
"WebhookDataList": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WebhookData"
}
},
"WebhookData": {
"allOf": [
{
"$ref": "#/components/schemas/WebhookDataBase"
},
{
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The id of the webhook",
"nullable": false
}
}
}
]
},
"WebhookDataCreate": {
"allOf": [
{
"$ref": "#/components/schemas/WebhookData"
},
{
"type": "object",
"properties": {
"secret": {
"type": "string",
"description": "Must be used by the callback receiver to ensure the delivery comes from BTCPay Server. BTCPay Server includes the `BTCPay-Sig` HTTP header, whose format is `sha256=[HMAC256(secret,bodyBytes)]`. The pattern to authenticate the webhook is similar to [how to secure webhooks in Github](https://developer.github.com/webhooks/securing/) but with sha256 instead of sha1.",
"nullable": false
}
}
}
]
},
"WebhookDataBase": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "The id of the webhook",
"nullable": false
},
"enabled": {
"type": "boolean",
"description": "Whether this webhook is enabled or not",
"nullable": false
},
"automaticRedelivery": {
"type": "boolean",
"description": "If true, BTCPay Server will retry to redeliver any failed delivery after 10 seconds, 1 minutes and up to 6 times after 10 minutes.",
"nullable": false
},
"url": {
"type": "string",
"description": "The endpoint where BTCPay Server will make the POST request with the webhook body",
"nullable": false
},
"authorizedEvents": {
"type": "object",
"description": "Which event should be received by this endpoint",
"properties": {
"everything": {
"type": "string",
"description": "If true, the endpoint will receive all events related to the store.",
"nullable": false
},
"specificEvents": {
"type": "string",
"description": "If `everything` is false, the specific events that the endpoint is interested in. Current events are: `InvoiceCreated`, `InvoiceReceivedPayment`, `InvoicePaidInFull`, `InvoiceExpired`, `InvoiceConfirmed`, `InvoiceInvalid`.",
"nullable": false
}
}
}
}
}
}
},
"tags": [
{
"name": "Webhooks"
}
]
}