// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package i18n import ( "fmt" "html/template" "net/http" "os" "path/filepath" "reflect" "slices" "strings" "sync" "github.com/mattermost/go-i18n/i18n" "github.com/mattermost/go-i18n/i18n/bundle" "github.com/mattermost/mattermost/server/public/shared/mlog" ) // mut is used to protect other global variables from concurrent access. // This should only be a concern in parallel tests. var mut sync.Mutex const defaultLocale = "en" // TranslateFunc is the type of the translate functions type TranslateFunc func(translationID string, args ...any) string // TranslationFuncByLocal is the type of function that takes local as a string and returns the translation function type TranslationFuncByLocal func(locale string) TranslateFunc var ( t TranslateFunc tDefault TranslateFunc ) // T is the translate function using the default server language as fallback language var T TranslateFunc = func(translationID string, args ...any) string { mut.Lock() defer mut.Unlock() if t == nil { return translationID } return t(translationID, args...) } // TDefault is the translate function using english as fallback language var TDefault TranslateFunc = func(translationID string, args ...any) string { mut.Lock() defer mut.Unlock() if tDefault == nil { return translationID } return t(translationID, args...) } var locales = make(map[string]string) // supportedLocales is a hard-coded list of locales considered ready for production use. It must // be kept in sync with ../../../../webapp/channels/src/i18n/i18n.jsx. var supportedLocales = []string{ "de", "en", "en-AU", "es", "fr", "it", "hu", "nl", "pl", "pt-BR", "ro", "sv", "vi", "tr", "bg", "ru", "uk", "fa", "ko", "zh-CN", "zh-TW", "ja", } var ( defaultServerLocale string defaultClientLocale string ) // TranslationsPreInit loads translations from filesystem if they are not // loaded already and assigns english while loading server config func TranslationsPreInit(translationsDir string) error { mut.Lock() defer mut.Unlock() if t != nil { return nil } // Set T even if we fail to load the translations. Lots of shutdown handling code will // segfault trying to handle the error, and the untranslated IDs are strictly better. t = tfuncWithFallback(defaultLocale) tDefault = tfuncWithFallback(defaultLocale) return initTranslationsWithDir(translationsDir) } // TranslationsPreInitFromFileBytes loads translations from a buffer -- useful if // we need to initialize i18n from an embedded i18n file (e.g., from a CLI tool) func TranslationsPreInitFromFileBytes(filename string, buf []byte) error { mut.Lock() defer mut.Unlock() if t != nil { return nil } // Set T even if we fail to load the translations. Lots of shutdown handling code will // segfault trying to handle the error, and the untranslated IDs are strictly better. t = tfuncWithFallback(defaultLocale) tDefault = tfuncWithFallback(defaultLocale) locale := strings.Split(filename, ".")[0] if !isSupportedLocale(locale) { return fmt.Errorf("locale not supported: %s", locale) } locales[locale] = filename return i18n.ParseTranslationFileBytes(filename, buf) } // InitTranslations set the defaults configured in the server and initialize // the T function using the server default as fallback language func InitTranslations(serverLocale, clientLocale string) error { mut.Lock() defaultServerLocale = serverLocale defaultClientLocale = clientLocale mut.Unlock() tfn, err := GetTranslationsBySystemLocale() mut.Lock() t = tfn mut.Unlock() return err } func initTranslationsWithDir(dir string) error { files, _ := os.ReadDir(dir) for _, f := range files { if filepath.Ext(f.Name()) == ".json" { filename := f.Name() locale := strings.Split(filename, ".")[0] if !isSupportedLocale(locale) { continue } locales[locale] = filepath.Join(dir, filename) if err := i18n.LoadTranslationFile(filepath.Join(dir, filename)); err != nil { return err } } } return nil } // GetTranslationFuncForDir loads translations from the filesystem into a new instance of the bundle. // It returns a function to access loaded translations. func GetTranslationFuncForDir(dir string) (TranslationFuncByLocal, error) { availableLocals := make(map[string]string) bundle := bundle.New() files, _ := os.ReadDir(dir) for _, f := range files { if filepath.Ext(f.Name()) != ".json" { continue } locale := strings.Split(f.Name(), ".")[0] if !isSupportedLocale(locale) { continue } filename := f.Name() availableLocals[locale] = filepath.Join(dir, filename) if err := bundle.LoadTranslationFile(filepath.Join(dir, filename)); err != nil { return nil, err } } return func(locale string) TranslateFunc { if _, ok := availableLocals[locale]; !ok { locale = defaultLocale } t, _ := bundle.Tfunc(locale) return func(translationID string, args ...any) string { if translated := t(translationID, args...); translated != translationID { return translated } t, _ := bundle.Tfunc(defaultLocale) return t(translationID, args...) } }, nil } func GetTranslationsBySystemLocale() (TranslateFunc, error) { mut.Lock() defer mut.Unlock() locale := defaultServerLocale if _, ok := locales[locale]; !ok { mlog.Warn("Failed to load system translations for selected locale, attempting to fall back to default", mlog.String("locale", locale), mlog.String("default_locale", defaultLocale)) locale = defaultLocale } if !isSupportedLocale(locale) { mlog.Warn("Selected locale is unsupported, attempting to fall back to default", mlog.String("locale", locale), mlog.String("default_locale", defaultLocale)) locale = defaultLocale } if locales[locale] == "" { return nil, fmt.Errorf("failed to load system translations for '%v'", defaultLocale) } translations := tfuncWithFallback(locale) if translations == nil { return nil, fmt.Errorf("failed to load system translations") } mlog.Info("Loaded system translations", mlog.String("for locale", locale), mlog.String("from locale", locales[locale])) return translations, nil } // GetUserTranslations get the translation function for an specific locale func GetUserTranslations(locale string) TranslateFunc { mut.Lock() defer mut.Unlock() if _, ok := locales[locale]; !ok { locale = defaultLocale } translations := tfuncWithFallback(locale) return translations } // GetTranslationsAndLocaleFromRequest return the translation function and the // locale based on a request headers func GetTranslationsAndLocaleFromRequest(r *http.Request) (TranslateFunc, string) { mut.Lock() defer mut.Unlock() // This is for checking against locales like pt_BR or zn_CN headerLocaleFull := strings.Split(r.Header.Get("Accept-Language"), ",")[0] // This is for checking against locales like en, es headerLocale := strings.Split(strings.Split(r.Header.Get("Accept-Language"), ",")[0], "-")[0] defaultLocale := defaultClientLocale if locales[headerLocaleFull] != "" { translations := tfuncWithFallback(headerLocaleFull) return translations, headerLocaleFull } else if locales[headerLocale] != "" { translations := tfuncWithFallback(headerLocale) return translations, headerLocale } else if locales[defaultLocale] != "" { translations := tfuncWithFallback(defaultLocale) return translations, headerLocale } translations := tfuncWithFallback(defaultLocale) return translations, defaultLocale } // GetSupportedLocales return a map of locale code and the file path with the // translations func GetSupportedLocales() map[string]string { mut.Lock() defer mut.Unlock() return locales } func tfuncWithFallback(pref string) TranslateFunc { t, _ := i18n.Tfunc(pref) return func(translationID string, args ...any) string { if translated := t(translationID, args...); translated != translationID { return translated } t, _ := i18n.Tfunc(defaultLocale) return t(translationID, args...) } } // TranslateAsHTML translates the translationID provided and return a // template.HTML object func TranslateAsHTML(t TranslateFunc, translationID string, args map[string]any) template.HTML { message := t(translationID, escapeForHTML(args)) message = strings.Replace(message, "[[", "", -1) message = strings.Replace(message, "]]", "", -1) return template.HTML(message) } func escapeForHTML(arg any) any { switch typedArg := arg.(type) { case string: return template.HTMLEscapeString(typedArg) case *string: return template.HTMLEscapeString(*typedArg) case map[string]any: safeArg := make(map[string]any, len(typedArg)) for key, value := range typedArg { safeArg[key] = escapeForHTML(value) } return safeArg default: mlog.Warn( "Unable to escape value for HTML template", mlog.Any("html_template", arg), mlog.String("template_type", reflect.ValueOf(arg).Type().String()), ) return "" } } // IdentityTfunc returns a translation function that don't translate, only // returns the same id func IdentityTfunc() TranslateFunc { return func(translationID string, args ...any) string { return translationID } } func isSupportedLocale(locale string) bool { return slices.Contains(supportedLocales, locale) }