fix(usernames): sync index active collectible aliases
This commit is contained in:
parent
2f995f607a
commit
5156b17c1b
29 changed files with 657 additions and 112 deletions
1
deploy/migrations/0160_peer_username_search.down.sql
Normal file
1
deploy/migrations/0160_peer_username_search.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP INDEX IF EXISTS public.peer_usernames_active_search_idx;
|
||||
6
deploy/migrations/0160_peer_username_search.up.sql
Normal file
6
deploy/migrations/0160_peer_username_search.up.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- Active usernames are searched by peer type and a case-normalized prefix.
|
||||
-- text_pattern_ops keeps the lookup indexable even when the database collation
|
||||
-- cannot use a regular btree index for LIKE 'prefix%'.
|
||||
CREATE INDEX peer_usernames_active_search_idx
|
||||
ON public.peer_usernames (peer_type, username_lower text_pattern_ops, peer_id)
|
||||
WHERE active;
|
||||
|
|
@ -597,7 +597,10 @@ func (s *Service) ResolvePublicUsername(ctx context.Context, userID int64, usern
|
|||
return domain.Channel{}, false, domain.ErrChannelInvalid
|
||||
}
|
||||
username = normalizeChannelUsername(username)
|
||||
if !validChannelUsername(username) {
|
||||
// Public resolution also covers Fragment-style collectible usernames,
|
||||
// whose protocol minimum is four characters. Channel username mutation
|
||||
// remains on the ordinary 5..32 validation path above.
|
||||
if !domain.ValidCollectibleUsername(username) {
|
||||
return domain.Channel{}, false, domain.ErrUsernameInvalid
|
||||
}
|
||||
return s.channels.ResolvePublicChannelUsername(ctx, userID, username)
|
||||
|
|
|
|||
|
|
@ -2904,7 +2904,10 @@ func TestListSendAsChannelsFiltersPostMessageRights(t *testing.T) {
|
|||
|
||||
func TestPublicChannelSearchAndResolveUsername(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := NewService(memory.NewChannelStore())
|
||||
channelStore := memory.NewChannelStore()
|
||||
registry := memory.NewCollectibleUsernameStore()
|
||||
channelStore.AttachUsernameRegistry(registry)
|
||||
service := NewService(channelStore)
|
||||
created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "CU Public Lab",
|
||||
MemberUserIDs: []int64{1002},
|
||||
|
|
@ -2945,6 +2948,53 @@ func TestPublicChannelSearchAndResolveUsername(t *testing.T) {
|
|||
if err != nil || !found || resolved.ID != public.ID {
|
||||
t.Fatalf("ResolvePublicUsername = %+v found %v err %v, want public channel", resolved, found, err)
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: public.ID}
|
||||
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "nfc4",
|
||||
Owner: peer,
|
||||
Currency: domain.CollectibleCurrencyStars,
|
||||
Amount: 1,
|
||||
Actor: "test",
|
||||
}); err != nil || !created {
|
||||
t.Fatalf("mint channel collectible: created=%v err=%v", created, err)
|
||||
}
|
||||
resolved, found, err = service.ResolvePublicUsername(ctx, 1003, "@NFC4")
|
||||
if err != nil || !found || resolved.ID != public.ID {
|
||||
t.Fatalf("ResolvePublicUsername collectible = %+v found %v err %v, want public channel", resolved, found, err)
|
||||
}
|
||||
collectibleSearch, err := service.SearchPublicChannels(ctx, 1003, "nfc", 10)
|
||||
if err != nil || len(collectibleSearch.Results) != 1 || collectibleSearch.Results[0].ID != public.ID {
|
||||
t.Fatalf("collectible channel search = %+v err=%v, want public channel", collectibleSearch, err)
|
||||
}
|
||||
if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: public.ID,
|
||||
Username: "",
|
||||
}); err != nil {
|
||||
t.Fatalf("clear editable username: %v", err)
|
||||
}
|
||||
resolved, found, err = service.ResolvePublicUsername(ctx, 1003, "nfc4")
|
||||
if err != nil || !found || resolved.ID != public.ID {
|
||||
t.Fatalf("NFT-only ResolvePublicUsername = %+v found=%v err=%v", resolved, found, err)
|
||||
}
|
||||
if view, err := service.GetChannel(ctx, 1003, public.ID); err != nil || view.Channel.ID != public.ID {
|
||||
t.Fatalf("NFT-only public preview = %+v err=%v", view, err)
|
||||
}
|
||||
if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{
|
||||
ChannelID: public.ID,
|
||||
Username: "cu_public_lab",
|
||||
}); err != nil {
|
||||
t.Fatalf("restore editable username: %v", err)
|
||||
}
|
||||
if changed, err := registry.SetUsernameActive(ctx, peer, "nfc4", false); err != nil || !changed {
|
||||
t.Fatalf("deactivate channel collectible: changed=%v err=%v", changed, err)
|
||||
}
|
||||
if _, found, err := service.ResolvePublicUsername(ctx, 1003, "nfc4"); err != nil || found {
|
||||
t.Fatalf("inactive collectible resolve found=%v err=%v, want hidden", found, err)
|
||||
}
|
||||
hiddenSearch, err := service.SearchPublicChannels(ctx, 1003, "nfc4", 10)
|
||||
if err != nil || len(hiddenSearch.Results) != 0 {
|
||||
t.Fatalf("inactive collectible search = %+v err=%v, want empty", hiddenSearch, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicChannelPreviewAllowsNonMemberHistory(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -644,6 +644,46 @@ func TestAcceptContactRequiresExistingContactRequest(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSearchFindsOnlyActiveCollectibleUsernames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
registry := memory.NewCollectibleUsernameStore()
|
||||
users.AttachUsernameRegistry(registry)
|
||||
viewer, err := users.Create(ctx, domain.User{Phone: "15550000101", FirstName: "Viewer"})
|
||||
if err != nil {
|
||||
t.Fatalf("create viewer: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "15550000102", FirstName: "Unrelated", Username: "target_slot"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}
|
||||
if _, err := registry.SetEditableUsername(ctx, peer, target.Username); err != nil {
|
||||
t.Fatalf("seed editable username: %v", err)
|
||||
}
|
||||
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "nft4",
|
||||
Owner: peer,
|
||||
Currency: domain.CollectibleCurrencyStars,
|
||||
Amount: 1,
|
||||
Actor: "test",
|
||||
}); err != nil || !created {
|
||||
t.Fatalf("mint collectible: created=%v err=%v", created, err)
|
||||
}
|
||||
svc := NewService(memory.NewContactStore(), users)
|
||||
found, err := svc.Search(ctx, viewer.ID, "@NFT4", 10)
|
||||
if err != nil || len(found.Results) != 1 || found.Results[0].ID != target.ID {
|
||||
t.Fatalf("search active collectible = %+v err=%v, want target", found, err)
|
||||
}
|
||||
if changed, err := registry.SetUsernameActive(ctx, peer, "nft4", false); err != nil || !changed {
|
||||
t.Fatalf("deactivate collectible: changed=%v err=%v", changed, err)
|
||||
}
|
||||
hidden, err := svc.Search(ctx, viewer.ID, "nft4", 10)
|
||||
if err != nil || len(hidden.Results) != 0 || len(hidden.MyResults) != 0 {
|
||||
t.Fatalf("search inactive collectible = %+v err=%v, want empty", hidden, err)
|
||||
}
|
||||
}
|
||||
|
||||
func contactByID(t *testing.T, list domain.ContactList, id int64) domain.Contact {
|
||||
t.Helper()
|
||||
for _, contact := range list.Contacts {
|
||||
|
|
|
|||
|
|
@ -577,7 +577,11 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
|||
return domain.User{}, false, err
|
||||
}
|
||||
username = normalizeUsername(username)
|
||||
if !validUsername(username) {
|
||||
// Resolution covers both the editable username slot (5..32) and
|
||||
// Fragment-style collectible usernames (4..32). Keep the stricter
|
||||
// validUsername check on create/update paths; only lookup accepts the
|
||||
// collectible lower bound.
|
||||
if !domain.ValidCollectibleUsername(username) {
|
||||
return domain.User{}, false, domain.ErrUsernameInvalid
|
||||
}
|
||||
u, found, err := s.users.ByUsername(ctx, username)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,34 @@ func TestServiceUsernameLifecycle(t *testing.T) {
|
|||
if err != nil || !found || resolved.ID != owner.ID {
|
||||
t.Fatalf("ResolveUsername = user %+v found %v err %v, want owner", resolved, found, err)
|
||||
}
|
||||
registry := memory.NewCollectibleUsernameStore()
|
||||
store.AttachUsernameRegistry(registry)
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
|
||||
if _, err := registry.SetEditableUsername(ctx, peer, updated.Username); err != nil {
|
||||
t.Fatalf("seed editable username registry: %v", err)
|
||||
}
|
||||
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "nft4",
|
||||
Owner: peer,
|
||||
Currency: domain.CollectibleCurrencyStars,
|
||||
Amount: 1,
|
||||
Actor: "test",
|
||||
}); err != nil || !created {
|
||||
t.Fatalf("mint four-character collectible: created=%v err=%v", created, err)
|
||||
}
|
||||
resolved, found, err = svc.ResolveUsername(ctx, other.ID, "@NFT4")
|
||||
if err != nil || !found || resolved.ID != owner.ID {
|
||||
t.Fatalf("ResolveUsername collectible = user %+v found %v err %v, want owner", resolved, found, err)
|
||||
}
|
||||
if _, err := svc.UpdateUsername(ctx, owner.ID, "nft4"); !errors.Is(err, domain.ErrUsernameInvalid) {
|
||||
t.Fatalf("four-character editable username err = %v, want username invalid", err)
|
||||
}
|
||||
if changed, err := registry.SetUsernameActive(ctx, peer, "nft4", false); err != nil || !changed {
|
||||
t.Fatalf("deactivate collectible: changed=%v err=%v", changed, err)
|
||||
}
|
||||
if _, found, err := svc.ResolveUsername(ctx, other.ID, "nft4"); err != nil || found {
|
||||
t.Fatalf("inactive collectible found=%v err=%v, want hidden", found, err)
|
||||
}
|
||||
if _, err := svc.UpdateUsername(ctx, owner.ID, "TAKEN_NAME"); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("UpdateUsername duplicate err = %v, want username occupied", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,12 +234,12 @@ func TestUsersGetUsersProjectsCollectibleUsernamesInOneBatch(t *testing.T) {
|
|||
registry := newFakeUsernameRegistry()
|
||||
f := newUsernameProjectionFixture(t, registry)
|
||||
registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}] = []domain.Username{
|
||||
{Username: "owner_slot", Editable: true, Active: true},
|
||||
{Username: "nft", Active: true, SortOrder: 0, CollectibleID: 7},
|
||||
{Username: "owner_slot", Editable: true, Active: true, SortOrder: 1},
|
||||
{Username: "nft4", Active: true, SortOrder: 0, CollectibleID: 7},
|
||||
}
|
||||
registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: f.friend.ID}] = []domain.Username{
|
||||
{Username: "friend_slot", Editable: true, Active: true},
|
||||
{Username: "gem", Active: false, SortOrder: 0, CollectibleID: 8},
|
||||
{Username: "gem4", Active: false, SortOrder: 1, CollectibleID: 8},
|
||||
}
|
||||
ctx := WithUserID(context.Background(), f.owner.ID)
|
||||
|
||||
|
|
@ -254,26 +254,29 @@ func TestUsersGetUsersProjectsCollectibleUsernamesInOneBatch(t *testing.T) {
|
|||
t.Fatalf("users = %d, want 2", len(out))
|
||||
}
|
||||
self := out[0].(*tg.User)
|
||||
if _, ok := self.GetUsername(); ok {
|
||||
t.Fatalf("self scalar username is set together with collectible vector")
|
||||
if scalar, ok := self.GetUsername(); !ok || scalar != "nft4" {
|
||||
t.Fatalf("self scalar username = %q (set %v), want primary collectible nft4", scalar, ok)
|
||||
}
|
||||
vector, ok := self.GetUsernames()
|
||||
if !ok {
|
||||
t.Fatalf("self usernames unset, want registry vector")
|
||||
}
|
||||
if got := usernameStrings(vector); len(got) != 2 || got[0] != "owner_slot" || got[1] != "nft" {
|
||||
t.Fatalf("self usernames = %v, want [owner_slot nft]", got)
|
||||
if got := usernameStrings(vector); len(got) != 2 || got[0] != "nft4" || got[1] != "owner_slot" {
|
||||
t.Fatalf("self usernames = %v, want [nft4 owner_slot]", got)
|
||||
}
|
||||
if !vector[0].Editable || !vector[0].Active {
|
||||
t.Fatalf("editable slot flags = %+v, want editable+active", vector[0])
|
||||
if vector[0].Editable || !vector[0].Active {
|
||||
t.Fatalf("primary collectible flags = %+v, want non-editable+active", vector[0])
|
||||
}
|
||||
if vector[1].Editable || !vector[1].Active {
|
||||
t.Fatalf("collectible flags = %+v, want non-editable+active", vector[1])
|
||||
if !vector[1].Editable || !vector[1].Active {
|
||||
t.Fatalf("editable slot flags = %+v, want editable+active", vector[1])
|
||||
}
|
||||
friend := out[1].(*tg.User)
|
||||
if scalar, ok := friend.GetUsername(); !ok || scalar != "friend_slot" {
|
||||
t.Fatalf("friend scalar username = %q (set %v), want friend_slot", scalar, ok)
|
||||
}
|
||||
friendVector, _ := friend.GetUsernames()
|
||||
if got := usernameStrings(friendVector); len(got) != 2 || got[1] != "gem" {
|
||||
t.Fatalf("friend usernames = %v, want [friend_slot gem]", got)
|
||||
if got := usernameStrings(friendVector); len(got) != 2 || got[1] != "gem4" {
|
||||
t.Fatalf("friend usernames = %v, want [friend_slot gem4]", got)
|
||||
}
|
||||
if friendVector[1].Active {
|
||||
t.Fatalf("inactive collectible projected active: %+v", friendVector[1])
|
||||
|
|
@ -663,7 +666,7 @@ func TestChannelsGetChannelsProjectsCollectibleUsernames(t *testing.T) {
|
|||
r, owner, channel := newCollectibleChannelFixture(t, registry)
|
||||
ctx := WithUserID(context.Background(), owner.ID)
|
||||
registry.byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}] = []domain.Username{
|
||||
{Username: "chan_slot", Editable: true, Active: true},
|
||||
{Username: "chan_slot", Editable: true, Active: true, SortOrder: 1},
|
||||
{Username: "chan_nft", Active: true, SortOrder: 0, CollectibleID: 44},
|
||||
}
|
||||
|
||||
|
|
@ -681,11 +684,11 @@ func TestChannelsGetChannelsProjectsCollectibleUsernames(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatalf("channel usernames unset, want registry vector")
|
||||
}
|
||||
if got := usernameStrings(vector); len(got) != 2 || got[0] != "chan_slot" || got[1] != "chan_nft" {
|
||||
t.Fatalf("channel usernames = %v, want [chan_slot chan_nft]", got)
|
||||
if got := usernameStrings(vector); len(got) != 2 || got[0] != "chan_nft" || got[1] != "chan_slot" {
|
||||
t.Fatalf("channel usernames = %v, want [chan_nft chan_slot]", got)
|
||||
}
|
||||
if scalar, ok := out[0].(*tg.Channel).GetUsername(); ok || scalar != "" {
|
||||
t.Fatalf("scalar channel username = %q (set %v), want absent with collectible vector", scalar, ok)
|
||||
if scalar, ok := out[0].(*tg.Channel).GetUsername(); !ok || scalar != "chan_nft" {
|
||||
t.Fatalf("scalar channel username = %q (set %v), want primary collectible chan_nft", scalar, ok)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
appprivacy "telesrv/internal/app/privacy"
|
||||
appstories "telesrv/internal/app/stories"
|
||||
appupdates "telesrv/internal/app/updates"
|
||||
usernamesapp "telesrv/internal/app/usernames"
|
||||
"telesrv/internal/app/userprojection"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -24,6 +25,8 @@ import (
|
|||
func TestContactsSearchFindsUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
registry := memory.NewCollectibleUsernameStore()
|
||||
users.AttachUsernameRegistry(registry)
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
|
|
@ -32,12 +35,29 @@ func TestContactsSearchFindsUsers(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("create friend: %v", err)
|
||||
}
|
||||
friendPeer := domain.Peer{Type: domain.PeerTypeUser, ID: friend.ID}
|
||||
if _, err := registry.SetEditableUsername(ctx, friendPeer, friend.Username); err != nil {
|
||||
t.Fatalf("seed editable username: %v", err)
|
||||
}
|
||||
if _, created, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "nft4",
|
||||
Owner: friendPeer,
|
||||
Currency: domain.CollectibleCurrencyStars,
|
||||
Amount: 1,
|
||||
Actor: "test",
|
||||
}); err != nil || !created {
|
||||
t.Fatalf("mint collectible: created=%v err=%v", created, err)
|
||||
}
|
||||
r := New(Config{}, Deps{
|
||||
Contacts: appcontacts.NewService(memory.NewContactStore(), users),
|
||||
Usernames: usernamesapp.NewService(
|
||||
usernamesapp.WithRegistryStore(registry),
|
||||
usernamesapp.WithCollectibleStore(registry),
|
||||
),
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
var in bin.Buffer
|
||||
if err := (&tg.ContactsSearchRequest{Q: "@search", Limit: 20}).Encode(&in); err != nil {
|
||||
if err := (&tg.ContactsSearchRequest{Q: "@NFT4", Limit: 20}).Encode(&in); err != nil {
|
||||
t.Fatalf("encode request: %v", err)
|
||||
}
|
||||
enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in)
|
||||
|
|
@ -55,6 +75,14 @@ func TestContactsSearchFindsUsers(t *testing.T) {
|
|||
if !ok || peer.UserID != friend.ID {
|
||||
t.Fatalf("peer = %T %+v, want friend", box.Results[0], box.Results[0])
|
||||
}
|
||||
user := box.Users[0].(*tg.User)
|
||||
if scalar, ok := user.GetUsername(); !ok || scalar != "search_friend" {
|
||||
t.Fatalf("search result scalar username = %q (set %v), want search_friend", scalar, ok)
|
||||
}
|
||||
vector, ok := user.GetUsernames()
|
||||
if !ok || len(vector) != 2 || vector[1].Username != "nft4" || !vector[1].Active {
|
||||
t.Fatalf("search result username vector = %+v (set %v), want active nft4 alias", vector, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactsEditCloseFriendsProjectsUserFlag(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -302,8 +302,16 @@ func applyUsernamesFromRegistry(users []tg.UserClass, chats []tg.ChatClass, byPe
|
|||
continue
|
||||
}
|
||||
if vector := tgUsernamesFromRegistry(list, u.Username); len(vector) > 0 {
|
||||
// Layer 228 defines username as the main active username, not as a
|
||||
// legacy alternative to usernames. TDesktop seeds its local search
|
||||
// index from this scalar before consuming the complete vector, so
|
||||
// both fields must be projected together.
|
||||
if primary := domain.ActiveUsername(list); primary != "" {
|
||||
u.SetUsername(primary)
|
||||
} else {
|
||||
u.Flags.Unset(3)
|
||||
u.Username = ""
|
||||
}
|
||||
u.SetUsernames(vector)
|
||||
}
|
||||
}
|
||||
|
|
@ -320,8 +328,12 @@ func applyUsernamesFromRegistry(users []tg.UserClass, chats []tg.ChatClass, byPe
|
|||
// when unset, which is exactly the fallback tgUsernamesFromRegistry wants.
|
||||
scalar, _ := ch.GetUsername()
|
||||
if vector := tgUsernamesFromRegistry(list, scalar); len(vector) > 0 {
|
||||
if primary := domain.ActiveUsername(list); primary != "" {
|
||||
ch.SetUsername(primary)
|
||||
} else {
|
||||
ch.Flags.Unset(6)
|
||||
ch.Username = ""
|
||||
}
|
||||
ch.SetUsernames(vector)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ func TestNotifyPeerUsernamesChangedUserPushesPreloadedVector(t *testing.T) {
|
|||
}
|
||||
registry := newFakeUsernameRegistry()
|
||||
registry.byPeer[domain.Peer{Type: domain.PeerTypeUser, ID: targetID}] = []domain.Username{
|
||||
{Username: "owner_slot", Editable: true, Active: true},
|
||||
{Username: "nft", Active: true, CollectibleID: 7},
|
||||
{Username: "owner_slot", Editable: true, Active: true, SortOrder: 1},
|
||||
{Username: "nft4", Active: true, SortOrder: 0, CollectibleID: 7},
|
||||
}
|
||||
sessions := &captureSessions{onlineUserIDs: []int64{targetID, viewerID}}
|
||||
r := New(Config{}, Deps{Users: users, Usernames: registry, Sessions: sessions}, zap.NewNop(), clock.System)
|
||||
|
|
@ -42,11 +42,11 @@ func TestNotifyPeerUsernamesChangedUserPushesPreloadedVector(t *testing.T) {
|
|||
}
|
||||
updates := sessions.lastUserPush().(*tg.Updates)
|
||||
user := updates.Users[0].(*tg.User)
|
||||
if scalar, ok := user.GetUsername(); ok || scalar != "" {
|
||||
t.Fatalf("pushed scalar username = %q (set %v), want absent", scalar, ok)
|
||||
if scalar, ok := user.GetUsername(); !ok || scalar != "nft4" {
|
||||
t.Fatalf("pushed scalar username = %q (set %v), want primary collectible nft4", scalar, ok)
|
||||
}
|
||||
vector, ok := user.GetUsernames()
|
||||
if !ok || len(vector) != 2 || vector[1].Username != "nft" {
|
||||
if !ok || len(vector) != 2 || vector[0].Username != "nft4" {
|
||||
t.Fatalf("pushed username vector = %+v (set %v)", vector, ok)
|
||||
}
|
||||
}
|
||||
|
|
@ -63,8 +63,8 @@ func TestNotifyPeerUsernamesChangedChannelPushesPreloadedVector(t *testing.T) {
|
|||
}}
|
||||
registry := newFakeUsernameRegistry()
|
||||
registry.byPeer[domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}] = []domain.Username{
|
||||
{Username: "channel_slot", Editable: true, Active: true},
|
||||
{Username: "collectible", Active: true, CollectibleID: 9},
|
||||
{Username: "channel_slot", Editable: true, Active: true, SortOrder: 1},
|
||||
{Username: "collectible", Active: true, SortOrder: 0, CollectibleID: 9},
|
||||
}
|
||||
sessions := &captureSessions{
|
||||
onlineUserIDs: []int64{ownerID, memberID},
|
||||
|
|
@ -84,11 +84,11 @@ func TestNotifyPeerUsernamesChangedChannelPushesPreloadedVector(t *testing.T) {
|
|||
}
|
||||
updates := sessions.lastUserPush().(*tg.Updates)
|
||||
channel := updates.Chats[0].(*tg.Channel)
|
||||
if scalar, ok := channel.GetUsername(); ok || scalar != "" {
|
||||
t.Fatalf("pushed scalar username = %q (set %v), want absent", scalar, ok)
|
||||
if scalar, ok := channel.GetUsername(); !ok || scalar != "collectible" {
|
||||
t.Fatalf("pushed scalar username = %q (set %v), want primary collectible", scalar, ok)
|
||||
}
|
||||
vector, ok := channel.GetUsernames()
|
||||
if !ok || len(vector) != 2 || vector[1].Username != "collectible" {
|
||||
if !ok || len(vector) != 2 || vector[0].Username != "collectible" {
|
||||
t.Fatalf("pushed username vector = %+v (set %v)", vector, ok)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import (
|
|||
|
||||
// UsernameRegistryStore reads a peer's full username list. The list is the
|
||||
// projection source for the TL usernames vector, so every caller sees the same
|
||||
// order the client will render: editable slot first, then collectibles.
|
||||
// stored order the client will render. Reorder may promote a collectible ahead
|
||||
// of the editable slot; the first active row is also the Layer 228 main scalar.
|
||||
type UsernameRegistryStore interface {
|
||||
// PeerUsernames returns the peer's registry rows in projection order.
|
||||
PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error)
|
||||
|
|
|
|||
|
|
@ -229,8 +229,15 @@ func (s *ChannelStore) GetChannelByID(_ context.Context, channelID int64) (domai
|
|||
return cloneChannel(channel), nil
|
||||
}
|
||||
|
||||
func publicPreviewableChannel(channel domain.Channel) bool {
|
||||
return publicSearchableChannel(channel)
|
||||
func (s *ChannelStore) publicPreviewableChannelLocked(channel domain.Channel) bool {
|
||||
hasActiveUsername := strings.TrimSpace(channel.Username) != ""
|
||||
if !hasActiveUsername && s.usernameRegistry != nil {
|
||||
hasActiveUsername = s.usernameRegistry.peerHasActiveCollectibleUsername(domain.Peer{
|
||||
Type: domain.PeerTypeChannel,
|
||||
ID: channel.ID,
|
||||
})
|
||||
}
|
||||
return publicSearchableChannel(channel) && hasActiveUsername
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
|
|
|
|||
|
|
@ -83,6 +83,13 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
|||
return domain.PublicChannelSearchResult{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
registry := s.usernameRegistry
|
||||
s.mu.RUnlock()
|
||||
var usernameMatches map[int64]int
|
||||
if registry != nil {
|
||||
usernameMatches = registry.activeUsernameMatches(query, domain.PeerTypeChannel)
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
type item struct {
|
||||
|
|
@ -92,6 +99,11 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
|
|||
items := make([]item, 0, limit)
|
||||
for channelID, channel := range s.channels {
|
||||
rank, ok := publicChannelSearchRank(channel, query)
|
||||
if usernameRank, matched := usernameMatches[channelID]; matched &&
|
||||
!channel.Deleted && (channel.Broadcast || channel.Megagroup) &&
|
||||
(!ok || usernameRank < rank) {
|
||||
rank, ok = usernameRank, true
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -426,7 +438,7 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C
|
|||
return channel, syntheticMonoforumUserMember(channel, userID), true, nil
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(channel) {
|
||||
if !s.publicPreviewableChannelLocked(channel) {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
|
||||
}
|
||||
return channel, publicPreviewMember(channel, userID, existing, found), true, nil
|
||||
|
|
@ -556,8 +568,7 @@ func recommendableChannel(channel domain.Channel) bool {
|
|||
|
||||
func publicSearchableChannel(channel domain.Channel) bool {
|
||||
return !channel.Deleted &&
|
||||
(channel.Broadcast || channel.Megagroup) &&
|
||||
strings.TrimSpace(channel.Username) != ""
|
||||
(channel.Broadcast || channel.Megagroup)
|
||||
}
|
||||
|
||||
func channelRoleOrder(role domain.ChannelMemberRole) int {
|
||||
|
|
|
|||
|
|
@ -902,7 +902,7 @@ func (s *ChannelStore) FilterChannelMessageAudienceIDs(_ context.Context, channe
|
|||
if !ok || channel.Deleted {
|
||||
return nil, nil
|
||||
}
|
||||
public := publicPreviewableChannel(channel)
|
||||
public := s.publicPreviewableChannelLocked(channel)
|
||||
members := s.members[channelID]
|
||||
out := make([]int64, 0, len(userIDs))
|
||||
seen := make(map[int64]struct{}, len(userIDs))
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6
|
|||
}
|
||||
member, ok := s.members[channelID][viewerUserID]
|
||||
joined := ok && member.Status == domain.ChannelMemberActive && !member.BannedRights.ViewMessages
|
||||
publicPreview := req.AllowPublicPreview && publicPreviewableChannel(channel) &&
|
||||
publicPreview := req.AllowPublicPreview && s.publicPreviewableChannelLocked(channel) &&
|
||||
(!ok || member.Status != domain.ChannelMemberKicked && !member.BannedRights.ViewMessages)
|
||||
if !joined && !publicPreview {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64,
|
|||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) {
|
||||
func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
|
|
@ -168,6 +168,11 @@ func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChanne
|
|||
}
|
||||
}
|
||||
}
|
||||
if s.usernameRegistry != nil {
|
||||
if _, err := s.usernameRegistry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, username); err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
}
|
||||
prevUsername := channel.Username
|
||||
channel.Username = username
|
||||
s.channels[req.ChannelID] = channel
|
||||
|
|
@ -311,16 +316,27 @@ func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUse
|
|||
return domain.Channel{}, false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
registry := s.usernameRegistry
|
||||
for _, channel := range s.channels {
|
||||
if !publicSearchableChannel(channel) {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(channel.Username) == username {
|
||||
s.mu.RUnlock()
|
||||
return cloneChannel(channel), true, nil
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if registry != nil {
|
||||
if peer, ok := registry.activeUsernamePeer(username, domain.PeerTypeChannel); ok {
|
||||
s.mu.RLock()
|
||||
channel, found := s.channels[peer.ID]
|
||||
s.mu.RUnlock()
|
||||
if found && !channel.Deleted && (channel.Broadcast || channel.Megagroup) {
|
||||
return cloneChannel(channel), true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ type ChannelStore struct {
|
|||
topicReads map[int64]map[int64]map[int]memoryTopicRead
|
||||
// polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。
|
||||
polls *PollStore
|
||||
usernameRegistry *CollectibleUsernameStore
|
||||
}
|
||||
|
||||
// AttachPollStore 注入共享 poll 权威。
|
||||
|
|
@ -109,6 +110,14 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) {
|
|||
s.polls = polls
|
||||
}
|
||||
|
||||
// AttachUsernameRegistry gives the memory backend the same global username
|
||||
// index the PostgreSQL stores share through peer_usernames.
|
||||
func (s *ChannelStore) AttachUsernameRegistry(registry *CollectibleUsernameStore) {
|
||||
s.mu.Lock()
|
||||
s.usernameRegistry = registry
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// NewChannelStore creates an in-memory ChannelStore.
|
||||
func NewChannelStore() *ChannelStore {
|
||||
return &ChannelStore{
|
||||
|
|
|
|||
|
|
@ -161,6 +161,61 @@ func (s *CollectibleUsernameStore) PeerUsernamesBatch(_ context.Context, peers [
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// activeUsernamePeer resolves an active registry name for the memory user and
|
||||
// channel stores. Keeping lookup on the same registry that owns toggle/reorder
|
||||
// state prevents the test backend from silently falling back to scalar-only
|
||||
// behavior.
|
||||
func (s *CollectibleUsernameStore) activeUsernamePeer(username string, peerType domain.PeerType) (domain.Peer, bool) {
|
||||
key := strings.ToLower(domain.NormalizeUsername(username))
|
||||
if key == "" {
|
||||
return domain.Peer{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, ok := s.registry[key]
|
||||
if !ok || !entry.row.Active || entry.peer.Type != peerType {
|
||||
return domain.Peer{}, false
|
||||
}
|
||||
return entry.peer, true
|
||||
}
|
||||
|
||||
// activeUsernameMatches returns the best username rank for each peer: exact
|
||||
// matches precede prefix matches. Inactive rows stay occupied in the registry
|
||||
// but are deliberately absent from client search.
|
||||
func (s *CollectibleUsernameStore) activeUsernameMatches(query string, peerType domain.PeerType) map[int64]int {
|
||||
query = strings.ToLower(domain.NormalizeUsername(query))
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make(map[int64]int)
|
||||
for username, entry := range s.registry {
|
||||
if !entry.row.Active || entry.peer.Type != peerType || !strings.HasPrefix(username, query) {
|
||||
continue
|
||||
}
|
||||
rank := 1
|
||||
if username == query {
|
||||
rank = 0
|
||||
}
|
||||
if current, ok := out[entry.peer.ID]; !ok || rank < current {
|
||||
out[entry.peer.ID] = rank
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *CollectibleUsernameStore) peerHasActiveCollectibleUsername(peer domain.Peer) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, entry := range s.registry {
|
||||
if entry.peer == peer && entry.row.Active && !entry.row.Editable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetUsernameActive toggles one collectible row. The domain validator owns the
|
||||
// rules: the editable slot is off limits and a peer that holds usernames must
|
||||
// keep at least one active.
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ func (s *CommunityStore) viewLocked(userID, id int64) (domain.CommunityView, err
|
|||
cm, ok := s.channels.members[l.Peer.ID][userID]
|
||||
joined = ok && cm.Status == domain.ChannelMemberActive
|
||||
if channel, ok := s.channels.channels[l.Peer.ID]; ok {
|
||||
inherentlyViewable = publicPreviewableChannel(channel)
|
||||
inherentlyViewable = s.channels.publicPreviewableChannelLocked(channel)
|
||||
}
|
||||
s.channels.mu.RUnlock()
|
||||
} else if l.Peer.Type == domain.PeerTypeUser && s.dialogs != nil {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ type UserStore struct {
|
|||
mu sync.RWMutex
|
||||
byID map[int64]domain.User
|
||||
nextID int64
|
||||
usernameRegistry *CollectibleUsernameStore
|
||||
}
|
||||
|
||||
// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather / Stickers / ChatBot)
|
||||
|
|
@ -28,6 +29,14 @@ func NewUserStore() *UserStore {
|
|||
return s
|
||||
}
|
||||
|
||||
// AttachUsernameRegistry gives the memory backend the same global username
|
||||
// index the PostgreSQL stores share through peer_usernames.
|
||||
func (s *UserStore) AttachUsernameRegistry(registry *CollectibleUsernameStore) {
|
||||
s.mu.Lock()
|
||||
s.usernameRegistry = registry
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *UserStore) ByID(_ context.Context, id int64) (domain.User, bool, error) {
|
||||
s.mu.RLock()
|
||||
u, ok := s.byID[id]
|
||||
|
|
@ -104,18 +113,25 @@ func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User,
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User, bool, error) {
|
||||
func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.User, bool, error) {
|
||||
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
|
||||
if username == "" {
|
||||
return domain.User{}, false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
registry := s.usernameRegistry
|
||||
for _, u := range s.byID {
|
||||
if !u.Deleted && strings.ToLower(u.Username) == username {
|
||||
s.mu.RUnlock()
|
||||
return u, true, nil
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if registry != nil {
|
||||
if peer, ok := registry.activeUsernamePeer(username, domain.PeerTypeUser); ok {
|
||||
return s.ByID(ctx, peer.ID)
|
||||
}
|
||||
}
|
||||
return domain.User{}, false, nil
|
||||
}
|
||||
|
||||
|
|
@ -144,13 +160,21 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ
|
|||
return domain.UserSearchResult{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
registry := s.usernameRegistry
|
||||
s.mu.RUnlock()
|
||||
var usernameMatches map[int64]int
|
||||
if registry != nil {
|
||||
usernameMatches = registry.activeUsernameMatches(query, domain.PeerTypeUser)
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
users := make([]domain.User, 0)
|
||||
for _, u := range s.byID {
|
||||
if u.ID == currentUserID || u.Deleted {
|
||||
continue
|
||||
}
|
||||
if userMatchesSearch(u, query, phoneQuery) {
|
||||
_, usernameMatch := usernameMatches[u.ID]
|
||||
if usernameMatch || userMatchesSearch(u, query, phoneQuery) {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
|
|
@ -163,7 +187,7 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ
|
|||
return domain.UserSearchResult{Results: users}, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username string) (domain.User, error) {
|
||||
func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
|
||||
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
|
||||
usernameLower := strings.ToLower(username)
|
||||
s.mu.Lock()
|
||||
|
|
@ -179,6 +203,11 @@ func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username str
|
|||
}
|
||||
}
|
||||
}
|
||||
if s.usernameRegistry != nil {
|
||||
if _, err := s.usernameRegistry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, username); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
}
|
||||
u.Username = username
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
|
|
|
|||
|
|
@ -395,6 +395,10 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
publicUsernameIDs, err := activeCollectibleUsernamePeerIDs(ctx, s.db, peerUsernameTypeChannel, remaining)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, channel := range channels {
|
||||
if member, ok := linkedGuests[channel.ID]; ok {
|
||||
views[channel.ID] = domain.ChannelView{
|
||||
|
|
@ -427,7 +431,8 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
|
|||
continue
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(channel) {
|
||||
_, hasActiveUsername := publicUsernameIDs[channel.ID]
|
||||
if !publicPreviewableChannel(channel, hasActiveUsername) {
|
||||
continue
|
||||
}
|
||||
existing, found := previewMembers[channel.ID]
|
||||
|
|
@ -514,10 +519,10 @@ func finishChannelScan(ch *domain.Channel, rights, reactionPolicy string, wallpa
|
|||
}
|
||||
}
|
||||
|
||||
func publicPreviewableChannel(channel domain.Channel) bool {
|
||||
func publicPreviewableChannel(channel domain.Channel, hasActiveUsername bool) bool {
|
||||
return !channel.Deleted &&
|
||||
(channel.Broadcast || channel.Megagroup) &&
|
||||
strings.TrimSpace(channel.Username) != ""
|
||||
(strings.TrimSpace(channel.Username) != "" || hasActiveUsername)
|
||||
}
|
||||
|
||||
func refreshChannelCountsTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) (domain.Channel, error) {
|
||||
|
|
|
|||
|
|
@ -150,11 +150,28 @@ func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID in
|
|||
queryPrefix := escapeLike(queryLower) + "%"
|
||||
queryLike := "%" + escapeLike(queryLower) + "%"
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH username_matches AS (
|
||||
SELECT
|
||||
peer_id,
|
||||
MIN(CASE
|
||||
WHEN username_lower = $2 THEN 0
|
||||
ELSE 1
|
||||
END) AS rank
|
||||
FROM peer_usernames
|
||||
WHERE peer_type = 'channel'
|
||||
AND active
|
||||
AND collectible_id IS NOT NULL
|
||||
AND (
|
||||
username_lower = $2
|
||||
OR username_lower LIKE $3 ESCAPE '\'
|
||||
)
|
||||
GROUP BY peer_id
|
||||
)
|
||||
SELECT `+channelColumns+`
|
||||
FROM channels c
|
||||
LEFT JOIN username_matches um ON um.peer_id = c.id
|
||||
WHERE NOT c.deleted
|
||||
AND (c.broadcast OR c.megagroup)
|
||||
AND COALESCE(c.username, '') <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_members m
|
||||
|
|
@ -163,15 +180,16 @@ WHERE NOT c.deleted
|
|||
AND m.status = 'active'
|
||||
)
|
||||
AND (
|
||||
lower(c.username) = $2
|
||||
um.peer_id IS NOT NULL
|
||||
OR lower(c.username) = $2
|
||||
OR lower(c.username) LIKE $3 ESCAPE '\'
|
||||
OR lower(c.title) LIKE $3 ESCAPE '\'
|
||||
OR lower(c.username) LIKE $4 ESCAPE '\'
|
||||
OR lower(c.title) LIKE $4 ESCAPE '\'
|
||||
)
|
||||
ORDER BY CASE
|
||||
WHEN lower(c.username) = $2 THEN 0
|
||||
WHEN lower(c.username) LIKE $3 ESCAPE '\' THEN 1
|
||||
WHEN um.rank = 0 OR lower(c.username) = $2 THEN 0
|
||||
WHEN um.rank = 1 OR lower(c.username) LIKE $3 ESCAPE '\' THEN 1
|
||||
WHEN lower(c.username) LIKE $4 ESCAPE '\' THEN 2
|
||||
WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3
|
||||
ELSE 4
|
||||
|
|
@ -421,7 +439,12 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX,
|
|||
return ch, syntheticMonoforumUserMember(ch, viewerUserID), true, nil
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(ch) {
|
||||
publicUsernameIDs, err := activeCollectibleUsernamePeerIDs(ctx, db, peerUsernameTypeChannel, []int64{ch.ID})
|
||||
if err != nil {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, err
|
||||
}
|
||||
_, hasActiveUsername := publicUsernameIDs[ch.ID]
|
||||
if !publicPreviewableChannel(ch, hasActiveUsername) {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
|
||||
}
|
||||
member, err = s.getPublicPreviewMember(ctx, db, viewerUserID, ch)
|
||||
|
|
|
|||
|
|
@ -510,6 +510,9 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU
|
|||
if !found || owner.peerType != peerUsernameTypeChannel {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
if !owner.active {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
ch, err := getChannelByID(ctx, s.db, owner.peerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
|
|
@ -517,7 +520,7 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU
|
|||
}
|
||||
return domain.Channel{}, false, fmt.Errorf("resolve public channel username channel: %w", err)
|
||||
}
|
||||
if !publicPreviewableChannel(ch) {
|
||||
if !publicPreviewableChannel(ch, true) {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
// A collectible row is authoritative for its own name: the channel's scalar
|
||||
|
|
@ -527,9 +530,6 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU
|
|||
if !owner.collectible && !strings.EqualFold(ch.Username, usernameLower) {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
if owner.collectible && !owner.active {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
return ch, true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -90,6 +91,164 @@ func registryRows(t *testing.T, pool *pgxpool.Pool, peer domain.Peer) []domain.U
|
|||
return list
|
||||
}
|
||||
|
||||
func TestCollectibleUsernameResolveAndSearchUseActiveRegistry(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
viewer := collectibleTestUser(t, pool, 3_100_000_000+seed, "")
|
||||
userPeer := collectibleTestUser(t, pool, 3_200_000_000+seed, "")
|
||||
userEditable := fmt.Sprintf("uedit%d", seed)
|
||||
userCollectible := fmt.Sprintf("unft%d", seed)
|
||||
setEditableUsername(t, pool, userPeer, userEditable)
|
||||
cleanupCollectible(t, pool, lowerASCII(userCollectible))
|
||||
|
||||
registry := NewCollectibleUsernameStore(pool)
|
||||
if _, created, err := registry.MintCollectibleUsername(ctx, mintRequest(userCollectible, userPeer, "")); err != nil || !created {
|
||||
t.Fatalf("mint user collectible: created=%v err=%v", created, err)
|
||||
}
|
||||
users := NewUserStore(pool)
|
||||
resolvedUser, found, err := users.ByUsername(ctx, userCollectible)
|
||||
if err != nil || !found || resolvedUser.ID != userPeer.ID {
|
||||
t.Fatalf("resolve user collectible = %+v found=%v err=%v", resolvedUser, found, err)
|
||||
}
|
||||
userSearch, err := users.Search(ctx, viewer.ID, userCollectible, "", 10)
|
||||
if err != nil || len(userSearch.Results) != 1 || userSearch.Results[0].ID != userPeer.ID {
|
||||
t.Fatalf("search user collectible = %+v err=%v", userSearch, err)
|
||||
}
|
||||
if changed, err := registry.SetUsernameActive(ctx, userPeer, userCollectible, false); err != nil || !changed {
|
||||
t.Fatalf("deactivate user collectible: changed=%v err=%v", changed, err)
|
||||
}
|
||||
if _, found, err := users.ByUsername(ctx, userCollectible); err != nil || found {
|
||||
t.Fatalf("resolve inactive user collectible found=%v err=%v", found, err)
|
||||
}
|
||||
if hidden, err := users.Search(ctx, viewer.ID, userCollectible, "", 10); err != nil || len(hidden.Results)+len(hidden.MyResults) != 0 {
|
||||
t.Fatalf("search inactive user collectible = %+v err=%v", hidden, err)
|
||||
}
|
||||
|
||||
channels := NewChannelStore(pool)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: userPeer.ID,
|
||||
Title: "Unrelated collectible channel",
|
||||
Broadcast: true,
|
||||
Date: int(time.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM channels WHERE id = $1`, channelPeer.ID)
|
||||
})
|
||||
channelEditable := fmt.Sprintf("cedit%d", seed)
|
||||
if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
|
||||
UserID: userPeer.ID,
|
||||
ChannelID: channelPeer.ID,
|
||||
Username: channelEditable,
|
||||
}); err != nil {
|
||||
t.Fatalf("set channel editable username: %v", err)
|
||||
}
|
||||
channelCollectible := fmt.Sprintf("cnft%d", seed)
|
||||
cleanupCollectible(t, pool, lowerASCII(channelCollectible))
|
||||
if _, created, err := registry.MintCollectibleUsername(ctx, mintRequest(channelCollectible, channelPeer, "")); err != nil || !created {
|
||||
t.Fatalf("mint channel collectible: created=%v err=%v", created, err)
|
||||
}
|
||||
resolvedChannel, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible)
|
||||
if err != nil || !found || resolvedChannel.ID != channelPeer.ID {
|
||||
t.Fatalf("resolve channel collectible = %+v found=%v err=%v", resolvedChannel, found, err)
|
||||
}
|
||||
channelSearch, err := channels.SearchPublicChannels(ctx, viewer.ID, channelCollectible, 10)
|
||||
if err != nil || len(channelSearch.Results) != 1 || channelSearch.Results[0].ID != channelPeer.ID {
|
||||
t.Fatalf("search channel collectible = %+v err=%v", channelSearch, err)
|
||||
}
|
||||
if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
|
||||
UserID: userPeer.ID,
|
||||
ChannelID: channelPeer.ID,
|
||||
Username: "",
|
||||
}); err != nil {
|
||||
t.Fatalf("clear channel editable username: %v", err)
|
||||
}
|
||||
if nftOnly, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible); err != nil || !found || nftOnly.ID != channelPeer.ID {
|
||||
t.Fatalf("resolve NFT-only channel = %+v found=%v err=%v", nftOnly, found, err)
|
||||
}
|
||||
if view, err := channels.GetChannel(ctx, viewer.ID, channelPeer.ID); err != nil || view.Channel.ID != channelPeer.ID {
|
||||
t.Fatalf("preview NFT-only channel = %+v err=%v", view, err)
|
||||
}
|
||||
if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
|
||||
UserID: userPeer.ID,
|
||||
ChannelID: channelPeer.ID,
|
||||
Username: channelEditable,
|
||||
}); err != nil {
|
||||
t.Fatalf("restore channel editable username: %v", err)
|
||||
}
|
||||
if changed, err := registry.SetUsernameActive(ctx, channelPeer, channelCollectible, false); err != nil || !changed {
|
||||
t.Fatalf("deactivate channel collectible: changed=%v err=%v", changed, err)
|
||||
}
|
||||
if _, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, channelCollectible); err != nil || found {
|
||||
t.Fatalf("resolve inactive channel collectible found=%v err=%v", found, err)
|
||||
}
|
||||
if hidden, err := channels.SearchPublicChannels(ctx, viewer.ID, channelCollectible, 10); err != nil || len(hidden.Results) != 0 {
|
||||
t.Fatalf("search inactive channel collectible = %+v err=%v", hidden, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleUsernameSearchPrefixUsesActiveRegistryIndex(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
WITH names AS (
|
||||
SELECT
|
||||
CASE WHEN n = 1 THEN 'nftplanfixture' ELSE 'otherplanfixture' || n::text END AS username,
|
||||
9100000000 + n AS owner_peer_id
|
||||
FROM generate_series(1, 5000) AS n
|
||||
),
|
||||
assets AS (
|
||||
INSERT INTO collectible_usernames (
|
||||
username, username_lower, status, owner_peer_type, owner_peer_id,
|
||||
purchase_date, currency, amount,
|
||||
original_owner_peer_type, original_owner_peer_id,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
username, username, 'owned', 'user', owner_peer_id,
|
||||
now(), 'XTR', 1,
|
||||
'user', owner_peer_id,
|
||||
now(), now()
|
||||
FROM names
|
||||
RETURNING id, username, username_lower, owner_peer_id
|
||||
)
|
||||
INSERT INTO peer_usernames (
|
||||
username_lower, username, peer_type, peer_id,
|
||||
active, editable, sort_order, collectible_id
|
||||
)
|
||||
SELECT
|
||||
username_lower, username, 'user', owner_peer_id,
|
||||
true, false, 0, id
|
||||
FROM assets`); err != nil {
|
||||
t.Fatalf("seed username plan fixture: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "ANALYZE peer_usernames"); err != nil {
|
||||
t.Fatalf("analyze username plan fixture: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil {
|
||||
t.Fatalf("disable seqscan: %v", err)
|
||||
}
|
||||
plan := explainText(t, ctx, tx, `
|
||||
SELECT peer_id
|
||||
FROM peer_usernames
|
||||
WHERE peer_type = 'user'
|
||||
AND active
|
||||
AND collectible_id IS NOT NULL
|
||||
AND username_lower LIKE $1 || '%' ESCAPE '\'`, "nft")
|
||||
if !strings.Contains(plan, "peer_usernames_active_search_idx") {
|
||||
t.Fatalf("active username prefix plan = %s, want peer_usernames_active_search_idx", plan)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectibleUsernameMintIntoVault covers a vault mint: the asset exists, the
|
||||
// name is not projected into any peer's registry, and the provenance log records
|
||||
// the mint.
|
||||
|
|
|
|||
|
|
@ -69,6 +69,39 @@ func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower,
|
|||
return owner.matches(peerType, peerID), nil
|
||||
}
|
||||
|
||||
// activeCollectibleUsernamePeerIDs returns the requested peers that own at
|
||||
// least one active collectible username. Editable registry rows are excluded:
|
||||
// their scalar users.username/channels.username value is the cross-check that
|
||||
// prevents a stale registry row from making a private peer public.
|
||||
func activeCollectibleUsernamePeerIDs(ctx context.Context, db sqlcgen.DBTX, peerType string, peerIDs []int64) (map[int64]struct{}, error) {
|
||||
out := make(map[int64]struct{})
|
||||
if len(peerIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT DISTINCT peer_id
|
||||
FROM peer_usernames
|
||||
WHERE peer_type = $1
|
||||
AND active
|
||||
AND collectible_id IS NOT NULL
|
||||
AND peer_id = ANY($2::bigint[])`, peerType, peerIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list peers with active usernames: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var peerID int64
|
||||
if err := rows.Scan(&peerID); err != nil {
|
||||
return nil, fmt.Errorf("scan peer with active username: %w", err)
|
||||
}
|
||||
out[peerID] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list peers with active usernames: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// replacePeerUsernameTx rewrites the peer's editable username slot. username is
|
||||
// the display form (original case) and usernameLower its registry key; an empty
|
||||
// pair clears the slot.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,18 @@ ORDER BY id;
|
|||
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL;
|
||||
|
||||
-- name: SearchUsers :many
|
||||
WITH matched AS (
|
||||
WITH username_matches AS (
|
||||
SELECT
|
||||
peer_id,
|
||||
bool_or(username_lower = sqlc.arg(query_lower)::text) AS exact
|
||||
FROM peer_usernames
|
||||
WHERE peer_type = 'user'
|
||||
AND active
|
||||
AND collectible_id IS NOT NULL
|
||||
AND username_lower LIKE sqlc.arg(query_like)::text || '%' ESCAPE '\'
|
||||
GROUP BY peer_id
|
||||
),
|
||||
matched AS (
|
||||
SELECT
|
||||
u.id,
|
||||
u.access_hash,
|
||||
|
|
@ -51,7 +62,7 @@ WITH matched AS (
|
|||
COALESCE(c.mutual, false)::boolean AS mutual,
|
||||
CASE
|
||||
WHEN sqlc.arg(phone_query)::text <> '' AND u.phone = sqlc.arg(phone_query)::text THEN 0
|
||||
WHEN lower(u.username) = sqlc.arg(query_lower)::text THEN 1
|
||||
WHEN COALESCE(um.exact, false) OR lower(u.username) = sqlc.arg(query_lower)::text THEN 1
|
||||
WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = sqlc.arg(query_lower)::text THEN 2
|
||||
WHEN lower(u.first_name) = sqlc.arg(query_lower)::text THEN 3
|
||||
WHEN c.contact_user_id IS NOT NULL THEN 4
|
||||
|
|
@ -59,11 +70,13 @@ WITH matched AS (
|
|||
END AS rank
|
||||
FROM users u
|
||||
LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id
|
||||
LEFT JOIN username_matches um ON um.peer_id = u.id
|
||||
WHERE u.id <> sqlc.arg(current_user_id)::bigint
|
||||
AND u.deleted_at IS NULL
|
||||
AND sqlc.arg(query_lower)::text <> ''
|
||||
AND (
|
||||
(sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%')
|
||||
OR um.peer_id IS NOT NULL
|
||||
OR lower(u.username) LIKE sqlc.arg(query_like)::text || '%' ESCAPE '\'
|
||||
OR lower(u.first_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
|
||||
OR lower(u.last_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\'
|
||||
|
|
|
|||
|
|
@ -364,7 +364,18 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
|
|||
}
|
||||
|
||||
const searchUsers = `-- name: SearchUsers :many
|
||||
WITH matched AS (
|
||||
WITH username_matches AS (
|
||||
SELECT
|
||||
peer_id,
|
||||
bool_or(username_lower = $2::text) AS exact
|
||||
FROM peer_usernames
|
||||
WHERE peer_type = 'user'
|
||||
AND active
|
||||
AND collectible_id IS NOT NULL
|
||||
AND username_lower LIKE $3::text || '%' ESCAPE '\'
|
||||
GROUP BY peer_id
|
||||
),
|
||||
matched AS (
|
||||
SELECT
|
||||
u.id,
|
||||
u.access_hash,
|
||||
|
|
@ -394,27 +405,29 @@ WITH matched AS (
|
|||
(c.contact_user_id IS NOT NULL)::boolean AS contact,
|
||||
COALESCE(c.mutual, false)::boolean AS mutual,
|
||||
CASE
|
||||
WHEN $2::text <> '' AND u.phone = $2::text THEN 0
|
||||
WHEN lower(u.username) = $3::text THEN 1
|
||||
WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = $3::text THEN 2
|
||||
WHEN lower(u.first_name) = $3::text THEN 3
|
||||
WHEN $4::text <> '' AND u.phone = $4::text THEN 0
|
||||
WHEN COALESCE(um.exact, false) OR lower(u.username) = $2::text THEN 1
|
||||
WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = $2::text THEN 2
|
||||
WHEN lower(u.first_name) = $2::text THEN 3
|
||||
WHEN c.contact_user_id IS NOT NULL THEN 4
|
||||
ELSE 5
|
||||
END AS rank
|
||||
FROM users u
|
||||
LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id
|
||||
WHERE u.id <> $4::bigint
|
||||
LEFT JOIN contacts c ON c.user_id = $5::bigint AND c.contact_user_id = u.id
|
||||
LEFT JOIN username_matches um ON um.peer_id = u.id
|
||||
WHERE u.id <> $5::bigint
|
||||
AND u.deleted_at IS NULL
|
||||
AND $3::text <> ''
|
||||
AND $2::text <> ''
|
||||
AND (
|
||||
($2::text <> '' AND u.phone LIKE $2::text || '%')
|
||||
OR lower(u.username) LIKE $5::text || '%' ESCAPE '\'
|
||||
OR lower(u.first_name) LIKE '%' || $5::text || '%' ESCAPE '\'
|
||||
OR lower(u.last_name) LIKE '%' || $5::text || '%' ESCAPE '\'
|
||||
OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || $5::text || '%' ESCAPE '\'
|
||||
OR lower(c.contact_first_name) LIKE '%' || $5::text || '%' ESCAPE '\'
|
||||
OR lower(c.contact_last_name) LIKE '%' || $5::text || '%' ESCAPE '\'
|
||||
OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || $5::text || '%' ESCAPE '\'
|
||||
($4::text <> '' AND u.phone LIKE $4::text || '%')
|
||||
OR um.peer_id IS NOT NULL
|
||||
OR lower(u.username) LIKE $3::text || '%' ESCAPE '\'
|
||||
OR lower(u.first_name) LIKE '%' || $3::text || '%' ESCAPE '\'
|
||||
OR lower(u.last_name) LIKE '%' || $3::text || '%' ESCAPE '\'
|
||||
OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || $3::text || '%' ESCAPE '\'
|
||||
OR lower(c.contact_first_name) LIKE '%' || $3::text || '%' ESCAPE '\'
|
||||
OR lower(c.contact_last_name) LIKE '%' || $3::text || '%' ESCAPE '\'
|
||||
OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || $3::text || '%' ESCAPE '\'
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
|
|
@ -452,10 +465,10 @@ LIMIT $1
|
|||
|
||||
type SearchUsersParams struct {
|
||||
LimitCount int32
|
||||
PhoneQuery string
|
||||
QueryLower string
|
||||
CurrentUserID int64
|
||||
QueryLike string
|
||||
PhoneQuery string
|
||||
CurrentUserID int64
|
||||
}
|
||||
|
||||
type SearchUsersRow struct {
|
||||
|
|
@ -491,10 +504,10 @@ type SearchUsersRow struct {
|
|||
func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]SearchUsersRow, error) {
|
||||
rows, err := q.db.Query(ctx, searchUsers,
|
||||
arg.LimitCount,
|
||||
arg.PhoneQuery,
|
||||
arg.QueryLower,
|
||||
arg.CurrentUserID,
|
||||
arg.QueryLike,
|
||||
arg.PhoneQuery,
|
||||
arg.CurrentUserID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -703,15 +703,20 @@ func (h *handler) resolvePublicPeer(ctx context.Context, username string) (publi
|
|||
}
|
||||
|
||||
func (h *handler) publicUserPeer(ctx context.Context, requested string, u domain.User) (publicPeer, bool, error) {
|
||||
if u.ID == 0 || !strings.EqualFold(strings.TrimSpace(u.Username), requested) || !validUsernamePath(u.Username) {
|
||||
requested = domain.NormalizeUsername(requested)
|
||||
if u.ID == 0 || !validUsernamePath(requested) {
|
||||
return publicPeer{}, false, fmt.Errorf("user username lookup returned invalid owner for %q", requested)
|
||||
}
|
||||
canonicalUsername := requested
|
||||
if strings.EqualFold(strings.TrimSpace(u.Username), requested) && validUsernamePath(u.Username) {
|
||||
canonicalUsername = strings.TrimSpace(u.Username)
|
||||
}
|
||||
title := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||
if title == "" {
|
||||
title = u.Username
|
||||
title = canonicalUsername
|
||||
}
|
||||
if err := validatePublicPeerText(title, u.About); err != nil {
|
||||
return publicPeer{}, false, fmt.Errorf("invalid public user %q: %w", u.Username, err)
|
||||
return publicPeer{}, false, fmt.Errorf("invalid public user %q: %w", requested, err)
|
||||
}
|
||||
about := strings.TrimSpace(u.About)
|
||||
photoKind := domain.ProfilePhotoKindProfile
|
||||
|
|
@ -733,7 +738,7 @@ func (h *handler) publicUserPeer(ctx context.Context, requested string, u domain
|
|||
}
|
||||
peer := publicPeer{
|
||||
kind: publicPeerUser,
|
||||
username: u.Username,
|
||||
username: canonicalUsername,
|
||||
title: title,
|
||||
about: about,
|
||||
verified: u.Verified,
|
||||
|
|
@ -756,15 +761,20 @@ func (h *handler) publicUserPeer(ctx context.Context, requested string, u domain
|
|||
}
|
||||
|
||||
func (h *handler) publicChannelPeer(ctx context.Context, requested string, ch domain.Channel) (publicPeer, bool, error) {
|
||||
if ch.ID == 0 || ch.Deleted || ch.ParticipantsCount < 0 || (!ch.Broadcast && !ch.Megagroup) || !strings.EqualFold(strings.TrimSpace(ch.Username), requested) || !validUsernamePath(ch.Username) {
|
||||
requested = domain.NormalizeUsername(requested)
|
||||
if ch.ID == 0 || ch.Deleted || ch.ParticipantsCount < 0 || (!ch.Broadcast && !ch.Megagroup) || !validUsernamePath(requested) {
|
||||
return publicPeer{}, false, fmt.Errorf("channel username lookup returned invalid owner for %q", requested)
|
||||
}
|
||||
canonicalUsername := requested
|
||||
if strings.EqualFold(strings.TrimSpace(ch.Username), requested) && validUsernamePath(ch.Username) {
|
||||
canonicalUsername = strings.TrimSpace(ch.Username)
|
||||
}
|
||||
if err := validatePublicPeerText(ch.Title, ch.About); err != nil {
|
||||
return publicPeer{}, false, fmt.Errorf("invalid public channel %q: %w", ch.Username, err)
|
||||
return publicPeer{}, false, fmt.Errorf("invalid public channel %q: %w", requested, err)
|
||||
}
|
||||
peer := publicPeer{
|
||||
kind: publicPeerChannel,
|
||||
username: ch.Username,
|
||||
username: canonicalUsername,
|
||||
title: strings.TrimSpace(ch.Title),
|
||||
about: strings.TrimSpace(ch.About),
|
||||
verified: ch.Verified,
|
||||
|
|
@ -1044,21 +1054,7 @@ func validStarGiftSlugPath(slug string) bool {
|
|||
}
|
||||
|
||||
func validUsernamePath(username string) bool {
|
||||
if username == "" || len(username) < 5 || len(username) > 32 {
|
||||
return false
|
||||
}
|
||||
for i, r := range username {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9', r == '_':
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return domain.ValidCollectibleUsername(domain.NormalizeUsername(username))
|
||||
}
|
||||
|
||||
func linkKind(set domain.StickerSet) string {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue