Auto-detect language on payment page (#2552)

* Auto-detect language on payment page

based on the requst Accept-Language header, which is the language you configured in your browser/OS and this 99.99% accurate

* Update BTCPayServer/Services/LanguageService.cs

Co-authored-by: britttttk <39231115+britttttk@users.noreply.github.com>

* Update BTCPayServer/Services/LanguageService.cs

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>

* Update BTCPayServer/Services/LanguageService.cs

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>

* Update BTCPayServer/Services/LanguageService.cs

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>

* Added loop for all locales in Accept-Language sorted by weight + check if know this language

* New public method so a unit test can be created for it

* Unit test for language detection

* Fix language service when not in browser context

* fall back to default lang

* Auto-detect setting + ?lang=auto support

* Added invoice param "?lang=auto" info to docs

* Using null-coalescing assignment operator

* Reduce complexity and http dependency in language service

Co-authored-by: britttttk <39231115+britttttk@users.noreply.github.com>
Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
This commit is contained in:
Wouter Samaey
2021-07-27 08:17:56 +02:00
committed by GitHub
parent 71cbe716f9
commit d8c1c51a21
12 changed files with 185 additions and 27 deletions

View File

@@ -1236,19 +1236,19 @@ namespace BTCPayServer.Tests
var langs = tester.PayTester.GetService<LanguageService>(); var langs = tester.PayTester.GetService<LanguageService>();
foreach (var match in new[] { "it", "it-IT", "it-LOL" }) foreach (var match in new[] { "it", "it-IT", "it-LOL" })
{ {
Assert.Equal("it-IT", langs.FindBestMatch(match).Code); Assert.Equal("it-IT", langs.FindLanguage(match).Code);
} }
foreach (var match in new[] { "pt-BR" }) foreach (var match in new[] { "pt-BR" })
{ {
Assert.Equal("pt-BR", langs.FindBestMatch(match).Code); Assert.Equal("pt-BR", langs.FindLanguage(match).Code);
} }
foreach (var match in new[] { "en", "en-US" }) foreach (var match in new[] { "en", "en-US" })
{ {
Assert.Equal("en", langs.FindBestMatch(match).Code); Assert.Equal("en", langs.FindLanguage(match).Code);
} }
foreach (var match in new[] { "pt", "pt-pt", "pt-PT" }) foreach (var match in new[] { "pt", "pt-pt", "pt-PT" })
{ {
Assert.Equal("pt-PT", langs.FindBestMatch(match).Code); Assert.Equal("pt-PT", langs.FindLanguage(match).Code);
} }
//payment method activation tests //payment method activation tests

View File

@@ -0,0 +1,52 @@
using System.Threading.Tasks;
using BTCPayServer.Services;
using BTCPayServer.Tests.Logging;
using Xunit;
using Xunit.Abstractions;
namespace BTCPayServer.Tests
{
public class LanguageServiceTests
{
public const int TestTimeout = TestUtils.TestTimeout;
public LanguageServiceTests(ITestOutputHelper helper)
{
Logs.Tester = new XUnitLog(helper) { Name = "Tests" };
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanAutoDetectLanguage()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
var languageService = tester.PayTester.GetService<LanguageService>();
// Most common format. First option does not have a quality score. Others do in descending order.
// Result should be nl-NL (because the default weight is 1 for nl)
var lang1 = languageService.FindLanguageInAcceptLanguageHeader("nl,fr;q=0.7,en;q=0.5");
Assert.NotNull(lang1);
Assert.Equal("nl-NL", lang1?.Code);
// Most common format. First option does not have a quality score. Others do in descending order. This time the first option includes a country.
// Result should be nl-NL (because the default weight is 1 for nl-BE and it does not exist in BTCPay Server, but nl-NL does and applies too for language "nl")
var lang2 = languageService.FindLanguageInAcceptLanguageHeader("nl-BE,fr;q=0.7,en;q=0.5");
Assert.NotNull(lang2);
Assert.Equal("nl-NL", lang2?.Code);
// Unusual format, but still valid. All values have a quality score and not ordered.
// Result should be fr-FR (because 0.7 is the highest quality score)
var lang3 = languageService.FindLanguageInAcceptLanguageHeader("nl;q=0.1,fr;q=0.7,en;q=0.5");
Assert.NotNull(lang3);
Assert.Equal("fr-FR", lang3?.Code);
// Unusual format, but still valid. Some language is given that we don't have and a wildcard for everything else.
// Result should be NULL, because "xx" does not exist and * is a wildcard and has no meaning.
var lang4 = languageService.FindLanguageInAcceptLanguageHeader("xx,*;q=0.5");
Assert.Null(lang4);
}
}
}
}

