From ca4abcb497a45b8f0be3752610dc399bb8d64567 Mon Sep 17 00:00:00 2001 From: Nicolas Dorier Date: Wed, 24 Jul 2024 20:16:20 +0900 Subject: [PATCH] Allow translations of BTCPay Server Backend by admins (#5662) --- .../Configuration/DataDirectories.cs | 1 + .../20231219031609_translationsmigration.cs | 61 +++++ BTCPayServer.Tests/BTCPayServer.Tests.csproj | 2 + BTCPayServer.Tests/BTCPayServerTester.cs | 10 +- BTCPayServer.Tests/LanguageServiceTests.cs | 162 +++++++++++- BTCPayServer.Tests/SeleniumTester.cs | 4 + BTCPayServer.Tests/ServerTester.cs | 13 + BTCPayServer.Tests/TestData/Langs/Cypherpunk | 3 + BTCPayServer.Tests/UnitTest1.cs | 4 +- BTCPayServer.Tests/UtilitiesTests.cs | 125 ++++++++- BTCPayServer/BTCPayServer.csproj | 3 + .../Components/MainNav/Default.cshtml | 3 + .../Controllers/UIAccountController.cs | 1 + .../UIServerController.Translations.cs | 108 ++++++++ .../Controllers/UIServerController.cs | 25 +- BTCPayServer/Extensions.cs | 1 + BTCPayServer/Hosting/BTCPayServerServices.cs | 10 + .../Hosting/LoadTranslationsStartupTask.cs | 86 +++++++ BTCPayServer/Hosting/Startup.cs | 2 + .../AccountViewModels/LoginViewModel.cs | 1 + .../CreateDictionaryViewModel.cs | 21 ++ .../EditDictionaryViewModel.cs | 16 ++ .../ListDictionariesViewModel.cs | 21 ++ .../GeneralSettingsViewModel.cs | 4 +- .../StoreViewModels/PaymentViewModel.cs | 2 +- .../WalletPSBTCombineViewModel.cs | 4 +- .../WalletViewModels/WalletPSBTViewModel.cs | 2 +- .../Plugins/Shopify/Models/ShopifySettings.cs | 2 +- BTCPayServer/Services/LocalizerFactory.cs | 130 ++++++++++ BTCPayServer/Services/LocalizerService.cs | 170 ++++++++++++ BTCPayServer/Services/PoliciesSettings.cs | 5 + BTCPayServer/Services/Translations.Default.cs | 242 ++++++++++++++++++ BTCPayServer/Services/Translations.cs | 125 +++++++++ BTCPayServer/TagHelpers/TranslateTagHelper.cs | 55 ++++ .../Views/Shared/_LayoutSignedOut.cshtml | 2 +- BTCPayServer/Views/UIAccount/Login.cshtml | 8 +- BTCPayServer/Views/UIHome/Home.cshtml | 2 +- .../Views/UIServer/CreateDictionary.cshtml | 37 +++ .../Views/UIServer/EditDictionary.cshtml | 49 ++++ .../Views/UIServer/ListDictionaries.cshtml | 67 +++++ BTCPayServer/Views/UIServer/Policies.cshtml | 9 + BTCPayServer/Views/UIServer/ServerNavPages.cs | 3 +- BTCPayServer/_ViewImports.cshtml | 1 + 43 files changed, 1575 insertions(+), 27 deletions(-) create mode 100644 BTCPayServer.Data/Migrations/20231219031609_translationsmigration.cs create mode 100644 BTCPayServer.Tests/TestData/Langs/Cypherpunk create mode 100644 BTCPayServer/Controllers/UIServerController.Translations.cs create mode 100644 BTCPayServer/Hosting/LoadTranslationsStartupTask.cs create mode 100644 BTCPayServer/Models/ServerViewModels/CreateDictionaryViewModel.cs create mode 100644 BTCPayServer/Models/ServerViewModels/EditDictionaryViewModel.cs create mode 100644 BTCPayServer/Models/ServerViewModels/ListDictionariesViewModel.cs create mode 100644 BTCPayServer/Services/LocalizerFactory.cs create mode 100644 BTCPayServer/Services/LocalizerService.cs create mode 100644 BTCPayServer/Services/Translations.Default.cs create mode 100644 BTCPayServer/Services/Translations.cs create mode 100644 BTCPayServer/TagHelpers/TranslateTagHelper.cs create mode 100644 BTCPayServer/Views/UIServer/CreateDictionary.cshtml create mode 100644 BTCPayServer/Views/UIServer/EditDictionary.cshtml create mode 100644 BTCPayServer/Views/UIServer/ListDictionaries.cshtml diff --git a/BTCPayServer.Abstractions/Configuration/DataDirectories.cs b/BTCPayServer.Abstractions/Configuration/DataDirectories.cs index b5aaf893f..aa21d7868 100644 --- a/BTCPayServer.Abstractions/Configuration/DataDirectories.cs +++ b/BTCPayServer.Abstractions/Configuration/DataDirectories.cs @@ -9,6 +9,7 @@ namespace BTCPayServer.Configuration public string TempStorageDir { get; set; } public string StorageDir { get; set; } public string TempDir { get; set; } + public string LangsDir { get; set; } public string ToDatadirFullPath(string path) { diff --git a/BTCPayServer.Data/Migrations/20231219031609_translationsmigration.cs b/BTCPayServer.Data/Migrations/20231219031609_translationsmigration.cs new file mode 100644 index 000000000..f8f752ff4 --- /dev/null +++ b/BTCPayServer.Data/Migrations/20231219031609_translationsmigration.cs @@ -0,0 +1,61 @@ +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20231219031609_translationsmigration")] + public partial class translationsmigration : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder.IsNpgsql()) + { + migrationBuilder.Sql(""" +CREATE TABLE lang_dictionaries ( + dict_id TEXT PRIMARY KEY, + fallback TEXT DEFAULT NULL, + source TEXT DEFAULT NULL, + metadata JSONB DEFAULT NULL, + FOREIGN KEY (fallback) REFERENCES lang_dictionaries(dict_id) ON UPDATE CASCADE ON DELETE SET NULL +); +INSERT INTO lang_dictionaries(dict_id, source) VALUES ('English', 'Default'); + +CREATE TABLE lang_translations ( + dict_id TEXT NOT NULL, + sentence TEXT NOT NULL, + translation TEXT NOT NULL, + PRIMARY KEY (dict_id, sentence), + FOREIGN KEY (dict_id) REFERENCES lang_dictionaries(dict_id) ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE VIEW translations AS +WITH RECURSIVE translations_with_paths AS ( + SELECT d.dict_id, t.sentence, t.translation, ARRAY[d.dict_id] AS path FROM lang_translations t + INNER JOIN lang_dictionaries d USING (dict_id) + + UNION ALL + + SELECT d.dict_id, t.sentence, t.translation, d.dict_id || t.path FROM translations_with_paths t + INNER JOIN lang_dictionaries d ON d.fallback=t.dict_id +), +ranked_translations AS ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY dict_id, sentence ORDER BY array_length(path, 1)) AS rn + FROM translations_with_paths +) +SELECT dict_id, sentence, translation, path FROM ranked_translations WHERE rn=1; +COMMENT ON VIEW translations IS 'Compute the translation for all sentences for all dictionaries, taking into account fallbacks'; +"""); + } + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/BTCPayServer.Tests/BTCPayServer.Tests.csproj b/BTCPayServer.Tests/BTCPayServer.Tests.csproj index 7e8b64672..97255a70c 100644 --- a/BTCPayServer.Tests/BTCPayServer.Tests.csproj +++ b/BTCPayServer.Tests/BTCPayServer.Tests.csproj @@ -21,10 +21,12 @@ + + diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index fb43b342d..31c7c6baa 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -39,8 +39,7 @@ namespace BTCPayServer.Tests public class BTCPayServerTester : IDisposable { - private readonly string _Directory; - + internal readonly string _Directory; public ILoggerProvider LoggerProvider { get; } ILog TestLogs; @@ -95,6 +94,13 @@ namespace BTCPayServer.Tests get; set; } + public async Task RestartStartupTask() + { + var startupTask = GetService().GetServices() + .Single(task => task is T); + await startupTask.ExecuteAsync(); + } + public bool MockRates { get; set; } = true; public string SocksEndpoint { get; set; } diff --git a/BTCPayServer.Tests/LanguageServiceTests.cs b/BTCPayServer.Tests/LanguageServiceTests.cs index 61b74fe98..d94a7271b 100644 --- a/BTCPayServer.Tests/LanguageServiceTests.cs +++ b/BTCPayServer.Tests/LanguageServiceTests.cs @@ -1,8 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; using System.Threading.Tasks; +using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Data; +using BTCPayServer.Hosting; using BTCPayServer.Services; -using BTCPayServer.Tests.Logging; +using Dapper; +using Microsoft.EntityFrameworkCore; +using OpenQA.Selenium; +using OpenQA.Selenium.Support.Extensions; using Xunit; using Xunit.Abstractions; +using static BTCPayServer.Services.LocalizerService; namespace BTCPayServer.Tests { @@ -14,6 +25,155 @@ namespace BTCPayServer.Tests { } + [Fact(Timeout = TestTimeout)] + [Trait("Selenium", "Selenium")] + public async Task CanTranslateLoginPage() + { + using var tester = CreateSeleniumTester(newDb: true); + tester.Server.ActivateLangs(); + await tester.StartAsync(); + await tester.Server.PayTester.RestartStartupTask(); + + // Check if the Cypherpunk translation has been loaded from the file + tester.RegisterNewUser(true); + tester.CreateNewStore(); + tester.GoToServer(Views.Server.ServerNavPages.Translations); + tester.Driver.FindElement(By.Id("Select-Cypherpunk")).Click(); + tester.Logout(); + Assert.Contains("Cyphercode", tester.Driver.PageSource); + Assert.Contains("Yo at BTCPay Server", tester.Driver.PageSource); + + // Create English (Custom) + tester.LogIn(); + tester.GoToServer(Views.Server.ServerNavPages.Translations); + tester.Driver.FindElement(By.Id("CreateDictionary")).Click(); + tester.Driver.FindElement(By.Name("Name")).SendKeys("English (Custom)"); + tester.Driver.FindElement(By.Id("Create")).Click(); + var translations = tester.Driver.FindElement(By.Name("Translations")); + var text = translations.Text; + text = text.Replace("Password => Password", "Password => Mot de passe"); + translations.Clear(); + translations.SendKeys("Password => Mot de passe"); + tester.Driver.FindElement(By.Id("SaveButton")).Click(); + + // Check English (Custom) can be selected + tester.Driver.FindElement(By.Id("Select-English (Custom)")).Click(); + tester.Logout(); + Assert.Contains("Mot de passe", tester.Driver.PageSource); + + // Check if we can remove English (Custom) + tester.LogIn(); + tester.GoToServer(Views.Server.ServerNavPages.Translations); + text = tester.Driver.PageSource; + Assert.Contains("Select-Cypherpunk", text); + Assert.DoesNotContain("Select-English (Custom)", text); + // Cypherpunk is loaded from file, can't edit + Assert.DoesNotContain("Delete-Cypherpunk", text); + // English (Custom) is selected, can't edit + Assert.DoesNotContain("Delete-English (Custom)", text); + tester.Driver.FindElement(By.Id("Select-Cypherpunk")).Click(); + tester.Driver.FindElement(By.Id("Delete-English (Custom)")).Click(); + tester.Driver.WaitForElement(By.Id("ConfirmInput")).SendKeys("DELETE"); + tester.Driver.FindElement(By.Id("ConfirmContinue")).Click(); + + text = tester.Driver.PageSource; + Assert.DoesNotContain("Select-English (Custom)", text); + Assert.Contains("English (Custom) deleted", text); + } + + [Fact(Timeout = TestTimeout)] + [Trait("Integration", "Integration")] + public async Task CanUpdateTranslationsInDatabase() + { + using var tester = CreateServerTester(newDb: true); + await tester.StartAsync(); + var localizer = tester.PayTester.GetService(); + var factory = tester.PayTester.GetService(); + var db = factory.CreateContext().Database.GetDbConnection(); + + TestLogs.LogInformation("French fallback to english"); + await db.ExecuteAsync("INSERT INTO lang_dictionaries VALUES ('French', 'English', NULL)"); + + async Task SetDictionary(string dictId, (string Sentence, string Translation)[] translations) + { + var dict = await localizer.GetDictionary(dictId); + var t = new Translations(translations.Select(t => KeyValuePair.Create(t.Sentence, t.Translation))); + await localizer.Save(dict, t); + } + async Task AssertTranslations(string dictionary, (string Sentence, string Expected)[] expectations) + { + var all = await db.QueryAsync<(string sentence, string translation)>($"SELECT sentence, translation from translations WHERE dict_id='{dictionary}'"); + foreach (var expectation in expectations) + { + if (expectation.Expected is not null) + Assert.Equal(expectation.Expected, all.Single(a => a.sentence == expectation.Sentence).translation); + else + Assert.DoesNotContain(all, a => a.sentence == expectation.Sentence); + } + } + + await SetDictionary("English", + [ + ("Hello", "Hello"), + ("Goodbye", "Goodbye"), + ("Good afternoon", "Good afternoon") + ]); + await SetDictionary("French", + [ + ("Hello", "Salut"), + ("Good afternoon", "Bonne aprem") + ]); + + TestLogs.LogInformation("French should override Hello and Good afternoon, but not Goodbye"); + await AssertTranslations("French", + [("Hello", "Salut"), + ("Good afternoon", "Bonne aprem"), + ("Goodbye", "Goodbye"), + ("lol", null)]); + await AssertTranslations("English", + [("Hello", "Hello"), + ("Good afternoon", "Good afternoon"), + ("Goodbye", "Goodbye"), + ("lol", null)]); + + TestLogs.LogInformation("Can use fallback by setting null to a sentence"); + await SetDictionary("French", + [ + ("Good afternoon", "Bonne aprem"), + ("Goodbye", "Goodbye"), + ("Hello", null) + ]); + await AssertTranslations("French", + [("Hello", "Hello"), + ("Good afternoon", "Bonne aprem"), + ("Goodbye", "Goodbye"), + ("lol", null)]); + + TestLogs.LogInformation("Can use fallback by setting same as fallback to a sentence"); + await SetDictionary("French", + [ + ("Good afternoon", "Good afternoon") + ]); + await AssertTranslations("French", + [("Hello", "Hello"), + ("Good afternoon", "Good afternoon"), + ("Goodbye", "Goodbye"), + ("lol", null)]); + + await SetDictionary("English", + [ + ("Hello", null as string), + ("Good afternoon", "Good afternoon"), + ("Goodbye", "Goodbye") + ]); + await AssertTranslations("French", + [("Hello", null), + ("Good afternoon", "Good afternoon"), + ("Goodbye", "Goodbye"), + ("lol", null)]); + await db.ExecuteAsync("DELETE FROM lang_dictionaries WHERE dict_id='English'"); + } + [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] public async Task CanAutoDetectLanguage() diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 3ec5d39b9..f626356cf 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -396,6 +396,10 @@ retry: Driver.FindElement(By.Id("Nav-Logout")).Click(); } + public void LogIn() + { + LogIn(CreatedUser, "123456"); + } public void LogIn(string user, string password = "123456") { Driver.FindElement(By.Id("Email")).SendKeys(user); diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index 83d3cbdab..afe3acc87 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -18,6 +18,8 @@ using NBitcoin; using NBitcoin.RPC; using NBitpayClient; using NBXplorer; +using BTCPayServer.Abstractions.Contracts; +using System.Diagnostics.Metrics; namespace BTCPayServer.Tests { @@ -72,6 +74,17 @@ namespace BTCPayServer.Tests PayTester.SSHConnection = GetEnvironment("TESTS_SSHCONNECTION", "root@127.0.0.1:21622"); PayTester.SocksEndpoint = GetEnvironment("TESTS_SOCKSENDPOINT", "localhost:9050"); } + + public void ActivateLangs() + { + TestLogs.LogInformation("Activating Langs..."); + var dir = TestUtils.GetTestDataFullPath("Langs"); + var langdir = Path.Combine(PayTester._Directory, "Langs"); + Directory.CreateDirectory(langdir); + foreach (var file in Directory.GetFiles(dir)) + File.Copy(file, Path.Combine(langdir, Path.GetFileName(file))); + } + #if ALTCOINS public void ActivateLTC() { diff --git a/BTCPayServer.Tests/TestData/Langs/Cypherpunk b/BTCPayServer.Tests/TestData/Langs/Cypherpunk new file mode 100644 index 000000000..9e512111f --- /dev/null +++ b/BTCPayServer.Tests/TestData/Langs/Cypherpunk @@ -0,0 +1,3 @@ +Password => Cyphercode +Email address => Cypher ID +Welcome to {0} => Yo at {0} diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index f98295e9e..59ada9576 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -2878,9 +2878,7 @@ namespace BTCPayServer.Tests { var settings = tester.PayTester.GetService(); await settings.UpdateSetting(new MigrationSettings()); - var migrationStartupTask = tester.PayTester.GetService().GetServices() - .Single(task => task is MigrationStartupTask); - await migrationStartupTask.ExecuteAsync(); + await tester.PayTester.RestartStartupTask(); } [Fact(Timeout = LongRunningTestTimeout)] diff --git a/BTCPayServer.Tests/UtilitiesTests.cs b/BTCPayServer.Tests/UtilitiesTests.cs index faada4fd5..9591870e5 100644 --- a/BTCPayServer.Tests/UtilitiesTests.cs +++ b/BTCPayServer.Tests/UtilitiesTests.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Net.Http; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Amazon.Auth.AccessControlPolicy; @@ -13,6 +15,10 @@ using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Controllers; using ExchangeSharp; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using NBitcoin; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -21,18 +27,17 @@ using OpenQA.Selenium.Chrome; using OpenQA.Selenium.Support.UI; using Xunit; using Xunit.Abstractions; -using static BTCPayServer.Tests.TransifexClient; namespace BTCPayServer.Tests { /// /// This class hold easy to run utilities for dev time /// - public class UtilitiesTests + public class UtilitiesTests : UnitTestBase { public ITestOutputHelper Logs { get; } - public UtilitiesTests(ITestOutputHelper logs) + public UtilitiesTests(ITestOutputHelper logs) : base(logs) { Logs = logs; } @@ -262,6 +267,120 @@ retry: Thread.Sleep(200); } + /// + /// This utilities crawl through the cs files in search for + /// Display attributes, then update Translations.Default to list them + /// + [Trait("Utilities", "Utilities")] + [Fact] + public async Task UpdateDefaultTranslations() + { + var soldir = TestUtils.TryGetSolutionDirectoryInfo(); + List defaultTranslatedKeys = new List(); + + // Go through all cs files, and find [Display] and [DisplayName] attributes + foreach (var file in soldir.EnumerateFiles("*.cs", SearchOption.AllDirectories)) + { + var txt = File.ReadAllText(file.FullName); + var tree = CSharpSyntaxTree.ParseText(txt, new CSharpParseOptions(LanguageVersion.Default)); + var walker = new DisplayNameWalker(); + walker.Visit(tree.GetRoot()); + foreach (var k in walker.Keys) + { + defaultTranslatedKeys.Add(k); + } + } + + // Go through all cshtml file, search for text-translate or ViewLocalizer usage + using (var tester = CreateServerTester()) + { + await tester.StartAsync(); + var engine = tester.PayTester.GetService(); + foreach (var file in soldir.EnumerateFiles("*.cshtml", SearchOption.AllDirectories)) + { + var filePath = file.FullName; + var txt = File.ReadAllText(file.FullName); + if (txt.Contains("ViewLocalizer")) + { + var matches = Regex.Matches(txt, "ViewLocalizer\\[\"(.*?)\"[\\],]"); + foreach (Match match in matches) + { + defaultTranslatedKeys.Add(match.Groups[1].Value); + } + } + else if (txt.Contains("text-translate")) + { + filePath = filePath.Replace(Path.Combine(soldir.FullName, "BTCPayServer"), "/"); + var item = engine.FileSystem.GetItem(filePath); + + var node = (DocumentIntermediateNode)engine.Process(item).Items[typeof(DocumentIntermediateNode)]; + foreach (var n in node.FindDescendantNodes()) + { + foreach (var tagHelper in n.TagHelpers) + { + if (tagHelper.Name.EndsWith("TranslateTagHelper")) + { + var htmlContent = n.FindDescendantNodes().First(); + var inner = txt.Substring(htmlContent.Source.Value.AbsoluteIndex, htmlContent.Source.Value.Length); + defaultTranslatedKeys.Add(inner); + } + } + } + + } + } + + } + defaultTranslatedKeys = defaultTranslatedKeys.Distinct().OrderBy(o => o).ToList(); + var path = Path.Combine(soldir.FullName, "BTCPayServer/Services/Translations.Default.cs"); + var defaultTranslation = File.ReadAllText(path); + var startIdx = defaultTranslation.IndexOf("\"\"\""); + var endIdx = defaultTranslation.LastIndexOf("\"\"\""); + var content = defaultTranslation.Substring(0, startIdx + 3); + content += "\n" + String.Join('\n', defaultTranslatedKeys) + "\n"; + content += defaultTranslation.Substring(endIdx); + File.WriteAllText(path, content); + } + class DisplayNameWalker : CSharpSyntaxWalker + { + public List Keys = new List(); + public bool InAttribute = false; + public override void VisitAttribute(AttributeSyntax node) + { + InAttribute = true; + base.VisitAttribute(node); + InAttribute = false; + } + public override void VisitIdentifierName(IdentifierNameSyntax node) + { + if (InAttribute) + { + InAttribute = node.Identifier.Text switch + { + "Display" => true, + "DisplayAttribute" => true, + "DisplayName" => true, + "DisplayNameAttribute" => true, + _ => false + }; + } + } + public override void VisitAttributeArgument(AttributeArgumentSyntax node) + { + if (InAttribute) + { + var name = node.Expression switch + { + LiteralExpressionSyntax les => les.Token.ValueText, + IdentifierNameSyntax ins => ins.Identifier.Text, + _ => throw new InvalidOperationException("Unknown node") + }; + Keys.Add(name); + InAttribute = false; + } + } + } + /// /// This utility will make sure that permission documentation is properly written in swagger.template.json /// diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 43e264b01..6fd1ad5ed 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -111,6 +111,9 @@ + + + PreserveNewest $(IncludeRazorContentInPack) diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml index 9a6e10da0..c22cd6f0d 100644 --- a/BTCPayServer/Components/MainNav/Default.cshtml +++ b/BTCPayServer/Components/MainNav/Default.cshtml @@ -309,6 +309,9 @@ + @if (BtcPayServerOptions.DockerDeployment) {