GreenField: Notifications API (#2055)

* GreenField: Notifications API

This refactors notifications so that we dont have a bunch of duplicated direct access to db contexts in controllers and then introduces new endpoints to fetch/toggle seen/remove  notifications of the current user.

* add tests + docs

* fix test

* pr changes

* fix permission json
This commit is contained in:
Andrew Camilleri 2020-12-11 15:11:08 +01:00 committed by GitHub
parent 1c58fabc30
commit 0652e30c30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 606 additions and 109 deletions

View File

@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Client
{
public partial class BTCPayServerClient
{
public virtual async Task<IEnumerable<NotificationData>> GetNotifications(bool? seen = null,
CancellationToken token = default)
{
var response =
await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/users/me/notifications",
seen != null ? new Dictionary<string, object>() {{nameof(seen), seen}} : null), token);
return await HandleResponse<IEnumerable<NotificationData>>(response);
}
public virtual async Task<NotificationData> GetNotification(string notificationId,
CancellationToken token = default)
{
var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/users/me/notifications/{notificationId}"), token);
return await HandleResponse<NotificationData>(response);
}
public virtual async Task<NotificationData> UpdateNotification(string notificationId, bool? seen,
CancellationToken token = default)
{
var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/users/me/notifications/{notificationId}",
method: HttpMethod.Put, bodyPayload: new UpdateNotification() {Seen = seen}), token);
return await HandleResponse<NotificationData>(response);
}
public virtual async Task RemoveNotification(string notificationId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/users/me/notifications/{notificationId}",
method: HttpMethod.Delete), token);
await HandleResponse(response);
}
}
}

View File

