Merge pull request #1651 from btcpayserver/feat/notifications

Notification system
This commit is contained in:
rockstardev
2020-06-15 00:59:30 -05:00
21 changed files with 904 additions and 209 deletions

View File

@@ -11,15 +11,15 @@ namespace BTCPayServer.Data
{
public ApplicationDbContext CreateDbContext(string[] args)
{
var builder = new DbContextOptionsBuilder<ApplicationDbContext>();
builder.UseSqlite("Data Source=temp.db");
return new ApplicationDbContext(builder.Options, true);
}
}
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
private readonly bool _designTime;
@@ -37,90 +37,27 @@ namespace BTCPayServer.Data
public DbSet<PlannedTransaction> PlannedTransactions { get; set; }
public DbSet<PayjoinLock> PayjoinLocks { get; set; }
public DbSet<AppData> Apps
{
get; set;
}
public DbSet<InvoiceEventData> InvoiceEvents
{
get; set;
}
public DbSet<AppData> Apps { get; set; }
public DbSet<InvoiceEventData> InvoiceEvents { get; set; }
public DbSet<OffchainTransactionData> OffchainTransactions { get; set; }
public DbSet<HistoricalAddressInvoiceData> HistoricalAddressInvoices
{
get; set;
}
public DbSet<PendingInvoiceData> PendingInvoices
{
get; set;
}
public DbSet<RefundAddressesData> RefundAddresses
{
get; set;
}
public DbSet<PaymentData> Payments
{
get; set;
}
public DbSet<PaymentRequestData> PaymentRequests
{
get; set;
}
public DbSet<HistoricalAddressInvoiceData> HistoricalAddressInvoices { get; set; }
public DbSet<PendingInvoiceData> PendingInvoices { get; set; }
public DbSet<RefundAddressesData> RefundAddresses { get; set; }
public DbSet<PaymentData> Payments { get; set; }
public DbSet<PaymentRequestData> PaymentRequests { get; set; }
public DbSet<WalletData> Wallets { get; set; }
public DbSet<WalletTransactionData> WalletTransactions { get; set; }
public DbSet<StoreData> Stores { get; set; }
public DbSet<UserStore> UserStore { get; set; }
public DbSet<AddressInvoiceData> AddressInvoices { get; set; }
public DbSet<SettingData> Settings { get; set; }
public DbSet<PairingCodeData> PairingCodes { get; set; }
public DbSet<PairedSINData> PairedSINData { get; set; }
public DbSet<APIKeyData> ApiKeys { get; set; }
public DbSet<StoredFile> Files { get; set; }
public DbSet<U2FDevice> U2FDevices { get; set; }
public DbSet<NotificationData> Notifications { get; set; }
public DbSet<StoreData> Stores
{
get; set;
}
public DbSet<UserStore> UserStore
{
get; set;
}
public DbSet<AddressInvoiceData> AddressInvoices
{
get; set;
}
public DbSet<SettingData> Settings
{
get; set;
}
public DbSet<PairingCodeData> PairingCodes
{
get; set;
}
public DbSet<PairedSINData> PairedSINData
{
get; set;
}
public DbSet<APIKeyData> ApiKeys
{
get; set;
}
public DbSet<StoredFile> Files
{
get; set;
}
public DbSet<U2FDevice> U2FDevices { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var isConfigured = optionsBuilder.Options.Extensions.OfType<RelationalOptionsExtension>().Any();
@@ -131,6 +68,9 @@ namespace BTCPayServer.Data
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
NotificationData.OnModelCreating(builder);
builder.Entity<InvoiceData>()
.HasOne(o => o.StoreData)
.WithMany(a => a.Invoices).OnDelete(DeleteBehavior.Cascade);
@@ -164,12 +104,12 @@ namespace BTCPayServer.Data
.HasOne(o => o.StoreData)
.WithMany(i => i.APIKeys)
.HasForeignKey(i => i.StoreId).OnDelete(DeleteBehavior.Cascade);
builder.Entity<APIKeyData>()
.HasOne(o => o.User)
.WithMany(i => i.APIKeys)
.HasForeignKey(i => i.UserId).OnDelete(DeleteBehavior.Cascade);
builder.Entity<APIKeyData>()
.HasIndex(o => o.StoreId);
@@ -240,8 +180,8 @@ namespace BTCPayServer.Data
o.UniqueId
#pragma warning restore CS0618
});
builder.Entity<PaymentRequestData>()
.HasOne(o => o.StoreData)
.WithMany(i => i.PaymentRequests)
@@ -264,7 +204,7 @@ namespace BTCPayServer.Data
builder.Entity<WalletTransactionData>()
.HasOne(o => o.WalletData)
.WithMany(w => w.WalletTransactions).OnDelete(DeleteBehavior.Cascade);
if (Database.IsSqlite() && !_designTime)
{
// SQLite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations

View File

@@ -10,6 +10,7 @@ namespace BTCPayServer.Data
// Add profile data for application users by adding properties to the ApplicationUser class
public class ApplicationUser : IdentityUser
{
public List<NotificationData> Notifications { get; set; }
public List<UserStore> UserStores
{
get;
@@ -26,7 +27,7 @@ namespace BTCPayServer.Data
get;
set;
}
public List<U2FDevice> U2FDevices { get; set; }
public List<APIKeyData> APIKeys { get; set; }
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Data
{
public class NotificationData
{
[MaxLength(36)]
public string Id { get; set; }
public DateTimeOffset Created { get; set; }
[MaxLength(50)]
public string ApplicationUserId { get; set; }
public ApplicationUser ApplicationUser { get; set; }
[MaxLength(100)]
public string NotificationType { get; set; }
public bool Seen { get; set; }
public byte[] Blob { get; set; }
internal static void OnModelCreating(ModelBuilder builder)
{
builder.Entity<NotificationData>()
.HasOne(o => o.ApplicationUser)
.WithMany(n => n.Notifications)
.HasForeignKey(k => k.ApplicationUserId).OnDelete(DeleteBehavior.Cascade);
}
}
}

View File

@@ -0,0 +1,48 @@
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20200614002524_AddNotificationDataEntity")]
public partial class AddNotificationDataEntity : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Notifications",
columns: table => new
{
Id = table.Column<string>(maxLength: 36, nullable: false),
Created = table.Column<DateTimeOffset>(nullable: false),
ApplicationUserId = table.Column<string>(maxLength: 50, nullable: true),
NotificationType = table.Column<string>(maxLength: 100, nullable: true),
Seen = table.Column<bool>(nullable: false),
Blob = table.Column<byte[]>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.Id);
table.ForeignKey(
name: "FK_Notifications_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Notifications_ApplicationUserId",
table: "Notifications",
column: "ApplicationUserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Notifications");
}
}
}

