Allow translations of BTCPay Server Backend by admins (#5662)

This commit is contained in:
Nicolas Dorier 2024-07-24 20:16:20 +09:00 committed by GitHub
parent acbc75d077
commit ca4abcb497
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1575 additions and 27 deletions

View File

@ -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)
{

View File

@ -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)
{
}
}
}

View File

@ -21,10 +21,12 @@
<ItemGroup>
<None Remove="TestData\OldInvoices.csv" />
<None Remove="TestData\Langs\Cypherpunk" />
</ItemGroup>
<ItemGroup>
<Content Include="TestData\OldInvoices.csv" />
<Content Include="TestData\Langs\Cypherpunk" />
</ItemGroup>
<ItemGroup>

View File

@ -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<T>()
{
var startupTask = GetService<IServiceProvider>().GetServices<Abstractions.Contracts.IStartupTask>()
.Single(task => task is T);
await startupTask.ExecuteAsync();
}
public bool MockRates { get; set; } = true;
public string SocksEndpoint { get; set; }

View File

@ -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<LoadTranslationsStartupTask>();
// 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<LocalizerService>();
var factory = tester.PayTester.GetService<ApplicationDbContextFactory>();
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()

View File

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

View File

@ -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()
{

View File

@ -0,0 +1,3 @@
Password => Cyphercode
Email address => Cypher ID
Welcome to {0} => Yo at {0}

View File

@ -2878,9 +2878,7 @@ namespace BTCPayServer.Tests
{
var settings = tester.PayTester.GetService<SettingsRepository>();
await settings.UpdateSetting<MigrationSettings>(new MigrationSettings());
var migrationStartupTask = tester.PayTester.GetService<IServiceProvider>().GetServices<IStartupTask>()
.Single(task => task is MigrationStartupTask);
await migrationStartupTask.ExecuteAsync();
await tester.PayTester.RestartStartupTask<MigrationStartupTask>();
}
[Fact(Timeout = LongRunningTestTimeout)]

View File

@ -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
{
/// <summary>
/// This class hold easy to run utilities for dev time
/// </summary>
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);
}
/// <summary>
/// This utilities crawl through the cs files in search for
/// Display attributes, then update Translations.Default to list them
/// </summary>
[Trait("Utilities", "Utilities")]
[Fact]
public async Task UpdateDefaultTranslations()
{
var soldir = TestUtils.TryGetSolutionDirectoryInfo();
List<string> defaultTranslatedKeys = new List<string>();
// 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<RazorProjectEngine>();
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<TagHelperIntermediateNode>())
{
foreach (var tagHelper in n.TagHelpers)
{
if (tagHelper.Name.EndsWith("TranslateTagHelper"))
{
var htmlContent = n.FindDescendantNodes<HtmlContentIntermediateNode>().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<string> Keys = new List<string>();
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;
}
}
}
/// <summary>
/// This utility will make sure that permission documentation is properly written in swagger.template.json
/// </summary>

View File

@ -111,6 +111,9 @@
<Watch Remove="Views\Shared\LocalhostBrowserSupport.cshtml" />
<Watch Remove="Views\UIAccount\CheatPermissions.cshtml" />
<Watch Remove="Views\UIReports\StoreReports.cshtml" />
<Watch Remove="Views\UIServer\CreateDictionary.cshtml" />
<Watch Remove="Views\UIServer\EditDictionary.cshtml" />
<Watch Remove="Views\UIServer\ListDictionaries.cshtml" />
<Content Update="Views\UIApps\_ViewImports.cshtml">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<Pack>$(IncludeRazorContentInPack)</Pack>

View File

