From 38d533581bd3eba1207532212fd101c0ea1ab201 Mon Sep 17 00:00:00 2001 From: Evan Goode Date: Sun, 6 Jul 2025 15:10:21 -0400 Subject: [PATCH] Initial i18n support --- Makefile | 2 +- front.go | 178 +++++++++++++++++---------------------- go.mod | 5 +- go.sum | 2 + locales/en-US/default.po | 0 locales/es/default.po | 7 ++ main.go | 79 +++++++++++------ public/style.css | 2 +- view/header.tmpl | 2 +- 9 files changed, 145 insertions(+), 132 deletions(-) create mode 100644 locales/en-US/default.po create mode 100644 locales/es/default.po diff --git a/Makefile b/Makefile index 76bbceb..f33dd20 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ install: build install -Dm 755 drasl "$(prefix)/bin/drasl" install -Dm 644 LICENSE "$(prefix)/share/licenses/drasl/LICENSE" mkdir -p "$(prefix)/share/drasl/" - cp -R assets view public "$(prefix)/share/drasl/" + cp -R assets view public locales "$(prefix)/share/drasl/" clean: rm -f drasl diff --git a/front.go b/front.go index 762198e..8ca4665 100644 --- a/front.go +++ b/front.go @@ -10,9 +10,11 @@ import ( "github.com/google/uuid" "github.com/jxskiss/base62" "github.com/labstack/echo/v4" + "github.com/leonelquinteros/gotext" "github.com/samber/mo" "github.com/zitadel/oidc/v3/pkg/client/rp" "github.com/zitadel/oidc/v3/pkg/oidc" + "golang.org/x/text/language" "gorm.io/gorm" "html/template" "io" @@ -28,6 +30,7 @@ import ( Web front end for creating user accounts, changing passwords, skins, player names, etc. */ +const CONTEXT_KEY_LOCALE = "DraslLocale" const BROWSER_TOKEN_AGE_SEC = 24 * 60 * 60 const COOKIE_PREFIX = "__Host-" const BROWSER_TOKEN_COOKIE_NAME = COOKIE_PREFIX + "browserToken" @@ -82,6 +85,21 @@ func NewTemplate(app *App) *Template { return t } +func (app *App) GetLanguageMiddleware() func(echo.HandlerFunc) echo.HandlerFunc { + matcher := language.NewMatcher(app.LocaleTags) + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + header := c.Request().Header.Get("Accept-Language") + t, _, _ := language.ParseAcceptLanguage(header) + // Use only the returned index, not the returned tag: https://github.com/golang/go/issues/24211 + _, localeTagIndex, _ := matcher.Match(t...) + l := app.Locales[app.LocaleTags[localeTagIndex]] + c.Set(CONTEXT_KEY_LOCALE, l) + return next(c) + } + } +} + func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { return t.Templates[name].ExecuteTemplate(w, "base", data) } @@ -138,15 +156,31 @@ func NewWebError(returnURL string, message string, args ...interface{}) error { } } -type errorContext struct { +type baseContext struct { App *App - User *User + L *gotext.Locale URL string SuccessMessage string WarningMessage string ErrorMessage string - Message string - StatusCode int +} + +func (app *App) NewBaseContext(c *echo.Context) baseContext { + return baseContext{ + App: app, + L: (*c).Get(CONTEXT_KEY_LOCALE).(*gotext.Locale), + URL: (*c).Request().URL.RequestURI(), + SuccessMessage: app.lastSuccessMessage(c), + WarningMessage: app.lastWarningMessage(c), + ErrorMessage: app.lastErrorMessage(c), + } +} + +type errorContext struct { + baseContext + User *User + Message string + StatusCode int } // Set error message and redirect @@ -182,14 +216,10 @@ func (app *App) HandleWebError(err error, c *echo.Context) error { } if Contains(safeMethods, (*c).Request().Method) { return (*c).Render(code, "error", errorContext{ - App: app, - User: nil, - URL: (*c).Request().URL.RequestURI(), - Message: message, - SuccessMessage: app.lastSuccessMessage(c), - WarningMessage: app.lastWarningMessage(c), - ErrorMessage: app.lastErrorMessage(c), - StatusCode: code, + baseContext: app.NewBaseContext(c), + User: nil, + Message: message, + StatusCode: code, }) } else { returnURL := getReturnURL(app, c) @@ -301,13 +331,9 @@ func EncodeOIDCState(state oidcState) (string, error) { // GET / func FrontRoot(app *App) func(c echo.Context) error { type rootContext struct { - App *App + baseContext User *User - URL string Destination string - SuccessMessage string - WarningMessage string - ErrorMessage string WebOIDCProviders []webOIDCProvider } @@ -348,13 +374,9 @@ func FrontRoot(app *App) func(c echo.Context) error { } return c.Render(http.StatusOK, "root", rootContext{ - App: app, + baseContext: app.NewBaseContext(&c), User: user, - URL: c.Request().URL.RequestURI(), Destination: destination, - SuccessMessage: app.lastSuccessMessage(&c), - WarningMessage: app.lastWarningMessage(&c), - ErrorMessage: app.lastErrorMessage(&c), WebOIDCProviders: webOIDCProviders, }) }) @@ -408,12 +430,8 @@ type oidcState struct { // GET /registration func FrontRegistration(app *App) func(c echo.Context) error { type registrationContext struct { - App *App + baseContext User *User - URL string - SuccessMessage string - WarningMessage string - ErrorMessage string InviteCode string WebOIDCProviders []webOIDCProvider } @@ -454,12 +472,8 @@ func FrontRegistration(app *App) func(c echo.Context) error { } return c.Render(http.StatusOK, "registration", registrationContext{ - App: app, + baseContext: app.NewBaseContext(&c), User: user, - URL: c.Request().URL.RequestURI(), - SuccessMessage: app.lastSuccessMessage(&c), - WarningMessage: app.lastWarningMessage(&c), - ErrorMessage: app.lastErrorMessage(&c), InviteCode: inviteCode, WebOIDCProviders: webOIDCProviders, }) @@ -502,12 +516,8 @@ func (app *App) getIDTokenCookie(c *echo.Context) (*OIDCProvider, string, oidc.I func FrontCompleteRegistration(app *App) func(c echo.Context) error { type completeRegistrationContext struct { - App *App + baseContext User *User - URL string - SuccessMessage string - WarningMessage string - ErrorMessage string InviteCode string AnyUnmigratedUsers bool AllowChoosingPlayerName bool @@ -547,12 +557,8 @@ func FrontCompleteRegistration(app *App) func(c echo.Context) error { } return c.Render(http.StatusOK, "complete-registration", completeRegistrationContext{ - App: app, + baseContext: app.NewBaseContext(&c), User: user, - URL: c.Request().URL.RequestURI(), - SuccessMessage: app.lastSuccessMessage(&c), - WarningMessage: app.lastWarningMessage(&c), - ErrorMessage: app.lastErrorMessage(&c), InviteCode: inviteCode, PreferredPlayerName: preferredPlayerName, AllowChoosingPlayerName: provider.Config.AllowChoosingPlayerName, @@ -775,14 +781,10 @@ func FrontOIDCCallback(app *App) func(c echo.Context) error { // GET /web/admin func FrontAdmin(app *App) func(c echo.Context) error { type adminContext struct { - App *App - User *User - URL string - SuccessMessage string - WarningMessage string - ErrorMessage string - Users []User - Invites []Invite + baseContext + User *User + Users []User + Invites []Invite } return withBrowserAdmin(app, func(c echo.Context, user *User) error { @@ -799,14 +801,10 @@ func FrontAdmin(app *App) func(c echo.Context) error { } return c.Render(http.StatusOK, "admin", adminContext{ - App: app, - User: user, - URL: c.Request().URL.RequestURI(), - SuccessMessage: app.lastSuccessMessage(&c), - WarningMessage: app.lastWarningMessage(&c), - ErrorMessage: app.lastErrorMessage(&c), - Users: users, - Invites: invites, + baseContext: app.NewBaseContext(&c), + User: user, + Users: users, + Invites: invites, }) }) } @@ -925,12 +923,8 @@ func FrontNewInvite(app *App) func(c echo.Context) error { // GET /web/user/:uuid func FrontUser(app *App) func(c echo.Context) error { type userContext struct { - App *App + baseContext User *User - URL string - SuccessMessage string - WarningMessage string - ErrorMessage string TargetUser *User TargetUserID string SkinURL *string @@ -1014,12 +1008,8 @@ func FrontUser(app *App) func(c echo.Context) error { } return c.Render(http.StatusOK, "user", userContext{ - App: app, + baseContext: app.NewBaseContext(&c), User: user, - URL: c.Request().URL.RequestURI(), - SuccessMessage: app.lastSuccessMessage(&c), - WarningMessage: app.lastWarningMessage(&c), - ErrorMessage: app.lastErrorMessage(&c), TargetUser: targetUser, AdminView: adminView, LinkedOIDCProviderNames: linkedOIDCProviderNames.ToSlice(), @@ -1032,18 +1022,14 @@ func FrontUser(app *App) func(c echo.Context) error { // GET /web/player/:uuid func FrontPlayer(app *App) func(c echo.Context) error { type playerContext struct { - App *App - User *User - URL string - SuccessMessage string - WarningMessage string - ErrorMessage string - PlayerUser *User - Player *Player - PlayerID string - SkinURL *string - CapeURL *string - AdminView bool + baseContext + User *User + PlayerUser *User + Player *Player + PlayerID string + SkinURL *string + CapeURL *string + AdminView bool } return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error { @@ -1081,18 +1067,14 @@ func FrontPlayer(app *App) func(c echo.Context) error { } return c.Render(http.StatusOK, "player", playerContext{ - App: app, - User: user, - URL: c.Request().URL.RequestURI(), - SuccessMessage: app.lastSuccessMessage(&c), - WarningMessage: app.lastWarningMessage(&c), - ErrorMessage: app.lastErrorMessage(&c), - PlayerUser: &playerUser, - Player: &player, - PlayerID: id, - SkinURL: skinURL, - CapeURL: capeURL, - AdminView: adminView, + baseContext: app.NewBaseContext(&c), + User: user, + PlayerUser: &playerUser, + Player: &player, + PlayerID: id, + SkinURL: skinURL, + CapeURL: capeURL, + AdminView: adminView, }) }) } @@ -1283,12 +1265,8 @@ func FrontRegisterChallenge(app *App) func(c echo.Context) error { func frontChallenge(app *App, action string) func(c echo.Context) error { type challengeContext struct { - App *App + baseContext User *User - URL string - SuccessMessage string - WarningMessage string - ErrorMessage string PlayerName string RegistrationProvider string SkinBase64 string @@ -1372,12 +1350,8 @@ func frontChallenge(app *App, action string) func(c echo.Context) error { skinBase64 := base64.StdEncoding.EncodeToString(challengeSkinBytes) return c.Render(http.StatusOK, "challenge", challengeContext{ - App: app, + baseContext: app.NewBaseContext(&c), User: user, - URL: c.Request().URL.RequestURI(), - SuccessMessage: app.lastSuccessMessage(&c), - WarningMessage: app.lastWarningMessage(&c), - ErrorMessage: app.lastErrorMessage(&c), PlayerName: playerName, SkinBase64: skinBase64, SkinFilename: playerName + "-challenge.png", diff --git a/go.mod b/go.mod index af46dc8..d4d42f6 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module unmojang.org/drasl -go 1.23.0 +go 1.23.5 -toolchain go1.23.2 +toolchain go1.23.10 require ( github.com/BurntSushi/toml v1.3.2 @@ -38,6 +38,7 @@ require ( github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/labstack/gommon v0.4.2 // indirect + github.com/leonelquinteros/gotext v1.7.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.18 // indirect diff --git a/go.sum b/go.sum index c678b5c..9a8dad0 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zG github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leonelquinteros/gotext v1.7.2 h1:bDPndU8nt+/kRo1m4l/1OXiiy2v7Z7dfPQ9+YP7G1Mc= +github.com/leonelquinteros/gotext v1.7.2/go.mod h1:9/haCkm5P7Jay1sxKDGJ5WIg4zkz8oZKw4ekNpALob8= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= diff --git a/locales/en-US/default.po b/locales/en-US/default.po new file mode 100644 index 0000000..e69de29 diff --git a/locales/es/default.po b/locales/es/default.po new file mode 100644 index 0000000..d09b2d6 --- /dev/null +++ b/locales/es/default.po @@ -0,0 +1,7 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: es\n" + +msgid "Register" +msgstr "Registrarse" diff --git a/main.go b/main.go index f2ca6a3..1b81fea 100644 --- a/main.go +++ b/main.go @@ -13,8 +13,10 @@ import ( "github.com/dgraph-io/ristretto" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "github.com/leonelquinteros/gotext" "github.com/zitadel/oidc/v3/pkg/client/rp" httphelper "github.com/zitadel/oidc/v3/pkg/http" + "golang.org/x/text/language" "golang.org/x/time/rate" "gorm.io/gorm" "image" @@ -25,6 +27,7 @@ import ( "net/url" "os" "path" + "path/filepath" "regexp" "strings" "time" @@ -71,6 +74,8 @@ type App struct { OIDCProvidersByName map[string]*OIDCProvider OIDCProvidersByIssuer map[string]*OIDCProvider FallbackAPIServers []FallbackAPIServer + Locales map[language.Tag]*gotext.Locale + LocaleTags []language.Tag } func LogInfo(args ...interface{}) { @@ -195,31 +200,36 @@ func (app *App) MakeServer() *echo.Echo { t := NewTemplate(app) e.Renderer = t frontUser := FrontUser(app) - e.GET("/", FrontRoot(app)) - e.GET("/web/admin", FrontAdmin(app)) - e.GET("/web/complete-registration", FrontCompleteRegistration(app)) - e.GET("/web/create-player-challenge", FrontCreatePlayerChallenge(app)) - e.GET("/web/manifest.webmanifest", FrontWebManifest(app)) - e.GET("/web/oidc-callback/:providerName", FrontOIDCCallback(app)) - e.GET("/web/player/:uuid", FrontPlayer(app)) - e.GET("/web/register-challenge", FrontRegisterChallenge(app)) - e.GET("/web/registration", FrontRegistration(app)) - e.GET("/web/user", frontUser) - e.GET("/web/user/:uuid", frontUser) - e.POST("/web/admin/delete-invite", FrontDeleteInvite(app)) - e.POST("/web/admin/new-invite", FrontNewInvite(app)) - e.POST("/web/admin/update-users", FrontUpdateUsers(app)) - e.POST("/web/create-player", FrontCreatePlayer(app)) - e.POST("/web/delete-player", FrontDeletePlayer(app)) - e.POST("/web/delete-user", FrontDeleteUser(app)) - e.POST("/web/login", FrontLogin(app)) - e.POST("/web/logout", FrontLogout(app)) - e.POST("/web/oidc-migrate", app.FrontOIDCMigrate()) - e.POST("/web/oidc-unlink", app.FrontOIDCUnlink()) - e.POST("/web/register", FrontRegister(app)) - e.POST("/web/update-player", FrontUpdatePlayer(app)) - e.POST("/web/update-user", FrontUpdateUser(app)) - e.Static("/web/public", path.Join(app.Config.DataDirectory, "public")) + + getLanguage := app.GetLanguageMiddleware() + + e.GET("/", FrontRoot(app), getLanguage) + g := e.Group("/web") + g.Use(getLanguage) + g.GET("/admin", FrontAdmin(app)) + g.GET("/complete-registration", FrontCompleteRegistration(app)) + g.GET("/create-player-challenge", FrontCreatePlayerChallenge(app)) + g.GET("/manifest.webmanifest", FrontWebManifest(app)) + g.GET("/oidc-callback/:providerName", FrontOIDCCallback(app)) + g.GET("/player/:uuid", FrontPlayer(app)) + g.GET("/register-challenge", FrontRegisterChallenge(app)) + g.GET("/registration", FrontRegistration(app)) + g.GET("/user", frontUser) + g.GET("/user/:uuid", frontUser) + g.POST("/admin/delete-invite", FrontDeleteInvite(app)) + g.POST("/admin/new-invite", FrontNewInvite(app)) + g.POST("/admin/update-users", FrontUpdateUsers(app)) + g.POST("/create-player", FrontCreatePlayer(app)) + g.POST("/delete-player", FrontDeletePlayer(app)) + g.POST("/delete-user", FrontDeleteUser(app)) + g.POST("/login", FrontLogin(app)) + g.POST("/logout", FrontLogout(app)) + g.POST("/oidc-migrate", app.FrontOIDCMigrate()) + g.POST("/oidc-unlink", app.FrontOIDCUnlink()) + g.POST("/register", FrontRegister(app)) + g.POST("/update-player", FrontUpdatePlayer(app)) + g.POST("/update-user", FrontUpdateUser(app)) + g.Static("/public", path.Join(app.Config.DataDirectory, "public")) } e.Static("/web/texture/cape", path.Join(app.Config.StateDirectory, "cape")) e.Static("/web/texture/default-cape", path.Join(app.Config.StateDirectory, "default-cape")) @@ -425,6 +435,23 @@ func setup(config *Config) *App { log.Fatalf("Couldn't access DataDirectory: %s", err) } + // Locales + locales := map[language.Tag]*gotext.Locale{} + localeTags := make([]language.Tag, 0) + locales_path := path.Join(config.DataDirectory, "locales") + lang_paths, err := filepath.Glob(path.Join(locales_path, "*")) + for _, lang_path := range lang_paths { + lang := filepath.Base(lang_path) + tag, err := language.Parse(lang) + if err != nil { + log.Fatalf("Unrecognized language tag: %s", lang) + } + l := gotext.NewLocale(locales_path, lang) + l.AddDomain("default") + localeTags = append(localeTags, tag) + locales[tag] = l + } + // Crypto key := ReadOrCreateKey(config) keyBytes := Unwrap(x509.MarshalPKCS8PrivateKey(key)) @@ -575,6 +602,8 @@ func setup(config *Config) *App { OIDCProvidersByName: oidcProvidersByName, OIDCProvidersByIssuer: oidcProvidersByIssuer, FallbackAPIServers: fallbackAPIServers, + Locales: locales, + LocaleTags: localeTags, } // Post-setup diff --git a/public/style.css b/public/style.css index b018d5e..6769c86 100644 --- a/public/style.css +++ b/public/style.css @@ -92,7 +92,7 @@ a:visited { } .logo { - font-family: Helvetica, Arial, sans-serif; + font-family: Arial, sans-serif; color: white; text-decoration: none; font-size: 2rem; diff --git a/view/header.tmpl b/view/header.tmpl index ef7b065..d9d98a7 100644 --- a/view/header.tmpl +++ b/view/header.tmpl @@ -24,7 +24,7 @@ {{ else }} - Register + {{ .L.Get "Register" }} {{ end }}