diff --git a/server/db/mysql/tenant_groups.go b/server/db/mysql/tenant_groups.go index c26d362..f84b9f2 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -1555,27 +1555,33 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous return tx.Commit() } -func (a *adapter) TenantGroupCanPost(id t.TenantID, topic string, uid t.Uid, admin bool, now time.Time) (bool, error) { +func (a *adapter) TenantGroupCanPost(id t.TenantID, topic string, uid t.Uid, _ bool, now time.Time) (bool, error) { if tenantAdp, err := a.routeGroupTenant(id); err != nil { return false, err } else if tenantAdp != a { - return tenantAdp.TenantGroupCanPost(id, topic, uid, admin, now) - } - if admin { - return true, nil + return tenantAdp.TenantGroupCanPost(id, topic, uid, false, now) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } + var role string var muteAll, memberMuted bool - err := a.db.QueryRowContext(ctx, `SELECT + err := a.db.QueryRowContext(ctx, tenantShardSQL(id, `SELECT + COALESCE((SELECT role FROM im_group_members WHERE tenant_id=? AND topic=? AND user_id=? AND member_status=? LIMIT 1),''), COALESCE((SELECT mute_all FROM im_group_settings WHERE tenant_id=? AND topic=? AND group_status=? LIMIT 1),0), - EXISTS(SELECT 1 FROM im_group_mutes WHERE tenant_id=? AND topic=? AND user_id=? AND status=? AND (expires_at IS NULL OR expires_at>?))`, - id, topic, t.GroupStatusActive, id, topic, decodeUID(uid), t.GroupMuteStatusActive, now).Scan(&muteAll, &memberMuted) + EXISTS(SELECT 1 FROM im_group_mutes WHERE tenant_id=? AND topic=? AND user_id=? AND status=? AND (expires_at IS NULL OR expires_at>?))`), + id, topic, decodeUID(uid), t.GroupMemberStatusActive, + id, topic, t.GroupStatusActive, id, topic, decodeUID(uid), t.GroupMuteStatusActive, now).Scan(&role, &muteAll, &memberMuted) if err != nil { return false, err } + if role == t.GroupMemberRoleOwner || role == t.GroupMemberRoleAdmin { + return true, nil + } + if role != t.GroupMemberRoleMember { + return false, nil + } return !muteAll && !memberMuted, nil } diff --git a/server/hdl_files.go b/server/hdl_files.go index b305f8c..455fa85 100644 --- a/server/hdl_files.go +++ b/server/hdl_files.go @@ -203,7 +203,7 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) { // Preflight request: process before any security checks. if req.Method == http.MethodOptions { - headers, statusCode, err := mh.Headers(types.ZeroTenantID, req.Method, req.URL, req.Header, true) + headers, statusCode, err := mh.Headers(types.ZeroTenantID, req.Method, req.URL, req.Header, false) if err != nil { writeHttpResponse(decodeStoreError(err, "", now, nil), err) return diff --git a/server/hdl_files_test.go b/server/hdl_files_test.go index 0983d32..22f7d24 100644 --- a/server/hdl_files_test.go +++ b/server/hdl_files_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" @@ -23,11 +24,16 @@ type testLargeFileMediaHandler struct { data string fid types.Uid headersCalled bool + corsOrigins []media.AllowedOrigin } func (h *testLargeFileMediaHandler) Init(string) error { return nil } -func (h *testLargeFileMediaHandler) Headers(types.TenantID, string, *url.URL, http.Header, bool) (http.Header, int, error) { +func (h *testLargeFileMediaHandler) Headers(_ types.TenantID, method string, _ *url.URL, headers http.Header, serve bool) (http.Header, int, error) { h.headersCalled = true + if h.corsOrigins != nil { + response, status := media.CORSHandler(method, headers, h.corsOrigins, serve) + return response, status, nil + } return nil, 0, nil } func (h *testLargeFileMediaHandler) Upload(def *types.FileDef, file io.Reader) (string, int64, error) { @@ -46,6 +52,42 @@ func (h *testLargeFileMediaHandler) GetIdFromUrl(string) types.Uid { return h.fid } +func TestLargeFileUploadPreflightAllowsPost(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + oldStore := store.Store + defer func() { store.Store = oldStore }() + + origins, err := media.ParseCORSAllow([]string{"https://web1.118im.com"}) + if err != nil { + t.Fatal(err) + } + handler := &testLargeFileMediaHandler{corsOrigins: origins} + mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) + mockStore.EXPECT().GetMediaHandler().Return(handler) + store.Store = mockStore + + req := httptest.NewRequest(http.MethodOptions, "/v0/file/u/", nil) + req.Header.Set("Origin", "https://web1.118im.com") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + req.Header.Set("Access-Control-Request-Headers", "authorization,x-meetingim-apikey") + wrt := httptest.NewRecorder() + largeFileReceiveHTTP(wrt, req) + + if wrt.Code != http.StatusNoContent || !handler.headersCalled { + t.Fatalf("preflight response: status=%d headers_called=%t", wrt.Code, handler.headersCalled) + } + if got := wrt.Header().Get("Access-Control-Allow-Origin"); got != "https://web1.118im.com" { + t.Fatalf("allowed origin = %q", got) + } + if got := wrt.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(got, http.MethodPost) { + t.Fatalf("POST missing from allowed methods: %q", got) + } + if got := wrt.Header().Get("Access-Control-Allow-Headers"); got != "authorization,x-meetingim-apikey" { + t.Fatalf("requested headers not allowed explicitly: %q", got) + } +} + func TestAuthFileRequestFromSessionRequiresTenant(t *testing.T) { oldSessionStore := globals.sessionStore globals.sessionStore = NewSessionStore(time.Minute) diff --git a/server/im_group_events.go b/server/im_group_events.go index d1cca39..ddf493f 100644 --- a/server/im_group_events.go +++ b/server/im_group_events.go @@ -162,7 +162,7 @@ func enrichIMGroupMemberEventPayload(eventType string, payload any) any { switch eventType { case "group.member.left": copyPayload["member_sync"] = "remove" - case "group.member.joined", "group.member.updated", "group.role.updated", "group.mute.updated": + case "group.member.joined", "group.member.updated", "group.member.profile.updated", "group.role.updated", "group.mute.updated": if _, ok := copyPayload["user_id"]; ok { copyPayload["member_sync"] = "single" } else { @@ -171,7 +171,7 @@ func enrichIMGroupMemberEventPayload(eventType string, payload any) any { } if isAdminOnlyIMGroupEvent(eventType) { copyPayload["visibility"] = "admins_and_affected" - } else if eventType == "group.member.joined" { + } else if eventType == "group.member.joined" || eventType == "group.member.profile.updated" { copyPayload["visibility"] = "all_members" } return copyPayload diff --git a/server/im_group_events_test.go b/server/im_group_events_test.go index 067b14d..38e2534 100644 --- a/server/im_group_events_test.go +++ b/server/im_group_events_test.go @@ -11,6 +11,17 @@ import ( "github.com/tinode/chat/server/store/types" ) +func TestGroupMemberProfileEventIsVisibleToAllMembers(t *testing.T) { + const event = "group.member.profile.updated" + if isAdminOnlyIMGroupEvent(event) { + t.Fatal("group nickname update must reach ordinary members") + } + payload := enrichIMGroupMemberEventPayload(event, map[string]any{"user_id": "usrMember"}).(map[string]any) + if payload["member_sync"] != "single" || payload["visibility"] != "all_members" { + t.Fatalf("unexpected group profile event payload: %#v", payload) + } +} + func TestPublishIMGroupRealtimeEventRestrictsMemberRemoval(t *testing.T) { oldGroupBusiness := store.GroupBusiness oldSessions := globals.sessionStore diff --git a/server/im_http.go b/server/im_http.go index 2c157e0..e7706b1 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -4892,7 +4892,7 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str if _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname}); err != nil { if errors.Is(err, types.ErrNotFound) { logs.Warn.Printf("group[%s]: member profile saved to subscription only; business member row missing for user %s", topic, principal.Uid.UserId()) - publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated", + publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.profile.updated", map[string]any{"user_id": principal.Uid.UserId()}) writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)}) return @@ -4903,7 +4903,7 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str writeIMRelationError(wrt, req, err) return } - publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.updated", + publishIMGroupRealtimeEvent(principal.TenantID, topic, principal.Uid, "group.member.profile.updated", map[string]any{"user_id": principal.Uid.UserId()}) writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)}) } diff --git a/server/media/media.go b/server/media/media.go index 7978b2e..d2fe429 100644 --- a/server/media/media.go +++ b/server/media/media.go @@ -298,7 +298,9 @@ func CORSHandler(method string, reqHeader http.Header, allowedOrigins []AllowedO return respHeader, http.StatusNoContent } - respHeader["Access-Control-Allow-Headers"] = []string{"*"} + if requestedHeaders := reqHeader.Get("Access-Control-Request-Headers"); requestedHeaders != "" { + respHeader["Access-Control-Allow-Headers"] = []string{requestedHeaders} + } respHeader["Access-Control-Allow-Credentials"] = []string{"true"} respHeader["Access-Control-Allow-Methods"] = []string{strings.Join(allowMethods, ", ")} respHeader["Access-Control-Max-Age"] = []string{"86400"} diff --git a/server/store/store.go b/server/store/store.go index 1701db2..d8afc0f 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -2628,15 +2628,22 @@ func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string, return nil } -func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) { +func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid types.Uid, _ bool, now time.Time) (bool, error) { if strings.TrimSpace(topic) == "" || uid.IsZero() || now.IsZero() { return false, types.ErrMalformed } - if admin { - return true, nil - } topic = strings.TrimSpace(topic) mapper := groupBusinessMapper{} + member, err := mapper.GetMember(tenantID, topic, uid) + if errors.Is(err, types.ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + if allowed, checkMute := groupMemberPostRole(member); !checkMute { + return allowed, nil + } settings, err := mapper.GetSettings(tenantID, topic) if err != nil && !errors.Is(err, types.ErrNotFound) { return false, err @@ -2655,6 +2662,21 @@ func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid ty (mute.ExpiresAt != nil && !mute.ExpiresAt.After(now)), nil } +func groupMemberPostRole(member *types.GroupMember) (allowed, checkMute bool) { + if member == nil || member.MemberStatus != types.GroupMemberStatusActive { + return false, false + } + // The loaded Topic access snapshot can lag behind a role change. + switch member.Role { + case types.GroupMemberRoleOwner, types.GroupMemberRoleAdmin: + return true, false + case types.GroupMemberRoleMember: + return false, true + default: + return false, false + } +} + type groupMuteCacheValue struct { Found bool `json:"found"` Mute *types.GroupMute `json:"mute,omitempty"` diff --git a/server/store/tenant_store_test.go b/server/store/tenant_store_test.go index 2597ce9..29f6921 100644 --- a/server/store/tenant_store_test.go +++ b/server/store/tenant_store_test.go @@ -15,6 +15,30 @@ func TestForTenantRejectsZero(t *testing.T) { } } +func TestGroupMemberPostRoleUsesCurrentBusinessRole(t *testing.T) { + tests := []struct { + name string + member *types.GroupMember + allowed bool + checkMute bool + }{ + {name: "missing member"}, + {name: "ordinary member", member: &types.GroupMember{Role: types.GroupMemberRoleMember, MemberStatus: types.GroupMemberStatusActive}, checkMute: true}, + {name: "administrator", member: &types.GroupMember{Role: types.GroupMemberRoleAdmin, MemberStatus: types.GroupMemberStatusActive}, allowed: true}, + {name: "owner", member: &types.GroupMember{Role: types.GroupMemberRoleOwner, MemberStatus: types.GroupMemberStatusActive}, allowed: true}, + {name: "departed administrator", member: &types.GroupMember{Role: types.GroupMemberRoleAdmin, MemberStatus: types.GroupMemberStatusLeft}}, + {name: "unknown role", member: &types.GroupMember{Role: "unknown", MemberStatus: types.GroupMemberStatusActive}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + allowed, checkMute := groupMemberPostRole(test.member) + if allowed != test.allowed || checkMute != test.checkMute { + t.Fatalf("got allowed=%t checkMute=%t, want allowed=%t checkMute=%t", allowed, checkMute, test.allowed, test.checkMute) + } + }) + } +} + func TestValidateMessageHeadersProductContract(t *testing.T) { valid := []types.KVMap{ {"x-im-schema": float64(1), "x-im-type": "text"},