View File

@@ -14,7 +14,7 @@ namespace BTCPayServer.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.1.1");
.HasAnnotation("ProductVersion", "3.1.4");
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
{
@@ -243,6 +243,36 @@ namespace BTCPayServer.Migrations
b.ToTable("InvoiceEvents");
});
modelBuilder.Entity("BTCPayServer.Data.NotificationData", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasMaxLength(36);
b.Property<string>("ApplicationUserId")
.HasColumnType("TEXT")
.HasMaxLength(50);
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<string>("NotificationType")
.HasColumnType("TEXT")
.HasMaxLength(100);
b.Property<bool>("Seen")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.ToTable("Notifications");
});
modelBuilder.Entity("BTCPayServer.Data.OffchainTransactionData", b =>
{
b.Property<string>("Id")
@@ -760,6 +790,14 @@ namespace BTCPayServer.Migrations
.IsRequired();
});
modelBuilder.Entity("BTCPayServer.Data.NotificationData", b =>
{
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
.WithMany("Notifications")
.HasForeignKey("ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.PairedSINData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")

View File

@@ -1195,6 +1195,39 @@ namespace BTCPayServer.Tests
}
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanListNotifications()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
var acc = tester.NewAccount();
acc.GrantAccess(true);
acc.RegisterDerivationScheme("BTC");
const string newVersion = "1.0.4.4";
var ctrl = acc.GetController<NotificationsController>();
var resp = await ctrl.Generate(newVersion);
var vm = Assert.IsType<Models.NotificationViewModels.IndexViewModel>(
Assert.IsType<ViewResult>(ctrl.Index().Result).Model);
Assert.True(vm.Skip == 0);
Assert.True(vm.Count == 50);
Assert.True(vm.Total == 1);
Assert.True(vm.Items.Count == 1);
var fn = vm.Items.First();
var now = DateTimeOffset.UtcNow;
Assert.True(fn.Created >= now.AddSeconds(-3));
Assert.True(fn.Created <= now);
Assert.Equal($"New version {newVersion} released!", fn.Body);
Assert.Equal($"https://github.com/btcpayserver/btcpayserver/releases/tag/v{newVersion}", fn.ActionLink);
Assert.False(fn.Seen);
}
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanGetRates()

View File

@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.HostedServices;
using BTCPayServer.Models.NotificationViewModels;
using BTCPayServer.Security;
using BTCPayServer.Services.Notifications;
using Google;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
[BitpayAPIConstraint(false)]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public class NotificationsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly NotificationSender _notificationSender;
private readonly UserManager<ApplicationUser> _userManager;
public NotificationsController(ApplicationDbContext db, NotificationSender notificationSender, UserManager<ApplicationUser> userManager)
{
_db = db;
_notificationSender = notificationSender;
_userManager = userManager;
}
[HttpGet]
public async Task<IActionResult> Index(int skip = 0, int count = 50, int timezoneOffset = 0)
{
if (!ValidUserClaim(out var userId))
return RedirectToAction("Index", "Home");
var model = new IndexViewModel()
{
Skip = skip,
Count = count,
Items = _db.Notifications
.OrderByDescending(a => a.Created)
.Skip(skip).Take(count)
.Where(a => a.ApplicationUserId == userId)
.Select(a => a.ViewModel())
.ToList(),
Total = _db.Notifications.Where(a => a.ApplicationUserId == userId).Count()
};
return View(model);
}
[HttpGet]
public async Task<IActionResult> Generate(string version)
{
await _notificationSender.NoticeNewVersionAsync(version);
// waiting for event handler to catch up
await Task.Delay(500);
return RedirectToAction(nameof(Index));
}
[HttpPost]
public async Task<IActionResult> FlipRead(string id)
{
if (ValidUserClaim(out var userId))
{
var notif = _db.Notifications.Single(a => a.Id == id && a.ApplicationUserId == userId);
notif.Seen = !notif.Seen;
await _db.SaveChangesAsync();
}
return RedirectToAction(nameof(Index));
}
[HttpPost]
public async Task<IActionResult> MassAction(string command, string[] selectedItems)
{
if (selectedItems != null)
{
if (command == "delete" && ValidUserClaim(out var userId))
{
var toRemove = _db.Notifications.Where(a => a.ApplicationUserId == userId && selectedItems.Contains(a.Id));
_db.Notifications.RemoveRange(toRemove);
await _db.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
}
return RedirectToAction(nameof(Index));
}
private bool ValidUserClaim(out string userId)
{
userId = _userManager.GetUserId(User);
return userId != null;
}
}
}

