fix: group moderation and profile sync
This commit is contained in:
@@ -1555,27 +1555,33 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous
|
|||||||
return tx.Commit()
|
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 {
|
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
} else if tenantAdp != a {
|
} else if tenantAdp != a {
|
||||||
return tenantAdp.TenantGroupCanPost(id, topic, uid, admin, now)
|
return tenantAdp.TenantGroupCanPost(id, topic, uid, false, now)
|
||||||
}
|
|
||||||
if admin {
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
ctx, cancel := a.getContext()
|
ctx, cancel := a.getContext()
|
||||||
if cancel != nil {
|
if cancel != nil {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
|
var role string
|
||||||
var muteAll, memberMuted bool
|
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),
|
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>?))`,
|
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)
|
id, topic, decodeUID(uid), t.GroupMemberStatusActive,
|
||||||
|
id, topic, t.GroupStatusActive, id, topic, decodeUID(uid), t.GroupMuteStatusActive, now).Scan(&role, &muteAll, &memberMuted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if role == t.GroupMemberRoleOwner || role == t.GroupMemberRoleAdmin {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if role != t.GroupMemberRoleMember {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
return !muteAll && !memberMuted, nil
|
return !muteAll && !memberMuted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -203,7 +203,7 @@ func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
// Preflight request: process before any security checks.
|
// Preflight request: process before any security checks.
|
||||||
if req.Method == http.MethodOptions {
|
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 {
|
if err != nil {
|
||||||
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
|
writeHttpResponse(decodeStoreError(err, "", now, nil), err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,11 +24,16 @@ type testLargeFileMediaHandler struct {
|
|||||||
data string
|
data string
|
||||||
fid types.Uid
|
fid types.Uid
|
||||||
headersCalled bool
|
headersCalled bool
|
||||||
|
corsOrigins []media.AllowedOrigin
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *testLargeFileMediaHandler) Init(string) error { return nil }
|
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
|
h.headersCalled = true
|
||||||
|
if h.corsOrigins != nil {
|
||||||
|
response, status := media.CORSHandler(method, headers, h.corsOrigins, serve)
|
||||||
|
return response, status, nil
|
||||||
|
}
|
||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
func (h *testLargeFileMediaHandler) Upload(def *types.FileDef, file io.Reader) (string, int64, error) {
|
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
|
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) {
|
func TestAuthFileRequestFromSessionRequiresTenant(t *testing.T) {
|
||||||
oldSessionStore := globals.sessionStore
|
oldSessionStore := globals.sessionStore
|
||||||
globals.sessionStore = NewSessionStore(time.Minute)
|
globals.sessionStore = NewSessionStore(time.Minute)
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ func enrichIMGroupMemberEventPayload(eventType string, payload any) any {
|
|||||||
switch eventType {
|
switch eventType {
|
||||||
case "group.member.left":
|
case "group.member.left":
|
||||||
copyPayload["member_sync"] = "remove"
|
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 {
|
if _, ok := copyPayload["user_id"]; ok {
|
||||||
copyPayload["member_sync"] = "single"
|
copyPayload["member_sync"] = "single"
|
||||||
} else {
|
} else {
|
||||||
@@ -171,7 +171,7 @@ func enrichIMGroupMemberEventPayload(eventType string, payload any) any {
|
|||||||
}
|
}
|
||||||
if isAdminOnlyIMGroupEvent(eventType) {
|
if isAdminOnlyIMGroupEvent(eventType) {
|
||||||
copyPayload["visibility"] = "admins_and_affected"
|
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"
|
copyPayload["visibility"] = "all_members"
|
||||||
}
|
}
|
||||||
return copyPayload
|
return copyPayload
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ import (
|
|||||||
"github.com/tinode/chat/server/store/types"
|
"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) {
|
func TestPublishIMGroupRealtimeEventRestrictsMemberRemoval(t *testing.T) {
|
||||||
oldGroupBusiness := store.GroupBusiness
|
oldGroupBusiness := store.GroupBusiness
|
||||||
oldSessions := globals.sessionStore
|
oldSessions := globals.sessionStore
|
||||||
|
|||||||
+2
-2
@@ -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 _, err := store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname}); err != nil {
|
||||||
if errors.Is(err, types.ErrNotFound) {
|
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())
|
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()})
|
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)})
|
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
|
||||||
return
|
return
|
||||||
@@ -4903,7 +4903,7 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
|
|||||||
writeIMRelationError(wrt, req, err)
|
writeIMRelationError(wrt, req, err)
|
||||||
return
|
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()})
|
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)})
|
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,7 +298,9 @@ func CORSHandler(method string, reqHeader http.Header, allowedOrigins []AllowedO
|
|||||||
return respHeader, http.StatusNoContent
|
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-Credentials"] = []string{"true"}
|
||||||
respHeader["Access-Control-Allow-Methods"] = []string{strings.Join(allowMethods, ", ")}
|
respHeader["Access-Control-Allow-Methods"] = []string{strings.Join(allowMethods, ", ")}
|
||||||
respHeader["Access-Control-Max-Age"] = []string{"86400"}
|
respHeader["Access-Control-Max-Age"] = []string{"86400"}
|
||||||
|
|||||||
+26
-4
@@ -2628,15 +2628,22 @@ func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string,
|
|||||||
return nil
|
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() {
|
if strings.TrimSpace(topic) == "" || uid.IsZero() || now.IsZero() {
|
||||||
return false, types.ErrMalformed
|
return false, types.ErrMalformed
|
||||||
}
|
}
|
||||||
if admin {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
topic = strings.TrimSpace(topic)
|
topic = strings.TrimSpace(topic)
|
||||||
mapper := groupBusinessMapper{}
|
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)
|
settings, err := mapper.GetSettings(tenantID, topic)
|
||||||
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
if err != nil && !errors.Is(err, types.ErrNotFound) {
|
||||||
return false, err
|
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
|
(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 {
|
type groupMuteCacheValue struct {
|
||||||
Found bool `json:"found"`
|
Found bool `json:"found"`
|
||||||
Mute *types.GroupMute `json:"mute,omitempty"`
|
Mute *types.GroupMute `json:"mute,omitempty"`
|
||||||
|
|||||||
@@ -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) {
|
func TestValidateMessageHeadersProductContract(t *testing.T) {
|
||||||
valid := []types.KVMap{
|
valid := []types.KVMap{
|
||||||
{"x-im-schema": float64(1), "x-im-type": "text"},
|
{"x-im-schema": float64(1), "x-im-type": "text"},
|
||||||
|
|||||||
Reference in New Issue
Block a user