View File

@@ -195,7 +195,7 @@ namespace BTCPayServer.Controllers.GreenField
if (request.Checkout.DefaultLanguage != null) if (request.Checkout.DefaultLanguage != null)
{ {
var lang = LanguageService.FindBestMatch(request.Checkout.DefaultLanguage); var lang = LanguageService.FindLanguage(request.Checkout.DefaultLanguage);
if (lang == null) if (lang == null)
{ {
request.AddModelError(invoiceRequest => invoiceRequest.Checkout.DefaultLanguage, request.AddModelError(invoiceRequest => invoiceRequest.Checkout.DefaultLanguage,

View File

@@ -482,7 +482,7 @@ namespace BTCPayServer.Controllers
return View(model); return View(model);
} }
private async Task<PaymentModel?> GetInvoiceModel(string invoiceId, PaymentMethodId paymentMethodId, string lang) private async Task<PaymentModel?> GetInvoiceModel(string invoiceId, PaymentMethodId paymentMethodId, string? lang)
{ {
var invoice = await _InvoiceRepository.GetInvoice(invoiceId); var invoice = await _InvoiceRepository.GetInvoice(invoiceId);
if (invoice == null) if (invoice == null)
@@ -531,6 +531,21 @@ namespace BTCPayServer.Controllers
var paymentMethodHandler = _paymentMethodHandlerDictionary[paymentMethodId]; var paymentMethodHandler = _paymentMethodHandlerDictionary[paymentMethodId];
var divisibility = _CurrencyNameTable.GetNumberFormatInfo(paymentMethod.GetId().CryptoCode, false)?.CurrencyDecimalDigits; var divisibility = _CurrencyNameTable.GetNumberFormatInfo(paymentMethod.GetId().CryptoCode, false)?.CurrencyDecimalDigits;
switch (lang?.ToLowerInvariant())
{
case "auto":
case null when storeBlob.AutoDetectLanguage:
lang = _languageService.AutoDetectLanguageUsingHeader(HttpContext.Request.Headers, null).Code;
break;
case { } langs when !string.IsNullOrEmpty(langs):
{
lang = _languageService.FindLanguage(langs)?.Code;
break;
}
}
lang ??= storeBlob.DefaultLang;
var model = new PaymentModel() var model = new PaymentModel()
{ {
Activated = paymentMethodDetails.Activated, Activated = paymentMethodDetails.Activated,

View File

@@ -14,6 +14,7 @@ using BTCPayServer.Models;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Rating; using BTCPayServer.Rating;
using BTCPayServer.Security; using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
@@ -44,6 +45,7 @@ namespace BTCPayServer.Controllers
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary; private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
private readonly ApplicationDbContextFactory _dbContextFactory; private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly PullPaymentHostedService _paymentHostedService; private readonly PullPaymentHostedService _paymentHostedService;
private readonly LanguageService _languageService;
public WebhookNotificationManager WebhookNotificationManager { get; } public WebhookNotificationManager WebhookNotificationManager { get; }
@@ -59,7 +61,8 @@ namespace BTCPayServer.Controllers
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary, PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
ApplicationDbContextFactory dbContextFactory, ApplicationDbContextFactory dbContextFactory,
PullPaymentHostedService paymentHostedService, PullPaymentHostedService paymentHostedService,
WebhookNotificationManager webhookNotificationManager) WebhookNotificationManager webhookNotificationManager,
LanguageService languageService)
{ {
_CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable)); _CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
_StoreRepository = storeRepository ?? throw new ArgumentNullException(nameof(storeRepository)); _StoreRepository = storeRepository ?? throw new ArgumentNullException(nameof(storeRepository));
@@ -73,6 +76,7 @@ namespace BTCPayServer.Controllers
_paymentHostedService = paymentHostedService; _paymentHostedService = paymentHostedService;
WebhookNotificationManager = webhookNotificationManager; WebhookNotificationManager = webhookNotificationManager;
_CSP = csp; _CSP = csp;
_languageService = languageService;
} }

View File

@@ -416,6 +416,7 @@ namespace BTCPayServer.Controllers
vm.CustomCSS = storeBlob.CustomCSS; vm.CustomCSS = storeBlob.CustomCSS;
vm.CustomLogo = storeBlob.CustomLogo; vm.CustomLogo = storeBlob.CustomLogo;
vm.HtmlTitle = storeBlob.HtmlTitle; vm.HtmlTitle = storeBlob.HtmlTitle;
vm.AutoDetectLanguage = storeBlob.AutoDetectLanguage;
vm.SetLanguages(_LangService, storeBlob.DefaultLang); vm.SetLanguages(_LangService, storeBlob.DefaultLang);
return View(vm); return View(vm);
} }
@@ -490,6 +491,7 @@ namespace BTCPayServer.Controllers
blob.CustomLogo = model.CustomLogo; blob.CustomLogo = model.CustomLogo;
blob.CustomCSS = model.CustomCSS; blob.CustomCSS = model.CustomCSS;
blob.HtmlTitle = string.IsNullOrWhiteSpace(model.HtmlTitle) ? null : model.HtmlTitle; blob.HtmlTitle = string.IsNullOrWhiteSpace(model.HtmlTitle) ? null : model.HtmlTitle;
blob.AutoDetectLanguage = model.AutoDetectLanguage;
blob.DefaultLang = model.DefaultLang; blob.DefaultLang = model.DefaultLang;
if (CurrentStore.SetStoreBlob(blob)) if (CurrentStore.SetStoreBlob(blob))

View File

@@ -83,6 +83,8 @@ namespace BTCPayServer.Data
public string CustomCSS { get; set; } public string CustomCSS { get; set; }
public string CustomLogo { get; set; } public string CustomLogo { get; set; }
public string HtmlTitle { get; set; } public string HtmlTitle { get; set; }
public bool AutoDetectLanguage { get; set; }
public bool RateScripting { get; set; } public bool RateScripting { get; set; }

View File

@@ -55,8 +55,10 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Recommended fee confirmation target blocks")] [Display(Name = "Recommended fee confirmation target blocks")]
[Range(1, double.PositiveInfinity)] [Range(1, double.PositiveInfinity)]
public int RecommendedFeeBlockTarget { get; set; } public int RecommendedFeeBlockTarget { get; set; }
[Display(Name = "Auto-detect language on checkout")]
public bool AutoDetectLanguage { get; set; }
[Display(Name = "Default language on checkout")] [Display(Name = "Default language on checkout")]
public string DefaultLang { get; set; } public string DefaultLang { get; set; }

View File

@@ -1,15 +1,29 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization; using Newtonsoft.Json.Serialization;
namespace BTCPayServer.Services namespace BTCPayServer.Services
{ {
public class Language
{
public Language(string code, string displayName)
{
DisplayName = displayName;
Code = code;
}
[JsonProperty("code")] public string Code { get; set; }
[JsonProperty("currentLanguage")] public string DisplayName { get; set; }
}
public class LanguageService public class LanguageService
{ {
private readonly Language[] _languages; private readonly Language[] _languages;
@@ -31,35 +45,95 @@ namespace BTCPayServer.Services
_languages = result.ToArray(); _languages = result.ToArray();
} }
public Language[] GetLanguages() public Language[] GetLanguages()
{ {
return _languages; return _languages;
} }
public Language FindBestMatch(string defaultLang) public Language FindLanguageInAcceptLanguageHeader(string acceptLanguageHeader)
{ {
if (defaultLang is null) IDictionary<string, float> acceptedLocales = new Dictionary<string, float>();
return null; var locales = acceptLanguageHeader.Split(',', StringSplitOptions.RemoveEmptyEntries);
defaultLang = defaultLang.Trim(); for (int i = 0; i < locales.Length; i++)
if (defaultLang.Length < 2) {
return null; try
var split = defaultLang.Split('-', StringSplitOptions.RemoveEmptyEntries); {
if (split.Length != 1 && split.Length != 2) var oneLocale = locales[i];
return null; var parts = oneLocale.Split(';', StringSplitOptions.RemoveEmptyEntries);
var locale = parts[0];
var qualityScore = 1.0f;
if (parts.Length == 2)
{
var qualityScorePart = parts[1];
if (qualityScorePart.StartsWith("q=", StringComparison.OrdinalIgnoreCase))
{
qualityScorePart = qualityScorePart.Substring(2);
qualityScore = float.Parse(qualityScorePart, CultureInfo.InvariantCulture);
}
else
{
// Invalid format, continue with next
continue;
}
}
if (!locale.Equals("*", StringComparison.OrdinalIgnoreCase))
{
acceptedLocales[locale] = qualityScore;
}
}
catch (System.FormatException e)
{
// Can't use this piece, moving on...
}
}
var sortedAcceptedLocales = from entry in acceptedLocales orderby entry.Value descending select entry;
foreach (var pair in sortedAcceptedLocales)
{
var lang = FindLanguage(pair.Key);
if (lang != null)
{
return lang;
}
}
return null;
}
/**
* Look for a supported language that matches the given locale (can be in different notations like "nl" or "nl-NL").
* Example: "nl" is not supported, but we do have "nl-NL"
*/
public Language FindLanguage(string locale)
{
var supportedLangs = GetLanguages();
var split = locale.Split('-', StringSplitOptions.RemoveEmptyEntries);
var lang = split[0]; var lang = split[0];
var country = split.Length == 2 ? split[1] : split[0].ToUpperInvariant(); var country = split.Length == 2 ? split[1] : split[0].ToUpperInvariant();
var langStart = lang + "-"; var langStart = lang + "-";
var langMatches = GetLanguages() var langMatches = supportedLangs
.Where(l => l.Code.Equals(lang, StringComparison.OrdinalIgnoreCase) || .Where(l => l.Code.Equals(lang, StringComparison.OrdinalIgnoreCase) ||
l.Code.StartsWith(langStart, StringComparison.OrdinalIgnoreCase)); l.Code.StartsWith(langStart, StringComparison.OrdinalIgnoreCase))
.ToList();
var countryMatches = langMatches; var countryMatches = langMatches;
var countryEnd = "-" + country; var countryEnd = "-" + country;
countryMatches = countryMatches = countryMatches.Where(l =>
countryMatches l.Code.EndsWith(countryEnd, StringComparison.OrdinalIgnoreCase)).ToList();
.Where(l => l.Code.EndsWith(countryEnd, StringComparison.OrdinalIgnoreCase));
return countryMatches.FirstOrDefault() ?? langMatches.FirstOrDefault(); return countryMatches.FirstOrDefault() ?? langMatches.FirstOrDefault();
} }
public Language AutoDetectLanguageUsingHeader(IHeaderDictionary headerDictionary, string defaultLang)
{
if (headerDictionary?.TryGetValue("Accept-Language",
out var acceptLanguage) is true && !string.IsNullOrEmpty(acceptLanguage))
{
return FindLanguageInAcceptLanguageHeader(acceptLanguage.ToString()) ?? FindLanguageInAcceptLanguageHeader(defaultLang);
}
return FindLanguageInAcceptLanguageHeader(defaultLang);
}
} }
} }

