diff --git a/BTCPayServer/Extensions/ServiceCollectionExtensions.cs b/BTCPayServer/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..3f681a011 --- /dev/null +++ b/BTCPayServer/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Hosting; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddStartupTask(this IServiceCollection services) + where T : class, IStartupTask + => services.AddTransient(); + } +} diff --git a/BTCPayServer/Extensions/WebHostExtensions.cs b/BTCPayServer/Extensions/WebHostExtensions.cs new file mode 100644 index 000000000..f2d20cd05 --- /dev/null +++ b/BTCPayServer/Extensions/WebHostExtensions.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Hosting +{ + public static class WebHostExtensions + { + public static async Task StartWithTasksAsync(this IWebHost webHost, CancellationToken cancellationToken = default) + { + // Load all tasks from DI + var startupTasks = webHost.Services.GetServices(); + + // Execute all the tasks + foreach (var startupTask in startupTasks) + { + await startupTask.ExecuteAsync(cancellationToken).ConfigureAwait(false); + } + + // Start the tasks as normal + await webHost.StartAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/BTCPayServer/Hosting/IStartupTask.cs b/BTCPayServer/Hosting/IStartupTask.cs new file mode 100644 index 000000000..708852b27 --- /dev/null +++ b/BTCPayServer/Hosting/IStartupTask.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace BTCPayServer.Hosting +{ + public interface IStartupTask + { + Task ExecuteAsync(CancellationToken cancellationToken = default); + } +} diff --git a/BTCPayServer/Hosting/MigrationStartupTask.cs b/BTCPayServer/Hosting/MigrationStartupTask.cs new file mode 100644 index 000000000..b786dd80b --- /dev/null +++ b/BTCPayServer/Hosting/MigrationStartupTask.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Logging; +using BTCPayServer.Services; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace BTCPayServer.Hosting +{ + public class MigrationStartupTask : IStartupTask + { + private ApplicationDbContextFactory _DBContextFactory; + private StoreRepository _StoreRepository; + private BTCPayNetworkProvider _NetworkProvider; + private SettingsRepository _Settings; + private readonly UserManager _userManager; + public MigrationStartupTask( + BTCPayNetworkProvider networkProvider, + StoreRepository storeRepository, + ApplicationDbContextFactory dbContextFactory, + UserManager userManager, + SettingsRepository settingsRepository) + { + _DBContextFactory = dbContextFactory; + _StoreRepository = storeRepository; + _NetworkProvider = networkProvider; + _Settings = settingsRepository; + _userManager = userManager; + } + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + try + { + await Migrate(cancellationToken); + var settings = (await _Settings.GetSettingAsync()) ?? new MigrationSettings(); + if (!settings.DeprecatedLightningConnectionStringCheck) + { + await DeprecatedLightningConnectionStringCheck(); + settings.DeprecatedLightningConnectionStringCheck = true; + await _Settings.UpdateSetting(settings); + } + if (!settings.UnreachableStoreCheck) + { + await UnreachableStoreCheck(); + settings.UnreachableStoreCheck = true; + await _Settings.UpdateSetting(settings); + } + if (!settings.ConvertMultiplierToSpread) + { + await ConvertMultiplierToSpread(); + settings.ConvertMultiplierToSpread = true; + await _Settings.UpdateSetting(settings); + } + if (!settings.ConvertNetworkFeeProperty) + { + await ConvertNetworkFeeProperty(); + settings.ConvertNetworkFeeProperty = true; + await _Settings.UpdateSetting(settings); + } + if (!settings.ConvertCrowdfundOldSettings) + { + await ConvertCrowdfundOldSettings(); + settings.ConvertCrowdfundOldSettings = true; + await _Settings.UpdateSetting(settings); + } + if (!settings.ConvertWalletKeyPathRoots) + { + await ConvertConvertWalletKeyPathRoots(); + settings.ConvertWalletKeyPathRoots = true; + await _Settings.UpdateSetting(settings); + } + if (!settings.CheckedFirstRun) + { + var themeSettings = await _Settings.GetSettingAsync() ?? new ThemeSettings(); + var admin = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin); + themeSettings.FirstRun = admin.Count == 0; + await _Settings.UpdateSetting(themeSettings); + settings.CheckedFirstRun = true; + await _Settings.UpdateSetting(settings); + } + } + catch (Exception ex) + { + Logs.PayServer.LogError(ex, "Error on the MigrationStartupTask"); + throw; + } + } + + private async Task Migrate(CancellationToken cancellationToken) + { + using (CancellationTokenSource timeout = new CancellationTokenSource(10_000)) + using (CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, cancellationToken)) + { +retry: + try + { + await _DBContextFactory.CreateContext().Database.MigrateAsync(); + } + // Starting up + catch when (!cts.Token.IsCancellationRequested) + { + try + { + await Task.Delay(1000, cts.Token); + } + catch { } + goto retry; + } + } + } + + private async Task ConvertConvertWalletKeyPathRoots() + { + bool save = false; + using (var ctx = _DBContextFactory.CreateContext()) + { + foreach (var store in await ctx.Stores.AsQueryable().ToArrayAsync()) + { +#pragma warning disable CS0618 // Type or member is obsolete + var blob = store.GetStoreBlob(); + if (blob.WalletKeyPathRoots == null) + continue; + foreach (var scheme in store.GetSupportedPaymentMethods(_NetworkProvider).OfType()) + { + if (blob.WalletKeyPathRoots.TryGetValue(scheme.PaymentId.ToString().ToLowerInvariant(), out var root)) + { + scheme.AccountKeyPath = new NBitcoin.KeyPath(root); + store.SetSupportedPaymentMethod(scheme); + save = true; + } + } + blob.WalletKeyPathRoots = null; + store.SetStoreBlob(blob); +#pragma warning restore CS0618 // Type or member is obsolete + } + if (save) + await ctx.SaveChangesAsync(); + } + } + + private async Task ConvertCrowdfundOldSettings() + { + using (var ctx = _DBContextFactory.CreateContext()) + { + foreach (var app in await ctx.Apps.Where(a => a.AppType == "Crowdfund").ToArrayAsync()) + { + var settings = app.GetSettings(); +#pragma warning disable CS0618 // Type or member is obsolete + if (settings.UseAllStoreInvoices) +#pragma warning restore CS0618 // Type or member is obsolete + { + app.TagAllInvoices = true; + } + } + await ctx.SaveChangesAsync(); + } + } + + private async Task ConvertNetworkFeeProperty() + { + using (var ctx = _DBContextFactory.CreateContext()) + { + foreach (var store in await ctx.Stores.AsQueryable().ToArrayAsync()) + { + var blob = store.GetStoreBlob(); +#pragma warning disable CS0618 // Type or member is obsolete + if (blob.NetworkFeeDisabled != null) + { + blob.NetworkFeeMode = blob.NetworkFeeDisabled.Value ? NetworkFeeMode.Never : NetworkFeeMode.Always; + blob.NetworkFeeDisabled = null; + store.SetStoreBlob(blob); + } +#pragma warning restore CS0618 // Type or member is obsolete + } + await ctx.SaveChangesAsync(); + } + } + + private async Task ConvertMultiplierToSpread() + { + using (var ctx = _DBContextFactory.CreateContext()) + { + foreach (var store in await ctx.Stores.AsQueryable().ToArrayAsync()) + { + var blob = store.GetStoreBlob(); +#pragma warning disable CS0612 // Type or member is obsolete + decimal multiplier = 1.0m; + if (blob.RateRules != null && blob.RateRules.Count != 0) + { + foreach (var rule in blob.RateRules) + { + multiplier = rule.Apply(null, multiplier); + } + } + blob.RateRules = null; + blob.Spread = Math.Min(1.0m, Math.Max(0m, -(multiplier - 1.0m))); + store.SetStoreBlob(blob); +#pragma warning restore CS0612 // Type or member is obsolete + } + await ctx.SaveChangesAsync(); + } + } + + private Task UnreachableStoreCheck() + { + return _StoreRepository.CleanUnreachableStores(); + } + + private async Task DeprecatedLightningConnectionStringCheck() + { + using (var ctx = _DBContextFactory.CreateContext()) + { + foreach (var store in await ctx.Stores.AsQueryable().ToArrayAsync()) + { + foreach (var method in store.GetSupportedPaymentMethods(_NetworkProvider).OfType()) + { + var lightning = method.GetLightningUrl(); + if (lightning.IsLegacy) + { + method.SetLightningUrl(lightning); + store.SetSupportedPaymentMethod(method); + } + } + } + await ctx.SaveChangesAsync(); + } + } + } +} diff --git a/RELEASE-CHECKLIST.md b/RELEASE-CHECKLIST.md new file mode 100644 index 000000000..7f6717d1f --- /dev/null +++ b/RELEASE-CHECKLIST.md @@ -0,0 +1,10 @@ +# Release checklist + +Things to think about when creating a new release: + +* Run `dotnet format` on the solution +* Run `PullTransifexTranslations` test. +* Write chanlog in CHANGELOG.md +* Bump version in `Build/Version.csproj` +* Run `publish-docker.ps1` +* When the docker images has been built by CI, copy the changelog for the new version in the github's release