diff --git a/server/im_http.go b/server/im_http.go index 309a58a..88b0802 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1115,6 +1115,93 @@ func serveIMFriends(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) } +func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) { + topic, tail, ok := groupResourcePath(req.URL.Path) + if !ok { + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil) + return + } + if tail == "members" { + serveIMGroupMembers(wrt, req, topic) + return + } + writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil) +} + +func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic string) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + if types.GetTopicCat(topic) != types.TopicCatGrp { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) + return + } + selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + selfAccess := types.ModeNone + if selfSub != nil { + selfAccess = selfSub.ModeGiven & selfSub.ModeWant + } + if selfSub == nil || !selfAccess.IsReader() { + writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil) + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100) + cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor")) + if err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"}) + return + } + query := strings.TrimSpace(req.URL.Query().Get("query")) + mentionableOnly := parseIMBool(req.URL.Query().Get("mentionable")) + members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + items := make([]map[string]any, 0, limit) + seen := 0 + nextCursor := "" + for _, member := range members { + access := member.ModeGiven & member.ModeWant + mentionable := access.IsJoiner() && access.IsReader() + if mentionableOnly && !mentionable { + continue + } + item := groupMemberPayload(member, mentionable) + if query != "" && !groupMemberMatches(item, query) { + continue + } + if seen < cursor { + seen++ + continue + } + if len(items) == limit { + nextCursor = strconv.Itoa(seen) + break + } + items = append(items, item) + seen++ + } + permissions := map[string]any{ + "can_mention_all": selfAccess.IsAdmin(), + } + writeIMSuccess(wrt, req, map[string]any{ + "topic": topic, + "items": items, + "next_cursor": nextCursor, + "permissions": permissions, + }) +} + func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -1525,6 +1612,27 @@ func parseIMLimit(raw string, fallback, max int) int { return limit } +func parseIMNonNegativeCursor(raw string) (int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, nil + } + cursor, err := strconv.Atoi(raw) + if err != nil || cursor < 0 { + return 0, errors.New("invalid cursor") + } + return cursor, nil +} + +func parseIMBool(raw string) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "y": + return true + default: + return false + } +} + func conversationMatches(sub types.Subscription, query string) bool { if strings.Contains(strings.ToLower(sub.Topic), query) { return true @@ -1560,6 +1668,45 @@ func receiptMemberPayload(sub types.Subscription) map[string]any { } } +func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any { + access := sub.ModeGiven & sub.ModeWant + userID := types.ParseUserId(sub.User) + user := &types.User{ObjHeader: types.ObjHeader{Id: userID.String()}, Public: sub.GetPublic(), Trusted: sub.GetTrusted()} + data := userSummaryPayload(user, "") + data["role"] = groupMemberRole(access) + data["mentionable"] = mentionable + data["acs"] = map[string]any{ + "mode": access.String(), + "want": sub.ModeWant.String(), + "given": sub.ModeGiven.String(), + } + return data +} + +func groupMemberRole(access types.AccessMode) string { + switch { + case access.IsOwner(): + return "owner" + case access.IsAdmin(): + return "admin" + default: + return "member" + } +} + +func groupMemberMatches(item map[string]any, query string) bool { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return true + } + for _, key := range []string{"display_name", "user_id"} { + if value, ok := item[key]; ok && strings.Contains(strings.ToLower(fmt.Sprint(value)), query) { + return true + } + } + return false +} + func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) { if principal == nil { return nil, false, types.ErrPermissionDenied @@ -2068,6 +2215,20 @@ func conversationContextTopicFromPath(path string) string { return strings.TrimSuffix(strings.TrimPrefix(path[idx:], prefix), suffix) } +func groupResourcePath(path string) (string, string, bool) { + const prefix = "/v1/im/groups/" + idx := strings.Index(path, prefix) + if idx < 0 { + return "", "", false + } + rest := strings.TrimPrefix(path[idx:], prefix) + topic, tail, ok := strings.Cut(rest, "/") + if !ok || topic == "" || tail == "" { + return "", "", false + } + return topic, strings.Trim(tail, "/"), true +} + func friendRequestActionFromPath(path string) (string, string) { const prefix = "/v1/im/friend-requests/" idx := strings.Index(path, prefix) diff --git a/server/im_http_test.go b/server/im_http_test.go index a3a0a26..7f24a95 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -821,6 +821,53 @@ func TestServeIMMessageReceipts(t *testing.T) { } } +func TestServeIMGroupMembers(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSubs := store.Subs + oldTopics := store.Topics + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) + store.Subs = subs + store.Topics = topics + defer func() { + store.Subs = oldSubs + store.Topics = oldTopics + }() + + subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{ + TenantID: 7, + User: types.Uid(42).String(), + Topic: "grpMention", + ModeWant: types.ModeCFull, + ModeGiven: types.ModeCFull, + }, nil) + alice := types.Subscription{User: types.Uid(101).UserId(), ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic} + alice.SetPublic(map[string]any{"display_name": "Alice"}) + bob := types.Subscription{User: types.Uid(102).UserId(), ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin} + bob.SetPublic(map[string]any{"display_name": "Bob"}) + topics.EXPECT().GetUsers(types.TenantID(7), "grpMention", gomock.Any()).Return([]types.Subscription{alice, bob}, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMGroupResource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["display_name"] != "Alice" || items[0].(map[string]any)["mentionable"] != true { + t.Fatalf("unexpected members response: %#v", data) + } + if data["permissions"].(map[string]any)["can_mention_all"] != true { + t.Fatalf("expected can_mention_all permission: %#v", data) + } +} + func TestServeIMMessageRecall(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/main.go b/server/main.go index bff249e..ee7fb03 100644 --- a/server/main.go +++ b/server/main.go @@ -794,6 +794,7 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead) mux.HandleFunc(config.ApiPath+"v1/im/topics/", serveIMTopicMessageAction) mux.HandleFunc(config.ApiPath+"v1/im/conversations/", serveIMConversationContext) + mux.HandleFunc(config.ApiPath+"v1/im/groups/", serveIMGroupResource) mux.HandleFunc(config.ApiPath+"v1/im/friend-requests", serveIMFriendRequests) mux.HandleFunc(config.ApiPath+"v1/im/friend-requests/", serveIMFriendRequestAction) mux.HandleFunc(config.ApiPath+"v1/im/blocks/", serveIMBlockUser) diff --git a/server/topic.go b/server/topic.go index 33b4881..4874cda 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1052,6 +1052,14 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, return types.ErrPermissionDenied } } + if t.cat == types.TopicCatGrp && head != nil && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot { + normalized, err := t.validateAndNormalizeMentions(asUid, head) + if err != nil { + msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp)) + return err + } + head = normalized + } if t.cat == types.TopicCatP2P && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot && store.Relations != nil && store.Store != nil && store.Store.IsOpen() { peer := t.p2pOtherUser(asUid) @@ -1196,6 +1204,67 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) { } } +func (t *Topic) validateAndNormalizeMentions(asUid types.Uid, head map[string]any) (map[string]any, error) { + mentionAll, _ := head["mention_all"].(bool) + rawMentions, hasMentions := head["mentions"] + if !mentionAll && !hasMentions { + return head, nil + } + pud := t.perUser[asUid] + access := pud.modeGiven & pud.modeWant + if mentionAll && !access.IsAdmin() { + return nil, types.ErrPermissionDenied + } + validMembers := make(map[string]struct{}, len(t.perUser)) + for uid, pud := range t.perUser { + mode := pud.modeGiven & pud.modeWant + if !pud.deleted && mode.IsJoiner() && mode.IsReader() { + validMembers[uid.UserId()] = struct{}{} + } + } + normalized := make(map[string]any, len(head)) + for key, value := range head { + normalized[key] = value + } + if hasMentions { + filtered := filterValidMentions(rawMentions, validMembers) + if len(filtered) == 0 { + delete(normalized, "mentions") + } else { + normalized["mentions"] = filtered + } + } + return normalized, nil +} + +func filterValidMentions(raw any, validMembers map[string]struct{}) []map[string]any { + mentions, ok := raw.([]any) + if !ok { + if typed, ok := raw.([]map[string]any); ok { + mentions = make([]any, 0, len(typed)) + for _, item := range typed { + mentions = append(mentions, item) + } + } + } + if len(mentions) == 0 { + return nil + } + filtered := make([]map[string]any, 0, len(mentions)) + for _, item := range mentions { + mention, ok := item.(map[string]any) + if !ok { + continue + } + userID, _ := mention["user_id"].(string) + if _, ok = validMembers[userID]; !ok { + continue + } + filtered = append(filtered, mention) + } + return filtered +} + // handleNoteBroadcast fans out {note} -> {info} messages to recipients in a master topic. // This is a NON-proxy broadcast (at master topic). func (t *Topic) handleNoteBroadcast(msg *ClientComMessage) { diff --git a/server/topic_test.go b/server/topic_test.go index 72dd72e..a2d6cc4 100644 --- a/server/topic_test.go +++ b/server/topic_test.go @@ -761,6 +761,73 @@ func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) { } } +func TestHandleBroadcastDataRejectsMentionAllByMember(t *testing.T) { + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, "grp-test", true) + defer helper.tearDown() + pud := helper.topic.perUser[helper.uids[0]] + pud.modeWant = types.ModeCPublic + pud.modeGiven = types.ModeCPublic + helper.topic.perUser[helper.uids[0]] = pud + + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + Id: "mention-all-1", + AsUser: from, + Pub: &MsgClientPub{ + Topic: "grp-test", + Head: map[string]any{"mention_all": true}, + Content: "hello all", + }, + sess: helper.sessions[0], + } + + helper.topic.handleClientMsg(msg) + helper.finish() + + verifyResponseCodes(helper.results[0], []int{http.StatusForbidden}, t) + if helper.topic.lastID != 0 { + t.Fatalf("Topic.lastID: expected 0, got %d", helper.topic.lastID) + } +} + +func TestHandleBroadcastDataFiltersInvalidMentions(t *testing.T) { + numUsers := 2 + helper := TopicTestHelper{} + helper.setUp(t, numUsers, types.TopicCatGrp, "grp-test", true) + defer helper.tearDown() + helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { + mentions, ok := msg.Head["mentions"].([]map[string]any) + if !ok || len(mentions) != 1 || mentions[0]["user_id"] != helper.uids[1].UserId() { + t.Fatalf("unexpected normalized mentions: %#v", msg.Head["mentions"]) + } + return &types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil + }) + + from := helper.uids[0].UserId() + msg := &ClientComMessage{ + Id: "mention-1", + AsUser: from, + Pub: &MsgClientPub{ + Topic: "grp-test", + Head: map[string]any{"mentions": []any{ + map[string]any{"user_id": helper.uids[1].UserId(), "offset": 0, "length": 2}, + map[string]any{"user_id": types.Uid(99).UserId(), "offset": 3, "length": 2}, + }}, + Content: "hi", + NoEcho: true, + }, + sess: helper.sessions[0], + } + + helper.topic.handleClientMsg(msg) + helper.finish() + + verifyResponseCodes(helper.results[0], []int{http.StatusAccepted}, t) +} + func TestHandleBroadcastDataInactiveTopic(t *testing.T) { numUsers := 2 helper := TopicTestHelper{}