diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index 3b317a5f0..e76ce5861 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -95,7 +95,7 @@ namespace BTCPayServer.Controllers } [HttpGet("invoices/{invoiceId}")] - [HttpGet("/stores/{storeId}/invoices/${invoiceId}")] + [HttpGet("/stores/{storeId}/invoices/{invoiceId}")] [Authorize(Policy = Policies.CanViewInvoices, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task Invoice(string invoiceId) { @@ -837,13 +837,7 @@ namespace BTCPayServer.Controllers lang ??= storeBlob.DefaultLang; var receiptEnabled = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, invoice.ReceiptOptions).Enabled is true; - var receiptUrl = receiptEnabled ? _linkGenerator.GetUriByAction( - nameof(InvoiceReceipt), - "UIInvoice", - new { invoiceId }, - Request.Scheme, - Request.Host, - Request.PathBase) : null; + var receiptUrl = receiptEnabled ? _linkGenerator.ReceiptLink(invoiceId, Request.GetRequestBaseUrl()) : null; var orderId = invoice.Metadata.OrderId; var supportUrl = !string.IsNullOrEmpty(storeBlob.StoreSupportUrl) diff --git a/BTCPayServer/EventAggregator.cs b/BTCPayServer/EventAggregator.cs index 924b8f912..f098507b1 100644 --- a/BTCPayServer/EventAggregator.cs +++ b/BTCPayServer/EventAggregator.cs @@ -14,16 +14,19 @@ namespace BTCPayServer { void Unsubscribe(); } + public class EventAggregator : IDisposable { public EventAggregator(Logs logs) { Logs = logs; } + class Subscription : IEventAggregatorSubscription { private readonly EventAggregator aggregator; readonly Type t; + public Subscription(EventAggregator aggregator, Type t) { this.aggregator = aggregator; @@ -31,21 +34,24 @@ namespace BTCPayServer } public Action Act { get; set; } + public bool Any { get; set; } bool _Disposed; + public void Dispose() { if (_Disposed) return; _Disposed = true; - lock (this.aggregator._Subscriptions) + var dict = Any ? aggregator._SubscriptionsAny : aggregator._Subscriptions; + lock (dict) { - if (this.aggregator._Subscriptions.TryGetValue(t, out Dictionary> actions)) + if (dict.TryGetValue(t, out Dictionary> actions)) { if (actions.Remove(this)) { if (actions.Count == 0) - this.aggregator._Subscriptions.Remove(t); + dict.Remove(t); } } } @@ -56,18 +62,25 @@ namespace BTCPayServer Dispose(); } } + public Task WaitNext(CancellationToken cancellation = default(CancellationToken)) { return WaitNext(o => true, cancellation); } + public async Task WaitNext(Func predicate, CancellationToken cancellation = default(CancellationToken)) { - TaskCompletionSource tcs = new TaskCompletionSource(); - using var subscription = Subscribe((a, b) => { if (predicate(b)) { tcs.TrySetResult(b); a.Unsubscribe(); } }); - using (cancellation.Register(() => { tcs.TrySetCanceled(); })) + TaskCompletionSource tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = Subscribe((a, b) => { - return await tcs.Task.ConfigureAwait(false); - } + if (predicate(b)) + { + tcs.TrySetResult(b); + a.Unsubscribe(); + } + }); + await using var reg = cancellation.Register(() => tcs.TrySetCanceled(cancellation)); + return await tcs.Task.ConfigureAwait(false); } public void Publish(T evt) where T : class @@ -87,8 +100,17 @@ namespace BTCPayServer } } + lock (_SubscriptionsAny) + { + foreach (var kv in _SubscriptionsAny) + { + if (kv.Key.IsAssignableFrom(evtType)) + actionList.AddRange(kv.Value.Values); + } + } + if (Logs.Events.IsEnabled(LogLevel.Information)) - Logs.Events.LogInformation("Event published {0}", string.IsNullOrEmpty(evt?.ToString()) ? evtType.GetType().Name : evt.ToString()); + Logs.Events.LogInformation("{0}", string.IsNullOrEmpty(evt?.ToString()) ? evtType.Name : evt.ToString()); foreach (var sub in actionList) { @@ -103,6 +125,12 @@ namespace BTCPayServer } } + /// + /// Subscribe to any event of exactly type T + /// + /// + /// + /// public IEventAggregatorSubscription Subscribe(Action subscription) { var eventType = typeof(T); @@ -111,6 +139,20 @@ namespace BTCPayServer return Subscribe(eventType, s); } + /// + /// Subscribe to any event of type T or any of its derived type + /// + /// + /// + /// + public IEventAggregatorSubscription SubscribeAny(Action subscription) + { + var eventType = typeof(T); + var s = new Subscription(this, eventType) { Any = true }; + s.Act = (o) => subscription(s, (T)o); + return Subscribe(eventType, s); + } + public IEventAggregatorSubscription Subscribe(Type eventType, Action subscription) { var s = new Subscription(this, eventType); @@ -120,20 +162,26 @@ namespace BTCPayServer private IEventAggregatorSubscription Subscribe(Type eventType, Subscription subscription) { - lock (_Subscriptions) + var subscriptions = subscription.Any ? _SubscriptionsAny : _Subscriptions; + lock (subscriptions) { - if (!_Subscriptions.TryGetValue(eventType, out Dictionary> actions)) + if (!subscriptions.TryGetValue(eventType, out Dictionary> actions)) { actions = new Dictionary>(); - _Subscriptions.Add(eventType, actions); + subscriptions.Add(eventType, actions); } + actions.Add(subscription, subscription.Act); } + return subscription; } readonly Dictionary>> _Subscriptions = new Dictionary>>(); + readonly Dictionary>> + _SubscriptionsAny = new Dictionary>>(); + public Logs Logs { get; } public IEventAggregatorSubscription Subscribe(Func subscription) @@ -145,6 +193,7 @@ namespace BTCPayServer { return Subscribe(new Action((sub, t) => subscription(sub, t))); } + class ChannelSubscription : IEventAggregatorSubscription { private Channel _evts; @@ -187,23 +236,35 @@ namespace BTCPayServer _evts.Writer.TryComplete(); } } + public IEventAggregatorSubscription SubscribeAsync(Func subscription) { Channel evts = Channel.CreateUnbounded(); var innerSubscription = Subscribe(new Action((sub, t) => evts.Writer.TryWrite(t))); return new ChannelSubscription(evts, innerSubscription, subscription, Logs); } + public IEventAggregatorSubscription Subscribe(Action subscription) { return Subscribe(new Action((sub, t) => subscription(t))); } + public IEventAggregatorSubscription SubscribeAny(Action subscription) + { + return SubscribeAny(new Action((sub, t) => subscription(t))); + } + public void Dispose() { lock (_Subscriptions) { _Subscriptions.Clear(); } + + lock (_SubscriptionsAny) + { + _SubscriptionsAny.Clear(); + } } } } diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs index c30867714..7327e0baa 100644 --- a/BTCPayServer/Extensions/UrlHelperExtensions.cs +++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs @@ -1,6 +1,6 @@ - using System; using BTCPayServer; +using BTCPayServer.Abstractions; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Client.Models; using BTCPayServer.Controllers; @@ -83,7 +83,32 @@ namespace Microsoft.AspNetCore.Mvc values: new { invoiceId }, scheme, host, pathbase); } +#nullable enable + public static string ReceiptLink(this LinkGenerator urlHelper, string invoiceId, RequestBaseUrl baseUrl) + => urlHelper.GetUriByAction( + action: nameof(UIInvoiceController.InvoiceReceipt), + controller: "UIInvoice", + values: new { invoiceId }, + baseUrl); + + public static string InvoiceCheckoutLink(this LinkGenerator urlHelper, string invoiceId, RequestBaseUrl baseUrl) + => urlHelper.GetUriByAction( + action: nameof(UIInvoiceController.Checkout), + controller: "UIInvoice", + values: new { invoiceId }, + baseUrl + ); + + public static string GetUriByAction( + this LinkGenerator generator, + string action, + string controller, + object? values, + RequestBaseUrl requestBaseUrl, + FragmentString fragment = default, + LinkOptions? options = null) => generator.GetUriByAction(action, controller, values, requestBaseUrl.Scheme, requestBaseUrl.Host, requestBaseUrl.PathBase, fragment, options) ?? throw new InvalidOperationException($"Bug, unable to generate link for {controller}.{action}"); +#nullable restore public static string PayoutLink(this LinkGenerator urlHelper, string walletIdOrStoreId, string pullPaymentId, PayoutState payoutState, string scheme, HostString host, string pathbase) { WalletId.TryParse(walletIdOrStoreId, out var wallet); diff --git a/BTCPayServer/HostedServices/EventHostedServiceBase.cs b/BTCPayServer/HostedServices/EventHostedServiceBase.cs index c3cd5e20a..ae0611559 100644 --- a/BTCPayServer/HostedServices/EventHostedServiceBase.cs +++ b/BTCPayServer/HostedServices/EventHostedServiceBase.cs @@ -36,13 +36,35 @@ namespace BTCPayServer.HostedServices readonly Channel _Events = Channel.CreateUnbounded(); public async Task ProcessEvents(CancellationToken cancellationToken) { - while (await _Events.Reader.WaitToReadAsync(cancellationToken)) + // We want current job to finish before exiting + // ReSharper disable once MethodSupportsCancellation + while (await _Events.Reader.WaitToReadAsync()) { - if (_Events.Reader.TryRead(out var evt)) + while (_Events.Reader.TryRead(out var evt)) { try { - await ProcessEvent(evt, cancellationToken); + if (evt is ExecutingEvent e) + { + try + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, e.CancellationToken); + await ProcessEvent(e.Event, linkedCts.Token); + e.Tcs.TrySetResult(); + } + catch (OperationCanceledException pce) when (e.CancellationToken.IsCancellationRequested || cancellationToken.IsCancellationRequested) + { + e.Tcs.TrySetCanceled(pce.CancellationToken); + } + catch (Exception ex) + { + e.Tcs.TrySetException(ex); + } + } + else + { + await ProcessEvent(evt, cancellationToken); + } } catch when (cancellationToken.IsCancellationRequested) { @@ -71,12 +93,24 @@ namespace BTCPayServer.HostedServices { _Subscriptions.Add(_EventAggregator.Subscribe(e => _Events.Writer.TryWrite(e!))); } + protected void SubscribeAny() + { + _Subscriptions.Add(_EventAggregator.SubscribeAny(e => _Events.Writer.TryWrite(e!))); + } protected void PushEvent(object obj) { _Events.Writer.TryWrite(obj); } + record ExecutingEvent(object Event, TaskCompletionSource Tcs, CancellationToken CancellationToken); + protected Task RunEvent(object obj, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _Events.Writer.TryWrite(new ExecutingEvent(obj, tcs, cancellationToken)); + return tcs.Task; + } + public virtual Task StartAsync(CancellationToken cancellationToken) { SubscribeToEvents(); @@ -87,6 +121,7 @@ namespace BTCPayServer.HostedServices public virtual async Task StopAsync(CancellationToken cancellationToken) { + _Events.Writer.TryComplete(); _Subscriptions.ForEach(subscription => subscription.Dispose()); _Cts.Cancel(); try diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs index 97dbe41f8..988dc3142 100644 --- a/BTCPayServer/Hosting/Startup.cs +++ b/BTCPayServer/Hosting/Startup.cs @@ -168,6 +168,12 @@ namespace BTCPayServer.Hosting // /Components/{View Component Name}/{View Name}.cshtml o.ViewLocationFormats.Add("/{0}.cshtml"); o.PageViewLocationFormats.Add("/{0}.cshtml"); + + // Allows the use of Area for plugins + o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{1}/{0}.cshtml"); + o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{0}.cshtml"); + o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/Shared/{0}.cshtml"); + o.AreaViewLocationFormats.Add("/{0}.cshtml"); }) .AddNewtonsoftJson() .AddPlugins(services, Configuration, LoggerFactory, bootstrapServiceProvider)