Initial i18n support

This commit is contained in:
Evan Goode 2025-07-06 15:10:21 -04:00
parent ecd485cc1d
commit 9feea940d0
9 changed files with 145 additions and 132 deletions

View File

@ -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

178
front.go
View File

@ -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",

5
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

0
locales/en-US/default.po Normal file
View File

7
locales/es/default.po Normal file
View File

@ -0,0 +1,7 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Language: es\n"
msgid "Register"
msgstr "Registrarse"

79
main.go
View File

@ -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

View File

@ -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;

View File

@ -24,7 +24,7 @@
<input type="submit" value="Log out" />
</form>
{{ else }}
<a href="{{ .App.FrontEndURL }}/web/registration">Register</a>
<a href="{{ .App.FrontEndURL }}/web/registration">{{ .L.Get "Register" }}</a>
{{ end }}
</div>
</nav>