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 <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri 2022-06-13 06:36:47 +02:00 committed by GitHub
parent 4691e896a1
commit 9a24e4ade1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 216 additions and 13 deletions

View File

@ -0,0 +1,10 @@
#nullable enable
using System.Threading.Tasks;
namespace BTCPayServer.Abstractions.Contracts;
public interface IStoreRepository
{
Task<T?> GetSettingAsync<T>(string storeId, string name) where T : class;
Task UpdateSetting<T>(string storeId, string name, T obj) where T : class;
}

View File

@ -53,6 +53,7 @@ namespace BTCPayServer.Data
public DbSet<PullPaymentData> PullPayments { get; set; }
public DbSet<RefundData> Refunds { get; set; }
public DbSet<SettingData> Settings { get; set; }
public DbSet<StoreSettingData> StoreSettings { get; set; }
public DbSet<StoreWebhookData> StoreWebhooks { get; set; }
public DbSet<StoreData> Stores { get; set; }
public DbSet<U2FDevice> 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);

View File

@ -47,5 +47,6 @@ namespace BTCPayServer.Data
public IEnumerable<PayoutProcessorData> PayoutProcessors { get; set; }
public IEnumerable<PayoutData> Payouts { get; set; }
public IEnumerable<CustodianAccountData> CustodianAccounts { get; set; }
public IEnumerable<StoreSettingData> Settings { get; set; }
}
}

View File

@ -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<StoreSettingData>().HasKey(data => new { data.StoreId, data.Name });
builder.Entity<StoreSettingData>()
.HasOne(o => o.Store)
.WithMany(o => o.Settings).OnDelete(DeleteBehavior.Cascade);
if (databaseFacade.IsNpgsql())
{
builder.Entity<StoreSettingData>()
.Property(o => o.Value)
.HasColumnType("JSONB");
}
}
}

View File

@ -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<string>(type: "TEXT", nullable: false),
StoreId = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(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");
}
}
}

View File

@ -746,6 +746,22 @@ namespace BTCPayServer.Migrations
b.ToTable("Files");
});
modelBuilder.Entity("BTCPayServer.Data.StoreSettingData", b =>
{
b.Property<string>("StoreId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("StoreId", "Name");
b.ToTable("StoreSettings");
});
modelBuilder.Entity("BTCPayServer.Data.StoreWebhookData", b =>
{
b.Property<string>("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");
});

View File

@ -187,6 +187,37 @@ namespace BTCPayServer.Tests
Assert.Equal(description, json["components"]["securitySchemes"]["API_Key"]["description"].Value<string>());
}
[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<IStoreRepository>();
var arbValue = await settingsRepo.GetSettingAsync<string>(user.StoreId,"arbitrary");
Assert.Null(arbValue);
await settingsRepo.UpdateSetting(user.StoreId, "arbitrary", "saved");
arbValue = await settingsRepo.GetSettingAsync<string>(user.StoreId,"arbitrary");
Assert.Equal("saved", arbValue);
await settingsRepo.UpdateSetting<TestData>(user.StoreId, "arbitrary", new TestData() { Name = "hello" });
var arbData = await settingsRepo.GetSettingAsync<TestData>(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<string>(user.StoreId, "arbitrary");
Assert.Null(arbValue);
}
class TestData
{
public string Name { get; set; }
}
private async Task CheckDeadLinks(Regex regex, HttpClient httpClient, string file)
{
List<Task> checkLinks = new List<Task>();

View File

@ -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";

View File

@ -76,6 +76,7 @@ namespace BTCPayServer.Hosting
public static IServiceCollection AddBTCPayServer(this IServiceCollection services, IConfiguration configuration, Logs logs)
{
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
services.AddSingleton<JsonSerializerSettings>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value.SerializerSettings);
services.AddDbContext<ApplicationDbContext>((provider, o) =>
{
var factory = provider.GetRequiredService<ApplicationDbContextFactory>();
@ -99,6 +100,7 @@ namespace BTCPayServer.Hosting
#endif
services.TryAddSingleton<SettingsRepository>();
services.TryAddSingleton<ISettingsRepository>(provider => provider.GetService<SettingsRepository>());
services.TryAddSingleton<IStoreRepository>(provider => provider.GetService<StoreRepository>());
services.TryAddSingleton<LabelFactory>();
services.TryAddSingleton<TorServices>();
services.AddSingleton<IHostedService>(provider => provider.GetRequiredService<TorServices>());

View File

@ -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<StoreData> FindStore(string storeId)
public async Task<StoreData?> FindStore(string storeId)
{
if (storeId == null)
return null;
@ -31,7 +38,7 @@ namespace BTCPayServer.Services.Stores
return result;
}
public async Task<StoreData> FindStore(string storeId, string userId)
public async Task<StoreData?> 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<StoreUser[]> GetStoreUsers(string storeId)
{
ArgumentNullException.ThrowIfNull(storeId);
@ -72,7 +80,7 @@ namespace BTCPayServer.Services.Stores
}).ToArrayAsync();
}
public async Task<StoreData[]> GetStoresByUserId(string userId, IEnumerable<string> storeIds = null)
public async Task<StoreData[]> GetStoresByUserId(string userId, IEnumerable<string>? storeIds = null)
{
using var ctx = _ContextFactory.CreateContext();
return (await ctx.UserStore
@ -86,7 +94,7 @@ namespace BTCPayServer.Services.Stores
}).ToArray();
}
public async Task<StoreData> GetStoreByInvoiceId(string invoiceId)
public async Task<StoreData?> 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<WebhookDeliveryData> GetWebhookDelivery(string storeId, string webhookId, string deliveryId)
public async Task<WebhookDeliveryData?> 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<WebhookData> GetWebhook(string storeId, string webhookId)
public async Task<WebhookData?> 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<WebhookData> GetWebhook(string webhookId)
public async Task<WebhookData?> GetWebhook(string webhookId)
{
ArgumentNullException.ThrowIfNull(webhookId);
using var ctx = _ContextFactory.CreateContext();
@ -323,9 +330,12 @@ namespace BTCPayServer.Services.Stores
{
using var ctx = _ContextFactory.CreateContext();
var existing = await ctx.FindAsync<StoreData>(store.Id);
if (existing is not null)
{
ctx.Entry(existing).CurrentValues.SetValues(store);
await ctx.SaveChangesAsync().ConfigureAwait(false);
}
}
public async Task<bool> DeleteStore(string storeId)
{
@ -357,6 +367,51 @@ namespace BTCPayServer.Services.Stores
return true;
}
private T? Deserialize<T>(string value) where T : class
{
return JsonConvert.DeserializeObject<T>(value, SerializerSettings);
}
private string Serialize<T>(T obj)
{
return JsonConvert.SerializeObject(obj, SerializerSettings);
}
public async Task<T?> GetSettingAsync<T>(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<T>(data.Value);
}
public async Task UpdateSetting<T>(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";