View File

@@ -0,0 +1,10 @@
using BTCPayServer.Services.Notifications.Blobs;
namespace BTCPayServer.Events
{
internal class NotificationEvent
{
internal string[] ApplicationUserIds { get; set; }
internal BaseNotification Notification { get; set; }
}
}

View File

@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models.NotificationViewModels;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Memory;
namespace BTCPayServer.HostedServices
{
public class NotificationDbSaver : EventHostedServiceBase
{
private readonly ApplicationDbContextFactory _ContextFactory;
public NotificationDbSaver(ApplicationDbContextFactory contextFactory,
EventAggregator eventAggregator) : base(eventAggregator)
{
_ContextFactory = contextFactory;
}
protected override void SubscribeToEvents()
{
Subscribe<NotificationEvent>();
base.SubscribeToEvents();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
var casted = (NotificationEvent)evt;
using (var db = _ContextFactory.CreateContext())
{
foreach (var uid in casted.ApplicationUserIds)
{
var data = casted.Notification.ToData(uid);
db.Notifications.Add(data);
}
await db.SaveChangesAsync();
}
}
}
public class NotificationManager
{
private readonly ApplicationDbContextFactory _factory;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IMemoryCache _memoryCache;
public NotificationManager(ApplicationDbContextFactory factory, UserManager<ApplicationUser> userManager, IMemoryCache memoryCache)
{
_factory = factory;
_userManager = userManager;
_memoryCache = memoryCache;
}
private const int _cacheExpiryMs = 5000;
public NotificationSummaryViewModel GetSummaryNotifications(ClaimsPrincipal user)
{
var userId = _userManager.GetUserId(user);
if (_memoryCache.TryGetValue<NotificationSummaryViewModel>(userId, out var obj))
return obj;
var resp = FetchNotificationsFromDb(userId);
_memoryCache.Set(userId, resp, new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMilliseconds(_cacheExpiryMs)));
return resp;
}
private NotificationSummaryViewModel FetchNotificationsFromDb(string userId)
{
var resp = new NotificationSummaryViewModel();
using (var _db = _factory.CreateContext())
{
resp.UnseenCount = _db.Notifications
.Where(a => a.ApplicationUserId == userId && !a.Seen)
.Count();
if (resp.UnseenCount > 0)
{
try
{
resp.Last5 = _db.Notifications
.Where(a => a.ApplicationUserId == userId && !a.Seen)
.OrderByDescending(a => a.Created)
.Take(5)
.Select(a => a.ViewModel())
.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;
resp.Last5 = new List<NotificationViewModel>();
}
}
else
{
resp.Last5 = new List<NotificationViewModel>();
}
}
return resp;
}
}
public class NotificationSummaryViewModel
{
public int UnseenCount { get; set; }
public List<NotificationViewModel> Last5 { get; set; }
}
}

View File

