From 6d34843fd0b0e160aed62391647c7b3007132484 Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 9 Sep 2026 11:15:56 +0100 Subject: [PATCH] forum: let non-members browse a public forum's topic list ListForumTopics / GetForumTopicsByID / GeneralForumTopic gated on membership while channel history uses the public-preview path, so a public forum's topics (General included) were invisible until you joined. Switch them to getChannelForViewer / channelForViewerLocked; private forums and write paths keep the membership gate. --- internal/rpc/forum_topics_preview_rpc_test.go | 98 +++++++++++++++++++ internal/store/memory/channel_topic_read.go | 2 +- internal/store/memory/channel_topics.go | 6 +- internal/store/postgres/channel_topic_read.go | 2 +- internal/store/postgres/channel_topics.go | 6 +- 5 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 internal/rpc/forum_topics_preview_rpc_test.go diff --git a/internal/rpc/forum_topics_preview_rpc_test.go b/internal/rpc/forum_topics_preview_rpc_test.go new file mode 100644 index 00000000..d40f3d47 --- /dev/null +++ b/internal/rpc/forum_topics_preview_rpc_test.go @@ -0,0 +1,98 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// A public forum's topic list is browsable before joining, like its history. +// Regression: getForumTopics used the member-only access path and returned +// CHANNEL_PRIVATE / an empty list to non-members, so the topic list (and even +// General) was invisible until they joined. +func TestGetForumTopicsVisibleToPublicNonMember(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 81, Phone: "15550008101", FirstName: "Owner"}) + outsider, _ := userStore.Create(ctx, domain.User{AccessHash: 82, Phone: "15550008102", FirstName: "Outsider"}) + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + }, zaptest.NewLogger(t), clock.System) + + ownerCtx := WithUserID(ctx, owner.ID) + outsiderCtx := WithUserID(ctx, outsider.ID) + + created, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{Title: "Public Forum", Megagroup: true}) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + forumPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + + if _, err := r.onChannelsToggleForum(ownerCtx, &tg.ChannelsToggleForumRequest{Channel: input, Enabled: true, Tabs: true}); err != nil { + t.Fatalf("toggle forum: %v", err) + } + if _, err := channelSvc.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: channel.ID, + Username: "publicforum", + }); err != nil { + t.Fatalf("set channel username: %v", err) + } + if _, err := r.onMessagesCreateForumTopic(ownerCtx, &tg.MessagesCreateForumTopicRequest{ + Peer: forumPeer, + Title: "Test", + IconColor: domain.DefaultForumTopicIconColor, + RandomID: 8101001, + }); err != nil { + t.Fatalf("create forum topic: %v", err) + } + + res, err := r.onMessagesGetForumTopics(outsiderCtx, &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 100, + }) + if err != nil { + t.Fatalf("getForumTopics as non-member: %v", err) + } + titles := map[string]bool{} + for _, tc := range res.Topics { + switch topic := tc.(type) { + case *tg.ForumTopic: + titles[topic.Title] = true + case *tg.ForumTopicDeleted: + } + } + if !titles["General"] { + t.Fatalf("non-member did not see the General topic: %+v", res.Topics) + } + if !titles["Test"] { + t.Fatalf("non-member did not see the Test topic: %+v", res.Topics) + } + + // A private forum still refuses a non-member. + priv, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{Title: "Private Forum", Megagroup: true}) + if err != nil { + t.Fatalf("create private channel: %v", err) + } + privCh := priv.(*tg.Updates).Chats[0].(*tg.Channel) + privInput := &tg.InputChannel{ChannelID: privCh.ID, AccessHash: privCh.AccessHash} + privPeer := &tg.InputPeerChannel{ChannelID: privCh.ID, AccessHash: privCh.AccessHash} + if _, err := r.onChannelsToggleForum(ownerCtx, &tg.ChannelsToggleForumRequest{Channel: privInput, Enabled: true, Tabs: true}); err != nil { + t.Fatalf("toggle private forum: %v", err) + } + if _, err := r.onMessagesGetForumTopics(outsiderCtx, &tg.MessagesGetForumTopicsRequest{Peer: privPeer, Limit: 100}); err == nil { + t.Fatal("non-member read a private forum's topic list") + } +} diff --git a/internal/store/memory/channel_topic_read.go b/internal/store/memory/channel_topic_read.go index f522324d..5007c74b 100644 --- a/internal/store/memory/channel_topic_read.go +++ b/internal/store/memory/channel_topic_read.go @@ -178,7 +178,7 @@ func (s *ChannelStore) GeneralForumTopic(_ context.Context, viewerUserID, channe } s.mu.RLock() defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID) if err != nil { return domain.ChannelForumTopic{}, err } diff --git a/internal/store/memory/channel_topics.go b/internal/store/memory/channel_topics.go index 99bd8eef..f8f76d41 100644 --- a/internal/store/memory/channel_topics.go +++ b/internal/store/memory/channel_topics.go @@ -420,7 +420,9 @@ func (s *ChannelStore) DeleteForumTopicHistory(_ context.Context, req domain.Del func (s *ChannelStore) ListForumTopics(_ context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) { s.mu.RLock() defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) + // channelForViewerLocked, not channelAndMemberLocked: a public forum's topic + // list is browsable before joining, exactly like its message history. + channel, member, _, err := s.channelForViewerLocked(viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelForumTopicList{}, err } @@ -463,7 +465,7 @@ func (s *ChannelStore) ListForumTopics(_ context.Context, viewerUserID int64, fi func (s *ChannelStore) GetForumTopicsByID(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { s.mu.RLock() defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID) if err != nil { return domain.ChannelForumTopicList{}, err } diff --git a/internal/store/postgres/channel_topic_read.go b/internal/store/postgres/channel_topic_read.go index f65a031c..b1d558fa 100644 --- a/internal/store/postgres/channel_topic_read.go +++ b/internal/store/postgres/channel_topic_read.go @@ -198,7 +198,7 @@ func (s *ChannelStore) GeneralForumTopic(ctx context.Context, viewerUserID, chan if viewerUserID == 0 || channelID == 0 { return domain.ChannelForumTopic{}, domain.ErrChannelInvalid } - channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.ChannelForumTopic{}, err } diff --git a/internal/store/postgres/channel_topics.go b/internal/store/postgres/channel_topics.go index 53426b81..92297ab2 100644 --- a/internal/store/postgres/channel_topics.go +++ b/internal/store/postgres/channel_topics.go @@ -470,7 +470,9 @@ WHERE channel_id = $1 AND topic_id = $2`, req.ChannelID, req.TopicID); err != ni } func (s *ChannelStore) ListForumTopics(ctx context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) { - channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) + // getChannelForViewer, not getChannelForMember: a public forum's topic list is + // browsable before joining, exactly like its message history. + channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelForumTopicList{}, err } @@ -539,7 +541,7 @@ LIMIT $`+fmt.Sprint(len(args)), args...) } func (s *ChannelStore) GetForumTopicsByID(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { - channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.ChannelForumTopicList{}, err }