diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index b5f6fc803..248ad9d66 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -3103,5 +3103,61 @@ namespace BTCPayServer.Tests .GetResult()) .Where(i => i.GetAddress() == h).Any(); } + + + class MockVersionFetcher : IVersionFetcher + { + public const string MOCK_NEW_VERSION = "9.9.9.9"; + public Task Fetch(CancellationToken cancellation) + { + return Task.FromResult(MOCK_NEW_VERSION); + } + } + + [Fact(Timeout = TestTimeout)] + [Trait("Integration", "Integration")] + public async Task CanCheckForNewVersion() + { + using (var tester = ServerTester.Create(newDb: true)) + { + await tester.StartAsync(); + + var acc = tester.NewAccount(); + acc.GrantAccess(true); + + var settings = tester.PayTester.GetService(); + await settings.UpdateSetting(new PoliciesSettings() { CheckForNewVersions = true }); + + var mockEnv = tester.PayTester.GetService(); + var mockSender = tester.PayTester.GetService(); + + var svc = new NewVersionCheckerHostedService(settings, mockEnv, mockSender, new MockVersionFetcher()); + await svc.ProcessVersionCheck(); + + // since last version present in database was null, it should've been updated with version mock returned + var lastVersion = await settings.GetSettingAsync(); + Assert.Equal(MockVersionFetcher.MOCK_NEW_VERSION, lastVersion.LastVersion); + + // we should also have notification in UI + var ctrl = acc.GetController(); + var newVersion = MockVersionFetcher.MOCK_NEW_VERSION; + + var vm = Assert.IsType( + Assert.IsType(ctrl.Index()).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); + } + } } } diff --git a/BTCPayServer/Configuration/BTCPayServerOptions.cs b/BTCPayServer/Configuration/BTCPayServerOptions.cs index 2eae346b7..3cd189a7b 100644 --- a/BTCPayServer/Configuration/BTCPayServerOptions.cs +++ b/BTCPayServer/Configuration/BTCPayServerOptions.cs @@ -176,6 +176,7 @@ namespace BTCPayServer.Configuration SocksEndpoint = endpoint; } + UpdateUrl = conf.GetOrDefault("updateurl", null); var sshSettings = ParseSSHConfiguration(conf); if ((!string.IsNullOrEmpty(sshSettings.Password) || !string.IsNullOrEmpty(sshSettings.KeyFile)) && !string.IsNullOrEmpty(sshSettings.Server)) @@ -301,5 +302,6 @@ namespace BTCPayServer.Configuration set; } public string TorrcFile { get; set; } + public Uri UpdateUrl { get; set; } } } diff --git a/BTCPayServer/Configuration/DefaultConfiguration.cs b/BTCPayServer/Configuration/DefaultConfiguration.cs index e24a31bd2..d16245d9a 100644 --- a/BTCPayServer/Configuration/DefaultConfiguration.cs +++ b/BTCPayServer/Configuration/DefaultConfiguration.cs @@ -38,6 +38,7 @@ namespace BTCPayServer.Configuration app.Option("--sshtrustedfingerprints", "SSH Host public key fingerprint or sha256 (default: empty, it will allow untrusted connections)", CommandOptionType.SingleValue); app.Option("--torrcfile", "Path to torrc file containing hidden services directories (default: empty)", CommandOptionType.SingleValue); app.Option("--socksendpoint", "Socks endpoint to connect to onion urls (default: empty)", CommandOptionType.SingleValue); + app.Option("--updateurl", $"Url used for once a day new release version check. Check performed only if value is not empty (default: empty)", CommandOptionType.SingleValue); app.Option("--debuglog", "A rolling log file for debug messages.", CommandOptionType.SingleValue); app.Option("--debugloglevel", "The severity you log (default:information)", CommandOptionType.SingleValue); app.Option("--disable-registration", "Disables new user registrations (default:true)", CommandOptionType.SingleValue); diff --git a/BTCPayServer/Controllers/AccountController.cs b/BTCPayServer/Controllers/AccountController.cs index b8c47a313..6d1b8d3c0 100644 --- a/BTCPayServer/Controllers/AccountController.cs +++ b/BTCPayServer/Controllers/AccountController.cs @@ -443,13 +443,8 @@ namespace BTCPayServer.Controllers var settings = await _SettingsRepository.GetSettingAsync(); settings.FirstRun = false; await _SettingsRepository.UpdateSetting(settings); - if (_Options.DisableRegistration) - { - // Once the admin user has been created lock subsequent user registrations (needs to be disabled for unit tests that require multiple users). - Logs.PayServer.LogInformation("First admin created, disabling subscription (disable-registration is set to true)"); - policies.LockSubscription = true; - await _SettingsRepository.UpdateSetting(policies); - } + + await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration); RegisteredAdmin = true; } @@ -626,7 +621,7 @@ namespace BTCPayServer.Controllers private bool CanLoginOrRegister() { - return _btcPayServerEnvironment.IsDevelopping || _btcPayServerEnvironment.IsSecure; + return _btcPayServerEnvironment.IsDeveloping || _btcPayServerEnvironment.IsSecure; } private void SetInsecureFlags() diff --git a/BTCPayServer/Controllers/GreenField/LightningNodeApiController.cs b/BTCPayServer/Controllers/GreenField/LightningNodeApiController.cs index 6979fcfff..5e10b25ab 100644 --- a/BTCPayServer/Controllers/GreenField/LightningNodeApiController.cs +++ b/BTCPayServer/Controllers/GreenField/LightningNodeApiController.cs @@ -288,7 +288,7 @@ namespace BTCPayServer.Controllers.GreenField protected bool CanUseInternalLightning(bool doingAdminThings) { - return (_btcPayServerEnvironment.IsDevelopping || User.IsInRole(Roles.ServerAdmin) || + return (_btcPayServerEnvironment.IsDeveloping || User.IsInRole(Roles.ServerAdmin) || (_cssThemeManager.AllowLightningInternalNodeForAll && !doingAdminThings)); } diff --git a/BTCPayServer/Controllers/GreenField/UsersController.cs b/BTCPayServer/Controllers/GreenField/UsersController.cs index 94adf201f..350b7cf02 100644 --- a/BTCPayServer/Controllers/GreenField/UsersController.cs +++ b/BTCPayServer/Controllers/GreenField/UsersController.cs @@ -148,13 +148,7 @@ namespace BTCPayServer.Controllers.GreenField await _userManager.AddToRoleAsync(user, Roles.ServerAdmin); if (!anyAdmin) { - if (_options.DisableRegistration) - { - // automatically lock subscriptions now that we have our first admin - Logs.PayServer.LogInformation("First admin created, disabling subscription (disable-registration is set to true)"); - policies.LockSubscription = true; - await _settingsRepository.UpdateSetting(policies); - } + await _settingsRepository.FirstAdminRegistered(policies, _options.UpdateUrl != null, _options.DisableRegistration); } } _eventAggregator.Publish(new UserRegisteredEvent() { RequestUri = Request.GetAbsoluteRootUri(), User = user, Admin = request.IsAdministrator is true }); diff --git a/BTCPayServer/Controllers/StoresController.LightningLike.cs b/BTCPayServer/Controllers/StoresController.LightningLike.cs index 46b892494..0abf1cc47 100644 --- a/BTCPayServer/Controllers/StoresController.LightningLike.cs +++ b/BTCPayServer/Controllers/StoresController.LightningLike.cs @@ -172,7 +172,7 @@ namespace BTCPayServer.Controllers private bool CanUseInternalLightning() { - return (_BTCPayEnv.IsDevelopping || User.IsInRole(Roles.ServerAdmin) || _CssThemeManager.AllowLightningInternalNodeForAll); + return (_BTCPayEnv.IsDeveloping || User.IsInRole(Roles.ServerAdmin) || _CssThemeManager.AllowLightningInternalNodeForAll); } } } diff --git a/BTCPayServer/Extensions/ActionLogicExtensions.cs b/BTCPayServer/Extensions/ActionLogicExtensions.cs new file mode 100644 index 000000000..9e5d247f2 --- /dev/null +++ b/BTCPayServer/Extensions/ActionLogicExtensions.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Configuration; +using BTCPayServer.Logging; +using BTCPayServer.Services; +using Microsoft.Extensions.Logging; + +namespace BTCPayServer +{ + // All logic that would otherwise be duplicated across solution goes into this utility class + // ~If~ Once this starts growing out of control, begin extracting action logic classes out of here + // Also some of logic in here may be result of parallel development of Greenfield API + // It's much better that we extract those common methods then copy paste and maintain same code across codebase + internal static class ActionLogicExtensions + { + internal static async Task FirstAdminRegistered(this SettingsRepository settingsRepository, PoliciesSettings policies, + bool updateCheck, bool disableRegistrations) + { + if (updateCheck) + { + Logs.PayServer.LogInformation("First admin created, enabling checks for new versions"); + policies.CheckForNewVersions = updateCheck; + } + + if (disableRegistrations) + { + // Once the admin user has been created lock subsequent user registrations (needs to be disabled for unit tests that require multiple users). + Logs.PayServer.LogInformation("First admin created, disabling subscription (disable-registration is set to true)"); + policies.LockSubscription = true; + } + + if (updateCheck || disableRegistrations) + await settingsRepository.UpdateSetting(policies); + } + } +} diff --git a/BTCPayServer/HostedServices/BaseAsyncService.cs b/BTCPayServer/HostedServices/BaseAsyncService.cs index 52efbb80a..4512d1c5f 100644 --- a/BTCPayServer/HostedServices/BaseAsyncService.cs +++ b/BTCPayServer/HostedServices/BaseAsyncService.cs @@ -10,12 +10,11 @@ namespace BTCPayServer.HostedServices { public abstract class BaseAsyncService : IHostedService { - private CancellationTokenSource _Cts; + private CancellationTokenSource _Cts = new CancellationTokenSource(); protected Task[] _Tasks; public virtual Task StartAsync(CancellationToken cancellationToken) { - _Cts = new CancellationTokenSource(); _Tasks = InitializeTasks(); return Task.CompletedTask; } diff --git a/BTCPayServer/HostedServices/NewVersionCheckerHostedService.cs b/BTCPayServer/HostedServices/NewVersionCheckerHostedService.cs new file mode 100644 index 000000000..c7dd75637 --- /dev/null +++ b/BTCPayServer/HostedServices/NewVersionCheckerHostedService.cs @@ -0,0 +1,121 @@ +using System; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Configuration; +using BTCPayServer.Logging; +using BTCPayServer.Services; +using BTCPayServer.Services.Notifications; +using BTCPayServer.Services.Notifications.Blobs; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.HostedServices +{ + public class NewVersionCheckerHostedService : BaseAsyncService + { + private readonly SettingsRepository _settingsRepository; + private readonly BTCPayServerEnvironment _env; + private readonly NotificationSender _notificationSender; + private readonly IVersionFetcher _versionFetcher; + + public NewVersionCheckerHostedService(SettingsRepository settingsRepository, BTCPayServerEnvironment env, + NotificationSender notificationSender, IVersionFetcher versionFetcher) + { + _settingsRepository = settingsRepository; + _env = env; + _notificationSender = notificationSender; + _versionFetcher = versionFetcher; + } + + internal override Task[] InitializeTasks() + { + return new Task[] { CreateLoopTask(LoopVersionCheck) }; + } + + protected async Task LoopVersionCheck() + { + try + { + await ProcessVersionCheck(); + } + catch (Exception ex) + { + Logs.Events.LogError(ex, "Error while performing new version check"); + } + await Task.Delay(TimeSpan.FromDays(1), Cancellation); + } + + public async Task ProcessVersionCheck() + { + var policies = await _settingsRepository.GetSettingAsync() ?? new PoliciesSettings(); + if (policies.CheckForNewVersions) + { + var tag = await _versionFetcher.Fetch(Cancellation); + if (tag != null && tag != _env.Version) + { + var dh = await _settingsRepository.GetSettingAsync() ?? new NewVersionCheckerDataHolder(); + if (dh.LastVersion != tag) + { + await _notificationSender.SendNotification(new AdminScope(), new NewVersionNotification(tag)); + + dh.LastVersion = tag; + await _settingsRepository.UpdateSetting(dh); + } + } + } + } + } + + public class NewVersionCheckerDataHolder + { + public string LastVersion { get; set; } + } + + public interface IVersionFetcher + { + Task Fetch(CancellationToken cancellation); + } + + public class GithubVersionFetcher : IVersionFetcher + { + private readonly HttpClient _httpClient; + private readonly Uri _updateurl; + public GithubVersionFetcher(IHttpClientFactory httpClientFactory, BTCPayServerOptions options) + { + _httpClient = httpClientFactory.CreateClient(nameof(GithubVersionFetcher)); + _httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json"); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "BTCPayServer/NewVersionChecker"); + + _updateurl = options.UpdateUrl; + } + + private static readonly Regex _releaseVersionTag = new Regex("^(v[1-9]+(\\.[0-9]+)*(-[0-9]+)?)$"); + public async Task Fetch(CancellationToken cancellation) + { + if (_updateurl == null) + return null; + + using (var resp = await _httpClient.GetAsync(_updateurl, cancellation)) + { + var strResp = await resp.Content.ReadAsStringAsync(); + if (resp.IsSuccessStatusCode) + { + var jobj = JObject.Parse(strResp); + var tag = jobj["tag_name"].ToString(); + + var isReleaseVersionTag = _releaseVersionTag.IsMatch(tag); + return isReleaseVersionTag ? tag : null; + } + else + { + Logs.Events.LogWarning($"Unsuccessful status code returned during new version check. " + + $"Url: {_updateurl}, HTTP Code: {resp.StatusCode}, Response Body: {strResp}"); + } + } + + return null; + } + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index f0e86abc2..1cadb52b0 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -239,7 +239,11 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddScoped(); services.AddScoped(); + + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); @@ -284,7 +288,7 @@ namespace BTCPayServer.Hosting { var btcPayEnv = provider.GetService(); var rateLimits = new RateLimitService(); - if (btcPayEnv.IsDevelopping) + if (btcPayEnv.IsDeveloping) { rateLimits.SetZone($"zone={ZoneLimits.Login} rate=1000r/min burst=100 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.Register} rate=1000r/min burst=100 nodelay"); diff --git a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs index 2154d8335..56666857e 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs @@ -502,7 +502,7 @@ namespace BTCPayServer.Payments.PayJoin { var o = new JObject(); o.Add(new JProperty("errorCode", PayjoinReceiverHelper.GetErrorCode(error))); - if (string.IsNullOrEmpty(debug) || !_env.IsDevelopping) + if (string.IsNullOrEmpty(debug) || !_env.IsDeveloping) { o.Add(new JProperty("message", PayjoinReceiverHelper.GetMessage(error))); } diff --git a/BTCPayServer/Properties/launchSettings.json b/BTCPayServer/Properties/launchSettings.json index 79d84c25c..15805eab9 100644 --- a/BTCPayServer/Properties/launchSettings.json +++ b/BTCPayServer/Properties/launchSettings.json @@ -19,7 +19,8 @@ "BTCPAY_POSTGRES": "User ID=postgres;Host=127.0.0.1;Port=39372;Database=btcpayserver", "BTCPAY_DEBUGLOG": "debug.log", "BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc", - "BTCPAY_SOCKSENDPOINT": "localhost:9050" + "BTCPAY_SOCKSENDPOINT": "localhost:9050", + "BTCPAY_UPDATEURL": "" }, "applicationUrl": "http://127.0.0.1:14142/" }, diff --git a/BTCPayServer/Services/Altcoins/Monero/Services/MoneroRPCProvider.cs b/BTCPayServer/Services/Altcoins/Monero/Services/MoneroRPCProvider.cs index a0d08cd64..78137020a 100644 --- a/BTCPayServer/Services/Altcoins/Monero/Services/MoneroRPCProvider.cs +++ b/BTCPayServer/Services/Altcoins/Monero/Services/MoneroRPCProvider.cs @@ -64,7 +64,7 @@ namespace BTCPayServer.Services.Altcoins.Monero.Services await daemonRpcClient.SendCommandAsync("sync_info", JsonRpcClient.NoRequestModel.Instance); summary.TargetHeight = daemonResult.TargetHeight ?? daemonResult.Height; - summary.Synced = daemonResult.Height >= summary.TargetHeight && (summary.TargetHeight > 0 || _btcPayServerEnvironment.IsDevelopping); + summary.Synced = daemonResult.Height >= summary.TargetHeight && (summary.TargetHeight > 0 || _btcPayServerEnvironment.IsDeveloping); summary.CurrentHeight = daemonResult.Height; summary.UpdatedAt = DateTime.Now; summary.DaemonAvailable = true; diff --git a/BTCPayServer/Services/BTCPayServerEnvironment.cs b/BTCPayServer/Services/BTCPayServerEnvironment.cs index eb625133c..175c4ccbe 100644 --- a/BTCPayServer/Services/BTCPayServerEnvironment.cs +++ b/BTCPayServer/Services/BTCPayServerEnvironment.cs @@ -54,7 +54,7 @@ namespace BTCPayServer.Services } public bool AltcoinsVersion { get; set; } - public bool IsDevelopping + public bool IsDeveloping { get { diff --git a/BTCPayServer/Services/PoliciesSettings.cs b/BTCPayServer/Services/PoliciesSettings.cs index 382992bab..a2305e671 100644 --- a/BTCPayServer/Services/PoliciesSettings.cs +++ b/BTCPayServer/Services/PoliciesSettings.cs @@ -24,6 +24,8 @@ namespace BTCPayServer.Services public bool AllowHotWalletForAll { get; set; } [Display(Name = "Allow non-admins to import their hot wallets to the node wallet")] public bool AllowHotWalletRPCImportForAll { get; set; } + [Display(Name = "Check releases on GitHub and alert when new BTCPayServer version is available")] + public bool CheckForNewVersions { get; set; } [Display(Name = "Display app on website root")] public string RootAppId { get; set; } diff --git a/BTCPayServer/Views/Server/Policies.cshtml b/BTCPayServer/Views/Server/Policies.cshtml index f28c0e2d8..a097216ec 100644 --- a/BTCPayServer/Views/Server/Policies.cshtml +++ b/BTCPayServer/Views/Server/Policies.cshtml @@ -42,6 +42,11 @@ +
+ + + +