View File

@@ -115,7 +115,7 @@
</invoice> </invoice>
<script> <script>
var availableLanguages = @Safe.Json(langService.GetLanguages().Select((language) => language.Code));; var availableLanguages = @Safe.Json(langService.GetLanguages().Select((language) => language.Code));;
var storeDefaultLang = @Safe.Json(langService.FindBestMatch(Model.DefaultLang)?.Code ?? Model.DefaultLang); var defaultLang = @Safe.Json(Model.DefaultLang);
var fallbackLanguage = "en"; var fallbackLanguage = "en";
startingLanguage = computeStartingLanguage(); startingLanguage = computeStartingLanguage();
i18next i18next
@@ -134,8 +134,8 @@
if (urlParams.lang && isLanguageAvailable(urlParams.lang)) { if (urlParams.lang && isLanguageAvailable(urlParams.lang)) {
return urlParams.lang; return urlParams.lang;
} }
else if (isLanguageAvailable(storeDefaultLang)) { else if (isLanguageAvailable(defaultLang)) {
return storeDefaultLang; return defaultLang;
} else { } else {
return fallbackLanguage; return fallbackLanguage;
} }

View File

@@ -82,6 +82,13 @@
</div> </div>
<h4 class="mt-5 mb-3">Appearance</h4> <h4 class="mt-5 mb-3">Appearance</h4>
<div class="form-group">
<div class="form-check">
<input asp-for="AutoDetectLanguage" type="checkbox" class="form-check-input"/>
<label asp-for="AutoDetectLanguage" class="form-check-label"></label>
<p class="form-text text-muted">Detects the language of the customer's browser with 99.9% accuracy.</p>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label asp-for="DefaultLang" class="form-label"></label> <label asp-for="DefaultLang" class="form-label"></label>
<select asp-for="DefaultLang" asp-items="Model.Languages" class="form-select w-auto"></select> <select asp-for="DefaultLang" asp-items="Model.Languages" class="form-select w-auto"></select>

View File

@@ -94,7 +94,7 @@
"name": "lang", "name": "lang",
"in": "query", "in": "query",
"required": false, "required": false,
"description": "The preferred language of the checkout page. You can see the list of language codes with [this operation](#operation/langCodes).", "description": "The preferred language of the checkout page. You can use \"auto\" to use the language of the customer's browser or see the list of language codes with [this operation](#operation/langCodes).",
"schema": { "schema": {
"type": "string" "type": "string"
} }