admin: bypass the reserved-username blocklist at write time too

UpdateUsernameAdmin already skipped the reserved-word check in the
availability lookup, but UserStore.UpdateUsername's own write path
(replacePeerUsernameTx / CollectibleUsernameStore.SetEditableUsername)
enforces the same operator blocklist a second time, independently and
unconditionally. That second check is what was still rejecting an admin
handing out a word they'd deliberately reserved, with "username occupied".

Add UpdateUsernameAdmin/SetEditableUsernameAdmin bypass variants down the
write path (postgres and memory) and route users.Service's actual write
through them when the availability check was already bypassed.
This commit is contained in:
Astra 2026-09-15 16:22:00 +01:00
parent 53872f8fc9
commit ea17d7da0d
6 changed files with 180 additions and 15 deletions

View file

@ -48,6 +48,24 @@ type usernameAvailabilityStore interface {
CheckUsername(ctx context.Context, userID int64, username string) (bool, error)
}
// adminUsernameAvailabilityStore is usernameAvailabilityStore's admin-bypass
// counterpart: CheckUsernameAdmin skips the operator reserved-username
// blocklist, so UpdateUsernameAdmin can hand a deliberately reserved word to
// a specific account instead of that same reservation blocking the operator's
// own assignment.
type adminUsernameAvailabilityStore interface {
CheckUsernameAdmin(ctx context.Context, userID int64, username string) (bool, error)
}
// adminUsernameStore is store.UserStore's admin-bypass counterpart for the
// actual write: UpdateUsername's own write path enforces the reserved-word
// blocklist a second time (independent of the availability check), so
// bypassing only checkUsernameAvailable isn't enough -- the write itself
// needs UpdateUsernameAdmin too.
type adminUsernameStore interface {
UpdateUsernameAdmin(ctx context.Context, userID int64, username string) (domain.User, error)
}
type moderationFlagAudienceStore interface {
ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error)
}
@ -318,6 +336,17 @@ func (s *Service) checkUsernameAvailable(ctx context.Context, selfID int64, user
return !found || u.ID == selfID, nil
}
// checkUsernameAvailableAdmin is checkUsernameAvailable without the operator
// reserved-username blocklist. Falls back to checkUsernameAvailable (which
// does enforce it) if the store doesn't implement the admin-bypass method --
// availability checking still works, just without the bypass.
func (s *Service) checkUsernameAvailableAdmin(ctx context.Context, selfID int64, username string) (bool, error) {
if checker, ok := s.users.(adminUsernameAvailabilityStore); ok {
return checker.CheckUsernameAdmin(ctx, selfID, username)
}
return s.checkUsernameAvailable(ctx, selfID, username)
}
// UpdateUsername 修改当前用户的主 usernameself-service。空字符串表示删除 username。
func (s *Service) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
return s.updateUsername(ctx, userID, username, true)
@ -350,7 +379,15 @@ func (s *Service) updateUsername(ctx context.Context, userID int64, username str
if !validUsername(username) || (enforceReserved && s.reserved.Contains(username)) {
return domain.User{}, domain.ErrUsernameInvalid
}
ok, err := s.checkUsernameAvailable(ctx, self.ID, username)
var (
ok bool
err error
)
if enforceReserved {
ok, err = s.checkUsernameAvailable(ctx, self.ID, username)
} else {
ok, err = s.checkUsernameAvailableAdmin(ctx, self.ID, username)
}
if err != nil {
return domain.User{}, err
}
@ -358,7 +395,14 @@ func (s *Service) updateUsername(ctx context.Context, userID int64, username str
return domain.User{}, domain.ErrUsernameOccupied
}
}
u, err := s.users.UpdateUsername(ctx, self.ID, username)
var u domain.User
if enforceReserved {
u, err = s.users.UpdateUsername(ctx, self.ID, username)
} else if admin, ok := s.users.(adminUsernameStore); ok {
u, err = admin.UpdateUsernameAdmin(ctx, self.ID, username)
} else {
u, err = s.users.UpdateUsername(ctx, self.ID, username)
}
if err != nil {
return domain.User{}, err
}