@ -309,6 +309,9 @@
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Branding" class="nav-link @ViewData.ActivePageClass(ServerNavPages.Branding)" asp-action="Branding">Branding</a>
</li>
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Translations" class="nav-link @ViewData.ActivePageClass(ServerNavPages.Translations)" asp-action="ListDictionaries">Translations</a>
</li>
@if (BtcPayServerOptions.DockerDeployment)
{
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;

View File

@ -0,0 +1,108 @@
using System.Data.Common;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Controllers
{
public partial class UIServerController
{
[HttpGet("server/dictionaries")]
public async Task<IActionResult> ListDictionaries()
{
var dictionaries = await this._localizer.GetDictionaries();
var vm = new ListDictionariesViewModel();
foreach (var dictionary in dictionaries)
{
vm.Dictionaries.Add(new()
{
Editable = dictionary.Source == "Custom",
Source = dictionary.Source,
DictionaryName = dictionary.DictionaryName,
Fallback = dictionary.Fallback,
IsSelected =
_policiesSettings.LangDictionary == dictionary.DictionaryName ||
(_policiesSettings.LangDictionary is null && dictionary.Source == "Default")
});
}
return View(vm);
}
[HttpGet("server/dictionaries/create")]
public async Task<IActionResult> CreateDictionary(string fallback = null)
{
var dictionaries = await this._localizer.GetDictionaries();
return View(new CreateDictionaryViewModel()
{
Name = fallback is not null ? $"{fallback} (Copy)" : "",
Fallback = fallback ?? Translations.DefaultLanguage,
}.SetDictionaries(dictionaries));
}
[HttpPost("server/dictionaries/create")]
public async Task<IActionResult> CreateDictionary(CreateDictionaryViewModel viewModel)
{
if (ModelState.IsValid)
{
try
{
await this._localizer.CreateDictionary(viewModel.Name, viewModel.Fallback, "Custom");
}
catch (DbException)
{
ModelState.AddModelError(nameof(viewModel.Name), $"'{viewModel.Name}' already exists");
}
}
if (!ModelState.IsValid)
return View(viewModel.SetDictionaries(await this._localizer.GetDictionaries()));
TempData[WellKnownTempData.SuccessMessage] = "Dictionary created";
return RedirectToAction(nameof(EditDictionary), new { dictionary = viewModel.Name });
}
[HttpGet("server/dictionaries/{dictionary}")]
public async Task<IActionResult> EditDictionary(string dictionary)
{
if ((await this._localizer.GetDictionary(dictionary)) is null)
return NotFound();
var translations = await _localizer.GetTranslations(dictionary);
return View(new EditDictionaryViewModel().SetTranslations(translations.Translations));
}
[HttpPost("server/dictionaries/{dictionary}")]
public async Task<IActionResult> EditDictionary(string dictionary, EditDictionaryViewModel viewModel)
{
var d = await this._localizer.GetDictionary(dictionary);
if (d is null)
return NotFound();
if (!Translations.TryCreateFromText(viewModel.Translations, out var translations))
{
ModelState.AddModelError(nameof(viewModel.Translations), "Syntax error");
return View(viewModel);
}
await _localizer.Save(d, translations);
TempData[WellKnownTempData.SuccessMessage] = "Dictionary updated";
return RedirectToAction(nameof(ListDictionaries));
}
[HttpGet("server/dictionaries/{dictionary}/select")]
public async Task<IActionResult> SelectDictionary(string dictionary)
{
var settings = await this._SettingsRepository.GetSettingAsync<PoliciesSettings>() ?? new();
settings.LangDictionary = dictionary;
await _SettingsRepository.UpdateSetting(settings);
await _localizer.Load();
TempData[WellKnownTempData.SuccessMessage] = $"Default dictionary changed to {dictionary}";
return RedirectToAction(nameof(ListDictionaries));
}
[HttpPost("server/dictionaries/{dictionary}/delete")]
public async Task<IActionResult> DeleteDictionary(string dictionary)
{
await _localizer.DeleteDictionary(dictionary);
TempData[WellKnownTempData.SuccessMessage] = $"Dictionary {dictionary} deleted";
return RedirectToAction(nameof(ListDictionaries));
}
}
}

View File

@ -68,6 +68,7 @@ namespace BTCPayServer.Controllers
private readonly UriResolver _uriResolver;
private readonly EmailSenderFactory _emailSenderFactory;
private readonly TransactionLinkProviders _transactionLinkProviders;
private readonly LocalizerService _localizer;
public UIServerController(
UserManager<ApplicationUser> userManager,
@ -93,7 +94,8 @@ namespace BTCPayServer.Controllers
EmailSenderFactory emailSenderFactory,
IHostApplicationLifetime applicationLifetime,
IHtmlHelper html,
TransactionLinkProviders transactionLinkProviders
TransactionLinkProviders transactionLinkProviders,
LocalizerService localizer
)
{
_policiesSettings = policiesSettings;
@ -120,6 +122,7 @@ namespace BTCPayServer.Controllers
ApplicationLifetime = applicationLifetime;
Html = html;
_transactionLinkProviders = transactionLinkProviders;
_localizer = localizer;
}
[HttpGet("server/stores")]
@ -325,17 +328,22 @@ namespace BTCPayServer.Controllers
[Route("server/policies")]
public async Task<IActionResult> Policies()
{
await UpdateViewBag();
return View(_policiesSettings);
}
private async Task UpdateViewBag()
{
ViewBag.UpdateUrlPresent = _Options.UpdateUrl != null;
ViewBag.AppsList = await GetAppSelectList();
return View(_policiesSettings);
ViewBag.LangDictionaries = await GetLangDictionariesSelectList();
}
[HttpPost("server/policies")]
public async Task<IActionResult> Policies([FromServices] BTCPayNetworkProvider btcPayNetworkProvider, PoliciesSettings settings, string command = "")
{
ViewBag.UpdateUrlPresent = _Options.UpdateUrl != null;
ViewBag.AppsList = await GetAppSelectList();
await UpdateViewBag();
if (command == "add-domain")
{
@ -385,8 +393,11 @@ namespace BTCPayServer.Controllers
}
}
await _SettingsRepository.UpdateSetting(settings);
_ = _transactionLinkProviders.RefreshTransactionLinkTemplates();
if (_policiesSettings.LangDictionary != settings.LangDictionary)
await _localizer.Load();
TempData[WellKnownTempData.SuccessMessage] = "Policies updated successfully";
return RedirectToAction(nameof(Policies));
}
@ -455,6 +466,12 @@ namespace BTCPayServer.Controllers
return apps;
}
private async Task<List<SelectListItem>> GetLangDictionariesSelectList()
{
var dictionaries = await this._localizer.GetDictionaries();
return dictionaries.Select(d => new SelectListItem(d.DictionaryName, d.DictionaryName)).OrderBy(d => d.Value).ToList();
}
private static bool TryParseAsExternalService(TorService torService, [MaybeNullWhen(false)] out ExternalService externalService)
{
externalService = null;

View File

@ -630,6 +630,7 @@ namespace BTCPayServer
dataDirectories.StorageDir = Path.Combine(dataDirectories.DataDir, Storage.Services.Providers.FileSystemStorage.FileSystemFileProviderService.LocalStorageDirectoryName);
dataDirectories.TempStorageDir = Path.Combine(dataDirectories.StorageDir, "tmp");
dataDirectories.TempDir = Path.Combine(dataDirectories.DataDir, "tmp");
dataDirectories.LangsDir = Path.Combine(dataDirectories.DataDir, "Langs");
return dataDirectories;
}

View File

