commit 0d0afd7df369e948e36ce3e3ebd0b86c0fa4d2d8
parent aa073ac4ef44c54dc2976dfddcdeebf0b3e3482c
Author: Florian Dold <dold@taler.net>
Date: Mon, 7 Sep 2026 15:20:14 +0200
merchant demos: redirect regional and unknown language URLs
Resolve language URLs to a supported regional or parent locale, falling
back to English for unknown codes. Preserve request methods, path suffixes
and query parameters when redirecting.
Link to the bank through language paths handled by the deployment proxy.
Issue: https://bugs.taler.net/n/7309
Diffstat:
6 files changed, 193 insertions(+), 36 deletions(-)
diff --git a/README.md b/README.md
@@ -27,6 +27,18 @@ $ make check
## Translation maintenance
+Language URLs are canonicalized across all three demos. Regional locales use
+an exact supported match first, then a supported parent: `/de_CH/` and
+`/de-CH/` currently redirect to `/de/`. Matching ignores case and treats
+underscores and hyphens equally. Unknown locale codes redirect to English.
+Locale redirects preserve the remaining path, query parameters, and forwarded
+prefix, and use HTTP 307 to preserve form submissions. Missing pages still return 404.
+The root URL chooses a supported language from `Accept-Language`.
+
+Bank links use the same language paths as the other demos, for example
+`https://bank.demo.taler.net/de/`. Sandcastle's Caddy configuration redirects
+these entry points to the bank UI with the selected language.
+
The user interface is available in English, German, French, Italian,
Portuguese, Spanish, Russian, Turkish, and Ukrainian. Run the extractor after
changing user-facing strings in the Go handlers or templates:
diff --git a/internal/web/app.go b/internal/web/app.go
@@ -15,6 +15,7 @@ import (
"net/http"
"net/url"
"path"
+ "regexp"
"sort"
"strconv"
"strings"
@@ -81,6 +82,33 @@ var supportedLocales = func() map[string]bool {
return result
}()
+var localePattern = regexp.MustCompile(`^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$`)
+
+// resolveLocale returns a supported locale or its closest supported parent.
+// Separators and case do not affect matching, but the returned spelling is
+// always the one used by the application.
+func resolveLocale(requested string, supported map[string]bool) string {
+ if !localePattern.MatchString(requested) {
+ return ""
+ }
+ normalize := func(value string) string {
+ return strings.ToLower(strings.ReplaceAll(value, "_", "-"))
+ }
+ for candidate := normalize(requested); candidate != ""; {
+ for locale, enabled := range supported {
+ if enabled && normalize(locale) == candidate {
+ return locale
+ }
+ }
+ index := strings.LastIndexByte(candidate, '-')
+ if index < 0 {
+ break
+ }
+ candidate = candidate[:index]
+ }
+ return ""
+}
+
type links struct {
Landing string
Bank string
@@ -230,8 +258,7 @@ func (a *App) registerRoutes() {
a.renderError(w, r, http.StatusNotFound, requestLang(r), "Page not found", nil)
})
a.mux.HandleFunc("/{lang}/{path...}", func(w http.ResponseWriter, r *http.Request) {
- if !validLocale(r.PathValue("lang")) {
- a.renderError(w, r, http.StatusNotFound, "en", "Page not found", nil)
+ if a.redirectLocale(w, r) {
return
}
a.routes.ServeHTTP(w, r)
@@ -261,12 +288,39 @@ func (a *App) serveStatic(w http.ResponseWriter, r *http.Request) {
}
func (a *App) redirectLanguage(w http.ResponseWriter, r *http.Request) {
- lang := r.PathValue("lang")
- if !validLocale(lang) {
- a.renderError(w, r, http.StatusNotFound, "en", "Page not found", nil)
+ if a.redirectLocale(w, r) {
return
}
- http.Redirect(w, r, forwardedPrefix(r)+"/"+url.PathEscape(lang)+"/", http.StatusPermanentRedirect)
+ target := forwardedPrefix(r) + "/" + url.PathEscape(r.PathValue("lang")) + "/"
+ if r.URL.RawQuery != "" {
+ target += "?" + r.URL.RawQuery
+ }
+ http.Redirect(w, r, target, http.StatusPermanentRedirect)
+}
+
+// redirectLocale handles language aliases before any page or payment handler
+// runs. It reports whether it has already answered the request.
+func (a *App) redirectLocale(w http.ResponseWriter, r *http.Request) bool {
+ requested := r.PathValue("lang")
+ if validLocale(requested) {
+ return false
+ }
+ if !localePattern.MatchString(requested) {
+ a.renderError(w, r, http.StatusNotFound, "en", "Page not found", nil)
+ return true
+ }
+ lang := resolveLocale(requested, supportedLocales)
+ if lang == "" {
+ lang = "en"
+ }
+ // Split the escaped path to retain encoded separators in article/file IDs.
+ _, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
+ target := forwardedPrefix(r) + "/" + url.PathEscape(lang) + "/" + rest
+ if r.URL.RawQuery != "" {
+ target += "?" + r.URL.RawQuery
+ }
+ http.Redirect(w, r, target, http.StatusTemporaryRedirect)
+ return true
}
type trackingResponseWriter struct {
@@ -340,23 +394,8 @@ func (a *App) bestLanguage(header string) string {
return preferences[i].q > preferences[j].q
})
for _, preferred := range preferences {
- candidate := preferred.lang
- if _, ok := a.catalogs[candidate]; ok {
- return candidate
- }
- base := strings.SplitN(candidate, "_", 2)[0]
- if _, ok := a.catalogs[base]; ok {
- return base
- }
- var variants []string
- for variant := range a.catalogs {
- if strings.HasPrefix(variant, base+"_") {
- variants = append(variants, variant)
- }
- }
- sort.Strings(variants)
- if len(variants) != 0 {
- return variants[0]
+ if lang := resolveLocale(preferred.lang, supportedLocales); lang != "" {
+ return lang
}
}
return "en"
@@ -370,7 +409,7 @@ func (a *App) makePage(r *http.Request, lang, title string, content any) page {
LanguageName: template.HTML("en"), Content: content,
Links: links{
Landing: appLanguageURL(a.opts.PublicURLs.Landing, lang),
- Bank: configuredURL(a.opts.PublicURLs.Bank) + "?lang=" + url.QueryEscape(lang),
+ Bank: appLanguageURL(a.opts.PublicURLs.Bank, lang),
Blog: appLanguageURL(a.opts.PublicURLs.Blog, lang),
Donations: appLanguageURL(a.opts.PublicURLs.Donations, lang),
},
@@ -461,7 +500,11 @@ func appLanguageURL(base, lang string) string {
if base == "#" {
return "#"
}
- return strings.TrimRight(base, "/") + "/" + url.PathEscape(lang) + "/"
+ target, err := url.Parse(base)
+ if err != nil {
+ return "#"
+ }
+ return target.JoinPath(url.PathEscape(lang) + "/").String()
}
func forwardedPrefix(r *http.Request) string {
diff --git a/internal/web/app_test.go b/internal/web/app_test.go
@@ -2,6 +2,7 @@ package web
import (
"encoding/json"
+ "io"
"net/http"
"net/http/httptest"
"net/url"
@@ -105,8 +106,7 @@ func TestPagesUseConfiguredPublicURLs(t *testing.T) {
body := response.Body.String()
for _, expected := range []string{
`href="https://landing.example/de/"`,
- `href="https://bank.example/?lang=de"`,
- `href="https://bank.example"`,
+ `href="https://bank.example/de/"`,
`href="https://blog.example/de/"`,
`href="https://donations.example/de/"`,
} {
@@ -221,7 +221,7 @@ func TestDonationCheckoutUsesBrandedPaymentCards(t *testing.T) {
}
}
-func TestUnsupportedLocalesAreNotNegotiatedOrRouted(t *testing.T) {
+func TestUnsupportedLocalesFallBackToEnglish(t *testing.T) {
app, err := New(Options{Shop: "landing", Currency: "KUDOS"})
if err != nil {
t.Fatal(err)
@@ -236,8 +236,81 @@ func TestUnsupportedLocalesAreNotNegotiatedOrRouted(t *testing.T) {
response = httptest.NewRecorder()
app.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/ar/", nil))
- if response.Code != http.StatusNotFound {
- t.Fatalf("unsupported locale response = %d", response.Code)
+ if response.Code != http.StatusTemporaryRedirect || response.Header().Get("Location") != "/en/" {
+ t.Fatalf("unsupported locale response = %d, location %q", response.Code, response.Header().Get("Location"))
+ }
+}
+
+func TestLocaleRedirects(t *testing.T) {
+ merchant, err := backend.New("https://merchant.example/", "secret-token:test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, shop := range []string{"landing", "blog", "donations"} {
+ t.Run(shop, func(t *testing.T) {
+ app, err := New(Options{
+ Shop: shop, Currency: "KUDOS", BlogBackend: merchant,
+ DonationBackends: map[string]*backend.Client{"gnunet": merchant, "taler": merchant, "tor": merchant},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, test := range []struct {
+ path, prefix, location string
+ status int
+ }{
+ {"/de_CH/", "", "/de/", 307},
+ {"/de-CH", "", "/de/", 307},
+ {"/DE-ch/", "", "/de/", 307},
+ {"/fr_CA/checkout?donor=A%2BB&x=1&x=2", "/demo", "/demo/fr/checkout?donor=A%2BB&x=1&x=2", 307},
+ {"/de_CH/essay/a%2Fb/data/c%20d?order_id=123", "", "/de/essay/a%2Fb/data/c%20d?order_id=123", 307},
+ {"/xx/", "", "/en/", 307},
+ {"/zzz_ZZ/checkout?x=1", "", "/en/checkout?x=1", 307},
+ {"/de?x=1", "", "/de/?x=1", 308},
+ {"/en/", "", "", 200},
+ {"/en/not-a-page", "", "", 404},
+ {"/favicon.ico", "", "", 404},
+ {"/not-a-locale/", "", "", 404},
+ {"/de__CH/", "", "", 404},
+ {"/static/missing.css", "", "", 404},
+ {"/de_CH/", "//evil.example", "/de/", 307},
+ } {
+ t.Run(test.path+test.prefix, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, test.path, nil)
+ request.Header.Set("Accept-Language", "fr")
+ request.Header.Set("X-Forwarded-Prefix", test.prefix)
+ response := httptest.NewRecorder()
+ app.ServeHTTP(response, request)
+ if response.Code != test.status || response.Header().Get("Location") != test.location {
+ t.Fatalf("response = %d, %q; want %d, %q", response.Code, response.Header().Get("Location"), test.status, test.location)
+ }
+ })
+ }
+ })
+ }
+}
+
+func TestLocaleRedirectPreservesPostBody(t *testing.T) {
+ app, err := New(Options{Shop: "landing", Currency: "KUDOS"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ app.routes.HandleFunc("POST /{lang}/echo", func(w http.ResponseWriter, r *http.Request) {
+ if r.PathValue("lang") != "de" || r.URL.RawQuery != "order_id=123" {
+ t.Errorf("redirected request = %s", r.URL)
+ }
+ _, _ = io.Copy(w, r.Body)
+ })
+ server := httptest.NewServer(app)
+ defer server.Close()
+ response, err := server.Client().Post(server.URL+"/de_CH/echo?order_id=123", "application/x-www-form-urlencoded", strings.NewReader("amount=10&reason=A%2BB"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ body, err := io.ReadAll(response.Body)
+ if err != nil || response.StatusCode != 200 || string(body) != "amount=10&reason=A%2BB" {
+ t.Fatalf("POST response = %d, %q, %v", response.StatusCode, body, err)
}
}
diff --git a/internal/web/blog.go b/internal/web/blog.go
@@ -57,7 +57,7 @@ func (a *App) blogIndex(w http.ResponseWriter, r *http.Request) {
articles := sortedArticles(a.articles[lang])
content := blogIndexContent{
Articles: articles,
- BankURL: configuredURL(a.opts.PublicURLs.Bank),
+ BankURL: appLanguageURL(a.opts.PublicURLs.Bank, lang),
Price: a.opts.Currency + ":" + articlePriceUnits,
}
a.render(w, "blog-index", a.makePage(r, lang, "GNU Taler Demo: Essay Shop", content), http.StatusOK)
diff --git a/internal/web/i18n_test.go b/internal/web/i18n_test.go
@@ -26,6 +26,35 @@ msgstr "Ignoriert"
}
}
+func TestResolveLocale(t *testing.T) {
+ supported := map[string]bool{"en": true, "de": true, "de_CH": true, "zh_Hant": true, "fr": false}
+ for _, test := range []struct{ requested, want string }{
+ {"de-CH", "de_CH"}, {"DE_ch", "de_CH"}, {"de_AT", "de"},
+ {"zh-Hant-TW", "zh_Hant"}, {"xx", ""}, {"fr", ""},
+ {"de__CH", ""}, {"", ""}, {"../../../de", ""},
+ } {
+ if got := resolveLocale(test.requested, supported); got != test.want {
+ t.Errorf("resolveLocale(%q) = %q, want %q", test.requested, got, test.want)
+ }
+ }
+}
+
+func TestBrowserLocaleMatching(t *testing.T) {
+ app, err := New(Options{Shop: "landing", Currency: "KUDOS"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, test := range []struct{ header, want string }{
+ {"DE-ch", "de"}, {"fr_CA", "fr"},
+ {"xx;q=1,de_CH;q=0.8,fr;q=0.5", "de"},
+ {"de;q=0,fr;q=1", "fr"}, {"xx", "en"},
+ } {
+ if got := app.bestLanguage(test.header); got != test.want {
+ t.Errorf("bestLanguage(%q) = %q, want %q", test.header, got, test.want)
+ }
+ }
+}
+
func TestFormatNamed(t *testing.T) {
if got := formatNamed("Pay {amount} to {receiver}", "amount", "KUDOS:1", "receiver", "GNUnet"); got != "Pay KUDOS:1 to GNUnet" {
t.Fatalf("formatted = %q", got)
diff --git a/internal/web/landing.go b/internal/web/landing.go
@@ -24,12 +24,12 @@ func (a *App) landing(w http.ResponseWriter, r *http.Request) {
a.renderError(w, r, http.StatusNotFound, "en", "Page not found", nil)
return
}
- bank := strings.TrimRight(a.opts.PublicURLs.Bank, "/")
- bankRegister := "#"
+ bankRegister := appLanguageURL(a.opts.PublicURLs.Bank, lang)
bankPublic := "#"
- if bank != "" {
- bankRegister = bank
- bankPublic = bank + "#public-accounts"
+ if bankRegister != "#" {
+ target, _ := url.Parse(bankRegister)
+ target.Fragment = "/public-accounts"
+ bankPublic = target.String()
}
localizedMerchant := func(base string) string {
base = strings.TrimRight(base, "/")