Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/cmd/map/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ const (
// ContextKeyUser is the key for storing username in request context
ContextKeyUser string = "username"
// ContextKeyAdmin is the key for storing admin status in request context
ContextKeyAdmin string = "isAdmin"
ContextKeyAdmin string = "isAdmin"
ContextKeyLanguage string = "language"
)

func (h *HandlersMap) LoginPOSTHandler(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -69,6 +70,7 @@ func (h *HandlersMap) LoginPOSTHandler(w http.ResponseWriter, r *http.Request) {
}
h.Sessions.Put(r.Context(), string(ContextKeyUser), user.Username)
h.Sessions.Put(r.Context(), string(ContextKeyAdmin), user.Admin)
h.Sessions.Put(r.Context(), string(ContextKeyLanguage), user.Language)
// Update last login time for the user and other relevant info
if err := h.Users.UpdateUserSession(user.Username, getRealIP(r), r.UserAgent(), uuid); err != nil {
log.Err(err).Msg("error updating user session")
Expand Down
21 changes: 21 additions & 0 deletions backend/cmd/map/handlers/locale.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ func (h *HandlersMap) catalog() *i18n.Catalog {
return defaultI18N()
}

// safeSessionString reads a string from the session, returning "" when the
// session manager is absent or the SCS context has not been prepared (e.g. the
// LoadAndSave middleware did not run). SCS panics in that case, so we guard it.
func (h *HandlersMap) safeSessionString(ctx context.Context, key string) string {
if h.Sessions == nil {
return ""
}
var v string
func() {
defer func() { _ = recover() }()
v = h.Sessions.GetString(ctx, key)
}()
return v
}

// LocaleMiddleware resolves the active language for UUID-scoped requests and
// stores the resolved language tag plus a translation func in the request
// context. Resolution order is: the per-game `language` setting (when the
Expand All @@ -60,6 +75,12 @@ func (h *HandlersMap) LocaleMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c := h.catalog()
var preferred []string
// Per-user language takes precedence: it is cached in the session at
// login/profile-save, so this is an in-memory read with no DB cost.
if lang := h.safeSessionString(r.Context(), string(ContextKeyLanguage)); lang != "" {
preferred = append(preferred, lang)
}
// Fall back to the per-game (admin-set) language, then Accept-Language.
if uuid := chi.URLParam(r, "uuid"); uuid != "" && uuid == h.Config.Map.UUID && h.Settings != nil {
if lang, err := h.Settings.GetLanguage(uuid); err == nil && lang != "" {
preferred = append(preferred, lang)
Expand Down
15 changes: 13 additions & 2 deletions backend/cmd/map/handlers/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ func (h *HandlersMap) ProfilePOSTHandler(w http.ResponseWriter, r *http.Request)

fullName := strings.TrimSpace(req.FullName)
email := strings.TrimSpace(req.Email)
language := strings.TrimSpace(req.Language)
if language != "" && !h.catalog().IsSupported(language) {
HTTPResponse(w, JSONApplicationUTF8, http.StatusBadRequest, MapErrorResponse{Error: h.T(r.Context())("profile.language_invalid")})
return
}
if email != "" {
parsed, err := mail.ParseAddress(email)
if err != nil || parsed.Address != email {
Expand All @@ -114,8 +119,9 @@ func (h *HandlersMap) ProfilePOSTHandler(w http.ResponseWriter, r *http.Request)
result := h.Users.DB.Model(&users.PlatformUser{}).
Where("username = ? AND uuid = ?", username, uuid).
Updates(map[string]interface{}{
"name": fullName,
"email": email,
"name": fullName,
"email": email,
"language": language,
})
if result.Error != nil {
log.Err(result.Error).Msg("error updating profile account")
Expand All @@ -134,6 +140,10 @@ func (h *HandlersMap) ProfilePOSTHandler(w http.ResponseWriter, r *http.Request)
return
}

if h.Sessions != nil {
h.Sessions.Put(r.Context(), string(ContextKeyLanguage), language)
}

HTTPResponse(w, JSONApplicationUTF8, http.StatusOK, MapProfileAccountUpdateResponse{
Success: true,
Message: h.T(r.Context())("profile.updated_msg"),
Expand Down Expand Up @@ -278,6 +288,7 @@ func profileAccountResponse(user users.PlatformUser, tr func(string, ...any) str
Email: user.Email,
Role: profileRole(user.Admin, user.Service, tr),
Status: profileStatus(user.Active, tr),
Language: user.Language,
}
}

Expand Down
100 changes: 100 additions & 0 deletions backend/cmd/map/handlers/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,3 +376,103 @@ func TestProfileGETHandlerTranslatesRoleAndStatus(t *testing.T) {
require.Equal(t, "Jugador", resp.Account.Role)
require.Equal(t, "Activo", resp.Account.Status)
}

func newProfileLanguageHandler(t *testing.T, gameLang string) *HandlersMap {
t.Helper()
db := newJSONTestDB(t)
userManager, err := users.CreateUserManager(db, &config.ConfigurationJWT{Secret: "test-secret", HoursToExpire: 24})
require.NoError(t, err)
settingsManager, err := settings.CreateSettingsManager(db, "test-service")
require.NoError(t, err)
require.NoError(t, settingsManager.Initialization(jsonTestUUID))
if gameLang != "" {
require.NoError(t, settingsManager.SetLanguage(gameLang, jsonSettingsAuthor, jsonTestUUID))
}
catalog, err := i18n.New()
require.NoError(t, err)
return CreateHandlersMap(
WithConfig(config.MapCTFConfiguration{Map: config.ConfigurationMap{UUID: jsonTestUUID, TemplatesDir: filepath.Join("..", "templates")}}),
WithUsers(userManager),
WithSettings(settingsManager),
WithSessions(scs.New()),
WithI18N(catalog),
)
}

// A user's session-cached language preference overrides the per-game setting.
func TestProfileLanguagePreferenceOverridesGameSetting(t *testing.T) {
handler := newProfileLanguageHandler(t, "es") // game default Spanish

user, err := handler.Users.New("alice", "password123", "alice@example.com", "Alice", false, false, jsonTestUUID, 0)
require.NoError(t, err)
user.Language = "fr"
require.NoError(t, handler.Users.Create(user))

req := newRequestWithUUID(http.MethodGet, "/profile", jsonTestUUID)
ctx, err := handler.Sessions.Load(req.Context(), "")
require.NoError(t, err)
handler.Sessions.Put(ctx, string(ContextKeyUser), "alice")
handler.Sessions.Put(ctx, string(ContextKeyLanguage), "fr")
req = req.WithContext(ctx)

rec := httptest.NewRecorder()
handler.LocaleMiddleware(http.HandlerFunc(handler.ProfileGETHandler)).ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
var resp MapProfileResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, "fr", resp.Account.Language)
// French role for a non-admin player, proving "fr" beat the game's "es".
require.Equal(t, "Joueur", resp.Account.Role)
}

// Saving a language from the profile updates the user record and the session.
func TestProfilePOSTHandlerUpdatesUserLanguage(t *testing.T) {
handler := newProfileLanguageHandler(t, "es")

user, err := handler.Users.New("alice", "password123", "alice@example.com", "Alice", false, false, jsonTestUUID, 0)
require.NoError(t, err)
require.NoError(t, handler.Users.Create(user))

req := newJSONBodyRequestWithUUID(http.MethodPost, "/profile", jsonTestUUID, MapProfileAccountUpdateRequest{
FullName: "Alice",
Email: "alice@example.com",
Language: "de",
})
ctx, err := handler.Sessions.Load(req.Context(), "")
require.NoError(t, err)
handler.Sessions.Put(ctx, string(ContextKeyUser), "alice")
req = req.WithContext(ctx)

rec := httptest.NewRecorder()
handler.LocaleMiddleware(http.HandlerFunc(handler.ProfilePOSTHandler)).ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
var resp MapProfileAccountUpdateResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, "de", resp.Account.Language)
// The session was updated so subsequent requests resolve German.
require.Equal(t, "de", handler.Sessions.GetString(req.Context(), string(ContextKeyLanguage)))
// And the preference persisted to the user record.
reloaded, err := handler.Users.Get("alice", jsonTestUUID)
require.NoError(t, err)
require.Equal(t, "de", reloaded.Language)
}

// An unsupported language code is rejected.
func TestProfilePOSTHandlerRejectsUnsupportedLanguage(t *testing.T) {
handler := newProfileLanguageHandler(t, "en")
user, err := handler.Users.New("alice", "password123", "alice@example.com", "Alice", false, false, jsonTestUUID, 0)
require.NoError(t, err)
require.NoError(t, handler.Users.Create(user))

req := newJSONBodyRequestWithUUID(http.MethodPost, "/profile", jsonTestUUID, MapProfileAccountUpdateRequest{Language: "xx"})
ctx, err := handler.Sessions.Load(req.Context(), "")
require.NoError(t, err)
handler.Sessions.Put(ctx, string(ContextKeyUser), "alice")
req = req.WithContext(ctx)

rec := httptest.NewRecorder()
handler.ProfilePOSTHandler(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
2 changes: 2 additions & 0 deletions backend/cmd/map/handlers/types-requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ type MapLogoutResponse MapLoginResponse
type MapProfileAccountUpdateRequest struct {
FullName string `json:"full_name"`
Email string `json:"email"`
Language string `json:"language"`
}

// MapProfilePasswordRequest receives password changes from the profile modal.
Expand All @@ -94,6 +95,7 @@ type MapProfileAccountResponse struct {
Email string `json:"email"`
Role string `json:"role"`
Status string `json:"status"`
Language string `json:"language"`
}

type MapProfileTeamResponse struct {
Expand Down
6 changes: 6 additions & 0 deletions backend/cmd/map/templates/gameboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,7 @@ <h6>{{ T "gb.game_clock" }}</h6>
setText(root, ".js-profile-username", account.username);
setFieldValue(root, '.js-profile-account-form [name="full_name"]', account.name);
setFieldValue(root, '.js-profile-account-form [name="email"]', account.email);
setFieldValue(root, '.js-profile-account-form [name="language"]', account.language || "");
setText(root, ".js-profile-role", account.role);
setText(root, ".js-profile-status-label", account.status);
if (!team) {
Expand Down Expand Up @@ -1118,6 +1119,7 @@ <h6>{{ T "gb.game_clock" }}</h6>
var payload = {
full_name: form.elements.full_name.value,
email: form.elements.email.value,
language: form.elements.language ? form.elements.language.value : "",
};

if (submitBtn) {
Expand Down Expand Up @@ -1146,9 +1148,13 @@ <h6>{{ T "gb.game_clock" }}</h6>
var account = (data && data.account) || {};
setFieldValue(root, '.js-profile-account-form [name="full_name"]', account.name);
setFieldValue(root, '.js-profile-account-form [name="email"]', account.email);
setFieldValue(root, '.js-profile-account-form [name="language"]', account.language || "");
setText(root, ".js-profile-role", account.role);
setText(root, ".js-profile-status-label", account.status);
setAccountMessage(root, "success", (data && data.message) || "Profile updated");
if (data && data.account && data.account.language) {
window.MCTF_LANG = data.account.language;
}
})
.catch(function (error) {
setAccountMessage(root, "error", error.message || "Profile could not be updated");
Expand Down
12 changes: 12 additions & 0 deletions backend/cmd/map/templates/static/inc/modals/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ <h5 class="js-profile-team-name" data-i18n="profile.loading">Loading...</h5>
<label for="profile-email" data-i18n="profile.email">Email</label>
<input id="profile-email" type="email" name="email" autocomplete="email" />
</div>
<div class="form-el el--block-label">
<label for="profile-language" data-i18n="profile.language">Language</label>
<select id="profile-language" name="language" autocomplete="language">
<option value="">—</option>
<option value="en">🇬🇧 English</option>
<option value="es">🇪🇸 Español</option>
<option value="fr">🇫🇷 Français</option>
<option value="pt">🇵🇹 Português</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="it">🇮🇹 Italiano</option>
</select>
</div>
</div>
<dl class="profile-modal-list profile-account-readonly">
<div>
Expand Down
20 changes: 20 additions & 0 deletions backend/pkg/i18n/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ func New() (*Catalog, error) {
// Supported returns the configured language tags.
func (c *Catalog) Supported() []language.Tag { return c.supported }

// SupportedCodes returns the BCP-47 codes (e.g. "en", "es") of the configured
// language tags, suitable for validating user-supplied language preferences.
func (c *Catalog) SupportedCodes() []string {
out := make([]string, 0, len(c.supported))
for _, t := range c.supported {
out = append(out, t.String())
}
return out
}

// IsSupported reports whether code matches one of the configured locales.
func (c *Catalog) IsSupported(code string) bool {
for _, sc := range c.SupportedCodes() {
if sc == code {
return true
}
}
return false
}

// Resolve picks the best supported tag for the requested language strings,
// ignoring empty values. English is returned when nothing matches.
func (c *Catalog) Resolve(preferred ...string) language.Tag {
Expand Down
4 changes: 3 additions & 1 deletion backend/pkg/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -832,5 +832,7 @@
"admin.msg.logo_name_required": "Logoname ist erforderlich",
"admin.msg.subject_message_required": "Betreff oder Nachricht ist erforderlich",
"admin.msg.title_flag_required": "Titel und Flag sind erforderlich",
"admin.msg.user_pass_required": "Benutzername und Passwort sind erforderlich"
"admin.msg.user_pass_required": "Benutzername und Passwort sind erforderlich",
"profile.language": "Sprache",
"profile.language_invalid": "Nicht unterstützte Sprache"
}
4 changes: 3 additions & 1 deletion backend/pkg/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -832,5 +832,7 @@
"admin.msg.logo_name_required": "Logo name is required",
"admin.msg.subject_message_required": "Subject or message is required",
"admin.msg.title_flag_required": "Title and flag are required",
"admin.msg.user_pass_required": "Username and password are required"
"admin.msg.user_pass_required": "Username and password are required",
"profile.language": "Language",
"profile.language_invalid": "Unsupported language"
}
4 changes: 3 additions & 1 deletion backend/pkg/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -832,5 +832,7 @@
"admin.msg.logo_name_required": "El nombre del logo es obligatorio",
"admin.msg.subject_message_required": "El asunto o el mensaje son obligatorios",
"admin.msg.title_flag_required": "El título y el flag son obligatorios",
"admin.msg.user_pass_required": "El usuario y la contraseña son obligatorios"
"admin.msg.user_pass_required": "El usuario y la contraseña son obligatorios",
"profile.language": "Idioma",
"profile.language_invalid": "Idioma no soportado"
}
4 changes: 3 additions & 1 deletion backend/pkg/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -832,5 +832,7 @@
"admin.msg.logo_name_required": "Le nom du logo est requis",
"admin.msg.subject_message_required": "Le sujet ou le message est requis",
"admin.msg.title_flag_required": "Le titre et le flag sont requis",
"admin.msg.user_pass_required": "Le nom d'utilisateur et le mot de passe sont requis"
"admin.msg.user_pass_required": "Le nom d'utilisateur et le mot de passe sont requis",
"profile.language": "Langue",
"profile.language_invalid": "Langue non prise en charge"
}
4 changes: 3 additions & 1 deletion backend/pkg/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -832,5 +832,7 @@
"admin.msg.logo_name_required": "Il nome del logo è obbligatorio",
"admin.msg.subject_message_required": "L'oggetto o il messaggio è obbligatorio",
"admin.msg.title_flag_required": "Il titolo e il flag sono obbligatori",
"admin.msg.user_pass_required": "Nome utente e password sono obbligatori"
"admin.msg.user_pass_required": "Nome utente e password sono obbligatori",
"profile.language": "Lingua",
"profile.language_invalid": "Lingua non supportata"
}
4 changes: 3 additions & 1 deletion backend/pkg/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -832,5 +832,7 @@
"admin.msg.logo_name_required": "O nome do logo é obrigatório",
"admin.msg.subject_message_required": "O assunto ou a mensagem é obrigatório",
"admin.msg.title_flag_required": "O título e o flag são obrigatórios",
"admin.msg.user_pass_required": "O nome de usuário e a senha são obrigatórios"
"admin.msg.user_pass_required": "O nome de usuário e a senha são obrigatórios",
"profile.language": "Idioma",
"profile.language_invalid": "Idioma não suportado"
}
1 change: 1 addition & 0 deletions backend/pkg/users/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type PlatformUser struct {
LastUserAgent string
LastAccess time.Time
LastTokenUse time.Time
Language string
UUID string `gorm:"index"`
}

Expand Down
Loading