@@ -46,6 +46,7 @@ using BTCPayServer.Security.Bitpay;
using Serilog;
using BTCPayServer.Security.GreenField;
using BTCPayServer.Services.Labels;
using BTCPayServer.Services.Notifications;
namespace BTCPayServer.Hosting
{
@@ -200,6 +201,10 @@ namespace BTCPayServer.Hosting
services.AddSingleton<ChangellyClientProvider>();
services.AddSingleton<IHostedService, NotificationDbSaver>();
services.AddSingleton<NotificationManager>();
services.AddScoped<NotificationSender>();
services.AddSingleton<IHostedService, NBXplorerWaiters>();
services.AddSingleton<IHostedService, InvoiceNotificationManager>();
services.AddSingleton<IHostedService, InvoiceWatcher>();

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Services.Notifications.Blobs;
using Newtonsoft.Json;
namespace BTCPayServer.Models.NotificationViewModels
{
public class IndexViewModel
{
public int Skip { get; set; }
public int Count { get; set; }
public int Total { get; set; }
public List<NotificationViewModel> Items { get; set; }
}
public class NotificationViewModel
{
public string Id { get; set; }
public DateTimeOffset Created { get; set; }
public string Body { get; set; }
public string ActionLink { get; set; }
public bool Seen { get; set; }
}
public static class NotificationViewModelExt
{
public static NotificationViewModel ViewModel(this NotificationData data)
{
var baseType = typeof(BaseNotification);
var fullTypeName = baseType.FullName.Replace(nameof(BaseNotification), data.NotificationType, StringComparison.OrdinalIgnoreCase);
var parsedType = baseType.Assembly.GetType(fullTypeName);
var casted = (BaseNotification)JsonConvert.DeserializeObject(ZipUtils.Unzip(data.Blob), parsedType);
var obj = new NotificationViewModel
{
Id = data.Id,
Created = data.Created,
Seen = data.Seen
};
casted.FillViewModel(ref obj);
return obj;
}
}
}

View File

@@ -0,0 +1,33 @@
using System;
using BTCPayServer.Data;
using BTCPayServer.Models.NotificationViewModels;
using Newtonsoft.Json;
namespace BTCPayServer.Services.Notifications.Blobs
{
// Make sure to keep all Blob Notification classes in same namespace
// because of dependent initialization and parsing to view models logic
// IndexViewModel.cs#32
internal abstract class BaseNotification
{
internal virtual string NotificationType { get { return GetType().Name; } }
public NotificationData ToData(string applicationUserId)
{
var obj = JsonConvert.SerializeObject(this);
var data = new NotificationData
{
Id = Guid.NewGuid().ToString(),
Created = DateTimeOffset.UtcNow,
ApplicationUserId = applicationUserId,
NotificationType = NotificationType,
Blob = ZipUtils.Zip(obj),
Seen = false
};
return data;
}
public abstract void FillViewModel(ref NotificationViewModel data);
}
}

View File

@@ -0,0 +1,19 @@
using BTCPayServer.Data;
using BTCPayServer.Models.NotificationViewModels;
using Newtonsoft.Json;
namespace BTCPayServer.Services.Notifications.Blobs
{
internal class NewVersionNotification : BaseNotification
{
internal override string NotificationType => "NewVersionNotification";
public string Version { get; set; }
public override void FillViewModel(ref NotificationViewModel vm)
{
vm.Body = $"New version {Version} released!";
vm.ActionLink = $"https://github.com/btcpayserver/btcpayserver/releases/tag/v{Version}";
}
}
}

View File

@@ -0,0 +1,37 @@
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Services.Notifications.Blobs;
using Microsoft.AspNetCore.Identity;
namespace BTCPayServer.Services.Notifications
{
public class NotificationSender
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly EventAggregator _eventAggregator;
public NotificationSender(UserManager<ApplicationUser> userManager, EventAggregator eventAggregator)
{
_userManager = userManager;
_eventAggregator = eventAggregator;
}
internal async Task NoticeNewVersionAsync(string version)
{
var admins = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var adminUids = admins.Select(a => a.Id).ToArray();
var evt = new NotificationEvent
{
ApplicationUserIds = adminUids,
Notification = new NewVersionNotification
{
Version = version
}
};
_eventAggregator.Publish(evt);
}
}
}

View File

