// Copyright © 2023 OpenIM. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package msg import ( "context" "encoding/json" "math/rand" "regexp" "strconv" "time" "git.imall.cloud/openim/open-im-server-deploy/pkg/authverify" "git.imall.cloud/openim/open-im-server-deploy/pkg/common/servererrs" "github.com/openimsdk/tools/utils/datautil" "github.com/openimsdk/tools/utils/encrypt" "github.com/openimsdk/tools/utils/timeutil" "git.imall.cloud/openim/protocol/constant" "git.imall.cloud/openim/protocol/msg" "git.imall.cloud/openim/protocol/sdkws" "github.com/openimsdk/tools/errs" "github.com/openimsdk/tools/log" ) var ExcludeContentType = []int{constant.HasReadReceipt} // URL正则表达式,匹配常见的URL格式 // 匹配以下格式的链接: // 1. http:// 或 https:// 开头的完整URL // 2. // 开头的协议相对URL(如 //s.yam.com/MvQzr) // 3. www. 开头的链接 // 4. 直接包含域名的链接(如 s.yam.com/MvQzr、xxx.cc/csd、t.cn/AX4fYkFZ),匹配包含至少一个点的域名格式,后面可跟路径 // 域名格式:至少包含一个点和一个2位以上的顶级域名,如 xxx.com、xxx.cn、s.yam.com、xxx.cc、t.cn 等 // 注意:匹配 // 后面必须跟着域名格式,避免误匹配其他 // 开头的文本 // 修复:支持单字符域名(如 t.cn),移除了点之前必须有两个字符的限制 var urlRegex = regexp.MustCompile(`(?i)(https?://[^\s<>"{}|\\^` + "`" + `\[\]]+|//[a-zA-Z0-9][a-zA-Z0-9\-\.]*\.[a-zA-Z]{2,}(/[^\s<>"{}|\\^` + "`" + `\[\]]*)?|www\.[^\s<>"{}|\\^` + "`" + `\[\]]+|[a-zA-Z0-9][a-zA-Z0-9\-\.]*\.[a-zA-Z]{2,}(/[^\s<>"{}|\\^` + "`" + `\[\]]*)?)`) type Validator interface { validate(pb *msg.SendMsgReq) (bool, int32, string) } // TextElem 用于解析文本消息内容 type TextElem struct { Content string `json:"content"` } // AtElem 用于解析@消息内容 type AtElem struct { Text string `json:"text"` } // checkMessageContainsLink 检测消息内容中是否包含链接 // userType: 0-普通用户(不能发送链接),1-特殊用户(可以发送链接) func (m *msgServer) checkMessageContainsLink(msgData *sdkws.MsgData, userType int32) error { // userType=1 的用户可以发送链接,不进行检测 if userType == 1 { return nil } // 只检测文本类型的消息 if msgData.ContentType != constant.Text && msgData.ContentType != constant.AtText { return nil } var textContent string var err error // 解析消息内容 if msgData.ContentType == constant.Text { var textElem TextElem if err = json.Unmarshal(msgData.Content, &textElem); err != nil { // 如果解析失败,尝试直接使用字符串 textContent = string(msgData.Content) } else { textContent = textElem.Content } } else if msgData.ContentType == constant.AtText { var atElem AtElem if err = json.Unmarshal(msgData.Content, &atElem); err != nil { // 如果解析失败,尝试直接使用字符串 textContent = string(msgData.Content) } else { textContent = atElem.Text } } // 检测是否包含链接 if urlRegex.MatchString(textContent) { return servererrs.ErrMessageContainsLink.WrapMsg("userType=0的用户不能发送包含链接的消息") } return nil } type MessageRevoked struct { RevokerID string `json:"revokerID"` RevokerRole int32 `json:"revokerRole"` ClientMsgID string `json:"clientMsgID"` RevokerNickname string `json:"revokerNickname"` RevokeTime int64 `json:"revokeTime"` SourceMessageSendTime int64 `json:"sourceMessageSendTime"` SourceMessageSendID string `json:"sourceMessageSendID"` SourceMessageSenderNickname string `json:"sourceMessageSenderNickname"` SessionType int32 `json:"sessionType"` Seq uint32 `json:"seq"` } func (m *msgServer) messageVerification(ctx context.Context, data *msg.SendMsgReq) error { webhookCfg := m.webhookConfig() switch data.MsgData.SessionType { case constant.SingleChatType: if datautil.Contain(data.MsgData.SendID, m.adminUserIDs...) { return nil } if data.MsgData.ContentType <= constant.NotificationEnd && data.MsgData.ContentType >= constant.NotificationBegin { return nil } if err := m.webhookBeforeSendSingleMsg(ctx, &webhookCfg.BeforeSendSingleMsg, data); err != nil { return err } u, err := m.UserLocalCache.GetUserInfo(ctx, data.MsgData.SendID) if err != nil { return err } if authverify.CheckSystemAccount(ctx, u.AppMangerLevel) { return nil } // userType=1 的用户可以发送链接和二维码,不进行检测 userType := u.GetUserType() if userType != 1 { // 检测userType=0的用户是否发送了包含链接的消息 if err := m.checkMessageContainsLink(data.MsgData, userType); err != nil { return err } // 检测userType=0的用户是否发送了包含二维码的图片 if err := m.checkImageContainsQRCode(ctx, data.MsgData, userType); err != nil { return err } } // 单聊中:不限制文件发送权限,所有用户都可以发送文件 black, err := m.FriendLocalCache.IsBlack(ctx, data.MsgData.SendID, data.MsgData.RecvID) if err != nil { return err } if black { return servererrs.ErrBlockedByPeer.Wrap() } if m.config.RpcConfig.FriendVerify { friend, err := m.FriendLocalCache.IsFriend(ctx, data.MsgData.SendID, data.MsgData.RecvID) if err != nil { return err } if !friend { return servererrs.ErrNotPeersFriend.Wrap() } return nil } return nil case constant.ReadGroupChatType: groupInfo, err := m.GroupLocalCache.GetGroupInfo(ctx, data.MsgData.GroupID) if err != nil { return err } if groupInfo.Status == constant.GroupStatusDismissed && data.MsgData.ContentType != constant.GroupDismissedNotification { return servererrs.ErrDismissedAlready.Wrap() } // 检查是否为系统管理员,系统管理员跳过部分检查 isSystemAdmin := datautil.Contain(data.MsgData.SendID, m.adminUserIDs...) // 通知消息类型跳过检查 if data.MsgData.ContentType <= constant.NotificationEnd && data.MsgData.ContentType >= constant.NotificationBegin { return nil } // SuperGroup 跳过部分检查,但仍需检查文件权限 if groupInfo.GroupType == constant.SuperGroup { // SuperGroup 也需要检查文件发送权限 if data.MsgData.ContentType == constant.File { if isSystemAdmin { // 系统管理员可以发送文件 return nil } memberIDs, err := m.GroupLocalCache.GetGroupMemberIDMap(ctx, data.MsgData.GroupID) if err != nil { return err } if _, ok := memberIDs[data.MsgData.SendID]; !ok { return servererrs.ErrNotInGroupYet.Wrap() } groupMemberInfo, err := m.GroupLocalCache.GetGroupMember(ctx, data.MsgData.GroupID, data.MsgData.SendID) if err != nil { if errs.ErrRecordNotFound.Is(err) { return servererrs.ErrNotInGroupYet.WrapMsg(err.Error()) } return err } u, err := m.UserLocalCache.GetUserInfo(ctx, data.MsgData.SendID) if err != nil { return err } isGroupOwner := groupMemberInfo.RoleLevel == constant.GroupOwner isGroupAdmin := groupMemberInfo.RoleLevel == constant.GroupAdmin canSendFile := u.GetUserType() == 1 || isGroupOwner || isGroupAdmin if !canSendFile { return servererrs.ErrNoPermission.WrapMsg("only group owner, admin, or userType=1 can send files in group chat") } } return nil } // 先获取用户信息,用于检查userType(系统管理员和userType=1的用户可以发送文件) memberIDs, err := m.GroupLocalCache.GetGroupMemberIDMap(ctx, data.MsgData.GroupID) if err != nil { return err } if _, ok := memberIDs[data.MsgData.SendID]; !ok { return servererrs.ErrNotInGroupYet.Wrap() } groupMemberInfo, err := m.GroupLocalCache.GetGroupMember(ctx, data.MsgData.GroupID, data.MsgData.SendID) if err != nil { if errs.ErrRecordNotFound.Is(err) { return servererrs.ErrNotInGroupYet.WrapMsg(err.Error()) } return err } // 获取用户信息以获取userType u, err := m.UserLocalCache.GetUserInfo(ctx, data.MsgData.SendID) if err != nil { return err } // 群聊中:检查文件发送权限(优先检查,确保不会被其他逻辑跳过) // 只有群主、管理员、userType=1的用户可以发送文件 // 系统管理员也可以发送文件 if data.MsgData.ContentType == constant.File { isGroupOwner := groupMemberInfo.RoleLevel == constant.GroupOwner isGroupAdmin := groupMemberInfo.RoleLevel == constant.GroupAdmin userType := u.GetUserType() // 如果是文件消息且 userType=0 且不是群主/管理员,可能是缓存问题,尝试清除缓存并重新获取 if userType == 0 && !isGroupOwner && !isGroupAdmin && !isSystemAdmin { // 清除本地缓存 m.UserLocalCache.DelUserInfo(ctx, data.MsgData.SendID) // 重新获取用户信息 u, err = m.UserLocalCache.GetUserInfo(ctx, data.MsgData.SendID) if err != nil { return err } userType = u.GetUserType() } canSendFile := isSystemAdmin || userType == 1 || isGroupOwner || isGroupAdmin if !canSendFile { return servererrs.ErrNoPermission.WrapMsg("only group owner, admin, or userType=1 can send files in group chat") } } if isSystemAdmin { // 系统管理员跳过大部分检查 return nil } // 群聊中:userType=1、群主、群管理员可以发送链接 isGroupOwner := groupMemberInfo.RoleLevel == constant.GroupOwner isGroupAdmin := groupMemberInfo.RoleLevel == constant.GroupAdmin canSendLink := u.GetUserType() == 1 || isGroupOwner || isGroupAdmin // 如果不符合发送链接的条件,进行链接检测 if !canSendLink { if err := m.checkMessageContainsLink(data.MsgData, u.GetUserType()); err != nil { return err } } // 群聊中:检测userType=0的普通成员是否发送了包含二维码的图片 // userType=1、群主、群管理员可以发送包含二维码的图片,不进行检测 if !canSendLink { if err := m.checkImageContainsQRCode(ctx, data.MsgData, u.GetUserType()); err != nil { return err } } if isGroupOwner { return nil } else { nowUnixMilli := time.Now().UnixMilli() // 记录禁言检查信息,用于排查自动禁言问题 if groupMemberInfo.MuteEndTime > 0 { muteEndTime := time.UnixMilli(groupMemberInfo.MuteEndTime) isMuted := groupMemberInfo.MuteEndTime >= nowUnixMilli log.ZInfo(ctx, "messageVerification: checking mute status", "groupID", data.MsgData.GroupID, "userID", data.MsgData.SendID, "muteEndTimeTimestamp", groupMemberInfo.MuteEndTime, "muteEndTime", muteEndTime.Format(time.RFC3339), "now", time.UnixMilli(nowUnixMilli).Format(time.RFC3339), "isMuted", isMuted, "mutedDurationSeconds", (groupMemberInfo.MuteEndTime-nowUnixMilli)/1000, "roleLevel", groupMemberInfo.RoleLevel) } if groupMemberInfo.MuteEndTime >= nowUnixMilli { return servererrs.ErrMutedInGroup.Wrap() } if groupInfo.Status == constant.GroupStatusMuted && !isGroupAdmin { return servererrs.ErrMutedGroup.Wrap() } } return nil default: return nil } } func (m *msgServer) encapsulateMsgData(msg *sdkws.MsgData) { msg.ServerMsgID = GetMsgID(msg.SendID) if msg.SendTime == 0 { msg.SendTime = timeutil.GetCurrentTimestampByMill() } switch msg.ContentType { case constant.Text, constant.Picture, constant.Voice, constant.Video, constant.File, constant.AtText, constant.Merger, constant.Card, constant.Location, constant.Custom, constant.Quote, constant.AdvancedText, constant.MarkdownText: case constant.Revoke: datautil.SetSwitchFromOptions(msg.Options, constant.IsUnreadCount, false) datautil.SetSwitchFromOptions(msg.Options, constant.IsOfflinePush, false) case constant.HasReadReceipt: datautil.SetSwitchFromOptions(msg.Options, constant.IsConversationUpdate, false) datautil.SetSwitchFromOptions(msg.Options, constant.IsSenderConversationUpdate, false) datautil.SetSwitchFromOptions(msg.Options, constant.IsUnreadCount, false) datautil.SetSwitchFromOptions(msg.Options, constant.IsOfflinePush, false) case constant.Typing: datautil.SetSwitchFromOptions(msg.Options, constant.IsHistory, false) datautil.SetSwitchFromOptions(msg.Options, constant.IsPersistent, false) datautil.SetSwitchFromOptions(msg.Options, constant.IsSenderSync, false) datautil.SetSwitchFromOptions(msg.Options, constant.IsConversationUpdate, false) datautil.SetSwitchFromOptions(msg.Options, constant.IsSenderConversationUpdate, false) datautil.SetSwitchFromOptions(msg.Options, constant.IsUnreadCount, false) datautil.SetSwitchFromOptions(msg.Options, constant.IsOfflinePush, false) } } func GetMsgID(sendID string) string { t := timeutil.GetCurrentTimeFormatted() return encrypt.Md5(t + "-" + sendID + "-" + strconv.Itoa(rand.Int())) } func (m *msgServer) modifyMessageByUserMessageReceiveOpt(ctx context.Context, userID, conversationID string, sessionType int, pb *msg.SendMsgReq) (bool, error) { opt, err := m.UserLocalCache.GetUserGlobalMsgRecvOpt(ctx, userID) if err != nil { return false, err } switch opt { case constant.ReceiveMessage: case constant.NotReceiveMessage: return false, nil case constant.ReceiveNotNotifyMessage: if pb.MsgData.Options == nil { pb.MsgData.Options = make(map[string]bool, 10) } datautil.SetSwitchFromOptions(pb.MsgData.Options, constant.IsOfflinePush, false) return true, nil } singleOpt, err := m.ConversationLocalCache.GetSingleConversationRecvMsgOpt(ctx, userID, conversationID) if errs.ErrRecordNotFound.Is(err) { return true, nil } else if err != nil { return false, err } switch singleOpt { case constant.ReceiveMessage: return true, nil case constant.NotReceiveMessage: if datautil.Contain(int(pb.MsgData.ContentType), ExcludeContentType...) { return true, nil } return false, nil case constant.ReceiveNotNotifyMessage: if pb.MsgData.Options == nil { pb.MsgData.Options = make(map[string]bool, 10) } datautil.SetSwitchFromOptions(pb.MsgData.Options, constant.IsOfflinePush, false) return true, nil } return true, nil }