@ -0,0 +1,16 @@
using System;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models
{
public class NotificationData
{
public string Id { get; set; }
public string Body { get; set; }
public bool Seen { get; set; }
public Uri Link { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset CreatedTime { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace BTCPayServer.Client.Models
{
public class UpdateNotification
{
public bool? Seen { get; set; }
}
}

View File

@ -21,6 +21,8 @@ namespace BTCPayServer.Client
public const string CanModifyPaymentRequests = "btcpay.store.canmodifypaymentrequests"; public const string CanModifyPaymentRequests = "btcpay.store.canmodifypaymentrequests";
public const string CanModifyProfile = "btcpay.user.canmodifyprofile"; public const string CanModifyProfile = "btcpay.user.canmodifyprofile";
public const string CanViewProfile = "btcpay.user.canviewprofile"; public const string CanViewProfile = "btcpay.user.canviewprofile";
public const string CanManageNotificationsForUser = "btcpay.user.canmanagenotificationsforuser";
public const string CanViewNotificationsForUser = "btcpay.user.canviewnotificationsforuser";
public const string CanCreateUser = "btcpay.server.cancreateuser"; public const string CanCreateUser = "btcpay.server.cancreateuser";
public const string CanManagePullPayments = "btcpay.store.canmanagepullpayments"; public const string CanManagePullPayments = "btcpay.store.canmanagepullpayments";
public const string Unrestricted = "unrestricted"; public const string Unrestricted = "unrestricted";
@ -39,6 +41,8 @@ namespace BTCPayServer.Client
yield return CanModifyProfile; yield return CanModifyProfile;
yield return CanViewProfile; yield return CanViewProfile;
yield return CanCreateUser; yield return CanCreateUser;
yield return CanManageNotificationsForUser;
yield return CanViewNotificationsForUser;
yield return Unrestricted; yield return Unrestricted;
yield return CanUseInternalLightningNode; yield return CanUseInternalLightningNode;
yield return CanCreateLightningInvoiceInternalNode; yield return CanCreateLightningInvoiceInternalNode;
@ -168,6 +172,7 @@ namespace BTCPayServer.Client
case Policies.CanViewPaymentRequests when this.Policy == Policies.CanViewStoreSettings: case Policies.CanViewPaymentRequests when this.Policy == Policies.CanViewStoreSettings:
case Policies.CanCreateLightningInvoiceInternalNode when this.Policy == Policies.CanUseInternalLightningNode: case Policies.CanCreateLightningInvoiceInternalNode when this.Policy == Policies.CanUseInternalLightningNode:
case Policies.CanCreateLightningInvoiceInStore when this.Policy == Policies.CanUseLightningNodeInStore: case Policies.CanCreateLightningInvoiceInStore when this.Policy == Policies.CanUseLightningNodeInStore:
case Policies.CanViewNotificationsForUser when this.Policy == Policies.CanManageNotificationsForUser:
return true; return true;
default: default:
return false; return false;

View File

@ -12,7 +12,8 @@ using BTCPayServer.JsonConverters;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Tests.Logging; using BTCPayServer.Tests.Logging;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -21,7 +22,6 @@ using NBitcoin.OpenAsset;
using NBitpayClient; using NBitpayClient;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using NUglify.Helpers;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest; using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest;
@ -1154,6 +1154,46 @@ namespace BTCPayServer.Tests
Assert.NotEqual(0, info.BlockHeight); Assert.NotEqual(0, info.BlockHeight);
} }
} }
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task NotificationAPITests()
{
using var tester = ServerTester.Create();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync(true);
var client = await user.CreateClient(Policies.CanManageNotificationsForUser);
var viewOnlyClient = await user.CreateClient(Policies.CanViewNotificationsForUser);
await tester.PayTester.GetService<NotificationSender>()
.SendNotification(new UserScope(user.UserId), new NewVersionNotification());
Assert.Single(await viewOnlyClient.GetNotifications());
Assert.Single(await viewOnlyClient.GetNotifications(false));
Assert.Empty(await viewOnlyClient.GetNotifications(true));
Assert.Single(await client.GetNotifications());
Assert.Single(await client.GetNotifications(false));
Assert.Empty(await client.GetNotifications(true));
var notification = (await client.GetNotifications()).First();
notification = await client.GetNotification(notification.Id);
Assert.False(notification.Seen);
await AssertHttpError(403, async () =>
{
await viewOnlyClient.UpdateNotification(notification.Id, true);
});
await AssertHttpError(403, async () =>
{
await viewOnlyClient.RemoveNotification(notification.Id);
});
Assert.True((await client.UpdateNotification(notification.Id, true)).Seen);
Assert.Single(await viewOnlyClient.GetNotifications(true));
Assert.Empty(await viewOnlyClient.GetNotifications(false));
await client.RemoveNotification(notification.Id);
Assert.Empty(await viewOnlyClient.GetNotifications(true));
Assert.Empty(await viewOnlyClient.GetNotifications(false));
}
@ -1197,5 +1237,6 @@ namespace BTCPayServer.Tests
Assert.Equal(1.2, jsonConverter.ReadJson(Get(stringJson), typeof(double), null, null)); Assert.Equal(1.2, jsonConverter.ReadJson(Get(stringJson), typeof(double), null, null));
Assert.Equal(1.2, jsonConverter.ReadJson(Get(stringJson), typeof(double?), null, null)); Assert.Equal(1.2, jsonConverter.ReadJson(Get(stringJson), typeof(double?), null, null));
} }
} }
} }

View File

@ -1322,7 +1322,7 @@ namespace BTCPayServer.Tests
var resp = await ctrl.Generate(newVersion); var resp = await ctrl.Generate(newVersion);
var vm = Assert.IsType<Models.NotificationViewModels.IndexViewModel>( var vm = Assert.IsType<Models.NotificationViewModels.IndexViewModel>(
Assert.IsType<ViewResult>(ctrl.Index()).Model); Assert.IsType<ViewResult>(await ctrl.Index()).Model);
Assert.True(vm.Skip == 0); Assert.True(vm.Skip == 0);
Assert.True(vm.Count == 50); Assert.True(vm.Count == 50);
@ -3330,7 +3330,7 @@ namespace BTCPayServer.Tests
var newVersion = MockVersionFetcher.MOCK_NEW_VERSION; var newVersion = MockVersionFetcher.MOCK_NEW_VERSION;
var vm = Assert.IsType<Models.NotificationViewModels.IndexViewModel>( var vm = Assert.IsType<Models.NotificationViewModels.IndexViewModel>(
Assert.IsType<ViewResult>(ctrl.Index()).Model); Assert.IsType<ViewResult>(await ctrl.Index()).Model);
Assert.True(vm.Skip == 0); Assert.True(vm.Skip == 0);
Assert.True(vm.Count == 50); Assert.True(vm.Count == 50);

