From 9a24e4ade1ec7c1eb50170c317c582712644e36f Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Mon, 13 Jun 2022 06:36:47 +0200 Subject: [PATCH] Store Settings feature with own table (#3851) * Store Settings feature with own table * fix test * Include the store settings to StoreRepository, remove caching stuff Co-authored-by: nicolas.dorier --- .../Contracts/IStoreRepository.cs | 10 +++ BTCPayServer.Data/ApplicationDbContext.cs | 2 + BTCPayServer.Data/Data/StoreData.cs | 1 + BTCPayServer.Data/Data/StoreSettingData.cs | 28 +++++++ .../20220610090843_AddSettingsToStore.cs | 43 ++++++++++ .../ApplicationDbContextModelSnapshot.cs | 29 +++++++ BTCPayServer.Tests/UnitTest1.cs | 31 +++++++ BTCPayServer/Events/SettingsChanged.cs | 2 + BTCPayServer/Hosting/BTCPayServerServices.cs | 2 + .../Services/Stores/StoreRepository.cs | 81 ++++++++++++++++--- 10 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 BTCPayServer.Abstractions/Contracts/IStoreRepository.cs create mode 100644 BTCPayServer.Data/Data/StoreSettingData.cs create mode 100644 BTCPayServer.Data/Migrations/20220610090843_AddSettingsToStore.cs diff --git a/BTCPayServer.Abstractions/Contracts/IStoreRepository.cs b/BTCPayServer.Abstractions/Contracts/IStoreRepository.cs new file mode 100644 index 000000000..12e4cad82 --- /dev/null +++ b/BTCPayServer.Abstractions/Contracts/IStoreRepository.cs @@ -0,0 +1,10 @@ +#nullable enable +using System.Threading.Tasks; + +namespace BTCPayServer.Abstractions.Contracts; + +public interface IStoreRepository +{ + Task GetSettingAsync(string storeId, string name) where T : class; + Task UpdateSetting(string storeId, string name, T obj) where T : class; +} diff --git a/BTCPayServer.Data/ApplicationDbContext.cs b/BTCPayServer.Data/ApplicationDbContext.cs index 84f32267b..32027f397 100644 --- a/BTCPayServer.Data/ApplicationDbContext.cs +++ b/BTCPayServer.Data/ApplicationDbContext.cs @@ -53,6 +53,7 @@ namespace BTCPayServer.Data public DbSet PullPayments { get; set; } public DbSet Refunds { get; set; } public DbSet Settings { get; set; } + public DbSet StoreSettings { get; set; } public DbSet StoreWebhooks { get; set; } public DbSet Stores { get; set; } public DbSet U2FDevices { get; set; } @@ -101,6 +102,7 @@ namespace BTCPayServer.Data PullPaymentData.OnModelCreating(builder); RefundData.OnModelCreating(builder); //SettingData.OnModelCreating(builder); + StoreSettingData.OnModelCreating(builder, Database); StoreWebhookData.OnModelCreating(builder); //StoreData.OnModelCreating(builder); U2FDevice.OnModelCreating(builder); diff --git a/BTCPayServer.Data/Data/StoreData.cs b/BTCPayServer.Data/Data/StoreData.cs index fa295d5ed..ef910d99f 100644 --- a/BTCPayServer.Data/Data/StoreData.cs +++ b/BTCPayServer.Data/Data/StoreData.cs @@ -47,5 +47,6 @@ namespace BTCPayServer.Data public IEnumerable PayoutProcessors { get; set; } public IEnumerable Payouts { get; set; } public IEnumerable CustodianAccounts { get; set; } + public IEnumerable Settings { get; set; } } } diff --git a/BTCPayServer.Data/Data/StoreSettingData.cs b/BTCPayServer.Data/Data/StoreSettingData.cs new file mode 100644 index 000000000..afd964fb4 --- /dev/null +++ b/BTCPayServer.Data/Data/StoreSettingData.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace BTCPayServer.Data; + +public class StoreSettingData +{ + public string Name { get; set; } + public string StoreId { get; set; } + + public string Value { get; set; } + + public StoreData Store { get; set; } + + public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) + { + builder.Entity().HasKey(data => new { data.StoreId, data.Name }); + builder.Entity() + .HasOne(o => o.Store) + .WithMany(o => o.Settings).OnDelete(DeleteBehavior.Cascade); + if (databaseFacade.IsNpgsql()) + { + builder.Entity() + .Property(o => o.Value) + .HasColumnType("JSONB"); + } + } +} diff --git a/BTCPayServer.Data/Migrations/20220610090843_AddSettingsToStore.cs b/BTCPayServer.Data/Migrations/20220610090843_AddSettingsToStore.cs new file mode 100644 index 000000000..be69aed6c --- /dev/null +++ b/BTCPayServer.Data/Migrations/20220610090843_AddSettingsToStore.cs @@ -0,0 +1,43 @@ +using System; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20220610090843_AddSettingsToStore")] + public partial class AddSettingsToStore : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "StoreSettings", + columns: table => new + { + Name = table.Column(type: "TEXT", nullable: false), + StoreId = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: migrationBuilder.IsNpgsql() ? "JSONB" : "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_StoreSettings", x => new { x.StoreId, x.Name }); + table.ForeignKey( + name: "FK_StoreSettings_Stores_StoreId", + column: x => x.StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "StoreSettings"); + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index 61a4ea70b..22d7e70a6 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -746,6 +746,22 @@ namespace BTCPayServer.Migrations b.ToTable("Files"); }); + modelBuilder.Entity("BTCPayServer.Data.StoreSettingData", b => + { + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("StoreId", "Name"); + + b.ToTable("StoreSettings"); + }); + modelBuilder.Entity("BTCPayServer.Data.StoreWebhookData", b => { b.Property("StoreId") @@ -1258,6 +1274,17 @@ namespace BTCPayServer.Migrations b.Navigation("ApplicationUser"); }); + modelBuilder.Entity("BTCPayServer.Data.StoreSettingData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "Store") + .WithMany("Settings") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + modelBuilder.Entity("BTCPayServer.Data.StoreWebhookData", b => { b.HasOne("BTCPayServer.Data.StoreData", "Store") @@ -1438,6 +1465,8 @@ namespace BTCPayServer.Migrations b.Navigation("PullPayments"); + b.Navigation("Settings"); + b.Navigation("UserStores"); }); diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 9627f5240..4ede08ec5 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -186,6 +186,37 @@ namespace BTCPayServer.Tests Assert.Equal(description, json["components"]["securitySchemes"]["API_Key"]["description"].Value()); } + + [Fact] + [Trait("Integration", "Integration")] + public async void CanStoreArbitrarySettingsWithStore() + { + using var tester = CreateServerTester(); + await tester.StartAsync(); + var user = tester.NewAccount(); + await user.GrantAccessAsync(); + var settingsRepo = tester.PayTester.ServiceProvider.GetRequiredService(); + var arbValue = await settingsRepo.GetSettingAsync(user.StoreId,"arbitrary"); + Assert.Null(arbValue); + await settingsRepo.UpdateSetting(user.StoreId, "arbitrary", "saved"); + + arbValue = await settingsRepo.GetSettingAsync(user.StoreId,"arbitrary"); + Assert.Equal("saved", arbValue); + + await settingsRepo.UpdateSetting(user.StoreId, "arbitrary", new TestData() { Name = "hello" }); + var arbData = await settingsRepo.GetSettingAsync(user.StoreId, "arbitrary"); + Assert.Equal("hello", arbData.Name); + + var client = await user.CreateClient(); + await client.RemoveStore(user.StoreId); + tester.Stores.Clear(); + arbValue = await settingsRepo.GetSettingAsync(user.StoreId, "arbitrary"); + Assert.Null(arbValue); + } + class TestData + { + public string Name { get; set; } + } private async Task CheckDeadLinks(Regex regex, HttpClient httpClient, string file) { diff --git a/BTCPayServer/Events/SettingsChanged.cs b/BTCPayServer/Events/SettingsChanged.cs index 70e7d0a95..e32f50c48 100644 --- a/BTCPayServer/Events/SettingsChanged.cs +++ b/BTCPayServer/Events/SettingsChanged.cs @@ -4,6 +4,8 @@ namespace BTCPayServer.Events { public string SettingsName { get; set; } public T Settings { get; set; } + public string StoreId { get; set; } + public override string ToString() { return $"Settings {typeof(T).Name} changed"; diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index d0b3b2979..274c3131c 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -76,6 +76,7 @@ namespace BTCPayServer.Hosting public static IServiceCollection AddBTCPayServer(this IServiceCollection services, IConfiguration configuration, Logs logs) { services.AddSingleton(o => o.GetRequiredService>().Value); + services.AddSingleton(o => o.GetRequiredService>().Value.SerializerSettings); services.AddDbContext((provider, o) => { var factory = provider.GetRequiredService(); @@ -99,6 +100,7 @@ namespace BTCPayServer.Hosting #endif services.TryAddSingleton(); services.TryAddSingleton(provider => provider.GetService()); + services.TryAddSingleton(provider => provider.GetService()); services.TryAddSingleton(); services.TryAddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); diff --git a/BTCPayServer/Services/Stores/StoreRepository.cs b/BTCPayServer/Services/Stores/StoreRepository.cs index 714294f4f..d8e969ae1 100644 --- a/BTCPayServer/Services/Stores/StoreRepository.cs +++ b/BTCPayServer/Services/Stores/StoreRepository.cs @@ -1,28 +1,35 @@ +#nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Data; using BTCPayServer.Migrations; using Microsoft.EntityFrameworkCore; using NBitcoin; using NBitcoin.DataEncoders; +using Newtonsoft.Json; namespace BTCPayServer.Services.Stores { - public class StoreRepository + public class StoreRepository : IStoreRepository { private readonly ApplicationDbContextFactory _ContextFactory; + + public JsonSerializerSettings SerializerSettings { get; } + public ApplicationDbContext CreateDbContext() { return _ContextFactory.CreateContext(); } - public StoreRepository(ApplicationDbContextFactory contextFactory) + public StoreRepository(ApplicationDbContextFactory contextFactory, JsonSerializerSettings serializerSettings) { _ContextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory)); + SerializerSettings = serializerSettings; } - public async Task FindStore(string storeId) + public async Task FindStore(string storeId) { if (storeId == null) return null; @@ -31,7 +38,7 @@ namespace BTCPayServer.Services.Stores return result; } - public async Task FindStore(string storeId, string userId) + public async Task FindStore(string storeId, string userId) { ArgumentNullException.ThrowIfNull(userId); await using var ctx = _ContextFactory.CreateContext(); @@ -50,13 +57,14 @@ namespace BTCPayServer.Services.Stores return us.Store; }).FirstOrDefault(); } - +#nullable disable public class StoreUser { public string Id { get; set; } public string Email { get; set; } public string Role { get; set; } } +#nullable enable public async Task GetStoreUsers(string storeId) { ArgumentNullException.ThrowIfNull(storeId); @@ -72,7 +80,7 @@ namespace BTCPayServer.Services.Stores }).ToArrayAsync(); } - public async Task GetStoresByUserId(string userId, IEnumerable storeIds = null) + public async Task GetStoresByUserId(string userId, IEnumerable? storeIds = null) { using var ctx = _ContextFactory.CreateContext(); return (await ctx.UserStore @@ -86,7 +94,7 @@ namespace BTCPayServer.Services.Stores }).ToArray(); } - public async Task GetStoreByInvoiceId(string invoiceId) + public async Task GetStoreByInvoiceId(string invoiceId) { await using var context = _ContextFactory.CreateContext(); var matched = await context.Invoices.Include(data => data.StoreData) @@ -152,7 +160,6 @@ namespace BTCPayServer.Services.Stores } } } - public async Task CreateStore(string ownerId, StoreData storeData) { if (!string.IsNullOrEmpty(storeData.Id)) @@ -194,7 +201,7 @@ namespace BTCPayServer.Services.Stores .Select(s => s.Webhook).ToArrayAsync(); } - public async Task GetWebhookDelivery(string storeId, string webhookId, string deliveryId) + public async Task GetWebhookDelivery(string storeId, string webhookId, string deliveryId) { ArgumentNullException.ThrowIfNull(webhookId); ArgumentNullException.ThrowIfNull(storeId); @@ -256,7 +263,7 @@ namespace BTCPayServer.Services.Stores return data.Id; } - public async Task GetWebhook(string storeId, string webhookId) + public async Task GetWebhook(string storeId, string webhookId) { ArgumentNullException.ThrowIfNull(webhookId); ArgumentNullException.ThrowIfNull(storeId); @@ -266,7 +273,7 @@ namespace BTCPayServer.Services.Stores .Select(s => s.Webhook) .FirstOrDefaultAsync(); } - public async Task GetWebhook(string webhookId) + public async Task GetWebhook(string webhookId) { ArgumentNullException.ThrowIfNull(webhookId); using var ctx = _ContextFactory.CreateContext(); @@ -323,8 +330,11 @@ namespace BTCPayServer.Services.Stores { using var ctx = _ContextFactory.CreateContext(); var existing = await ctx.FindAsync(store.Id); - ctx.Entry(existing).CurrentValues.SetValues(store); - await ctx.SaveChangesAsync().ConfigureAwait(false); + if (existing is not null) + { + ctx.Entry(existing).CurrentValues.SetValues(store); + await ctx.SaveChangesAsync().ConfigureAwait(false); + } } public async Task DeleteStore(string storeId) @@ -357,6 +367,51 @@ namespace BTCPayServer.Services.Stores return true; } + private T? Deserialize(string value) where T : class + { + return JsonConvert.DeserializeObject(value, SerializerSettings); + } + + private string Serialize(T obj) + { + return JsonConvert.SerializeObject(obj, SerializerSettings); + } + public async Task GetSettingAsync(string storeId, string name) where T : class + { + await using var ctx = _ContextFactory.CreateContext(); + var data = await ctx.StoreSettings.Where(s => s.Name == name && s.StoreId == storeId).FirstOrDefaultAsync(); + return data == null ? default : this.Deserialize(data.Value); + + } + + public async Task UpdateSetting(string storeId, string name, T obj) where T : class + { + await using var ctx = _ContextFactory.CreateContext(); + StoreSettingData? settings = null; + if (obj is null) + { + ctx.StoreSettings.RemoveRange(ctx.StoreSettings.Where(data => data.Name == name && data.StoreId == storeId)); + } + else + { + settings = new StoreSettingData() { Name = name, StoreId = storeId, Value = Serialize(obj) }; + ctx.Attach(settings); + ctx.Entry(settings).State = EntityState.Modified; + } + try + { + await ctx.SaveChangesAsync(); + } + catch (DbUpdateException) + { + if (settings is not null) + { + ctx.Entry(settings).State = EntityState.Added; + await ctx.SaveChangesAsync(); + } + } + } + private static bool IsDeadlock(DbUpdateException ex) { return ex.InnerException is Npgsql.PostgresException postgres && postgres.SqlState == "40P01";