@@ -0,0 +1,170 @@
@model BTCPayServer.Models.NotificationViewModels.IndexViewModel
@{
ViewData["Title"] = "Notifications";
}
<section>
<div class="container">
@if (TempData.HasStatusMessage())
{
<div class="row">
<div class="col-lg-12 text-center">
<partial name="_StatusMessage" />
</div>
</div>
}
<div class="row">
<div class="col-lg-12 section-heading">
<h2>@ViewData["Title"]</h2>
<hr class="primary">
</div>
</div>
<form method="post" asp-action="MassAction">
<div class="row button-row">
<div class="col-lg-6">
<span class="dropdown" style="display:none;" id="MassAction">
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Actions
</button>
<div class="dropdown-menu">
<button type="submit" class="dropdown-item" name="command" value="delete"><i class="fa fa-trash-o"></i> Delete</button>
</div>
</span>
</div>
</div>
<div class="row">
<div class="col-lg-12">
<table class="table table-sm table-responsive-md">
<thead>
<tr>
<th width="30px"></th>
<th width="165px">Date</th>
<th>Message</th>
<th width="80px">&nbsp;</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr data-guid="@item.Id">
<td>
<input name="selectedItems" type="checkbox" class="selector" value="@item.Id" />
</td>
<td onclick="rowseen(this)" class="cursor-pointer @(item.Seen ? "": "font-weight-bold")">@item.Created.ToBrowserDate()</td>
<td onclick="rowseen(this)" class="cursor-pointer @(item.Seen ? "": "font-weight-bold")">@item.Body</td>
<td>
@if (!String.IsNullOrEmpty(item.ActionLink))
{
<a href="@item.ActionLink">Action <i class="fa fa-angle-double-right"></i></a>
}
else
{
<span>&nbsp;</span>
}
</td>
</tr>
}
</tbody>
</table>
<nav aria-label="..." class="w-100">
@if (Model.Total != 0)
{
<ul class="pagination float-left">
<li class="page-item @(Model.Skip == 0 ? "disabled" : null)">
<a class="page-link" tabindex="-1" href="@ListItemsPage(-1, Model.Count)">&laquo;</a>
</li>
<li class="page-item disabled">
@if (Model.Total <= Model.Count)
{
<span class="page-link">
1@Model.Items.Count
</span>
}
else
{
<span class="page-link">
@(Model.Skip + 1)@(Model.Skip + Model.Items.Count), Total: @Model.Total
</span>
}
</li>
<li class="page-item @(Model.Total > (Model.Skip + Model.Items.Count) ? null : "disabled")">
<a class="page-link" href="@ListItemsPage(1, Model.Count)">&raquo;</a>
</li>
</ul>
}
<ul class="pagination float-right">
<li class="page-item disabled">
<span class="page-link">Page Size:</span>
</li>
<li class="page-item @(Model.Count == 50 ? "active" : null)">
<a class="page-link" href="@ListItemsPage(0, 50)">50</a>
</li>
<li class="page-item @(Model.Count == 100 ? "active" : null)">
<a class="page-link" href="@ListItemsPage(0, 100)">100</a>
</li>
<li class="page-item @(Model.Count == 250 ? "active" : null)">
<a class="page-link" href="@ListItemsPage(0, 250)">250</a>
</li>
<li class="page-item @(Model.Count == 500 ? "active" : null)">
<a class="page-link" href="@ListItemsPage(0, 500)">500</a>
</li>
</ul>
</nav>
@{
string ListItemsPage(int prevNext, int count)
{
var skip = Model.Skip;
if (prevNext == -1)
{
skip = Math.Max(0, Model.Skip - Model.Count);
}
else if (prevNext == 1)
{
skip = Model.Skip + count;
}
var act = Url.Action("Index", new
{
skip = skip,
count = count,
});
return act;
}
}
</div>
</div>
</form>
</div>
</section>
<script type="text/javascript">
function rowseen(sender) {
var weightClassName = "font-weight-bold";
$(sender).parent().children(".cursor-pointer").toggleClass(weightClassName);
$.post("/Notifications/FlipRead/" + $(sender).parent().data("guid"), function (data) {
});
}
$(function () {
$(".selector").change(updateSelectors);
updateSelectors();
});
function updateSelectors() {
var count = $(".selector:checked").length;
if (count > 0) {
$("#MassAction").children().eq(0).text("Batch Action (" + count + ")");
$("#MassAction").show();
} else {
$("#MassAction").hide();
}
}
</script>

View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,35 @@
@inject BTCPayServer.HostedServices.NotificationManager notificationManager
@{
var notificationModel = notificationManager.GetSummaryNotifications(User);
}
@if (notificationModel.UnseenCount > 0)
{
<li class="nav-item dropdown">
<a class="nav-link js-scroll-trigger" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" id="Notifications">
<i class="fa fa-bell"></i>
</a>
<span class="alerts-badge badge badge-pill badge-danger">@notificationModel.UnseenCount</span>
<div class="dropdown-menu dropdown-menu-right text-center" aria-labelledby="navbarDropdown">
@foreach (var notif in notificationModel.Last5)
{
<a href="@(notif.ActionLink)" class="dropdown-item border-bottom">
<div class="text-left" style="width: 200px; white-space:normal;">
@notif.Body
</div>
<div class="text-left">
<small class="text-muted">@notif.Created.ToBrowserDate()</small>
</div>
</a>
}
<a class="dropdown-item text-info" asp-controller="Notifications" asp-action="Index">See All</a>
</div>
</li>
}
else
{
<li class="nav-item">
<a asp-controller="Notifications" asp-action="Index" title="Notifications" class="nav-link js-scroll-trigger" id="Notifications"><i class="fa fa-bell"></i></a>
</li>
}

View File

