mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
fix(i18n): publish the translation bundle only once it is complete
InitLocalizer filled the shared bundle in place, and the Telegram bot runs it again while the panel is serving, so a request could read the bundle's maps while they were being written and the process died on a concurrent map access. Build the bundle privately and publish it, and the bot and web localizers, through atomic pointers; the lazy load in the middleware publishes only if nothing else did first.
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
|
||||||
@@ -15,10 +16,11 @@ import (
|
|||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Each is published whole: InitLocalizer runs again (Telegram bot start) while requests read them.
|
||||||
var (
|
var (
|
||||||
i18nBundle *i18n.Bundle
|
i18nBundle atomic.Pointer[i18n.Bundle]
|
||||||
LocalizerWeb *i18n.Localizer
|
localizerWeb atomic.Pointer[i18n.Localizer]
|
||||||
LocalizerBot *i18n.Localizer
|
localizerBot atomic.Pointer[i18n.Localizer]
|
||||||
)
|
)
|
||||||
|
|
||||||
// I18nType represents the type of interface for internationalization.
|
// I18nType represents the type of interface for internationalization.
|
||||||
@@ -37,13 +39,14 @@ type SettingService interface {
|
|||||||
// InitLocalizer initializes the internationalization system with embedded translation files.
|
// InitLocalizer initializes the internationalization system with embedded translation files.
|
||||||
func InitLocalizer(i18nFS fs.FS, settingService SettingService) error {
|
func InitLocalizer(i18nFS fs.FS, settingService SettingService) error {
|
||||||
// set default bundle to English
|
// set default bundle to English
|
||||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
bundle := i18n.NewBundle(language.MustParse("en-US"))
|
||||||
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||||
|
|
||||||
// parse files
|
// parse files
|
||||||
if err := parseTranslationFiles(i18nFS, i18nBundle); err != nil {
|
if err := parseTranslationFiles(i18nFS, bundle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
i18nBundle.Store(bundle)
|
||||||
|
|
||||||
// setup bot locale
|
// setup bot locale
|
||||||
if err := initTGBotLocalizer(settingService); err != nil {
|
if err := initTGBotLocalizer(settingService); err != nil {
|
||||||
@@ -77,9 +80,9 @@ func I18n(i18nType I18nType, key string, params ...string) string {
|
|||||||
|
|
||||||
switch i18nType {
|
switch i18nType {
|
||||||
case "bot":
|
case "bot":
|
||||||
localizer = LocalizerBot
|
localizer = localizerBot.Load()
|
||||||
case "web":
|
case "web":
|
||||||
localizer = LocalizerWeb
|
localizer = localizerWeb.Load()
|
||||||
default:
|
default:
|
||||||
logger.Errorf("Invalid type for I18n: %s", i18nType)
|
logger.Errorf("Invalid type for I18n: %s", i18nType)
|
||||||
return ""
|
return ""
|
||||||
@@ -106,10 +109,11 @@ func I18n(i18nType I18nType, key string, params ...string) string {
|
|||||||
|
|
||||||
// LocalizerFor returns a new Localizer for the given language tag using the global bundle.
|
// LocalizerFor returns a new Localizer for the given language tag using the global bundle.
|
||||||
func LocalizerFor(lang string) *i18n.Localizer {
|
func LocalizerFor(lang string) *i18n.Localizer {
|
||||||
if i18nBundle == nil {
|
bundle := i18nBundle.Load()
|
||||||
|
if bundle == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return i18n.NewLocalizer(i18nBundle, lang)
|
return i18n.NewLocalizer(bundle, lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
// I18nForLang retrieves a localized message for a specific language tag with optional params.
|
// I18nForLang retrieves a localized message for a specific language tag with optional params.
|
||||||
@@ -136,7 +140,7 @@ func initTGBotLocalizer(settingService SettingService) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang)
|
localizerBot.Store(i18n.NewLocalizer(i18nBundle.Load(), botLang))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,13 +151,17 @@ func initTGBotLocalizer(settingService SettingService) error {
|
|||||||
func LocalizerMiddleware() gin.HandlerFunc {
|
func LocalizerMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Ensure bundle is initialized so creating a Localizer won't panic
|
// Ensure bundle is initialized so creating a Localizer won't panic
|
||||||
if i18nBundle == nil {
|
bundle := i18nBundle.Load()
|
||||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
if bundle == nil {
|
||||||
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
bundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||||
|
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||||
// Try lazy-load from disk when running sub server without InitLocalizer
|
// Try lazy-load from disk when running sub server without InitLocalizer
|
||||||
if err := loadTranslationsFromDisk(i18nBundle); err != nil {
|
if err := loadTranslationsFromDisk(bundle); err != nil {
|
||||||
logger.Warning("i18n lazy load failed:", err)
|
logger.Warning("i18n lazy load failed:", err)
|
||||||
}
|
}
|
||||||
|
if !i18nBundle.CompareAndSwap(nil, bundle) {
|
||||||
|
bundle = i18nBundle.Load()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var lang string
|
var lang string
|
||||||
|
|
||||||
@@ -163,9 +171,10 @@ func LocalizerMiddleware() gin.HandlerFunc {
|
|||||||
lang = c.GetHeader("Accept-Language")
|
lang = c.GetHeader("Accept-Language")
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalizerWeb = i18n.NewLocalizer(i18nBundle, lang)
|
localizer := i18n.NewLocalizer(bundle, lang)
|
||||||
|
localizerWeb.Store(localizer)
|
||||||
|
|
||||||
c.Set("localizer", LocalizerWeb)
|
c.Set("localizer", localizer)
|
||||||
c.Set("I18n", I18n)
|
c.Set("I18n", I18n)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
@@ -216,3 +225,8 @@ func parseTranslationFiles(i18nFS fs.FS, i18nBundle *i18n.Bundle) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SwapBotLocalizer installs l as the bot localizer and returns the previous one.
|
||||||
|
func SwapBotLocalizer(l *i18n.Localizer) *i18n.Localizer {
|
||||||
|
return localizerBot.Swap(l)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package locale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fixedLang string
|
||||||
|
|
||||||
|
func (l fixedLang) GetTgLang() (string, error) { return string(l), nil }
|
||||||
|
|
||||||
|
// The Telegram bot re-runs InitLocalizer while the panel serves requests; a request must
|
||||||
|
// never read the bundle while it is being filled (a concurrent map access kills the process).
|
||||||
|
func TestInitLocalizerWhileServingRequests(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
translations := fstest.MapFS{"translation/en_US.json": {Data: []byte(`{"hello": "Hello"}`)}}
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(LocalizerMiddleware())
|
||||||
|
engine.GET("/", func(c *gin.Context) { c.String(http.StatusOK, I18nForLang("en-US", "hello")) })
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
for range 200 {
|
||||||
|
if err := InitLocalizer(translations, fixedLang("en-US")); err != nil {
|
||||||
|
t.Errorf("InitLocalizer: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for range 4 {
|
||||||
|
wg.Go(func() {
|
||||||
|
for range 200 {
|
||||||
|
engine.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if rec.Body.String() != "Hello" {
|
||||||
|
t.Fatalf("after re-initialisation the bundle answers %q, want Hello", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -636,9 +636,8 @@ func localizeWithRealBundle(t *testing.T) {
|
|||||||
if _, err := bundle.ParseMessageFileBytes(data, path); err != nil {
|
if _, err := bundle.ParseMessageFileBytes(data, path); err != nil {
|
||||||
t.Fatalf("parse %s: %v", path, err)
|
t.Fatalf("parse %s: %v", path, err)
|
||||||
}
|
}
|
||||||
orig := locale.LocalizerBot
|
orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US"))
|
||||||
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
|
t.Cleanup(func() { locale.SwapBotLocalizer(orig) })
|
||||||
t.Cleanup(func() { locale.LocalizerBot = orig })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// seeding a client bound to a Telegram user, which the client screens key on
|
// seeding a client bound to a Telegram user, which the client screens key on
|
||||||
|
|||||||
@@ -121,9 +121,8 @@ func broadcastLocalizer(t *testing.T) {
|
|||||||
&i18n.Message{ID: "tgbot.messages.broadcastCanceled", Other: "canceled"},
|
&i18n.Message{ID: "tgbot.messages.broadcastCanceled", Other: "canceled"},
|
||||||
&i18n.Message{ID: "tgbot.messages.broadcastUnreachable", Other: "ℹ️ {{ .Count }} recipients cannot be messaged — ask them to press Start."},
|
&i18n.Message{ID: "tgbot.messages.broadcastUnreachable", Other: "ℹ️ {{ .Count }} recipients cannot be messaged — ask them to press Start."},
|
||||||
)
|
)
|
||||||
orig := locale.LocalizerBot
|
orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US"))
|
||||||
t.Cleanup(func() { locale.LocalizerBot = orig })
|
t.Cleanup(func() { locale.SwapBotLocalizer(orig) })
|
||||||
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createBroadcastInbound(t *testing.T, tag, settings string) {
|
func createBroadcastInbound(t *testing.T, tag, settings string) {
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ func clientInfoLocalizer(t *testing.T) {
|
|||||||
&i18n.Message{ID: "tgbot.messages.expire", Other: "Expire Date: {{ .Time }}\r\n"},
|
&i18n.Message{ID: "tgbot.messages.expire", Other: "Expire Date: {{ .Time }}\r\n"},
|
||||||
&i18n.Message{ID: "tgbot.wentWrong", Other: "went wrong"},
|
&i18n.Message{ID: "tgbot.wentWrong", Other: "went wrong"},
|
||||||
)
|
)
|
||||||
orig := locale.LocalizerBot
|
orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US"))
|
||||||
t.Cleanup(func() { locale.LocalizerBot = orig })
|
t.Cleanup(func() { locale.SwapBotLocalizer(orig) })
|
||||||
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression test: a start-after-first-use client is stored as a negative duration,
|
// Regression test: a start-after-first-use client is stored as a negative duration,
|
||||||
|
|||||||
@@ -52,9 +52,8 @@ func draftLocalizer(t *testing.T, msgs ...*i18n.Message) {
|
|||||||
bundle := i18n.NewBundle(language.MustParse("en-US"))
|
bundle := i18n.NewBundle(language.MustParse("en-US"))
|
||||||
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||||
_ = bundle.AddMessages(language.MustParse("en-US"), msgs...)
|
_ = bundle.AddMessages(language.MustParse("en-US"), msgs...)
|
||||||
orig := locale.LocalizerBot
|
orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US"))
|
||||||
t.Cleanup(func() { locale.LocalizerBot = orig })
|
t.Cleanup(func() { locale.SwapBotLocalizer(orig) })
|
||||||
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression test: the wizard's own prompts are HTML-parsed as well, so the
|
// Regression test: the wizard's own prompts are HTML-parsed as well, so the
|
||||||
|
|||||||
@@ -291,9 +291,8 @@ func TestNotifyExhaustedLooksUpEachIDOnce(t *testing.T) {
|
|||||||
func TestServerUsageInfoShowsUnavailableUptime(t *testing.T) {
|
func TestServerUsageInfoShowsUnavailableUptime(t *testing.T) {
|
||||||
bundle := i18n.NewBundle(language.English)
|
bundle := i18n.NewBundle(language.English)
|
||||||
_ = bundle.AddMessages(language.English, &i18n.Message{ID: "tgbot.messages.serverUpTime", Other: "Uptime: {{.UpTime}} {{.Unit}}\n"}, &i18n.Message{ID: "tgbot.days", Other: "days"})
|
_ = bundle.AddMessages(language.English, &i18n.Message{ID: "tgbot.messages.serverUpTime", Other: "Uptime: {{.UpTime}} {{.Unit}}\n"}, &i18n.Message{ID: "tgbot.days", Other: "days"})
|
||||||
oldLocale := locale.LocalizerBot
|
oldLocale := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en"))
|
||||||
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en")
|
t.Cleanup(func() { locale.SwapBotLocalizer(oldLocale) })
|
||||||
t.Cleanup(func() { locale.LocalizerBot = oldLocale })
|
|
||||||
statusCache.mutex.Lock()
|
statusCache.mutex.Lock()
|
||||||
oldStatus, oldTime := statusCache.data, statusCache.timestamp
|
oldStatus, oldTime := statusCache.data, statusCache.timestamp
|
||||||
statusCache.mutex.Unlock()
|
statusCache.mutex.Unlock()
|
||||||
|
|||||||
Reference in New Issue
Block a user