@ -76,6 +76,10 @@ using ExchangeSharp;
using Microsoft.Extensions.Localization;
using Microsoft.AspNetCore.Mvc.Localization;
#if ALTCOINS
using BTCPayServer.Services.Altcoins.Monero;
using BTCPayServer.Services.Altcoins.Zcash;
@ -91,6 +95,11 @@ namespace BTCPayServer.Hosting
}
public static IServiceCollection AddBTCPayServer(this IServiceCollection services, IConfiguration configuration, Logs logs)
{
services.TryAddSingleton<IStringLocalizerFactory, LocalizerFactory>();
services.TryAddSingleton<IHtmlLocalizerFactory, LocalizerFactory>();
services.TryAddSingleton<LocalizerService>();
services.TryAddSingleton<ViewLocalizer>();
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
services.AddSingleton<JsonSerializerSettings>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value.SerializerSettings);
services.AddDbContext<ApplicationDbContext>((provider, o) =>
@ -161,6 +170,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IUIExtension>(new UIExtension("Lightning/ViewLightningLikePaymentData", "store-invoices-payments"));
services.AddStartupTask<BlockExplorerLinkStartupTask>();
services.AddStartupTask<LoadTranslationsStartupTask>();
services.TryAddSingleton<InvoiceRepository>();
services.AddSingleton<PaymentService>();
services.AddSingleton<BTCPayServerEnvironment>();

View File

@ -0,0 +1,86 @@
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Hosting
{
public class LoadTranslationsStartupTask : IStartupTask
{
public LoadTranslationsStartupTask(
ILogger<LoadTranslationsStartupTask> logger,
LocalizerService localizerService,
IOptions<DataDirectories> dataDirectories)
{
DataDirectories = dataDirectories.Value;
Logger = logger;
LocalizerService = localizerService;
}
public DataDirectories DataDirectories { get; }
public ILogger<LoadTranslationsStartupTask> Logger { get; }
public LocalizerService LocalizerService { get; }
class DictionaryFileMetadata
{
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
[JsonProperty("hash")]
public uint256 Hash { get; set; }
}
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
{
// This load languages files from a [datadir]/Langs into the database
// to make startup faster, we skip update if we see that files didn't changes
// since the last time they got loaded.
// We do this by comparing hashes of the current file, to the one stored in DB.
if (Directory.Exists(DataDirectories.LangsDir))
{
var files = Directory.GetFiles(DataDirectories.LangsDir);
if (files.Length > 0)
{
Logger.LogInformation("Loading language files...");
var dictionaries = await LocalizerService.GetDictionaries();
foreach (var file in Directory.GetFiles(DataDirectories.LangsDir))
{
var langName = Path.GetFileName(file);
var dictionary = dictionaries.FirstOrDefault(d => d.DictionaryName == langName);
if (dictionary is null)
dictionary = await LocalizerService.CreateDictionary(langName, null, "File");
if (dictionary.Source != "File")
{
Logger.LogWarning($"Impossible to load language '{langName}', as it is already existing in the database, not initially imported by a File");
continue;
}
var savedHash = dictionary.Metadata.ToObject<DictionaryFileMetadata>().Hash;
var translations = Translations.CreateFromText(File.ReadAllText(file));
var currentHash = new uint256(SHA256.HashData(Encoding.UTF8.GetBytes(translations.ToTextFormat())));
if (savedHash != currentHash)
{
var newMetadata = (JObject)dictionary.Metadata.DeepClone();
newMetadata["hash"] = currentHash.ToString();
dictionary = dictionary with { Metadata = newMetadata };
Logger.LogInformation($"Updating dictionary '{langName}'");
await LocalizerService.Save(dictionary, translations);
}
}
}
}
// Do not make startup longer for this
_ = LocalizerService.Load();
}
}
}

View File

@ -32,6 +32,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@ -168,6 +169,7 @@ namespace BTCPayServer.Hosting
.AddNewtonsoftJson()
.AddRazorRuntimeCompilation()
.AddPlugins(services, Configuration, LoggerFactory, bootstrapServiceProvider)
.AddDataAnnotationsLocalization()
.AddControllersAsServices();
services.AddServerSideBlazor();

View File

@ -11,6 +11,7 @@ namespace BTCPayServer.Models.AccountViewModels
[Required]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; }
public string LoginCode { get; set; }

View File

@ -0,0 +1,21 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Models.ServerViewModels;
public class CreateDictionaryViewModel
{
[Required(AllowEmptyStrings = false)]
public string Name { get; set; }
public string Fallback { get; set; }
public SelectListItem[] DictionariesListItems { get; set; }
internal CreateDictionaryViewModel SetDictionaries(LocalizerService.Dictionary[] dictionaries)
{
var items = dictionaries.Select(d => new SelectListItem(d.DictionaryName, d.DictionaryName, d.DictionaryName == Fallback)).ToArray();
DictionariesListItems = items;
return this;
}
}

View File

@ -0,0 +1,16 @@
using BTCPayServer.Services;
namespace BTCPayServer.Models.ServerViewModels;
public class EditDictionaryViewModel
{
public string Translations { get; set; }
public int Lines { get; set; }
internal EditDictionaryViewModel SetTranslations(Translations translations)
{
Translations = translations.ToTextFormat();
Lines = translations.Records.Count;
return this;
}
}

View File

@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Models.ServerViewModels
{
public class ListDictionariesViewModel
{
public class DictionaryViewModel
{
public string DictionaryName { get; set; }
public string Fallback { get; set; }
public string Source { get; set; }
public bool Editable { get; set; }
public bool IsSelected { get; set; }
}
public List<DictionaryViewModel> Dictionaries = new List<DictionaryViewModel>();
}
}

View File