@@ -0,0 +1,106 @@
@inject BTCPayServer.HostedServices.NBXplorerDashboard dashboard
@if (!dashboard.IsFullySynched())
{
<!-- Modal -->
<div id="modalDialog" class="modal-dialog animated bounceInRight"
style="z-index:1000">
@* Z-Index less then other bootstrap modals (1050) *@
<!-- Modal content-->
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Your nodes are synching...</h4>
<button type="button" class="close" onclick="dismissSyncModal()">&times;</button>
</div>
<div class="modal-body">
<p>
Your node is synching the entire blockchain and validating the consensus rules...
</p>
@foreach (var line in dashboard.GetAll().Where(summary => summary.Network.ShowSyncSummary))
{
<h4>@line.Network.CryptoCode</h4>
@if (line.Status == null)
{
<ul>
<li>The node is offline</li>
@if (line.Error != null)
{
<li>Last error: @line.Error</li>
}
</ul>
}
else
{
<ul>
<li>NBXplorer headers height: @line.Status.ChainHeight</li>
@if (line.Status.BitcoinStatus == null)
{
if (line.State == BTCPayServer.HostedServices.NBXplorerState.Synching)
{
<li>The node is starting...</li>
}
else
{
<li>The node is offline</li>
@if (line.Error != null)
{
<li>Last error: @line.Error</li>
}
}
}
else if (line.Status.BitcoinStatus.IsSynched)
{
<li>The node is synchronized (Height: @line.Status.BitcoinStatus.Headers)</li>
@if (line.Status.BitcoinStatus.IsSynched &&
line.Status.SyncHeight.HasValue &&
line.Status.SyncHeight.Value < line.Status.BitcoinStatus.Headers)
{
<li>NBXplorer is synchronizing... (Height: @line.Status.SyncHeight.Value)</li>
}
}
else
{
<li>Node headers height: @line.Status.BitcoinStatus.Headers</li>
<li>Validated blocks: @line.Status.BitcoinStatus.Blocks</li>
}
</ul>
@if (!line.Status.IsFullySynched && line.Status.BitcoinStatus != null)
{
<div class="progress">
<div class="progress-bar" role="progressbar" aria-valuenow="@((int)(line.Status.BitcoinStatus.VerificationProgress * 100))"
aria-valuemin="0" aria-valuemax="100" style="width:@((int)(line.Status.BitcoinStatus.VerificationProgress * 100))%">
@((int)(line.Status.BitcoinStatus.VerificationProgress * 100))%
</div>
</div>
}
}
}
<p>
<a href="https://www.youtube.com/watch?v=OrYDehC-8TU" target="_blank">Watch this video</a> to understand the importance of blockchain synchronization.
</p>
<p>If you really don't want to synch and you are familiar with the command line, check <a href="https://github.com/btcpayserver/btcpayserver-docker/blob/master/contrib/FastSync/README.md" target="_blank">FastSync</a>.</p>
</div>
</div>
</div>
@*<link href="~/vendor/animatecss/animate.css" rel="stylesheet" asp-append-version="true" />*@
<script type="text/javascript">
function dismissSyncModal() {
$("#modalDialog").addClass('animated bounceOutRight')
}
</script>
<style type="text/css">
#modalDialog {
position: fixed;
bottom: 20px;
right: 20px;
margin: 0px;
}
</style>
}

View File

@@ -1,101 +0,0 @@
@inject BTCPayServer.HostedServices.NBXplorerDashboard dashboard
<!-- Modal -->
<div id="modalDialog" class="modal-dialog animated bounceInRight"
style="z-index:1000">
@* Z-Index less then other bootstrap modals (1050) *@
<!-- Modal content-->
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Your nodes are synching...</h4>
<button type="button" class="close" onclick="dismissSyncModal()">&times;</button>
</div>
<div class="modal-body">
<p>
Your node is synching the entire blockchain and validating the consensus rules...
</p>
@foreach (var line in dashboard.GetAll().Where(summary => summary.Network.ShowSyncSummary))
{
<h4>@line.Network.CryptoCode</h4>
@if (line.Status == null)
{
<ul>
<li>The node is offline</li>
@if (line.Error != null)
{
<li>Last error: @line.Error</li>
}
</ul>
}
else
{
<ul>
<li>NBXplorer headers height: @line.Status.ChainHeight</li>
@if (line.Status.BitcoinStatus == null)
{
if (line.State == BTCPayServer.HostedServices.NBXplorerState.Synching)
{
<li>The node is starting...</li>
}
else
{
<li>The node is offline</li>
@if (line.Error != null)
{
<li>Last error: @line.Error</li>
}
}
}
else if (line.Status.BitcoinStatus.IsSynched)
{
<li>The node is synchronized (Height: @line.Status.BitcoinStatus.Headers)</li>
@if (line.Status.BitcoinStatus.IsSynched &&
line.Status.SyncHeight.HasValue &&
line.Status.SyncHeight.Value < line.Status.BitcoinStatus.Headers)
{
<li>NBXplorer is synchronizing... (Height: @line.Status.SyncHeight.Value)</li>
}
}
else
{
<li>Node headers height: @line.Status.BitcoinStatus.Headers</li>
<li>Validated blocks: @line.Status.BitcoinStatus.Blocks</li>
}
</ul>
@if (!line.Status.IsFullySynched && line.Status.BitcoinStatus != null)
{
<div class="progress">
<div class="progress-bar" role="progressbar" aria-valuenow="@((int)(line.Status.BitcoinStatus.VerificationProgress * 100))"
aria-valuemin="0" aria-valuemax="100" style="width:@((int)(line.Status.BitcoinStatus.VerificationProgress * 100))%">
@((int)(line.Status.BitcoinStatus.VerificationProgress * 100))%
</div>
</div>
}
}
}
<p>
<a href="https://www.youtube.com/watch?v=OrYDehC-8TU" target="_blank">Watch this video</a> to understand the importance of blockchain synchronization.
</p>
<p>If you really don't want to synch and you are familiar with the command line, check <a href="https://github.com/btcpayserver/btcpayserver-docker/blob/master/contrib/FastSync/README.md" target="_blank">FastSync</a>.</p>
</div>
</div>
</div>
@*<link href="~/vendor/animatecss/animate.css" rel="stylesheet" asp-append-version="true" />*@
<script type="text/javascript">
function dismissSyncModal() {
$("#modalDialog").addClass('animated bounceOutRight')
}
</script>
<style type="text/css">
#modalDialog {
position: fixed;
bottom: 20px;
right: 20px;
margin: 0px;
}
</style>

View File

