fix: group moderation and profile sync

This commit is contained in:
richjohnrambo
2026-10-17 10:12:20 +08:00
parent ea29fef40f
commit 9132207e21
9 changed files with 126 additions and 19 deletions
+14 -8
View File
@@ -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
}
+1 -1
View File
@@ -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
+43 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+11
View File
@@ -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
+2 -2
View File
@@ -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)})
}
+3 -1
View File
@@ -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"}
+26 -4
View File
@@ -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"`
+24
View File
@@ -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"},