@ -8,7 +8,7 @@ namespace BTCPayServer.Models.StoreViewModels
public class GeneralSettingsViewModel
{
[Display(Name = "Store ID")]
[Display(Name = "Store Id")]
public string Id { get; set; }
[Display(Name = "Store Name")]
@ -45,7 +45,7 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Add additional fee (network fee) to invoice …")]
public NetworkFeeMode NetworkFeeMode { get; set; }
[Display(Name = "Consider the invoice paid even if the paid amount is ... % less than expected")]
[Display(Name = "Consider the invoice paid even if the paid amount is % less than expected")]
[Range(0, 100)]
public double PaymentTolerance { get; set; }

View File

@ -17,7 +17,7 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Add additional fee (network fee) to invoice …")]
public NetworkFeeMode NetworkFeeMode { get; set; }
[Display(Name = "Consider the invoice paid even if the paid amount is ... % less than expected")]
[Display(Name = "Consider the invoice paid even if the paid amount is % less than expected")]
[Range(0, 100)]
public double PaymentTolerance { get; set; }

View File

@ -10,9 +10,9 @@ namespace BTCPayServer.Models.WalletViewModels
public class WalletPSBTCombineViewModel
{
public string OtherPSBT { get; set; }
[Display(Name = "PSBT to combine with...")]
[Display(Name = "PSBT to combine with")]
public string PSBT { get; set; }
[Display(Name = "Upload PSBT from file...")]
[Display(Name = "Upload PSBT from file")]
public IFormFile UploadedPSBTFile { get; set; }
public string BackUrl { get; set; }

View File

@ -32,7 +32,7 @@ namespace BTCPayServer.Models.WalletViewModels
public string PSBT { get; set; }
public List<string> Errors { get; set; } = new List<string>();
[Display(Name = "Upload PSBT from file")]
[Display(Name = "Upload PSBT from file")]
public IFormFile UploadedPSBTFile { get; set; }

View File

@ -9,7 +9,7 @@ namespace BTCPayServer.Plugins.Shopify.Models
[Display(Name = "Shop Name")]
public string ShopName { get; set; }
[Display(Name = "Api Key")]
[Display(Name = "API Key")]
public string ApiKey { get; set; }
[Display(Name = "Admin API access token")]

View File

@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Xml.Linq;
using BTCPayServer.Logging;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Mvc.TagHelpers;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Services
{
public class LocalizerFactory : IStringLocalizerFactory, IHtmlLocalizerFactory
{
internal readonly Logs _logs;
private readonly LocalizerService _localizerService;
class StringLocalizer : IStringLocalizer, IHtmlLocalizer
{
private Type _resourceSource;
private string _baseName;
private string _location;
private LocalizerFactory _Factory;
public StringLocalizer(LocalizerFactory factory, Type resourceSource)
{
_Factory = factory;
_resourceSource = resourceSource;
}
Logs Logs => _Factory._logs;
public StringLocalizer(LocalizerFactory jsonStringLocalizerFactory, string baseName, string location)
{
_Factory = jsonStringLocalizerFactory;
_baseName = baseName;
_location = location;
}
Translations Translations => _Factory._localizerService.Translations;
public LocalizedString this[string name]
{
get
{
//Logs.PayServer.LogInformation($"this[name] with name:{name}, location:{_location}, baseName:{_baseName}, resource:{_resourceSource}");
Translations.Records.TryGetValue(name, out var result);
result = result ?? name;
return new LocalizedString(name, result);
}
}
public LocalizedString this[string name, params object[] arguments]
{
get
{
//var args = String.Join(", ", arguments.Select((a, i) => $"arg[{i}]:{a}").ToArray());
//Logs.PayServer.LogInformation($"this[name, arguments] with name:{name}, {args}, location:{_location}, baseName:{_baseName}, resource:{_resourceSource}");
Translations.Records.TryGetValue(name, out var result);
result = result ?? name;
return new LocalizedString(name, string.Format(result, arguments));
}
}
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
{
//Logs.PayServer.LogInformation($"GetAllStrings");
return Translations.Records.Select(r => new LocalizedString(r.Key, r.Value));
}
LocalizedHtmlString IHtmlLocalizer.this[string name]
{
get
{
//Logs.PayServer.LogInformation($"[HTML]: this[name] with name:{name}, location:{_location}, baseName:{_baseName}, resource:{_resourceSource}");
Translations.Records.TryGetValue(name, out var result);
result = result ?? name;
return new LocalizedHtmlString(name, result);
}
}
LocalizedHtmlString IHtmlLocalizer.this[string name, params object[] arguments]
{
get
{
//var args = String.Join(", ", arguments.Select((a, i) => $"arg[{i}]:{a}").ToArray());
//Logs.PayServer.LogInformation($"[HTML]:this[name, arguments] with name:{name}, {args}, location:{_location}, baseName:{_baseName}, resource:{_resourceSource}");
Translations.Records.TryGetValue(name, out var result);
result = result ?? name;
return new LocalizedHtmlString(name, result, true, arguments);
}
}
public LocalizedString GetString(string name)
{
//Logs.PayServer.LogInformation($"[HTML] GetString(name):");
return this[name];
}
public LocalizedString GetString(string name, params object[] arguments)
{
//var args = String.Join(", ", arguments.Select((a, i) => $"arg[{i}]:{a}").ToArray());
Logs.PayServer.LogInformation($"[HTML] GetString(name,args):");
return this[name, arguments];
}
}
public LocalizerFactory(Logs logs, LocalizerService localizerService)
{
_logs = logs;
_localizerService = localizerService;
}
public IStringLocalizer Create(Type resourceSource)
{
return new StringLocalizer(this, resourceSource);
}
public IStringLocalizer Create(string baseName, string location)
{
return new StringLocalizer(this, baseName, location);
}
IHtmlLocalizer IHtmlLocalizerFactory.Create(Type resourceSource)
{
return new StringLocalizer(this, resourceSource);
}
IHtmlLocalizer IHtmlLocalizerFactory.Create(string baseName, string location)
{
return new StringLocalizer(this, baseName, location);
}
}
}

View File

@ -0,0 +1,170 @@
#nullable enable
using System.Collections;
using System.Collections.Frozen;
using Dapper;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using System;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json.Linq;
using System.Data;
using System.Data.Common;
using Microsoft.Extensions.Logging;
using static BTCPayServer.Services.LocalizerService;
namespace BTCPayServer.Services
{
public class LocalizerService
{
public LocalizerService(
ILogger<LocalizerService> logger,
ApplicationDbContextFactory contextFactory,
ISettingsAccessor<PoliciesSettings> settingsAccessor)
{
_logger = logger;
_ContextFactory = contextFactory;
_settingsAccessor = settingsAccessor;
_LoadedTranslations = new LoadedTranslations(Translations.Default, Translations.Default, Translations.DefaultLanguage);
}
public record LoadedTranslations(Translations Translations, Translations Fallback, string LangName);
LoadedTranslations _LoadedTranslations;
public Translations Translations => _LoadedTranslations.Translations;
private readonly ILogger<LocalizerService> _logger;
private readonly ApplicationDbContextFactory _ContextFactory;
private readonly ISettingsAccessor<PoliciesSettings> _settingsAccessor;
/// <summary>
/// Load the translation of the server into memory
/// </summary>
/// <returns></returns>
public async Task Load()
{
try
{
_LoadedTranslations = await GetTranslations(_settingsAccessor.Settings.LangDictionary);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load translations");
throw;
}
}
public async Task<LoadedTranslations> GetTranslations(string dictionaryName)
{
var ctx = _ContextFactory.CreateContext();
var conn = ctx.Database.GetDbConnection();
var all = await conn.QueryAsync<(bool fallback, string sentence, string? translation)>(
"SELECT 'f'::BOOL fallback, sentence, translation FROM translations WHERE dict_id=@dict_id " +
"UNION ALL " +
"SELECT 't'::BOOL fallback, sentence, translation FROM translations WHERE dict_id=(SELECT fallback FROM lang_dictionaries WHERE dict_id=@dict_id)",
new
{
dict_id = dictionaryName,
});
var fallback = new Translations(all.Where(a => a.fallback).Select(o => KeyValuePair.Create(o.sentence, o.translation)), Translations.Default);
var translations = new Translations(all.Where(a => !a.fallback).Select(o => KeyValuePair.Create(o.sentence, o.translation)), fallback);
return new LoadedTranslations(translations, fallback, dictionaryName);
}
public async Task Save(Dictionary dictionary, Translations translations)
{
var loadedTranslations = await GetTranslations(dictionary.DictionaryName);
translations = translations.WithFallback(loadedTranslations.Fallback);
await using var ctx = _ContextFactory.CreateContext();
var diffs = loadedTranslations.Translations.CalculateDiff(translations);
var conn = ctx.Database.GetDbConnection();
List<string> keys = new List<string>();
List<string> deletedKeys = new List<string>();
List<string> values = new List<string>();
// The basic idea here is that we can remove from
// the dictionary any translations which are the same
// as the fallback. This way, if the fallback get updated,
// it will also update the dictionary.
foreach (var diff in diffs)
{
if (diff is Translations.Diff.Added a)
{
if (a.Value != loadedTranslations.Fallback[a.Key])
{
keys.Add(a.Key);
values.Add(a.Value);
}
}
else if (diff is Translations.Diff.Modified m)
{
if (m.NewValue != loadedTranslations.Fallback[m.Key])
{
keys.Add(m.Key);
values.Add(m.NewValue);
}
else
{
deletedKeys.Add(m.Key);
}
}
else if (diff is Translations.Diff.Deleted d)
{
deletedKeys.Add(d.Key);
}
}
await conn.ExecuteAsync("INSERT INTO lang_translations SELECT @dict_id, sentence, translation FROM unnest(@keys, @values) AS t(sentence, translation) ON CONFLICT (dict_id, sentence) DO UPDATE SET translation = EXCLUDED.translation; ",
new
{
dict_id = loadedTranslations.LangName,
keys = keys.ToArray(),
values = values.ToArray()
});
await conn.ExecuteAsync("DELETE FROM lang_translations WHERE dict_id=@dict_id AND sentence=ANY(@keys)",
new
{
dict_id = loadedTranslations.LangName,
keys = deletedKeys.ToArray()
});
if (_LoadedTranslations.LangName == loadedTranslations.LangName)
_LoadedTranslations = loadedTranslations with { Translations = translations };
}
public record Dictionary(string DictionaryName, string? Fallback, string Source, JObject Metadata);
public async Task<Dictionary[]> GetDictionaries()
{
await using var ctx = _ContextFactory.CreateContext();
var db = ctx.Database.GetDbConnection();
var rows = await db.QueryAsync<(string dict_id, string? fallback, string? source, string? metadata)>("SELECT * FROM lang_dictionaries");
return rows.Select(r => new Dictionary(r.dict_id, r.fallback, r.source ?? "", JObject.Parse(r.metadata ?? "{}"))).ToArray();
}
public async Task<Dictionary?> GetDictionary(string name)
{
await using var ctx = _ContextFactory.CreateContext();
var db = ctx.Database.GetDbConnection();
var r = await db.QueryFirstAsync("SELECT * FROM lang_dictionaries WHERE dict_id=@dict_id", new { dict_id = name });
if (r is null)
return null;
return new Dictionary(r.dict_id, r.fallback, r.source ?? "", JObject.Parse(r.metadata ?? "{}"));
}
public async Task<Dictionary> CreateDictionary(string langName, string? fallback, string source)
{
await using var ctx = _ContextFactory.CreateContext();
var db = ctx.Database.GetDbConnection();
await db.ExecuteAsync("INSERT INTO lang_dictionaries (dict_id, fallback, source) VALUES (@langName, @fallback, @source)", new { langName, fallback, source });
return new Dictionary(langName, fallback, source ?? "", new JObject());
}
public async Task DeleteDictionary(string dictionary)
{
await using var ctx = _ContextFactory.CreateContext();
var db = ctx.Database.GetDbConnection();
await db.ExecuteAsync("DELETE FROM lang_dictionaries WHERE dict_id=@dict_id AND source='Custom'", new { dict_id = dictionary });
}
}
}

View File

@ -23,6 +23,11 @@ namespace BTCPayServer.Services
set { LockSubscription = !value; }
}
[DefaultValue("English")]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Backend's language")]
public string LangDictionary { get; set; } = "English";
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Admin must approve new users")]
public bool RequiresUserApproval { get; set; }

View File

@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
namespace BTCPayServer.Services
{
public partial class Translations
{
static Translations()
{
// Text generated by UpdateDefaultTranslations.
// Please run it before release.
var knownTranslations =
"""
Account key
Account key path
Add additional fee (network fee) to invoice
Add Exchange Rate Spread
Add hop hints for private channels to the Lightning invoice
Admin API access token
Admin must approve new users
Allow anyone to create invoice
Allow form for public use
Allow payee to create invoices with custom amounts
Allow payee to pass a comment
Allow Stores use the Server's SMTP email settings as their default
Always include non-witness UTXO if available
Amazon S3
Amount
API Key
App
App Name
App Type
Application
Authenticator code
Auto-detect language on checkout
Automatically approve claims
Available Payment Methods
Azure Blob Storage
Backend's language
Batch size
BIP39 Seed (12/24 word mnemonic phrase) or HD private key (xprv...)
Brand Color
Buyer Email
Callback Notification URL
Can use hot wallet
Can use RPC import
Celebrate payment with confetti
Check releases on GitHub and notify when new BTCPay Server version is available
Colors to rotate between with animation when a payment is made. One color per line.
Confirm new password
Confirm password
Connection string
Consider the invoice paid even if the paid amount is % less than expected
Consider the invoice settled when the payment transaction
Contact URL
Contribution Perks Template
Count all invoices created on the store as part of the goal
Currency
Current password
Custom CSS
Custom HTML title to display on Checkout page
Custom sound file for successful payment
Custom Theme Extension Type
Custom Theme File
Default currency
Default language on checkout
Default payment method on checkout
Default role for users on a new store
Derivation scheme
Derivation scheme format
Description
Description template of the lightning invoice
Destination Address
Disable public user registration
Disable stores from using the server's email settings as backup
Discourage search engines from indexing this site
Display app on website root
Display contribution ranking
Display contribution value
Display item selection for keypad
Display Lightning payment amounts in Satoshis
Display the category list
Display the search bar
Display Title
Disqus Shortname
Do not allow additional contributions after target has been reached
Does not extend a BTCPay Server theme, fully custom
Domain
Domain name
Don't create UTXO change
Email
Email address
Email confirmation required
Email confirmed?
Enable background animations on new payments
Enable Disqus Comments
Enable experimental features
Enable LNURL
Enable Payjoin/P2EP
Enable public receipt page for settled invoices
Enable public user registration
Enable sounds on checkout page
Enable sounds on new payments
Enable tips
End date
Error
Expiration Date
Extends the BTCPay Server Dark theme
Extends the BTCPay Server Light theme
Featured Image URL
Fee rate (sat/vB)
Form configuration (JSON)
Gap limit
Google Cloud Storage
GRPC SSL Cipher suite (GRPC_SSL_CIPHER_SUITES)
Image
Invoice currency
Invoice expires if the full amount has not been paid after
Invoice metadata
Is administrator?
Is signing key
Item Description
Keypad
Lightning node (LNURL Auth)
LNURL Classic Mode
Local File System
Logo
Make Crowdfund Public
Master fingerprint
Max sats
Memo
Metadata
Min sats
Minimum acceptable expiration time for BOLT11 for refunds
New password
Non-admins can access the User Creation API Endpoint
Non-admins can create Hot Wallets for their Store
Non-admins can import Hot Wallets for their Store
Non-admins can use the Internal Lightning Node for their Store
Non-admins cannot access the User Creation API Endpoint
Notification Email
Notification URL
Only enable the payment method after user explicitly chooses it
Optional seed passphrase
Order Id
Override the block explorers used
Pair to
Password
Password (leave blank to generate invite-link)
PayJoin BIP21
Payment invalid if transactions fails to confirm after invoice expiration
Payout Methods
Plugin server
Point of Sale Style
Policies
Preferred Price Source
Print display
Product list
Product list with cart
Profile Picture
PSBT content
PSBT to combine with
Public Key
Rate Rules
Recommended fee confirmation target blocks
Recovery Code
Redirect invoice to redirect url automatically after paid
Redirect URL
Remember me
Remember this machine
Request contributor data on checkout
Request customer data on checkout
Reset goal every
REST Uri
Role
Root fingerprint
Scope
Search engines can index this site
Security device (FIDO2)
Select the Default Currency during Store Creation
Select the payout method used for refund
Server Name
Shop Name
Show "Pay in wallet" button
Show a timer minutes before invoice expiration
Show plugins in pre-release
Show recommended fee
Show the payment list in the public receipt page
Show the QR code of the receipt in the public receipt page
Show the store header
Sign in
Sort contribution perks by popularity
Sounds to play when a payment is made. One sound per line
Specify the amount and currency for the refund
Start date
Starting index
Store
Store Id
Store Name
Store Website
Subtract fees from amount
Support URL
Supported Transaction Currencies
Target Amount
Test Email
Text to display in the tip input
Text to display on buttons allowing the user to enter a custom amount
Text to display on each button for items with a specific price
Tip percentage amounts (comma separated)
Unify on-chain and lightning payment URL/QR code
Upload PSBT from file
Url of the Dynamic DNS service you are using
Use custom theme
Use SSL
User can input custom amount
User can input discount in %
UTXOs to spend from
Verification Code
Wallet file
Wallet file content
Welcome to {0}
Your dynamic DNS hostname
""";
Default = Translations.CreateFromText(knownTranslations);
Default = new Translations(new KeyValuePair<string, string>[]
{
// You can add additional hard coded default here
// KeyValuePair.Create("key1", "value")
// KeyValuePair.Create("key2", "value")
}, Default);
}
/// <summary>
/// Translations which are already in the Default aren't saved into database.
/// This allows us to automatically update the english version if the translations didn't changed.
///
/// We only save into database the key/values that differ from Default
/// </summary>
public static Translations Default;
public readonly static string DefaultLanguage = "English";
}
}

View File

@ -0,0 +1,125 @@
#nullable enable
using System.Collections;
using System.Collections.Frozen;
using Dapper;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using System;
using System.Runtime.InteropServices;
namespace BTCPayServer.Services
{
public partial class Translations : IEnumerable<KeyValuePair<string, string>>
{
public record Diff(string Key)
{
public record Deleted(string Key, string OldValue) : Diff(Key);
public record Added(string Key, string Value) : Diff(Key);
public record Modified(string Key, string NewValue, string OldValue) : Diff(Key);
}
public static bool TryCreateFromText(string text, [MaybeNullWhen(false)] out Translations translations)
{
translations = null;
try
{
translations = CreateFromText(text);
return true;
}
catch
{
return false;
}
}
public static Translations CreateFromText(string text)
{
text = (text ?? "").Replace("\r\n", "\n");
var translations = new List<(string key, string? value)>();
foreach (var line in text.Split("\n", StringSplitOptions.RemoveEmptyEntries))
{
var splitted = line.Split("=>", StringSplitOptions.RemoveEmptyEntries);
if (splitted is [var key, var value])
{
translations.Add((key, value));
}
else if (splitted is [var key2])
{
translations.Add((key2, key2));
}
}
return new Translations(translations
.Select(t => KeyValuePair.Create(t.key, t.value)));
}
public Translations(IEnumerable<KeyValuePair<string, string?>> records) : this (records, null)
{
}
public Translations(IEnumerable<KeyValuePair<string, string?>> records, Translations? fallback)
{
Dictionary<string, string> thisRecords = new Dictionary<string, string>();
foreach (var r in records)
{
var v = r.Value?.Trim();
if (string.IsNullOrEmpty(v))
continue;
thisRecords.TryAdd(r.Key.Trim(), v);
}
if (fallback is not null)
{
foreach (var r in fallback.Records)
{
thisRecords.TryAdd(r.Key, r.Value);
}
}
Records = thisRecords.ToFrozenDictionary();
}
public readonly FrozenDictionary<string, string> Records;
public string? this[string? key] => key is null ? null : Records.TryGetValue(key, out var v) ? v : null;
public Diff[] CalculateDiff(Translations translations)
{
List<Diff> diff = new List<Diff>(translations.Records.Count + 10);
foreach (var kv in translations)
{
if (Records.TryGetValue(kv.Key, out var oldValue))
{
if (oldValue != kv.Value)
diff.Add(new Diff.Modified(kv.Key, kv.Value, oldValue));
}
else
{
diff.Add(new Diff.Added(kv.Key, kv.Value));
}
}
foreach (var kv in this)
{
if (!translations.Records.ContainsKey(kv.Key))
diff.Add(new Diff.Deleted(kv.Key, kv.Value));
}
return diff.ToArray();
}
public Translations WithFallback(Translations? fallback)
{
return new Translations(this!, fallback);
}
public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
{
return Records.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public string ToTextFormat()
{
return string.Join('\n', Records.OrderBy(r => r.Key).Select(r => $"{r.Key} => {r.Value}").ToArray());
}
}
}

View File

@ -0,0 +1,55 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Services;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.TagHelpers
{
[HtmlTargetElement(Attributes = "text-translate")]
[HtmlTargetElement(Attributes = "html-translate")]
public class TranslateTagHelper : TagHelper
{
private readonly IStringLocalizer<TranslateTagHelper> _localizer;
private readonly Safe _safe;
public bool TextTranslate { get; set; }
public bool HtmlTranslate { get; set; }
public TranslateTagHelper(
IStringLocalizer<TranslateTagHelper> localizer,
Safe safe)
{
_localizer = localizer;
_safe = safe;
}
public override int Order
{
get
{
// Run this TagHelper before others
return -10;
}
}
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var originalContent = output.Content.IsModified
? output.Content.GetContent()
: (await output.GetChildContentAsync()).GetContent();
var newContent = _localizer[originalContent];
if (TextTranslate)
output.Content.SetContent(newContent);
else
output.Content.SetHtmlContent(_safe.Raw(newContent.Value));
}
}
}

View File

@ -49,7 +49,7 @@
<vc:main-logo />
</a>
<h1 class="h2 mb-3">Welcome to @(string.IsNullOrWhiteSpace(settings.ServerName) ? "your BTCPay\u00a0Server" : settings.ServerName)</h1>
<h1 class="h2 mb-3" text-translate="true">@ViewLocalizer["Welcome to {0}", string.IsNullOrWhiteSpace(settings.ServerName) ? "BTCPay Server" : settings.ServerName]</h1>
@if (ViewBag.ShowLeadText)
{
<p class="lead">

View File

@ -2,7 +2,7 @@
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@inject BTCPayServer.Services.PoliciesSettings PoliciesSettings
@{
ViewData["Title"] = "Sign in";
ViewData["Title"] = ViewLocalizer["Sign in"];
Layout = "_LayoutSignedOut";
Csp.UnsafeEval();
}
@ -21,7 +21,7 @@
<div class="form-group">
<div class="d-flex justify-content-between">
<label asp-for="Password" class="form-label"></label>
<a asp-action="ForgotPassword" tabindex="-1">Forgot password?</a>
<a asp-action="ForgotPassword" text-translate="true" tabindex="-1">Forgot password?</a>
</div>
<div class="input-group d-flex">
<input asp-for="Password" class="form-control" required />
@ -35,7 +35,7 @@
</div>
<div class="form-group mt-4">
<div class="btn-group w-100">
<button type="submit" class="btn btn-primary btn-lg w-100" id="LoginButton"><span class="ps-3">Sign in</span></button>
<button type="submit" class="btn btn-primary btn-lg w-100" id="LoginButton"><span class="ps-3" text-translate="true">Sign in</span></button>
<button type="button" class="btn btn-outline-primary btn-lg w-auto only-for-js" data-bs-toggle="modal" data-bs-target="#scanModal" title="Scan Login code with camera">
<vc:icon symbol="scan-qr" />
</button>
@ -49,7 +49,7 @@
@if (!PoliciesSettings.LockSubscription)
{
<p class="text-center mt-2 mb-0">
<a id="Register" style="font-size:1.15rem" asp-action="Register" asp-route-returnurl="@ViewData["ReturnUrl"]">Create your account</a>
<a id="Register" style="font-size:1.15rem" asp-action="Register" asp-route-returnurl="@ViewData["ReturnUrl"]" text-translate="true">Create your account</a>
</p>
}

View File

@ -9,7 +9,7 @@
<partial name="_StatusMessage" />
<h2>Welcome to @(string.IsNullOrWhiteSpace(settings.ServerName) ? "your BTCPay\u00a0Server" : settings.ServerName)</h2>
<h2 text-translate="true">@ViewLocalizer["Welcome to {0}", string.IsNullOrWhiteSpace(settings.ServerName) ? "BTCPay Server" : settings.ServerName]</h2>
@if (!Model.HasStore)
{

View File

@ -0,0 +1,37 @@
@using BTCPayServer.Abstractions.Models
@model CreateDictionaryViewModel
@{
ViewData.SetActivePage(ServerNavPages.Translations);
ViewData["Title"] = "Create a new dictionary";
}
<form method="post" class="d-flex flex-column">
<div class="form-group">
<div class="sticky-header">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a asp-action="ListDictionaries">Dictionaries</a>
</li>
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
</ol>
<h2>@ViewData["Title"]</h2>
</nav>
<input type="submit" value="Create" class="btn btn-primary" id="Create" />
</div>
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="form-group">
<label asp-for="Name" class="form-label"></label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<label asp-for="Fallback" class="form-label"></label>
<select asp-for="Fallback" class="form-select w-auto" asp-items="@Model.DictionariesListItems"></select>
<span asp-validation-for="Fallback" class="text-danger"></span>
<div class="form-text">If a translation isnt available in the new dictionary, it will be searched in the fallback.</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,49 @@
@using BTCPayServer.Abstractions.Models
@model EditDictionaryViewModel
@{
ViewData.SetActivePage(ServerNavPages.Translations);
ViewData["Title"] = Context.GetRouteValue("dictionary");
}
<form method="post" class="d-flex flex-column">
<div class="sticky-header">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a asp-action="ListDictionaries">Dictionaries</a>
</li>
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
</ol>
<h2>@ViewData["Title"]</h2>
</nav>
<button id="SaveButton" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
</div>
<partial name="_StatusMessage" />
<div class="d-flex mb-4">
<div class="flex-fill">
<p>
Translations are formatted as <b>KEY => TRANSLATION</b>; for example, <b>Welcome => Bienvenue</b> translates <b>Welcome</b> to <b>Bienvenue</b>.
</p>
<p class="mb-0">
To use the translation from this dictionary's fallback, you can:
<ul>
<li>Remove the translation from this dictionary.</li>
<li>Set the translation to match the string in the fallback.</li>
</ul>
</p>
<p class="mb-0">Please note that not all text is translatable, and future updates may modify existing translations or introduce new translatable phrases.</p>
</div>
</div>
<div class="row">
<div class="col-xl-8 col-xxl-constrain d-flex flex-column">
<div class="form-group">
<label asp-for="Translations" class="form-label"></label>
<textarea asp-for="Translations" class="form-control translation-editor" rows="@Model.Lines" cols="40"></textarea>
<span asp-validation-for="Translations" class="text-danger"></span>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,67 @@
@using BTCPayServer.Abstractions.Models
@model ListDictionariesViewModel
@{
ViewData.SetActivePage(ServerNavPages.Translations);
ViewData["Title"] = "Dictionaries";
}
<div class="sticky-header">
<h2>@ViewData["Title"]</h2>
<a asp-action="CreateDictionary" class="btn btn-primary" role="button" id="CreateDictionary">
Create
</a>
</div>
<partial name="_StatusMessage" />
<div class="d-flex mb-4">
<div class="flex-fill">
<p class="mb-0">
Dictionaries enable you to translate the BTCPay Server backend into different languages.
</p>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Dictionary</th>
<th>Fallback</th>
<th class="actions-col"></th>
</tr>
</thead>
<tbody>
@foreach (var v in Model.Dictionaries)
{
<tr>
<td>
@if (!v.Editable)
{
<span class="@(v.IsSelected? "fw-bold" : "")">@v.DictionaryName</span>
}
else
{
<a class="@(v.IsSelected? "fw-bold" : "")" asp-action="EditDictionary" asp-route-dictionary="@v.DictionaryName">@v.DictionaryName</a>
}
</td>
<td>@v.Fallback</td>
<td class="actions-col">
<div class="d-inline-flex align-items-center gap-3">
<a asp-action="CreateDictionary" asp-route-fallback="@v.DictionaryName">Clone</a>
@if (!v.IsSelected)
{
<a id="Select-@v.DictionaryName" asp-action="SelectDictionary" asp-route-dictionary="@v.DictionaryName">Select</a>
}
@if (v.Editable && !v.IsSelected)
{
<a id="Delete-@v.DictionaryName" asp-action="DeleteDictionary" asp-route-dictionary="@v.DictionaryName" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The dictionary <b>@Html.Encode(v.DictionaryName)</b> will be removed from this server." data-confirm-input="DELETE">Remove</a>
}
</div>
</td>
</tr>
}
</tbody>
</table>
<partial name="_Confirm" model="@(new ConfirmModel("Delete dictionary", "This dictionary will be removed from this server.", "Delete"))" />
</div>

View File

@ -143,6 +143,15 @@
<span asp-validation-for="AllowSearchEngines" class="text-danger"></span>
</div>
</div>
<div class="form-check my-3">
<label asp-for="LangDictionary" class="form-label"></label>
<select asp-for="LangDictionary"
asp-items="@(new SelectList(ViewBag.LangDictionaries, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.LangDictionary))"
class="form-select">
</select>
<span asp-validation-for="LangDictionary" class="text-danger"></span>
<div class="form-text">Add or customize translations <a asp-action="ListDictionaries">here</a>.</div>
</div>
@if (ViewBag.UpdateUrlPresent)
{
<div class="d-flex my-3">

View File

@ -4,6 +4,7 @@ namespace BTCPayServer.Views.Server
{
Users, Emails, Policies, Branding, Services, Maintenance, Logs, Files, Plugins,
Roles,
Stores
Stores,
Translations
}
}

View File

@ -10,6 +10,7 @@
@using BTCPayServer.Data
@using Microsoft.AspNetCore.Routing;
@using BTCPayServer.Abstractions.Extensions;
@inject Microsoft.AspNetCore.Mvc.Localization.ViewLocalizer ViewLocalizer
@inject Safe Safe
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, BTCPayServer