@@ -2,7 +2,6 @@
@inject UserManager<ApplicationUser> UserManager
@inject RoleManager<IdentityRole> RoleManager
@inject BTCPayServer.Services.BTCPayServerEnvironment env
@inject BTCPayServer.HostedServices.NBXplorerDashboard dashboard
@inject BTCPayServer.HostedServices.CssThemeManager themeManager
<!DOCTYPE html>
@@ -17,13 +16,17 @@
<noscript>
<style>
.hide-when-js{
display:block !important;
.hide-when-js {
display: block !important;
}
.only-for-js{
display:none !important;
.only-for-js {
display: none !important;
}
[v-cloak]::before {
content: "" !important;
}
[v-cloak]::before { content: "" !important; }
</style>
</noscript>
</head>
@@ -42,7 +45,7 @@
<nav class='navbar navbar-expand-lg fixed-top @additionalStyle' id="mainNav">
<div class="container">
<a class="navbar-brand js-scroll-trigger" href="~/">
<svg class="logo" viewBox="0 0 192 84" xmlns="http://www.w3.org/2000/svg"><g><path d="M5.206 83.433a4.86 4.86 0 01-4.859-4.861V5.431a4.86 4.86 0 119.719 0v73.141a4.861 4.861 0 01-4.86 4.861" fill="#CEDC21" class="logo-brand-light"/><path d="M5.209 83.433a4.862 4.862 0 01-2.086-9.253L32.43 60.274 2.323 38.093a4.861 4.861 0 015.766-7.826l36.647 26.999a4.864 4.864 0 01-.799 8.306L7.289 82.964a4.866 4.866 0 01-2.08.469" fill="#51B13E" class="logo-brand-medium"/><path d="M5.211 54.684a4.86 4.86 0 01-2.887-8.774L32.43 23.73 3.123 9.821a4.861 4.861 0 014.166-8.784l36.648 17.394a4.86 4.86 0 01.799 8.305l-36.647 27a4.844 4.844 0 01-2.878.948" fill="#CEDC21" class="logo-brand-light"/><path d="M10.066 31.725v20.553L24.01 42.006z" fill="#1E7A44" class="logo-brand-dark"/><path d="M10.066 5.431A4.861 4.861 0 005.206.57 4.86 4.86 0 00.347 5.431v61.165h9.72V5.431h-.001z" fill="#CEDC21" class="logo-brand-light"/><path d="M74.355 41.412c3.114.884 4.84 3.704 4.84 7.238 0 5.513-3.368 8.082-7.955 8.082H60.761V27.271h9.259c4.504 0 7.997 2.146 7.997 7.743 0 2.821-1.179 5.43-3.662 6.398m-4.293-.716c3.324 0 6.018-1.179 6.018-5.724 0-4.586-2.776-5.808-6.145-5.808h-7.197v11.531h7.324v.001zm1.052 14.099c3.366 0 6.06-1.768 6.06-6.145 0-4.713-3.072-6.144-6.901-6.144h-7.534v12.288h8.375v.001zM98.893 27.271v1.81h-8.122v27.651h-1.979V29.081h-8.123v-1.81zM112.738 26.85c5.01 0 9.554 2.524 10.987 8.543h-1.895c-1.348-4.923-5.303-6.732-9.134-6.732-6.944 0-10.605 5.681-10.605 13.341 0 8.08 3.661 13.256 10.646 13.256 4.125 0 7.828-1.85 9.26-7.279h1.895c-1.264 6.271-6.229 9.174-11.154 9.174-7.87 0-12.583-5.808-12.583-15.15 0-8.966 4.969-15.153 12.583-15.153M138.709 27.271c5.091 0 8.795 3.326 8.795 9.764 0 6.06-3.704 9.722-8.795 9.722h-7.746v9.976h-1.935V27.271h9.681zm0 17.549c3.745 0 6.816-2.397 6.816-7.827 0-5.429-2.947-7.869-6.816-7.869h-7.746V44.82h7.746zM147.841 56.732v-.255l11.741-29.29h.885l11.615 29.29v.255h-2.062l-3.322-8.501H153.27l-3.324 8.501h-2.105zm12.164-26.052l-6.059 15.697h12.078l-6.019-15.697zM189.551 27.271h2.104v.293l-9.176 16.92v12.248h-2.02V44.484l-9.216-16.961v-.252h2.147l3.997 7.492 4.043 7.786h.04l4.081-7.786z" class="logo-brand-text"/></g></svg>
<svg class="logo" viewBox="0 0 192 84" xmlns="http://www.w3.org/2000/svg"><g><path d="M5.206 83.433a4.86 4.86 0 01-4.859-4.861V5.431a4.86 4.86 0 119.719 0v73.141a4.861 4.861 0 01-4.86 4.861" fill="#CEDC21" class="logo-brand-light" /><path d="M5.209 83.433a4.862 4.862 0 01-2.086-9.253L32.43 60.274 2.323 38.093a4.861 4.861 0 015.766-7.826l36.647 26.999a4.864 4.864 0 01-.799 8.306L7.289 82.964a4.866 4.866 0 01-2.08.469" fill="#51B13E" class="logo-brand-medium" /><path d="M5.211 54.684a4.86 4.86 0 01-2.887-8.774L32.43 23.73 3.123 9.821a4.861 4.861 0 014.166-8.784l36.648 17.394a4.86 4.86 0 01.799 8.305l-36.647 27a4.844 4.844 0 01-2.878.948" fill="#CEDC21" class="logo-brand-light" /><path d="M10.066 31.725v20.553L24.01 42.006z" fill="#1E7A44" class="logo-brand-dark" /><path d="M10.066 5.431A4.861 4.861 0 005.206.57 4.86 4.86 0 00.347 5.431v61.165h9.72V5.431h-.001z" fill="#CEDC21" class="logo-brand-light" /><path d="M74.355 41.412c3.114.884 4.84 3.704 4.84 7.238 0 5.513-3.368 8.082-7.955 8.082H60.761V27.271h9.259c4.504 0 7.997 2.146 7.997 7.743 0 2.821-1.179 5.43-3.662 6.398m-4.293-.716c3.324 0 6.018-1.179 6.018-5.724 0-4.586-2.776-5.808-6.145-5.808h-7.197v11.531h7.324v.001zm1.052 14.099c3.366 0 6.06-1.768 6.06-6.145 0-4.713-3.072-6.144-6.901-6.144h-7.534v12.288h8.375v.001zM98.893 27.271v1.81h-8.122v27.651h-1.979V29.081h-8.123v-1.81zM112.738 26.85c5.01 0 9.554 2.524 10.987 8.543h-1.895c-1.348-4.923-5.303-6.732-9.134-6.732-6.944 0-10.605 5.681-10.605 13.341 0 8.08 3.661 13.256 10.646 13.256 4.125 0 7.828-1.85 9.26-7.279h1.895c-1.264 6.271-6.229 9.174-11.154 9.174-7.87 0-12.583-5.808-12.583-15.15 0-8.966 4.969-15.153 12.583-15.153M138.709 27.271c5.091 0 8.795 3.326 8.795 9.764 0 6.06-3.704 9.722-8.795 9.722h-7.746v9.976h-1.935V27.271h9.681zm0 17.549c3.745 0 6.816-2.397 6.816-7.827 0-5.429-2.947-7.869-6.816-7.869h-7.746V44.82h7.746zM147.841 56.732v-.255l11.741-29.29h.885l11.615 29.29v.255h-2.062l-3.322-8.501H153.27l-3.324 8.501h-2.105zm12.164-26.052l-6.059 15.697h12.078l-6.019-15.697zM189.551 27.271h2.104v.293l-9.176 16.92v12.248h-2.02V44.484l-9.216-16.961v-.252h2.147l3.997 7.492 4.043 7.786h.04l4.081-7.786z" class="logo-brand-text" /></g></svg>
@if (env.NetworkType != NBitcoin.NetworkType.Mainnet)
{
@@ -56,7 +59,7 @@
</a>
}
<button class="navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation">
<svg class="navbar-toggler-icon" viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'><path stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/></svg>
<svg class="navbar-toggler-icon" viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'><path stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22' /></svg>
</button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav ml-auto">
@@ -74,9 +77,13 @@
<li class="nav-item">
<a asp-area="" asp-controller="Manage" asp-action="Index" title="My settings" class="nav-link js-scroll-trigger" id="MySettings"><i class="fa fa-user"></i></a>
</li>
<partial name="LayoutPartials/NotificationsNavItem" />
<li class="nav-item">
<a asp-area="" asp-controller="Account" asp- asp-action="Logout" title="Logout" class="nav-link js-scroll-trigger" id="Logout"><i class="fa fa-sign-out"></i></a>
</li>}
</li>
}
else if (env.IsSecure)
{
if (themeManager.ShowRegister)
@@ -96,8 +103,10 @@
{
<div class="alert alert-danger alert-dismissible" style="position:absolute; top:75px;" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<span>Your access to BTCPay Server is over an unsecured network. If you are using the docker deployment method with NGINX and HTTPS is not available, you probably did not configure your DNS settings correctly. <br />
We disabled the register and login link so you don't leak your credentials.</span>
<span>
Your access to BTCPay Server is over an unsecured network. If you are using the docker deployment method with NGINX and HTTPS is not available, you probably did not configure your DNS settings correctly. <br />
We disabled the register and login link so you don't leak your credentials.
</span>
</div>
}
</div>
@@ -113,21 +122,18 @@
</footer>
}
@if (!dashboard.IsFullySynched())
{
<partial name="SyncModal" />
}
<partial name="LayoutPartials/SyncModal" />
@RenderSection("Scripts", required: false)
<script type="text/javascript">
<script type="text/javascript">
var expectedDomain = @Safe.Json(env.ExpectedHost);
var expectedProtocol = @Safe.Json(env.ExpectedProtocol);
if (window.location.host != expectedDomain || window.location.protocol != expectedProtocol + ":") {
document.getElementById("badUrl").style.display = "block";
document.getElementById("browserScheme").innerText = window.location.protocol.substr(0, window.location.protocol.length -1);
}
</script>
</script>
</body>
</html>

View File

@@ -168,3 +168,9 @@ pre {
.list-group-item a {
color: inherit;
}
.alerts-badge {
position: absolute;
margin-top: -36px;
margin-left: 16px;
}