From 367f2be59c97fa726541133dd4b59ef3126731ac Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 9 Sep 2026 21:41:53 +0100 Subject: [PATCH] usernames: report reserved names as taken in the check paths too account.checkUsername / channels.checkUsername / bots.checkUsername said a reserved name was available and only updateUsername rejected it. Add the blocklist check to peerUsernameAvailable (covers account + channel, both backends) and to bots.Service.CheckUsername, so the client shows "username is taken" immediately. --- cmd/telesrv/main.go | 3 +- internal/app/bots/service.go | 23 ++++++++++++ internal/store/memory/channel_settings.go | 3 ++ internal/store/memory/collectible_username.go | 10 +++-- .../store/memory/reserved_username_test.go | 37 +++++++++++++++++++ internal/store/memory/users.go | 3 ++ internal/store/postgres/peer_username.go | 5 +++ 7 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 internal/store/memory/reserved_username_test.go diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 060bf48c..e297a8d8 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -1007,10 +1007,12 @@ func run(logger *zap.Logger) error { account.WithLoginEmailVerification(codeStore, loginEmailSender, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts, cfg.LoginEmailCodeLength)) } accountService := account.NewService(passwordStore, accountOptions...) + reservedUsernameStore := postgres.NewReservedUsernameStore(pool) botsService := botsapp.NewService(userStore, botStore, messageStore, botsapp.WithLogger(logger.Named("bots")), botsapp.WithBlockChecker(contactStore), botsapp.WithPublicChannelUsernameResolver(channelStore), + botsapp.WithReservedUsernames(reservedUsernameStore), botsapp.WithUserCache(userCache), botsapp.WithStickerSetCreator(filesService), botsapp.WithGifCatalogSource(filesService), @@ -1203,7 +1205,6 @@ func run(logger *zap.Logger) error { // Collectible (NFT) usernames are an optional read model projected at the // protocol edge. collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool) - reservedUsernameStore := postgres.NewReservedUsernameStore(pool) usernamesService := usernamesapp.NewService( usernamesapp.WithRegistryStore(collectibleUsernameStore), usernamesapp.WithCollectibleStore(collectibleUsernameStore), diff --git a/internal/app/bots/service.go b/internal/app/bots/service.go index fe171cad..ac564336 100644 --- a/internal/app/bots/service.go +++ b/internal/app/bots/service.go @@ -121,6 +121,7 @@ type Service struct { messages store.MessageStore blocker blockChecker channels publicChannelUsernameResolver + reserved reservedUsernameChecker stickers stickerSetCreator installer userStickerSetInstaller aiChat aiChatGenerator @@ -199,6 +200,21 @@ func WithBotAvatarStore(a botAvatarStore) Option { // WithPublicChannelUsernameResolver 注入公开频道 username 查询能力,用于 bot // username 预检,避免 bot 与 public channel 产生同名可见入口。 +// reservedUsernameChecker reports whether a name is on the operator blocklist. +type reservedUsernameChecker interface { + IsReserved(ctx context.Context, usernameLower string) (bool, error) +} + +// WithReservedUsernames wires the operator username blocklist so CheckUsername +// reports a reserved bot name as taken instead of available. +func WithReservedUsernames(c reservedUsernameChecker) Option { + return func(s *Service) { + if c != nil { + s.reserved = c + } + } +} + func WithPublicChannelUsernameResolver(c publicChannelUsernameResolver) Option { return func(s *Service) { if c != nil { @@ -533,6 +549,13 @@ func (s *Service) CheckUsername(ctx context.Context, ownerUserID int64, username if !domain.ValidBotUsername(username) { return false, domain.ErrBotUsernameInvalid } + if s.reserved != nil { + if r, err := s.reserved.IsReserved(ctx, strings.ToLower(username)); err != nil { + return false, err + } else if r { + return false, nil + } + } if _, found, err := s.users.ByUsername(ctx, username); err != nil { return false, err } else if found { diff --git a/internal/store/memory/channel_settings.go b/internal/store/memory/channel_settings.go index 14f9089e..c5c173cc 100644 --- a/internal/store/memory/channel_settings.go +++ b/internal/store/memory/channel_settings.go @@ -128,6 +128,9 @@ func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64, return false, err } usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + if s.usernameRegistry != nil && s.usernameRegistry.nameReserved(usernameLower) { + return false, nil + } for id, channel := range s.channels { if channel.Deleted || channel.Username == "" { continue diff --git a/internal/store/memory/collectible_username.go b/internal/store/memory/collectible_username.go index e53850f6..f4c79f8a 100644 --- a/internal/store/memory/collectible_username.go +++ b/internal/store/memory/collectible_username.go @@ -67,8 +67,10 @@ func (s *CollectibleUsernameStore) WithReservedUsernames(reserved *ReservedUsern return s } -func (s *CollectibleUsernameStore) nameReservedLocked(usernameLower string) bool { - if s.reserved == nil { +// nameReserved reports whether a name is on the operator blocklist. It touches +// only s.reserved (its own lock), so it is safe from any context. +func (s *CollectibleUsernameStore) nameReserved(usernameLower string) bool { + if s == nil || s.reserved == nil { return false } r, _ := s.reserved.IsReserved(context.Background(), usernameLower) @@ -119,7 +121,7 @@ func (s *CollectibleUsernameStore) SetEditableUsername(_ context.Context, peer d return false, domain.ErrUsernameInvalid } key := strings.ToLower(username) - if s.nameReservedLocked(key) { + if s.nameReserved(key) { return false, domain.ErrUsernameOccupied } if existing, ok := s.registry[key]; ok { @@ -334,7 +336,7 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, re if _, ok := s.registry[key]; ok { return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied } - if s.nameReservedLocked(key) { + if s.nameReserved(key) { return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied } now := time.Now().UTC() diff --git a/internal/store/memory/reserved_username_test.go b/internal/store/memory/reserved_username_test.go new file mode 100644 index 00000000..f6f01e9b --- /dev/null +++ b/internal/store/memory/reserved_username_test.go @@ -0,0 +1,37 @@ +package memory + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestCheckUsernameReportsReservedAsTaken(t *testing.T) { + ctx := context.Background() + reserved := NewReservedUsernameStore() + if _, err := reserved.ReserveUsername(ctx, "support", "official", "ops"); err != nil { + t.Fatalf("seed reserve: %v", err) + } + registry := NewCollectibleUsernameStore().WithReservedUsernames(reserved) + + users := NewUserStore() + users.AttachUsernameRegistry(registry) + u, _ := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550001000", FirstName: "A"}) + if ok, err := users.CheckUsername(ctx, u.ID, "support"); err != nil || ok { + t.Fatalf("CheckUsername(reserved) = %v, %v; want false, nil", ok, err) + } + if ok, err := users.CheckUsername(ctx, u.ID, "freename"); err != nil || !ok { + t.Fatalf("CheckUsername(free) = %v, %v; want true, nil", ok, err) + } + + channels := NewChannelStore() + channels.AttachUsernameRegistry(registry) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: u.ID, Title: "C", Megagroup: true, Date: 1}) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if ok, err := channels.CheckUsername(ctx, u.ID, created.Channel.ID, "support"); err != nil || ok { + t.Fatalf("channel CheckUsername(reserved) = %v, %v; want false, nil", ok, err) + } +} diff --git a/internal/store/memory/users.go b/internal/store/memory/users.go index 196851a7..0b3656c0 100644 --- a/internal/store/memory/users.go +++ b/internal/store/memory/users.go @@ -158,6 +158,9 @@ func (s *UserStore) CheckUsername(_ context.Context, userID int64, username stri if username == "" { return true, nil } + if s.usernameRegistry != nil && s.usernameRegistry.nameReserved(username) { + return false, nil + } s.mu.RLock() defer s.mu.RUnlock() for id, u := range s.byID { diff --git a/internal/store/postgres/peer_username.go b/internal/store/postgres/peer_username.go index 1de75769..7af1face 100644 --- a/internal/store/postgres/peer_username.go +++ b/internal/store/postgres/peer_username.go @@ -77,6 +77,11 @@ func usernameReservedTx(ctx context.Context, db sqlcgen.DBTX, usernameLower stri } func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower, peerType string, peerID int64) (bool, error) { + if reserved, err := usernameReservedTx(ctx, db, usernameLower); err != nil { + return false, err + } else if reserved { + return false, nil + } owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false) if err != nil || !found { return !found, err