diff --git a/BTCPayServer.Abstractions/Contracts/BaseDbContextFactory.cs b/BTCPayServer.Abstractions/Contracts/BaseDbContextFactory.cs index cfec344a7..4201010fa 100644 --- a/BTCPayServer.Abstractions/Contracts/BaseDbContextFactory.cs +++ b/BTCPayServer.Abstractions/Contracts/BaseDbContextFactory.cs @@ -11,7 +11,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Operations; namespace BTCPayServer.Abstractions.Contracts { - public abstract class BaseDbContextFactory where T : DbContext + public abstract class BaseDbContextFactory : IDbContextFactory where T : DbContext { private readonly IOptions _options; private readonly string _migrationTableName; @@ -90,5 +90,8 @@ namespace BTCPayServer.Abstractions.Contracts var searchPaths = connectionStringBuilder.SearchPath?.Split(','); return searchPaths is not { Length: > 0 } ? null : searchPaths[0]; } + + T IDbContextFactory.CreateDbContext() + => this.CreateContext(); } } diff --git a/BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs b/BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs index b0746f1c1..19dd18c9b 100644 --- a/BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs +++ b/BTCPayServer.Tests/PMO/ConfigureEmailPMO.cs @@ -15,11 +15,6 @@ public class ConfigureEmailPMO(PlaywrightTester s) public string? Password { get; set; } public bool? EnabledCertificateCheck { get; set; } } - public async Task UseMailPit() - { - await s.Page.ClickAsync("#mailpit"); - await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Info); - } public async Task ConfigureEmailRules() { @@ -27,14 +22,14 @@ public class ConfigureEmailPMO(PlaywrightTester s) return new EmailRulesPMO(s); } - public Task FillMailPit(Form form) + public Task FillMailPit(Form? form = null) => Fill(new() { Server = s.Server.MailPitSettings.Hostname, Port = s.Server.MailPitSettings.SmtpPort, - From = form.From, - Login = form.Login, - Password = form.Password, + From = form?.From ?? "from@example.com", + Login = form?.Login ?? "login@example.com", + Password = form?.Password ?? "password", EnabledCertificateCheck = false, }); diff --git a/BTCPayServer.Tests/PMO/EmailRulePMO.cs b/BTCPayServer.Tests/PMO/EmailRulePMO.cs index 30cac29a5..942fbff0f 100644 --- a/BTCPayServer.Tests/PMO/EmailRulePMO.cs +++ b/BTCPayServer.Tests/PMO/EmailRulePMO.cs @@ -10,6 +10,11 @@ public class EmailRulesPMO(PlaywrightTester s) await s.Page.ClickAsync("#CreateEmailRule"); return new EmailRulePMO(s); } + + public async Task EditRule(string trigger, int nth = 0) + { + await s.Page.ClickAsync($"tr[data-trigger='{trigger}']:nth-child({nth + 1}) a:has-text('Edit')"); + } } public class EmailRulePMO(PlaywrightTester s) diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs index 66efb4120..058508f9d 100644 --- a/BTCPayServer.Tests/PlaywrightTests.cs +++ b/BTCPayServer.Tests/PlaywrightTests.cs @@ -1535,9 +1535,9 @@ namespace BTCPayServer.Tests await s.GoToServer(ServerNavPages.Emails); - await mailPMO.UseMailPit(); + await mailPMO.FillMailPit(); var rules = await mailPMO.ConfigureEmailRules(); - await rules.CreateEmailRule(); + await rules.EditRule("SRV-PasswordReset"); await pmo.Fill(new() { Trigger = "SRV-PasswordReset", @@ -1610,11 +1610,10 @@ namespace BTCPayServer.Tests await s.GoToInvoices(); await s.ClickPagePrimary(); - Assert.Contains("To create an invoice, you need to", await s.Page.ContentAsync()); await s.AddDerivationScheme(); await s.GoToInvoices(); - var invoiceId = await s.CreateInvoice(); + await s.CreateInvoice(); await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-toggle"); await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-menu button:first-child"); await TestUtils.EventuallyAsync(async () => Assert.Contains("Invalid (marked)", await s.Page.ContentAsync())); diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index 82590a1a5..30df114a7 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -20,6 +20,7 @@ using NBitpayClient; using NBXplorer; using BTCPayServer.Abstractions.Contracts; using System.Diagnostics.Metrics; +using System.Threading; using BTCPayServer.Events; namespace BTCPayServer.Tests @@ -49,7 +50,7 @@ namespace BTCPayServer.Tests GetEnvironment("TESTS_MAILPIT_HOST", "127.0.0.1"), int.Parse(GetEnvironment("TESTS_MAILPIT_SMTP", "34219")), int.Parse(GetEnvironment("TESTS_MAILPIT_HTTP", "34218"))); - + TestLogs.LogInformation($"MailPit settings: http://{MailPitSettings.Hostname}:{MailPitSettings.HttpPort} (SMTP: {MailPitSettings.SmtpPort})"); _NetworkProvider = networkProvider; ExplorerNode = new RPCClient(RPCCredentialString.Parse(GetEnvironment("TESTS_BTCRPCCONNECTION", "server=http://127.0.0.1:43782;ceiwHEbqWI83:DwubwWsoo3")), NetworkProvider.GetNetwork("BTC").NBitcoinNetwork); ExplorerNode.ScanRPCCapabilities(); @@ -198,6 +199,8 @@ namespace BTCPayServer.Tests public async Task WaitForEvent(Func action, Func correctEvent = null) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await using var register = cts.Token.Register(() => tcs.TrySetCanceled()); var sub = PayTester.GetService().SubscribeAny(evt => { if (correctEvent is null) diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 0e89febef..fc5f1dedb 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -3352,14 +3352,17 @@ namespace BTCPayServer.Tests Assert.Equal("admin@admin.com", (await Assert.IsType(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login); Assert.Null(await Assert.IsType(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()); - Assert.IsType(await acc.GetController().ServerEmailSettings(new (new EmailSettings + Assert.IsType(await acc.GetController().StoreEmailSettings(acc.StoreId, new(new() { From = "store@store.com", Login = "store@store.com", Password = "store@store.com", Port = tester.MailPitSettings.SmtpPort, Server = tester.MailPitSettings.Hostname - }), "")); + }) + { + IsCustomSMTP = true + }, "")); Assert.Equal("store@store.com", (await Assert.IsType(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login); @@ -3370,6 +3373,26 @@ namespace BTCPayServer.Tests }); Assert.Equal("test", message.Subject); Assert.Equal("hello world", message.Text); + + // Configure at server level + Assert.IsType(await acc.GetController().ServerEmailSettings(new(new() + { + From = "server@server.com", + Login = "server@server.com", + Password = "server@server.com", + Port = tester.MailPitSettings.SmtpPort, + Server = tester.MailPitSettings.Hostname + }) + { + EnableStoresToUseServerEmailSettings = true + }, "")); + + // The store should now use it + Assert.IsType(await acc.GetController().StoreEmailSettings(acc.StoreId, new(new()) + { + IsCustomSMTP = false + }, "")); + Assert.Equal("server@server.com", (await Assert.IsType(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login); } [Fact(Timeout = TestUtils.TestTimeout)] diff --git a/BTCPayServer/Data/MigrationBase.cs b/BTCPayServer/Data/MigrationBase.cs new file mode 100644 index 000000000..ac3c60249 --- /dev/null +++ b/BTCPayServer/Data/MigrationBase.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace BTCPayServer.Data; + +public abstract class MigrationBase(string migrationId) + where TDbContext : DbContext +{ + public string MigrationId { get; } = migrationId; + + public abstract Task MigrateAsync(TDbContext dbContext, CancellationToken cancellationToken); +} diff --git a/BTCPayServer/Data/MigrationExecutor.cs b/BTCPayServer/Data/MigrationExecutor.cs new file mode 100644 index 000000000..706410ea8 --- /dev/null +++ b/BTCPayServer/Data/MigrationExecutor.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Logging; + +namespace BTCPayServer.Data; + +public interface IMigrationExecutor +{ + Task Execute(CancellationToken cancellationToken); +} + +public class MigrationExecutor( + ILoggerFactory loggerFactory, + IDbContextFactory dbContextFactory, + IEnumerable> migrations) : IMigrationExecutor + where TDbContext : DbContext +{ + ILogger logger = loggerFactory.CreateLogger($"BTCPayServer.Migrations.{typeof(TDbContext).Name}"); + public async Task Execute(CancellationToken cancellationToken) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var history = dbContext.Database.GetService(); + var appliedMigrations = (await history.GetAppliedMigrationsAsync(cancellationToken)).Select(m => m.MigrationId).ToHashSet(); + var insertedRows = new List(); + foreach (var migration in migrations + .Where(m => !appliedMigrations.Contains(m.MigrationId)) + .OrderBy(m => m.MigrationId)) + { + logger.LogInformation("Applying migration '{MigrationId}'", migration.MigrationId); + await migration.MigrateAsync(dbContext, cancellationToken); + insertedRows.Add(new HistoryRow(migration.MigrationId, ProductInfo.GetVersion())); + } + if (insertedRows.Count > 0) + { + await dbContext.SaveChangesAsync(cancellationToken); + var insertMigrations = + string.Concat(insertedRows + .Select(r => history.GetInsertScript(r)) + .ToArray()); + await dbContext.Database.ExecuteSqlRawAsync(insertMigrations, cancellationToken); + } + } +} diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index bd6984edb..2f9b08653 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -36,6 +36,7 @@ using BTCPayServer.Services.Wallets; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -454,6 +455,15 @@ namespace BTCPayServer return services; } + public static IServiceCollection AddMigration(this IServiceCollection services) + where TDbContext : DbContext + where TMigration : MigrationBase + { + services.TryAddSingleton>(); + services.TryAddSingleton, TMigration>(); + return services; + } + public static async Task CloseSocket(this WebSocket webSocket) { try diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 0b8bc05ab..e2d774918 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -74,6 +74,7 @@ using ExchangeSharp; using Microsoft.Extensions.Localization; using Microsoft.AspNetCore.Mvc.Localization; using System.Reflection; +using Microsoft.EntityFrameworkCore; namespace BTCPayServer.Hosting { @@ -96,6 +97,9 @@ namespace BTCPayServer.Hosting services.AddSingleton(o => o.GetRequiredService>().Value); services.AddSingleton(o => o.GetRequiredService>().Value.SerializerSettings); + + services.AddSingleton, ApplicationDbContextFactory>((provider) => provider.GetRequiredService()); + services.AddSingleton>(); services.AddDbContext((provider, o) => { var factory = provider.GetRequiredService(); diff --git a/BTCPayServer/Hosting/MigrationStartupTask.cs b/BTCPayServer/Hosting/MigrationStartupTask.cs index 7cea98615..683520a18 100644 --- a/BTCPayServer/Hosting/MigrationStartupTask.cs +++ b/BTCPayServer/Hosting/MigrationStartupTask.cs @@ -41,6 +41,7 @@ namespace BTCPayServer.Hosting private readonly ApplicationDbContextFactory _DBContextFactory; private readonly StoreRepository _StoreRepository; + private readonly IEnumerable _migrationExecutors; private readonly PaymentMethodHandlerDictionary _handlers; private readonly SettingsRepository _Settings; private readonly AppService _appService; @@ -54,6 +55,7 @@ namespace BTCPayServer.Hosting public IOptions LightningOptions { get; } public MigrationStartupTask( + IEnumerable migrationExecutors, PaymentMethodHandlerDictionary handlers, StoreRepository storeRepository, ApplicationDbContextFactory dbContextFactory, @@ -67,6 +69,7 @@ namespace BTCPayServer.Hosting IFileService fileService, LightningClientFactoryService lightningClientFactoryService) { + _migrationExecutors = migrationExecutors; _handlers = handlers; _DBContextFactory = dbContextFactory; _StoreRepository = storeRepository; @@ -230,6 +233,11 @@ namespace BTCPayServer.Hosting settings.MigrateOldDerivationSchemes = true; await _Settings.UpdateSetting(settings); } + + foreach (var executor in _migrationExecutors) + { + await executor.Execute(cancellationToken); + } } catch (Exception ex) { diff --git a/BTCPayServer/Plugins/Emails/DefaultServerEmailRulesMigration.cs b/BTCPayServer/Plugins/Emails/DefaultServerEmailRulesMigration.cs new file mode 100644 index 000000000..2b83733ed --- /dev/null +++ b/BTCPayServer/Plugins/Emails/DefaultServerEmailRulesMigration.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Plugins.Emails.Views; + +namespace BTCPayServer.Plugins.Emails; + +public class DefaultServerEmailRulesMigration(IEnumerable vms) : MigrationBase("20251109_defaultserverrules") +{ + public override Task MigrateAsync(ApplicationDbContext dbContext, CancellationToken cancellationToken) + { + var defaultRules = new string[] { + ServerMailTriggers.PasswordReset, + ServerMailTriggers.InvitePending, + ServerMailTriggers.ApprovalConfirmed, + ServerMailTriggers.ApprovalPending, + ServerMailTriggers.EmailConfirm, + ServerMailTriggers.ApprovalRequest, + ServerMailTriggers.InviteConfirmed + }.ToHashSet(); + foreach (var vm in vms.Where(v => defaultRules.Contains(v.Trigger))) + { + dbContext.EmailRules.Add(new() + { + To = vm.DefaultEmail.To, + CC = vm.DefaultEmail.CC, + BCC = vm.DefaultEmail.BCC, + Trigger = vm.Trigger, + Subject = vm.DefaultEmail.Subject, + Body = vm.DefaultEmail.Body, + }); + } + return Task.CompletedTask; + } +} diff --git a/BTCPayServer/Plugins/Emails/EmailsPlugin.cs b/BTCPayServer/Plugins/Emails/EmailsPlugin.cs index 5f775378c..8bf79eb4e 100644 --- a/BTCPayServer/Plugins/Emails/EmailsPlugin.cs +++ b/BTCPayServer/Plugins/Emails/EmailsPlugin.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using BTCPayServer.Abstractions.Models; +using BTCPayServer.Data; using BTCPayServer.Plugins.Emails.HostedServices; using BTCPayServer.Plugins.Emails.Views; using BTCPayServer.Plugins.Webhooks; @@ -21,6 +22,7 @@ public class EmailsPlugin : BaseBTCPayServerPlugin services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddMigration(); RegisterServerEmailTriggers(services); } private static string BODY_STYLE = "font-family: Open Sans, Helvetica Neue,Arial,sans-serif; font-color: #292929;"; diff --git a/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesList.cshtml b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesList.cshtml index 9f6d5fb6f..07de9f05c 100644 --- a/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesList.cshtml +++ b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesList.cshtml @@ -81,7 +81,7 @@ else @foreach (var rule in Model.Rules) { - + @rule.Trigger @if (Model.ShowCustomerEmailColumn) { @@ -94,12 +94,12 @@ else @if (storeId is not null) { - Edit + Edit {0}.", Html.Encode(rule.Trigger)]" data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove } else { - Edit + Edit {0}.", Html.Encode(rule.Trigger)]" data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove } diff --git a/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesManage.cshtml b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesManage.cshtml index be09dbb93..4c764c40c 100644 --- a/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesManage.cshtml +++ b/BTCPayServer/Plugins/Emails/Views/Shared/EmailRulesManage.cshtml @@ -180,7 +180,6 @@ var body = triggersByType[selectedTrigger].defaultEmail.body; if ($(bodyTextarea).summernote) { - console.log(body); $(bodyTextarea).summernote('reset'); $(bodyTextarea).summernote('code', body.replace(/\n/g, '
')); } else { @@ -213,7 +212,7 @@ const customerEmailContainer = document.querySelector('.customer-email-container'); const customerEmailCheckbox = document.querySelector('.customer-email-checkbox'); const selectedTrigger = triggerSelect.value; - if (triggersByType[selectedTrigger].canIncludeCustomerEmail) { + if (triggersByType[selectedTrigger].defaultEmail.canIncludeCustomerEmail) { customerEmailContainer.style.display = 'block'; } else { customerEmailContainer.style.display = 'none'; diff --git a/BTCPayServer/Plugins/Emails/Views/Shared/EmailSettings.cshtml b/BTCPayServer/Plugins/Emails/Views/Shared/EmailSettings.cshtml index 4f837274b..93be12c07 100644 --- a/BTCPayServer/Plugins/Emails/Views/Shared/EmailSettings.cshtml +++ b/BTCPayServer/Plugins/Emails/Views/Shared/EmailSettings.cshtml @@ -34,7 +34,7 @@ Go to email rules } - + diff --git a/BTCPayServer/Program.cs b/BTCPayServer/Program.cs index 04e83632a..0f7adc62a 100644 --- a/BTCPayServer/Program.cs +++ b/BTCPayServer/Program.cs @@ -58,6 +58,7 @@ namespace BTCPayServer // Uncomment this to see EF queries //l.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Trace); l.AddFilter("Microsoft.EntityFrameworkCore.Migrations", LogLevel.Information); + l.AddFilter("BTCPayServer.Migrations", LogLevel.Information); l.AddFilter("System.Net.Http.HttpClient", LogLevel.Critical); l.AddFilter("Microsoft.AspNetCore.Antiforgery.Internal", LogLevel.Critical); l.AddFilter("Fido2NetLib.DistributedCacheMetadataService", LogLevel.Error); diff --git a/BTCPayServer/Services/Mails/StoreEmailSender.cs b/BTCPayServer/Services/Mails/StoreEmailSender.cs index 3d168fea3..167cbf785 100644 --- a/BTCPayServer/Services/Mails/StoreEmailSender.cs +++ b/BTCPayServer/Services/Mails/StoreEmailSender.cs @@ -47,11 +47,7 @@ namespace BTCPayServer.Services.Mails EmailSettings? GetCustomSettings(StoreData store) { var emailSettings = store.GetStoreBlob().EmailSettings; - if (emailSettings?.IsComplete() is true) - { - return emailSettings; - } - return null; + return emailSettings?.IsComplete() is true ? emailSettings : null; } } }