View File

@ -0,0 +1,105 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.Notifications;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NotificationData = BTCPayServer.Client.Models.NotificationData;
namespace BTCPayServer.Controllers.GreenField
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class NotificationsController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly NotificationManager _notificationManager;
public NotificationsController(UserManager<ApplicationUser> userManager,
NotificationManager notificationManager)
{
_userManager = userManager;
_notificationManager = notificationManager;
}
[Authorize(Policy = Policies.CanViewNotificationsForUser,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/users/me/notifications")]
public async Task<IActionResult> GetNotifications(bool? seen = null)
{
var items = await _notificationManager.GetNotifications(new NotificationsQuery()
{
Seen = seen, UserId = _userManager.GetUserId(User)
});
return Ok(items.Items.Select(ToModel));
}
[Authorize(Policy = Policies.CanViewNotificationsForUser,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/users/me/notifications/{id}")]
public async Task<IActionResult> GetNotification(string id)
{
var items = await _notificationManager.GetNotifications(new NotificationsQuery()
{
Ids = new[] {id}, UserId = _userManager.GetUserId(User)
});
if (items.Count == 0)
{
return NotFound();
}
return Ok(ToModel(items.Items.First()));
}
[Authorize(Policy = Policies.CanManageNotificationsForUser,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/users/me/notifications/{id}")]
public async Task<IActionResult> UpdateNotification(string id, UpdateNotification request)
{
var items = await _notificationManager.ToggleSeen(
new NotificationsQuery() {Ids = new[] {id}, UserId = _userManager.GetUserId(User)}, request.Seen);
if (items.Count == 0)
{
return NotFound();
}
return Ok(ToModel(items.First()));
}
[Authorize(Policy = Policies.CanManageNotificationsForUser,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpDelete("~/api/v1/users/me/notifications/{id}")]
public async Task<IActionResult> DeleteNotification(string id)
{
await _notificationManager.Remove(new NotificationsQuery()
{
Ids = new[] {id}, UserId = _userManager.GetUserId(User)
});
return Ok();
}
private NotificationData ToModel(NotificationViewModel entity)
{
return new NotificationData()
{
Id = entity.Id,
CreatedTime = entity.Created,
Body = entity.Body,
Seen = entity.Seen,
Link = string.IsNullOrEmpty(entity.ActionLink) ? null : new Uri(entity.ActionLink)
};
}
}
}

View File

@ -18,12 +18,12 @@ namespace BTCPayServer.Controllers.GreenField
[ApiController] [ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)] [EnableCors(CorsPolicies.All)]
public class GreenFieldController : ControllerBase public class GreenFieldStoresController : ControllerBase
{ {
private readonly StoreRepository _storeRepository; private readonly StoreRepository _storeRepository;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
public GreenFieldController(StoreRepository storeRepository, UserManager<ApplicationUser> userManager) public GreenFieldStoresController(StoreRepository storeRepository, UserManager<ApplicationUser> userManager)
{ {
_storeRepository = storeRepository; _storeRepository = storeRepository;
_userManager = userManager; _userManager = userManager;

View File

@ -17,7 +17,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NicolasDorier.RateLimits; using NicolasDorier.RateLimits;
namespace BTCPayServer.Controllers.GreenField namespace BTCPayServer.Controllers.GreenField
@ -28,7 +27,6 @@ namespace BTCPayServer.Controllers.GreenField
public class UsersController : ControllerBase public class UsersController : ControllerBase
{ {
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly BTCPayServerOptions _btcPayServerOptions;
private readonly RoleManager<IdentityRole> _roleManager; private readonly RoleManager<IdentityRole> _roleManager;
private readonly SettingsRepository _settingsRepository; private readonly SettingsRepository _settingsRepository;
private readonly EventAggregator _eventAggregator; private readonly EventAggregator _eventAggregator;
@ -38,8 +36,9 @@ namespace BTCPayServer.Controllers.GreenField
private readonly IAuthorizationService _authorizationService; private readonly IAuthorizationService _authorizationService;
private readonly CssThemeManager _themeManager; private readonly CssThemeManager _themeManager;
public UsersController(UserManager<ApplicationUser> userManager, BTCPayServerOptions btcPayServerOptions, public UsersController(UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager, SettingsRepository settingsRepository, RoleManager<IdentityRole> roleManager,
SettingsRepository settingsRepository,
EventAggregator eventAggregator, EventAggregator eventAggregator,
IPasswordValidator<ApplicationUser> passwordValidator, IPasswordValidator<ApplicationUser> passwordValidator,
RateLimitService throttleService, RateLimitService throttleService,
@ -48,7 +47,6 @@ namespace BTCPayServer.Controllers.GreenField
CssThemeManager themeManager) CssThemeManager themeManager)
{ {
_userManager = userManager; _userManager = userManager;
_btcPayServerOptions = btcPayServerOptions;
_roleManager = roleManager; _roleManager = roleManager;
_settingsRepository = settingsRepository; _settingsRepository = settingsRepository;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;

View File

@ -474,6 +474,8 @@ namespace BTCPayServer.Controllers
{BTCPayServer.Client.Policies.CanModifyServerSettings, ("Manage your server", "The app will have total control on the server settings of your server")}, {BTCPayServer.Client.Policies.CanModifyServerSettings, ("Manage your server", "The app will have total control on the server settings of your server")},
{BTCPayServer.Client.Policies.CanViewProfile, ("View your profile", "The app will be able to view your user profile.")}, {BTCPayServer.Client.Policies.CanViewProfile, ("View your profile", "The app will be able to view your user profile.")},
{BTCPayServer.Client.Policies.CanModifyProfile, ("Manage your profile", "The app will be able to view and modify your user profile.")}, {BTCPayServer.Client.Policies.CanModifyProfile, ("Manage your profile", "The app will be able to view and modify your user profile.")},
{BTCPayServer.Client.Policies.CanManageNotificationsForUser, ("Manage your notifications", "The app will be able to view and modify your user notifications.")},
{BTCPayServer.Client.Policies.CanViewNotificationsForUser, ("View your notifications", "The app will be able to view your user notifications.")},
{BTCPayServer.Client.Policies.CanCreateInvoice, ("Create an invoice", "The app will be able to create new invoices.")}, {BTCPayServer.Client.Policies.CanCreateInvoice, ("Create an invoice", "The app will be able to create new invoices.")},
{$"{BTCPayServer.Client.Policies.CanCreateInvoice}:", ("Create an invoice", "The app will be able to create new invoices on the selected stores.")}, {$"{BTCPayServer.Client.Policies.CanCreateInvoice}:", ("Create an invoice", "The app will be able to create new invoices on the selected stores.")},
{BTCPayServer.Client.Policies.CanViewInvoices, ("View invoices", "The app will be able to view invoices.")}, {BTCPayServer.Client.Policies.CanViewInvoices, ("View invoices", "The app will be able to view invoices.")},

View File

@ -22,21 +22,18 @@ namespace BTCPayServer.Controllers
public class NotificationsController : Controller public class NotificationsController : Controller
{ {
private readonly BTCPayServerEnvironment _env; private readonly BTCPayServerEnvironment _env;
private readonly ApplicationDbContext _db;
private readonly NotificationSender _notificationSender; private readonly NotificationSender _notificationSender;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly NotificationManager _notificationManager; private readonly NotificationManager _notificationManager;
private readonly EventAggregator _eventAggregator; private readonly EventAggregator _eventAggregator;
public NotificationsController(BTCPayServerEnvironment env, public NotificationsController(BTCPayServerEnvironment env,
ApplicationDbContext db,
NotificationSender notificationSender, NotificationSender notificationSender,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
NotificationManager notificationManager, NotificationManager notificationManager,
EventAggregator eventAggregator) EventAggregator eventAggregator)
{ {
_env = env; _env = env;
_db = db;
_notificationSender = notificationSender; _notificationSender = notificationSender;
_userManager = userManager; _userManager = userManager;
_notificationManager = notificationManager; _notificationManager = notificationManager;
@ -57,6 +54,7 @@ namespace BTCPayServer.Controllers
{ {
return BadRequest(); return BadRequest();
} }
var websocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); var websocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
var userId = _userManager.GetUserId(User); var userId = _userManager.GetUserId(User);
var websocketHelper = new WebSocketHelper(websocket); var websocketHelper = new WebSocketHelper(websocket);
@ -90,34 +88,30 @@ namespace BTCPayServer.Controllers
} }
#if DEBUG #if DEBUG
[HttpGet] [HttpGet]
public async Task<IActionResult> GenerateJunk(int x = 100, bool admin=true) public async Task<IActionResult> GenerateJunk(int x = 100, bool admin = true)
{ {
for (int i = 0; i < x; i++) for (int i = 0; i < x; i++)
{ {
await _notificationSender.SendNotification(admin? (NotificationScope) new AdminScope(): new UserScope(_userManager.GetUserId(User)), new JunkNotification()); await _notificationSender.SendNotification(
admin ? (NotificationScope)new AdminScope() : new UserScope(_userManager.GetUserId(User)),
new JunkNotification());
} }
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
#endif #endif
[HttpGet] [HttpGet]
public IActionResult Index(int skip = 0, int count = 50, int timezoneOffset = 0) public async Task<IActionResult> Index(int skip = 0, int count = 50, int timezoneOffset = 0)
{ {
if (!ValidUserClaim(out var userId)) if (!ValidUserClaim(out var userId))
return RedirectToAction("Index", "Home"); return RedirectToAction("Index", "Home");
var model = new IndexViewModel() var res = await _notificationManager.GetNotifications(new NotificationsQuery()
{ {
Skip = skip, Skip = skip, Take = count, UserId = userId
Count = count, });
Items = _db.Notifications
.Where(a => a.ApplicationUserId == userId) var model = new IndexViewModel() {Skip = skip, Count = count, Items = res.Items, Total = res.Count};
.OrderByDescending(a => a.Created)
.Skip(skip).Take(count)
.Select(a => _notificationManager.ToViewModel(a))
.ToList(),
Total = _db.Notifications.Count(a => a.ApplicationUserId == userId)
};
return View(model); return View(model);
} }
@ -136,10 +130,7 @@ namespace BTCPayServer.Controllers
{ {
if (ValidUserClaim(out var userId)) if (ValidUserClaim(out var userId))
{ {
var notif = _db.Notifications.Single(a => a.Id == id && a.ApplicationUserId == userId); await _notificationManager.ToggleSeen(new NotificationsQuery() {Ids = new[] {id}, UserId = userId}, null);
notif.Seen = !notif.Seen;
await _db.SaveChangesAsync();
_notificationManager.InvalidateNotificationCache(userId);
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
@ -151,21 +142,19 @@ namespace BTCPayServer.Controllers
{ {
if (ValidUserClaim(out var userId)) if (ValidUserClaim(out var userId))
{ {
var notif = _db.Notifications.Single(a => a.Id == id && a.ApplicationUserId == userId); var items = await
if (!notif.Seen) _notificationManager.ToggleSeen(new NotificationsQuery()
{ {
notif.Seen = !notif.Seen; Ids = new[] {id}, UserId = userId
await _db.SaveChangesAsync(); }, true);
_notificationManager.InvalidateNotificationCache(userId);
} var link = items.FirstOrDefault()?.ActionLink ?? "";
if (string.IsNullOrEmpty(link))
var vm = _notificationManager.ToViewModel(notif);
if (string.IsNullOrEmpty(vm.ActionLink))
{ {
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
return Redirect(vm.ActionLink); return Redirect(link);
} }
return NotFound(); return NotFound();
@ -188,31 +177,29 @@ namespace BTCPayServer.Controllers
if (selectedItems != null) if (selectedItems != null)
{ {
var items = _db.Notifications.Where(a => a.ApplicationUserId == userId && selectedItems.Contains(a.Id));
switch (command) switch (command)
{ {
case "delete": case "delete":
_db.Notifications.RemoveRange(items); await _notificationManager.Remove(new NotificationsQuery()
{
UserId = userId, Ids = selectedItems
});
break; break;
case "mark-seen": case "mark-seen":
foreach (NotificationData notificationData in items) await _notificationManager.ToggleSeen(new NotificationsQuery()
{ {
notificationData.Seen = true; UserId = userId, Ids = selectedItems, Seen = false
} }, true);
break; break;
case "mark-unseen": case "mark-unseen":
foreach (NotificationData notificationData in items) await _notificationManager.ToggleSeen(new NotificationsQuery()
{ {
notificationData.Seen = false; UserId = userId, Ids = selectedItems, Seen = true
} }, false);
break; break;
} }
await _db.SaveChangesAsync();
_notificationManager.InvalidateNotificationCache(userId);
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }

View File

@ -89,6 +89,8 @@ namespace BTCPayServer.Security.GreenField
success = true; success = true;
} }
break; break;
case Policies.CanManageNotificationsForUser:
case Policies.CanViewNotificationsForUser:
case Policies.CanModifyProfile: case Policies.CanModifyProfile:
case Policies.CanViewProfile: case Policies.CanViewProfile:
case Policies.Unrestricted: case Policies.Unrestricted:

View File

@ -6,7 +6,6 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Components.NotificationsDropdown; using BTCPayServer.Components.NotificationsDropdown;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Models.NotificationViewModels;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
@ -38,21 +37,27 @@ namespace BTCPayServer.Services.Notifications
{ {
var userId = _userManager.GetUserId(user); var userId = _userManager.GetUserId(user);
var cacheKey = GetNotificationsCacheId(userId); var cacheKey = GetNotificationsCacheId(userId);
if (_memoryCache.TryGetValue<NotificationSummaryViewModel>(cacheKey, out var obj))
return obj;
var resp = await FetchNotificationsFromDb(userId); return await _memoryCache.GetOrCreateAsync(cacheKey, async entry =>
_memoryCache.Set(cacheKey, resp, {
new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMilliseconds(_cacheExpiryMs))); var resp = await GetNotifications(new NotificationsQuery()
{
return resp; Seen = false, Skip = 0, Take = 5, UserId = userId
});
entry.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(_cacheExpiryMs));
var res = new NotificationSummaryViewModel() {Last5 = resp.Items, UnseenCount = resp.Count};
entry.Value = res;
return res;
});
} }
public void InvalidateNotificationCache(string userId) public void InvalidateNotificationCache(params string[] userIds)
{ {
_memoryCache.Remove(GetNotificationsCacheId(userId)); foreach (var userId in userIds)
{
_eventAggregator.Publish(new UserNotificationsUpdatedEvent() { UserId = userId }); _memoryCache.Remove(GetNotificationsCacheId(userId));
_eventAggregator.Publish(new UserNotificationsUpdatedEvent() {UserId = userId});
}
} }
private static string GetNotificationsCacheId(string userId) private static string GetNotificationsCacheId(string userId)
@ -60,52 +65,87 @@ namespace BTCPayServer.Services.Notifications
return $"notifications-{userId}"; return $"notifications-{userId}";
} }
private async Task<NotificationSummaryViewModel> FetchNotificationsFromDb(string userId) public async Task<(List<NotificationViewModel> Items, int Count)> GetNotifications(NotificationsQuery query)
{ {
var resp = new NotificationSummaryViewModel(); await using var dbContext = _factory.CreateContext();
using (var _db = _factory.CreateContext())
{
resp.UnseenCount = _db.Notifications
.Where(a => a.ApplicationUserId == userId && !a.Seen)
.Count();
if (resp.UnseenCount > 0) var queryables = GetNotificationsQueryable(dbContext, query);
{
try
{
resp.Last5 = (await _db.Notifications
.Where(a => a.ApplicationUserId == userId && !a.Seen)
.OrderByDescending(a => a.Created)
.Take(5)
.ToListAsync())
.Select(a => ToViewModel(a))
.ToList();
}
catch (System.IO.InvalidDataException)
{
// invalid notifications that are not pkuzipable, burn them all
var notif = _db.Notifications.Where(a => a.ApplicationUserId == userId);
_db.Notifications.RemoveRange(notif);
_db.SaveChanges();
resp.UnseenCount = 0; return (Items: (await queryables.withPaging.ToListAsync()).Select(ToViewModel).ToList(),
resp.Last5 = new List<NotificationViewModel>(); Count: await queryables.withoutPaging.CountAsync());
}
}
else
{
resp.Last5 = new List<NotificationViewModel>();
}
}
return resp;
} }
public NotificationViewModel ToViewModel(NotificationData data) private ( IQueryable<NotificationData> withoutPaging, IQueryable<NotificationData> withPaging)
GetNotificationsQueryable(ApplicationDbContext dbContext, NotificationsQuery query)
{
var queryable = dbContext.Notifications.AsQueryable();
if (query.Ids?.Any() is true)
{
queryable = queryable.Where(data => query.Ids.Contains(data.Id));
}
if (!string.IsNullOrEmpty(query.UserId))
{
queryable = queryable.Where(data => data.ApplicationUserId == query.UserId);
}
if (query.Seen.HasValue)
{
queryable = queryable.Where(data => data.Seen == query.Seen);
}
queryable = queryable.OrderByDescending(a => a.Created);
var queryable2 = queryable;
if (query.Skip.HasValue)
{
queryable2 = queryable.Skip(query.Skip.Value);
}
if (query.Take.HasValue)
{
queryable2 = queryable.Take(query.Take.Value);
}
return (queryable, queryable2);
}
public async Task<List<NotificationViewModel>> ToggleSeen(NotificationsQuery notificationsQuery, bool? setSeen)
{
await using var dbContext = _factory.CreateContext();
var queryables = GetNotificationsQueryable(dbContext, notificationsQuery);
var items = await queryables.withPaging.ToListAsync();
var userIds = items.Select(data => data.ApplicationUserId).Distinct();
foreach (var notificationData in items)
{
notificationData.Seen = setSeen.GetValueOrDefault(!notificationData.Seen);
}
await dbContext.SaveChangesAsync();
InvalidateNotificationCache(userIds.ToArray());
return items.Select(ToViewModel).ToList();
}
public async Task Remove(NotificationsQuery notificationsQuery)
{
await using var dbContext = _factory.CreateContext();
var queryables = GetNotificationsQueryable(dbContext, notificationsQuery);
dbContext.RemoveRange(queryables.withPaging);
await dbContext.SaveChangesAsync();
if (!string.IsNullOrEmpty(notificationsQuery.UserId))
InvalidateNotificationCache(notificationsQuery.UserId);
}
private NotificationViewModel ToViewModel(NotificationData data)
{ {
var handler = GetHandler(data.NotificationType); var handler = GetHandler(data.NotificationType);
var notification = JsonConvert.DeserializeObject(ZipUtils.Unzip(data.Blob), handler.NotificationBlobType); var notification = JsonConvert.DeserializeObject(ZipUtils.Unzip(data.Blob), handler.NotificationBlobType);
var obj = new NotificationViewModel { Id = data.Id, Created = data.Created, Seen = data.Seen }; var obj = new NotificationViewModel {Id = data.Id, Created = data.Created, Seen = data.Seen};
handler.FillViewModel(notification, obj); handler.FillViewModel(notification, obj);
return obj; return obj;
} }
@ -117,4 +157,13 @@ namespace BTCPayServer.Services.Notifications
throw new InvalidOperationException($"No INotificationHandler found for {notificationId}"); throw new InvalidOperationException($"No INotificationHandler found for {notificationId}");
} }
} }
public class NotificationsQuery
{
public string[] Ids { get; set; }
public string UserId { get; set; }
public int? Skip { get; set; }
public int? Take { get; set; }
public bool? Seen { get; set; }
}
} }

View File

@ -34,7 +34,7 @@ namespace BTCPayServer.Services.Notifications
if (notification == null) if (notification == null)
throw new ArgumentNullException(nameof(notification)); throw new ArgumentNullException(nameof(notification));
var users = await GetUsers(scope, notification.Identifier); var users = await GetUsers(scope, notification.Identifier);
using (var db = _contextFactory.CreateContext()) await using (var db = _contextFactory.CreateContext())
{ {
foreach (var uid in users) foreach (var uid in users)
{ {
@ -48,7 +48,7 @@ namespace BTCPayServer.Services.Notifications
Blob = ZipUtils.Zip(obj), Blob = ZipUtils.Zip(obj),
Seen = false Seen = false
}; };
db.Notifications.Add(data); await db.Notifications.AddAsync(data);
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }

View File

@ -53,7 +53,7 @@
"securitySchemes": { "securitySchemes": {
"API Key": { "API Key": {
"type": "apiKey", "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 are available to the context of the user creating the API Key:\n\n* `unrestricted`: Unrestricted access\n* `btcpay.user.canviewprofile`: View your profile\n* `btcpay.user.canmodifyprofile`: Manage your profile\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\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\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.cancreatelightninginvoice`: Create invoices the lightning nodes associated with your stores\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 are available to the context of the user creating the API Key:\n\n* `unrestricted`: Unrestricted access\n* `btcpay.user.canviewprofile`: View your profile\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\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\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.cancreatelightninginvoice`: Create invoices the lightning nodes associated with your stores\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", "name": "Authorization",
"in": "header", "in": "header",
"scheme": "token" "scheme": "token"

View File

@ -0,0 +1,238 @@
{
"paths": {
"/api/v1/users/me/notifications": {
"get": {
"tags": [
"Notifications (Current User)"
],
"summary": "Get notifications",
"parameters": [
{
"name": "seen",
"in": "query",
"required": false,
"description": "filter by seen notifications",
"schema": {
"type": "string",
"nullable": true
}
}
],
"description": "View current user's notifications",
"operationId": "Notifications_GetNotifications",
"responses": {
"200": {
"description": "list of notifications",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationData"
}
}
}
}
},
"security": [
{
"API Key": [
"btcpay.user.canmanagenotificationsforuser",
"btcpay.user.canviewnotificationsforuser"
],
"Basic": []
}
]
}
},
"/api/v1/users/me/notifications/{id}": {
"get": {
"tags": [
"Notifications (Current User)"
],
"summary": "Get notification",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The notification to fetch",
"schema": {
"type": "string"
}
}
],
"description": "View information about the specified notification",
"operationId": "Notifications_GetInvoice",
"responses": {
"200": {
"description": "specified notification",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationData"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified notification"
},
"404": {
"description": "The key is not found for this notification"
}
},
"security": [
{
"API Key": [
"btcpay.user.canmanagenotificationsforuser",
"btcpay.user.canviewnotificationsforuser"
],
"Basic": []
}
]
},
"put": {
"tags": [
"Notifications (Current User)"
],
"summary": "Update notification",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The notification to update",
"schema": {
"type": "string"
}
}
],
"description": "Updates the notification",
"operationId": "Notifications_UpdateNotification",
"responses": {
"200": {
"description": "updated notification",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationData"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to update the specified notification"
},
"404": {
"description": "The key is not found for this notification"
}
},
"security": [
{
"API Key": [
"btcpay.user.canmanagenotificationsforuser"
],
"Basic": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateNotification"
}
}
}
}
},
"delete": {
"tags": [
"Notifications (Current User)"
],
"summary": "Remove Notification",
"description": "Removes the specified notification.",
"operationId": "Notifications_DeleteNotification",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The notification to remove",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The notification has been deleted"
},
"403": {
"description": "If you are authenticated but forbidden to remove the specified notification"
},
"404": {
"description": "The key is not found for this notification"
}
},
"security": [
{
"API Key": [
"btcpay.user.canmanagenotificationsforuser"
],
"Basic": []
}
]
}
}
},
"components": {
"schemas": {
"UpdateNotification": {
"type": "object",
"additionalProperties": false,
"properties": {
"seen": {
"type": "boolean",
"nullable": true,
"description": "Sets the notification as seen/unseen. If left null, sets it to the opposite value"
}
}
},
"NotificationData": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "The identifier of the notification"
},
"body": {
"type": "string",
"format": "html",
"description": "The html body of the notifications"
},
"link": {
"type": "string",
"format": "uri",
"nullable": true,
"description": "The link of the notification"
},
"createdTime": {
"type": "number",
"format": "int64",
"description": "The creation time of the notification"
},
"seen": {
"type": "boolean",
"description": "If the notification has been seen by the user"
}
}
}
}
},
"tags": [
{
"name": "Notifications (Current User)"
}
]
}