diff --git a/BTCPayServer.Abstractions/Contracts/INotificationHandler.cs b/BTCPayServer.Abstractions/Contracts/INotificationHandler.cs index c144e9bd8..999d54085 100644 --- a/BTCPayServer.Abstractions/Contracts/INotificationHandler.cs +++ b/BTCPayServer.Abstractions/Contracts/INotificationHandler.cs @@ -2,10 +2,17 @@ using System; namespace BTCPayServer.Contracts { + public abstract class BaseNotification + { + public abstract string Identifier { get; } + public abstract string NotificationType { get; } + } + public interface INotificationHandler { string NotificationType { get; } Type NotificationBlobType { get; } + public (string identifier, string name)[] Meta { get; } void FillViewModel(object notification, NotificationViewModel vm); } diff --git a/BTCPayServer.Data/Data/ApplicationUser.cs b/BTCPayServer.Data/Data/ApplicationUser.cs index 80c86dd43..f6825ced1 100644 --- a/BTCPayServer.Data/Data/ApplicationUser.cs +++ b/BTCPayServer.Data/Data/ApplicationUser.cs @@ -28,5 +28,6 @@ namespace BTCPayServer.Data public List U2FDevices { get; set; } public List APIKeys { get; set; } public DateTimeOffset? Created { get; set; } + public string DisabledNotifications { get; set; } } } diff --git a/BTCPayServer.Data/Migrations/20201015151438_AddDisabledNotificationsToUser.cs b/BTCPayServer.Data/Migrations/20201015151438_AddDisabledNotificationsToUser.cs new file mode 100644 index 000000000..0bdb584f7 --- /dev/null +++ b/BTCPayServer.Data/Migrations/20201015151438_AddDisabledNotificationsToUser.cs @@ -0,0 +1,26 @@ +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20201015151438_AddDisabledNotificationsToUser")] + public partial class AddDisabledNotificationsToUser : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DisabledNotifications", + table: "AspNetUsers", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DisabledNotifications", + table: "AspNetUsers"); + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index 0473d9fa6..db897fb04 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -111,6 +111,9 @@ namespace BTCPayServer.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("DisabledNotifications") + .HasColumnType("TEXT"); + b.Property("Email") .HasColumnType("TEXT") .HasMaxLength(256); diff --git a/BTCPayServer/Components/NotificationsDropdown/Default.cshtml b/BTCPayServer/Components/NotificationsDropdown/Default.cshtml index 01d6efc42..aa6f8f9b3 100644 --- a/BTCPayServer/Components/NotificationsDropdown/Default.cshtml +++ b/BTCPayServer/Components/NotificationsDropdown/Default.cshtml @@ -1,4 +1,7 @@ @inject LinkGenerator linkGenerator +@inject UserManager UserManager +@inject CssThemeManager CssThemeManager +@using BTCPayServer.HostedServices @model BTCPayServer.Components.NotificationsDropdown.NotificationSummaryViewModel @if (Model.UnseenCount > 0) @@ -32,36 +35,47 @@ else } - } - - diff --git a/BTCPayServer/Controllers/ManageController.Notifications.cs b/BTCPayServer/Controllers/ManageController.Notifications.cs new file mode 100644 index 000000000..df90cecf0 --- /dev/null +++ b/BTCPayServer/Controllers/ManageController.Notifications.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Contracts; +using BTCPayServer.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace BTCPayServer.Controllers +{ + public partial class ManageController + { + [HttpGet("notifications")] + public async Task NotificationSettings([FromServices] IEnumerable notificationHandlers) + { + var user = await _userManager.GetUserAsync(User); + if (user.DisabledNotifications == "all") + { + return View(new NotificationSettingsViewModel() {All = true}); + } + var disabledNotifications = + user.DisabledNotifications?.Split(';', StringSplitOptions.RemoveEmptyEntries)?.ToList() ?? + new List(); + var notifications = notificationHandlers.SelectMany(handler => handler.Meta.Select(tuple => + new SelectListItem(tuple.name, tuple.identifier, + disabledNotifications.Contains(tuple.identifier, StringComparer.InvariantCultureIgnoreCase)))) + .ToList(); + + return View(new NotificationSettingsViewModel() {DisabledNotifications = notifications}); + } + + [HttpPost("notifications")] + public async Task NotificationSettings(NotificationSettingsViewModel vm, string command) + { + var user = await _userManager.GetUserAsync(User); + if (command == "disable-all") + { + user.DisabledNotifications = "all"; + } + else if (command == "enable-all") + { + user.DisabledNotifications = ""; + } + else if (command == "update") + { + var disabled = vm.DisabledNotifications.Where(item => item.Selected).Select(item => item.Value) + .ToArray(); + user.DisabledNotifications = disabled.Any() is true + ? string.Join(';', disabled) + ";" + : string.Empty; + } + + await _userManager.UpdateAsync(user); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = "Updated successfully.", Severity = StatusMessageModel.StatusSeverity.Success + }); + return RedirectToAction("NotificationSettings"); + } + + public class NotificationSettingsViewModel + { + public bool All { get; set; } + public List DisabledNotifications { get; set; } + } + } +} diff --git a/BTCPayServer/Controllers/NotificationsController.cs b/BTCPayServer/Controllers/NotificationsController.cs index 45fa047be..d823bcb01 100644 --- a/BTCPayServer/Controllers/NotificationsController.cs +++ b/BTCPayServer/Controllers/NotificationsController.cs @@ -89,11 +89,11 @@ namespace BTCPayServer.Controllers } #if DEBUG [HttpGet] - public async Task GenerateJunk(int x = 100) + public async Task GenerateJunk(int x = 100, bool admin=true) { for (int i = 0; i < x; i++) { - await _notificationSender.SendNotification(new AdminScope(), new JunkNotification()); + await _notificationSender.SendNotification(admin? (NotificationScope) new AdminScope(): new UserScope(_userManager.GetUserId(User)), new JunkNotification()); } return RedirectToAction("Index"); diff --git a/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs b/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs index 2edbd2b46..4853b0f92 100644 --- a/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs +++ b/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs @@ -9,8 +9,9 @@ using Microsoft.AspNetCore.Routing; namespace BTCPayServer.Services.Notifications.Blobs { - internal class InvoiceEventNotification + internal class InvoiceEventNotification:BaseNotification { + private const string TYPE = "invoicestate"; internal class Handler : NotificationHandler { private readonly LinkGenerator _linkGenerator; @@ -22,7 +23,16 @@ namespace BTCPayServer.Services.Notifications.Blobs _options = options; } - public override string NotificationType => "invoicestate"; + public override string NotificationType => TYPE; + + public override (string identifier, string name)[] Meta + { + get + { + return new (string identifier, string name)[] {(TYPE, "All invoice updates"),} + .Concat(TextMapping.Select(pair => ($"{TYPE}_{pair.Key}", $"Invoice {pair.Value}"))).ToArray(); + } + } internal static Dictionary TextMapping = new Dictionary() { @@ -65,5 +75,7 @@ namespace BTCPayServer.Services.Notifications.Blobs public string InvoiceId { get; set; } public string Event { get; set; } + public override string Identifier => $"{TYPE}_{Event}"; + public override string NotificationType => TYPE; } } diff --git a/BTCPayServer/Services/Notifications/Blobs/JunkNotification.cs b/BTCPayServer/Services/Notifications/Blobs/JunkNotification.cs index c5149ef68..c334377db 100644 --- a/BTCPayServer/Services/Notifications/Blobs/JunkNotification.cs +++ b/BTCPayServer/Services/Notifications/Blobs/JunkNotification.cs @@ -1,19 +1,31 @@ #if DEBUG +using System.Data; using BTCPayServer.Contracts; namespace BTCPayServer.Services.Notifications.Blobs { - internal class JunkNotification + internal class JunkNotification: BaseNotification { + private const string TYPE = "junk"; internal class Handler : NotificationHandler { - public override string NotificationType => "junk"; + public override string NotificationType => TYPE; + public override (string identifier, string name)[] Meta + { + get + { + return new (string identifier, string name)[] {(TYPE, "Junk")}; + } + } protected override void FillViewModel(JunkNotification notification, NotificationViewModel vm) { vm.Body = $"All your junk r belong to us!"; } } + + public override string Identifier => NotificationType; + public override string NotificationType => TYPE; } } #endif diff --git a/BTCPayServer/Services/Notifications/Blobs/NewVersionNotification.cs b/BTCPayServer/Services/Notifications/Blobs/NewVersionNotification.cs index ad6029e44..1959e11e8 100644 --- a/BTCPayServer/Services/Notifications/Blobs/NewVersionNotification.cs +++ b/BTCPayServer/Services/Notifications/Blobs/NewVersionNotification.cs @@ -3,11 +3,20 @@ using BTCPayServer.Models.NotificationViewModels; namespace BTCPayServer.Services.Notifications.Blobs { - internal class NewVersionNotification + internal class NewVersionNotification:BaseNotification { + private const string TYPE = "newversion"; internal class Handler : NotificationHandler { - public override string NotificationType => "newversion"; + public override string NotificationType => TYPE; + public override (string identifier, string name)[] Meta + { + get + { + return new (string identifier, string name)[] {(TYPE, "New version")}; + } + } + protected override void FillViewModel(NewVersionNotification notification, NotificationViewModel vm) { vm.Body = $"New version {notification.Version} released!"; @@ -23,5 +32,7 @@ namespace BTCPayServer.Services.Notifications.Blobs Version = version; } public string Version { get; set; } + public override string Identifier => TYPE; + public override string NotificationType => TYPE; } } diff --git a/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs b/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs index 59719e953..5708c212b 100644 --- a/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs +++ b/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs @@ -6,8 +6,10 @@ using Microsoft.AspNetCore.Routing; namespace BTCPayServer.Services.Notifications.Blobs { - public class PayoutNotification + public class PayoutNotification : BaseNotification { + private const string TYPE = "payout"; + internal class Handler : NotificationHandler { private readonly LinkGenerator _linkGenerator; @@ -18,18 +20,30 @@ namespace BTCPayServer.Services.Notifications.Blobs _linkGenerator = linkGenerator; _options = options; } - public override string NotificationType => "payout"; + + public override string NotificationType => TYPE; + public override (string identifier, string name)[] Meta + { + get + { + return new (string identifier, string name)[] {(TYPE, "Payouts")}; + } + } + protected override void FillViewModel(PayoutNotification notification, NotificationViewModel vm) { vm.Body = "A new payout is awaiting for approval"; vm.ActionLink = _linkGenerator.GetPathByAction(nameof(WalletsController.Payouts), "Wallets", - new { walletId = new WalletId(notification.StoreId, notification.PaymentMethod) }, _options.RootPath); + new {walletId = new WalletId(notification.StoreId, notification.PaymentMethod)}, _options.RootPath); } } + public string PayoutId { get; set; } public string StoreId { get; set; } public string PaymentMethod { get; set; } public string Currency { get; set; } + public override string Identifier => TYPE; + public override string NotificationType => TYPE; } } diff --git a/BTCPayServer/Services/Notifications/INotificationHandler.cs b/BTCPayServer/Services/Notifications/INotificationHandler.cs index 204870365..abaf21b1e 100644 --- a/BTCPayServer/Services/Notifications/INotificationHandler.cs +++ b/BTCPayServer/Services/Notifications/INotificationHandler.cs @@ -1,6 +1,5 @@ using System; using BTCPayServer.Contracts; -using BTCPayServer.Models.NotificationViewModels; namespace BTCPayServer.Services.Notifications { @@ -9,6 +8,8 @@ namespace BTCPayServer.Services.Notifications { public abstract string NotificationType { get; } Type INotificationHandler.NotificationBlobType => typeof(TNotification); + public abstract (string identifier, string name)[] Meta { get; } + void INotificationHandler.FillViewModel(object notification, NotificationViewModel vm) { FillViewModel((TNotification)notification, vm); diff --git a/BTCPayServer/Services/Notifications/NotificationManager.cs b/BTCPayServer/Services/Notifications/NotificationManager.cs index e5d350582..1f548396d 100644 --- a/BTCPayServer/Services/Notifications/NotificationManager.cs +++ b/BTCPayServer/Services/Notifications/NotificationManager.cs @@ -21,7 +21,6 @@ namespace BTCPayServer.Services.Notifications private readonly IMemoryCache _memoryCache; private readonly EventAggregator _eventAggregator; private readonly Dictionary _handlersByNotificationType; - private readonly Dictionary _handlersByBlobType; public NotificationManager(ApplicationDbContextFactory factory, UserManager userManager, IMemoryCache memoryCache, IEnumerable handlers, EventAggregator eventAggregator) @@ -31,7 +30,6 @@ namespace BTCPayServer.Services.Notifications _memoryCache = memoryCache; _eventAggregator = eventAggregator; _handlersByNotificationType = handlers.ToDictionary(h => h.NotificationType); - _handlersByBlobType = handlers.ToDictionary(h => h.NotificationBlobType); } private const int _cacheExpiryMs = 5000; @@ -118,12 +116,5 @@ namespace BTCPayServer.Services.Notifications return h; throw new InvalidOperationException($"No INotificationHandler found for {notificationId}"); } - - public INotificationHandler GetHandler(Type blobType) - { - if (_handlersByBlobType.TryGetValue(blobType, out var h)) - return h; - throw new InvalidOperationException($"No INotificationHandler found for {blobType.Name}"); - } } } diff --git a/BTCPayServer/Services/Notifications/NotificationScopes.cs b/BTCPayServer/Services/Notifications/NotificationScopes.cs index 68278ad04..0415258d6 100644 --- a/BTCPayServer/Services/Notifications/NotificationScopes.cs +++ b/BTCPayServer/Services/Notifications/NotificationScopes.cs @@ -18,6 +18,16 @@ namespace BTCPayServer.Services.Notifications } public string StoreId { get; } } + public class UserScope : NotificationScope + { + public UserScope(string userId) + { + if (userId == null) + throw new ArgumentNullException(nameof(userId)); + UserId = userId; + } + public string UserId { get; } + } public interface NotificationScope { diff --git a/BTCPayServer/Services/Notifications/NotificationSender.cs b/BTCPayServer/Services/Notifications/NotificationSender.cs index 3f3d36d52..3b5df17dc 100644 --- a/BTCPayServer/Services/Notifications/NotificationSender.cs +++ b/BTCPayServer/Services/Notifications/NotificationSender.cs @@ -1,8 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using BTCPayServer.Contracts; using BTCPayServer.Data; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; namespace BTCPayServer.Services.Notifications @@ -24,13 +27,13 @@ namespace BTCPayServer.Services.Notifications _notificationManager = notificationManager; } - public async Task SendNotification(NotificationScope scope, object notification) + public async Task SendNotification(NotificationScope scope, BaseNotification notification) { if (scope == null) throw new ArgumentNullException(nameof(scope)); if (notification == null) throw new ArgumentNullException(nameof(notification)); - var users = await GetUsers(scope); + var users = await GetUsers(scope, notification.Identifier); using (var db = _contextFactory.CreateContext()) { foreach (var uid in users) @@ -41,7 +44,7 @@ namespace BTCPayServer.Services.Notifications Id = Guid.NewGuid().ToString(), Created = DateTimeOffset.UtcNow, ApplicationUserId = uid, - NotificationType = _notificationManager.GetHandler(notification.GetType()).NotificationType, + NotificationType = notification.NotificationType, Blob = ZipUtils.Zip(obj), Seen = false }; @@ -55,22 +58,47 @@ namespace BTCPayServer.Services.Notifications } } - private async Task GetUsers(NotificationScope scope) + private async Task GetUsers(NotificationScope scope, string notificationIdentifier) { - if (scope is AdminScope) + await using var ctx = _contextFactory.CreateContext(); + + var split = notificationIdentifier.Split('_', StringSplitOptions.None); + var terms = new List(); + foreach (var t in split) { - var admins = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin); - return admins.Select(a => a.Id).ToArray(); + terms.Add(terms.Any() ? $"{terms.Last().TrimEnd(';')}_{t};" : $"{t};"); } - if (scope is StoreScope s) + IQueryable query; + switch (scope) { - using var ctx = _contextFactory.CreateContext(); - return ctx.UserStore - .Where(u => u.StoreDataId == s.StoreId) - .Select(u => u.ApplicationUserId) - .ToArray(); + case AdminScope _: + { + query = _userManager.GetUsersInRoleAsync(Roles.ServerAdmin).Result.AsQueryable(); + + break; + } + case StoreScope s: + query = ctx.UserStore + .Include(store => store.ApplicationUser) + .Where(u => u.StoreDataId == s.StoreId) + .Select(u => u.ApplicationUser); + break; + case UserScope userScope: + query = ctx.Users + .Where(user => user.Id == userScope.UserId); + break; + default: + throw new NotSupportedException("Notification scope not supported"); + + } - throw new NotSupportedException("Notification scope not supported"); + query = query.Where(store => store.DisabledNotifications != "all"); + foreach (string term in terms) + { + query = query.Where(user => user.DisabledNotifications == null || !user.DisabledNotifications.Contains(term)); + } + + return query.Select(user => user.Id).ToArray(); } } } diff --git a/BTCPayServer/Services/PoliciesSettings.cs b/BTCPayServer/Services/PoliciesSettings.cs index a2305e671..ce853f918 100644 --- a/BTCPayServer/Services/PoliciesSettings.cs +++ b/BTCPayServer/Services/PoliciesSettings.cs @@ -25,7 +25,9 @@ namespace BTCPayServer.Services [Display(Name = "Allow non-admins to import their hot wallets to the node wallet")] public bool AllowHotWalletRPCImportForAll { get; set; } [Display(Name = "Check releases on GitHub and alert when new BTCPayServer version is available")] - public bool CheckForNewVersions { get; set; } + public bool CheckForNewVersions { get; set; } + [Display(Name = "Disable notifications automatically showing (no websockets)")] + public bool DisableInstantNotifications { get; set; } [Display(Name = "Display app on website root")] public string RootAppId { get; set; } diff --git a/BTCPayServer/Views/Manage/ManageNavPages.cs b/BTCPayServer/Views/Manage/ManageNavPages.cs index 044ef5903..55990d785 100644 --- a/BTCPayServer/Views/Manage/ManageNavPages.cs +++ b/BTCPayServer/Views/Manage/ManageNavPages.cs @@ -2,6 +2,6 @@ namespace BTCPayServer.Views.Manage { public enum ManageNavPages { - Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys + Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys, Notifications } } diff --git a/BTCPayServer/Views/Manage/NotificationSettings.cshtml b/BTCPayServer/Views/Manage/NotificationSettings.cshtml new file mode 100644 index 000000000..fde970170 --- /dev/null +++ b/BTCPayServer/Views/Manage/NotificationSettings.cshtml @@ -0,0 +1,45 @@ +@using BTCPayServer.Contracts +@model BTCPayServer.Controllers.ManageController.NotificationSettingsViewModel +@inject IEnumerable NotificationHandlers +@{ + ViewData.SetActivePageAndTitle(ManageNavPages.Notifications, "Notification preferences"); +} + + +
+ @if (Model.All) + { +
+ All notifications are disabled. + +
+ } + else + { +
+ + + +
+ +
    + @for (var index = 0; index < Model.DisabledNotifications.Count; index++) + { + var item = Model.DisabledNotifications[index]; +
  • + + + +
  • + } +
+
+
+
+ + +
+ } +
diff --git a/BTCPayServer/Views/Manage/_Nav.cshtml b/BTCPayServer/Views/Manage/_Nav.cshtml index fc68d22a2..16b4f810e 100644 --- a/BTCPayServer/Views/Manage/_Nav.cshtml +++ b/BTCPayServer/Views/Manage/_Nav.cshtml @@ -6,5 +6,6 @@ Two-factor authentication U2F Authentication API Keys + Notifications diff --git a/BTCPayServer/Views/Server/Policies.cshtml b/BTCPayServer/Views/Server/Policies.cshtml index 92ff442e5..b8a7cf453 100644 --- a/BTCPayServer/Views/Server/Policies.cshtml +++ b/BTCPayServer/Views/Server/Policies.cshtml @@ -42,6 +42,11 @@ +
+ + + +
@if (ViewBag.UpdateUrlPresent) {