From 01be01f4b7676af41bdd1758b1e9b096aa922546 Mon Sep 17 00:00:00 2001 From: boojack Date: Sun, 19 Apr 2026 10:44:25 +0800 Subject: [PATCH] fix: mixed-case user resource names (#5853) --- .../api/v1/test/user_resource_name_test.go | 56 +++++++++++++++++++ server/router/api/v1/user_resource_name.go | 28 +++++++--- server/router/api/v1/user_service.go | 9 ++- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/server/router/api/v1/test/user_resource_name_test.go b/server/router/api/v1/test/user_resource_name_test.go index 544c13452..799cf63eb 100644 --- a/server/router/api/v1/test/user_resource_name_test.go +++ b/server/router/api/v1/test/user_resource_name_test.go @@ -44,6 +44,62 @@ func TestUserResourceName(t *testing.T) { require.Equal(t, "users/newuser", created.Name) }) + t.Run("Mixed-case username remains usable after auth", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + user, err := ts.CreateRegularUser(ctx, "Gnammi") + require.NoError(t, err) + + userCtx := ts.CreateUserContext(ctx, user.ID) + currentUser, err := ts.Service.GetCurrentUser(userCtx, &apiv1.GetCurrentUserRequest{}) + require.NoError(t, err) + require.NotNil(t, currentUser.GetUser()) + require.Equal(t, "users/Gnammi", currentUser.GetUser().Name) + + settings, err := ts.Service.ListUserSettings(userCtx, &apiv1.ListUserSettingsRequest{ + Parent: currentUser.GetUser().Name, + }) + require.NoError(t, err) + require.NotNil(t, settings) + + shortcuts, err := ts.Service.ListShortcuts(userCtx, &apiv1.ListShortcutsRequest{ + Parent: currentUser.GetUser().Name, + }) + require.NoError(t, err) + require.NotNil(t, shortcuts) + }) + + t.Run("BatchGetUsers preserves mixed-case usernames", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + user, err := ts.CreateRegularUser(ctx, "Gnammi") + require.NoError(t, err) + + resp, err := ts.Service.BatchGetUsers(ctx, &apiv1.BatchGetUsersRequest{ + Usernames: []string{user.Username}, + }) + require.NoError(t, err) + require.Len(t, resp.Users, 1) + require.Equal(t, "users/Gnammi", resp.Users[0].Name) + }) + + t.Run("CreateUser rejects all-numeric usernames", func(t *testing.T) { + ts := NewTestService(t) + defer ts.Cleanup() + + _, err := ts.Service.CreateUser(ctx, &apiv1.CreateUserRequest{ + User: &apiv1.User{ + Username: "123", + Email: "123@example.com", + Password: "password123", + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid username") + }) + t.Run("GetUser rejects numeric user resource names", func(t *testing.T) { ts := NewTestService(t) defer ts.Cleanup() diff --git a/server/router/api/v1/user_resource_name.go b/server/router/api/v1/user_resource_name.go index cf91e5be4..e5d7716ee 100644 --- a/server/router/api/v1/user_resource_name.go +++ b/server/router/api/v1/user_resource_name.go @@ -2,8 +2,6 @@ package v1 import ( "context" - "strconv" - "strings" "github.com/pkg/errors" @@ -26,15 +24,31 @@ func ExtractUsernameFromName(name string) (string, error) { if username == "" { return "", errors.Errorf("invalid user name %q", name) } - if _, err := strconv.ParseInt(username, 10, 32); err == nil { - return "", errors.Errorf("invalid username %q", username) - } - if username != strings.ToLower(username) || !base.UIDMatcher.MatchString(username) { - return "", errors.Errorf("invalid username %q", username) + if err := validateUsername(username); err != nil { + return "", err } return username, nil } +func validateUsername(username string) error { + if username == "" || isNumericUsername(username) || !base.UIDMatcher.MatchString(username) { + return errors.Errorf("invalid username %q", username) + } + return nil +} + +func isNumericUsername(username string) bool { + if username == "" { + return false + } + for _, char := range username { + if char < '0' || char > '9' { + return false + } + } + return true +} + // ResolveUserByName resolves a username-based user resource name to a store user. func ResolveUserByName(ctx context.Context, stores *store.Store, name string) (*store.User, error) { username, err := ExtractUsernameFromName(name) diff --git a/server/router/api/v1/user_service.go b/server/router/api/v1/user_service.go index f77dbac89..3bc33d9ae 100644 --- a/server/router/api/v1/user_service.go +++ b/server/router/api/v1/user_service.go @@ -20,7 +20,6 @@ import ( "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/timestamppb" - "github.com/usememos/memos/internal/base" "github.com/usememos/memos/internal/util" "github.com/usememos/memos/internal/webhook" v1pb "github.com/usememos/memos/proto/gen/api/v1" @@ -76,8 +75,8 @@ func normalizeBatchUsernames(usernames []string) []string { uniqueUsernames := make([]string, 0, len(usernames)) seen := make(map[string]struct{}, len(usernames)) for _, username := range usernames { - username = strings.TrimSpace(strings.ToLower(username)) - if username == "" || !base.UIDMatcher.MatchString(username) { + username = strings.TrimSpace(username) + if validateUsername(username) != nil { continue } if _, ok := seen[username]; ok { @@ -177,7 +176,7 @@ func (s *APIV1Service) CreateUser(ctx context.Context, request *v1pb.CreateUserR roleToAssign = store.RoleUser } - if !base.UIDMatcher.MatchString(strings.ToLower(request.User.Username)) { + if err := validateUsername(request.User.Username); err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username) } @@ -254,7 +253,7 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR if instanceGeneralSetting.DisallowChangeUsername { return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change username") } - if !base.UIDMatcher.MatchString(strings.ToLower(request.User.Username)) { + if err := validateUsername(request.User.Username); err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username) } update.Username = &request.User.Username