✨ feat(im): 修一批上传安全与群聊交互问题
- 限制消息媒体上传大小,并让视频独立上传路径复用同一校验 - 禁止发送可执行 / 脚本类文件扩展名 - 切账号时废弃好友 store 未返回请求 - 多选转发过滤撤回 / 系统类消息 - 邀请群成员时前端拦截人数上限 - 允许群管理员 @ 所有人im
parent
fead282395
commit
5a983bb1eb
|
|
@ -39,6 +39,7 @@ import { useFriendStore } from '../../store/friendStore'
|
|||
import { useGroupStore } from '../../store/groupStore'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { ImGroupMemberRole } from '@/views/im/utils/constants'
|
||||
import { GROUP_MAX_MEMBER } from '@/views/im/utils/config'
|
||||
import FriendPickerPanel from '../picker/FriendPickerPanel.vue'
|
||||
import type { GroupMemberLite } from './GroupMember.vue'
|
||||
|
||||
|
|
@ -114,8 +115,18 @@ const willGoApproval = computed(() => {
|
|||
return myRole !== ImGroupMemberRole.ADMIN
|
||||
})
|
||||
|
||||
/** 添加按钮可点:至少有 1 个新邀请的好友 */
|
||||
const canSubmit = computed(() => selectedIds.value.length > 0)
|
||||
/** 当前群已启用成员数(DISABLE 即退群 / 被踢不计入),用于上限判定 */
|
||||
const activeMemberCount = computed(
|
||||
() => members.value.filter((member) => member.status !== CommonStatusEnum.DISABLE).length
|
||||
)
|
||||
|
||||
/** 邀请后群总人数若超 GROUP_MAX_MEMBER,前端先拦;activeMemberCount + selectedIds.length 即邀请后的成员数 */
|
||||
const willExceedLimit = computed(
|
||||
() => activeMemberCount.value + selectedIds.value.length > GROUP_MAX_MEMBER
|
||||
)
|
||||
|
||||
/** 添加按钮可点:至少有 1 个新邀请的好友 + 不超群人数上限 */
|
||||
const canSubmit = computed(() => selectedIds.value.length > 0 && !willExceedLimit.value)
|
||||
|
||||
/** 邀请入群:调 /im/group/invite,成功后 emit reload 让父侧刷新群成员 */
|
||||
async function handleOk() {
|
||||
|
|
@ -126,6 +137,11 @@ async function handleOk() {
|
|||
if (memberUserIds.length === 0) {
|
||||
return
|
||||
}
|
||||
// 群人数上限冗余防御:与 canSubmit 重复判一次,防止状态在 await 间隙变化或调用方绕过按钮直接调
|
||||
if (activeMemberCount.value + memberUserIds.length > GROUP_MAX_MEMBER) {
|
||||
message.warning(`群成员上限为 ${GROUP_MAX_MEMBER} 人`)
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await inviteGroupMember({ groupId: groupId.value, memberUserIds })
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import { updateFile } from '@/api/infra/file'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
|
||||
import { useConversationStore } from '../store/conversationStore'
|
||||
import { useMessageSender } from './useMessageSender'
|
||||
import { useMuteOverlay } from './useMuteOverlay'
|
||||
import { ImMessageStatus, ImMessageType } from '../../utils/constants'
|
||||
import {
|
||||
MESSAGE_FILE_MAX_MB,
|
||||
MESSAGE_IMAGE_MAX_MB,
|
||||
MESSAGE_VIDEO_MAX_MB,
|
||||
MESSAGE_VOICE_MAX_MB
|
||||
} from '../../utils/config'
|
||||
import { getConversationKey } from '../../utils/conversation'
|
||||
import {
|
||||
BLOB_URL_PREFIX,
|
||||
|
|
@ -113,10 +120,41 @@ export interface UploadAndSendMediaOptions {
|
|||
*
|
||||
* 任意失败把消息状态置 FAILED;MessageItem 上点重试再走一次本函数(_localFile 还在内存就行)
|
||||
*/
|
||||
/** 按消息类型映射体积上限(MB);未识别类型返回 0 表示不限 */
|
||||
function resolveMediaMaxMb(type: number): number {
|
||||
switch (type) {
|
||||
case ImMessageType.IMAGE:
|
||||
return MESSAGE_IMAGE_MAX_MB
|
||||
case ImMessageType.VIDEO:
|
||||
return MESSAGE_VIDEO_MAX_MB
|
||||
case ImMessageType.VOICE:
|
||||
return MESSAGE_VOICE_MAX_MB
|
||||
case ImMessageType.FILE:
|
||||
return MESSAGE_FILE_MAX_MB
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验媒体文件大小是否超过消息类型上限;超限触发 warn 并返回 false,调用方不应进入占位 / 上传链路 */
|
||||
export function ensureMediaSizeWithinLimit(
|
||||
file: File,
|
||||
type: number,
|
||||
warn: (text: string) => void
|
||||
): boolean {
|
||||
const maxMb = resolveMediaMaxMb(type)
|
||||
if (maxMb && file.size > maxMb * 1024 * 1024) {
|
||||
warn(`文件大小超过上限 ${maxMb}MB,请压缩后再发`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const useMediaUploader = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
const userStore = useUserStore()
|
||||
const muteOverlay = useMuteOverlay()
|
||||
const message = useMessage()
|
||||
const { sendRaw } = useMessageSender()
|
||||
|
||||
/**
|
||||
|
|
@ -279,6 +317,10 @@ export const useMediaUploader = () => {
|
|||
console.warn('[IM] uploadAndSendMedia 收到未注册的媒体类型', { type: opts.type })
|
||||
return ''
|
||||
}
|
||||
// 体积上限拦截:大文件浏览器内截帧 / 解码可致 OOM;超限直接 warning,不进入占位 / 上传链路
|
||||
if (!ensureMediaSizeWithinLimit(opts.file, opts.type, message.warning)) {
|
||||
return ''
|
||||
}
|
||||
const startKey = getConversationKey(conversation)
|
||||
const context = opts.context ?? {}
|
||||
const buildContent = (url: string): string =>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ const props = withDefaults(
|
|||
position: { x: number; top?: number; bottom?: number }
|
||||
members: GroupMemberLite[] // 当前群的成员列表
|
||||
searchText?: string // @ 后输入的过滤文本
|
||||
ownerId?: number // 群主 id,判断是否能展示"所有人"
|
||||
canAtAll?: boolean // 当前用户是否能 @ 全员(群主 / 管理员),父组件按角色算好传入
|
||||
}>(),
|
||||
{
|
||||
searchText: '',
|
||||
|
|
@ -99,19 +99,14 @@ const activeIdx = ref(0)
|
|||
/** 当前登录用户 id(成员列表过滤掉自己) */
|
||||
const selfUserId = computed(() => Number(userStore.getUser?.id) || 0)
|
||||
|
||||
/** 是否群主(只有群主能 @ 所有人,对齐微信) */
|
||||
const isOwner = computed(() => {
|
||||
return props.ownerId != null && props.ownerId === selfUserId.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 虚拟"所有人"项:群主 + 关键字命中"所有人"前缀时存在
|
||||
* 虚拟"所有人"项:群主 / 管理员(canAtAll=true)+ 关键字命中"所有人"前缀时存在
|
||||
*
|
||||
* MessageInput 走 token data-id 收集 atUserIds,不依赖文案字符串;
|
||||
* 这里的 userId / 文案都从 im/utils/constants 取,避免散落
|
||||
*/
|
||||
const allItem = computed<GroupMemberLite | null>(() => {
|
||||
if (!isOwner.value) {
|
||||
if (!props.canAtAll) {
|
||||
return null
|
||||
}
|
||||
if (!IM_AT_ALL_NICKNAME.startsWith(props.searchText)) {
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@
|
|||
:position="mentionPosition"
|
||||
:members="groupMembers"
|
||||
:search-text="mentionSearchText"
|
||||
:owner-id="groupOwnerId"
|
||||
:can-at-all="canAtAll"
|
||||
@select="onMentionSelect"
|
||||
/>
|
||||
|
||||
|
|
@ -169,12 +169,14 @@ import { useGroupStore } from '@/views/im/home/store/groupStore'
|
|||
import { useFriendStore } from '@/views/im/home/store/friendStore'
|
||||
import { useDraftStore } from '@/views/im/home/store/draftStore'
|
||||
import { getMemberDisplayName } from '@/views/im/utils/user'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
|
||||
import { useMediaUploader } from '@/views/im/home/composables/useMediaUploader'
|
||||
import { ensureMediaSizeWithinLimit, useMediaUploader } from '@/views/im/home/composables/useMediaUploader'
|
||||
import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay'
|
||||
import { getConversationKey } from '@/views/im/utils/conversation'
|
||||
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants'
|
||||
import { MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config'
|
||||
import { ImConversationType, ImGroupMemberRole, ImMessageType } from '@/views/im/utils/constants'
|
||||
import { DANGEROUS_FILE_EXTENSIONS, MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config'
|
||||
import {
|
||||
serializeMessage,
|
||||
type FaceMessage,
|
||||
|
|
@ -195,6 +197,8 @@ const conversationStore = useConversationStore()
|
|||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore()
|
||||
const draftStore = useDraftStore()
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
const { send, sendRaw } = useMessageSender()
|
||||
const {
|
||||
uploadAndSendMedia,
|
||||
|
|
@ -634,12 +638,24 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
|
|||
})
|
||||
})
|
||||
|
||||
const groupOwnerId = computed<number | undefined>(() => {
|
||||
/** 当前用户是否能 @ 全员;群主 + 管理员都允许(对齐微信 PC:admin 也能 @ 所有人) */
|
||||
const canAtAll = computed<boolean>(() => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
return undefined
|
||||
return false
|
||||
}
|
||||
return groupStore.getGroup(conversation.targetId)?.ownerUserId
|
||||
const group = groupStore.getGroup(conversation.targetId)
|
||||
if (!group) {
|
||||
return false
|
||||
}
|
||||
const myId = Number(userStore.getUser?.id) || 0
|
||||
if (!myId) {
|
||||
return false
|
||||
}
|
||||
if (group.ownerUserId === myId) {
|
||||
return true
|
||||
}
|
||||
return group.members?.find((member) => member.userId === myId)?.role === ImGroupMemberRole.ADMIN
|
||||
})
|
||||
|
||||
const mentionVisible = ref(false)
|
||||
|
|
@ -849,6 +865,12 @@ async function uploadAndSendImage(file: File) {
|
|||
|
||||
/** 上传并发送 FILE 消息;payload 由 mediaTypeHandlers[FILE] 自动拼 url + name + size */
|
||||
async function uploadAndSendFile(file: File) {
|
||||
// 文件名取最后一个 "." 之后的部分;无后缀 / 空字符串都按"未知"放行
|
||||
const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (extension && DANGEROUS_FILE_EXTENSIONS.includes(extension)) {
|
||||
message.warning(`不允许发送 .${extension} 类型文件`)
|
||||
return
|
||||
}
|
||||
const context = prepareMediaUpload()
|
||||
if (!context) {
|
||||
return
|
||||
|
|
@ -1007,6 +1029,9 @@ async function probeVideoFile(file: File): Promise<VideoProbe> {
|
|||
* 4. 视频链路耗时长,上传期间用户切会话则放弃发送(避免落到错误会话里);切走再切回来不算变化(key 仍相等)
|
||||
*/
|
||||
async function uploadAndSendVideo(file: File) {
|
||||
if (!ensureMediaSizeWithinLimit(file, ImMessageType.VIDEO, message.warning)) {
|
||||
return
|
||||
}
|
||||
const context = prepareMediaUpload()
|
||||
if (!context) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ import { useMessage } from '@/hooks/web/useMessage'
|
|||
|
||||
import { useConversationStore } from '@/views/im/home/store/conversationStore'
|
||||
import { useMessageMultiSelect } from '@/views/im/home/composables/useMessageMultiSelect'
|
||||
import { ImForwardMode } from '@/views/im/utils/constants'
|
||||
import { ImForwardMode, isNormalMessage } from '@/views/im/utils/constants'
|
||||
import type { Message } from '@/views/im/home/types'
|
||||
import { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys'
|
||||
|
||||
|
|
@ -66,14 +66,16 @@ const multiSelect = useMessageMultiSelect()
|
|||
/** 选中条数 */
|
||||
const selectedCount = computed(() => multiSelect.state.selectedClientMessageIds.length)
|
||||
|
||||
/** 当前会话内已选消息;conversation.messages 已按 sendTime 升序,filter 保序无需再 sort */
|
||||
/** 当前会话内已选消息;conversation.messages 已按 sendTime 升序,filter 保序无需再 sort;isNormalMessage 过滤掉 RECALL / 系统事件,与 MessageItem.canForward 对齐 */
|
||||
function getSelectedMessages(): Message[] {
|
||||
const conversation = conversationStore.activeConversation
|
||||
if (!conversation) {
|
||||
return []
|
||||
}
|
||||
const ids = multiSelect.selectedIdSet.value
|
||||
return conversation.messages.filter((m) => ids.has(m.clientMessageId))
|
||||
return conversation.messages.filter(
|
||||
(message) => ids.has(message.clientMessageId) && isNormalMessage(message.type)
|
||||
)
|
||||
}
|
||||
|
||||
/** 逐条转发:开 ForwardDialog 单条模式 */
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ let pendingFetchRequests: Promise<void> | null = null
|
|||
/** 当前正在进行的「加载更多申请」请求 */
|
||||
let pendingLoadMoreRequests: Promise<void> | null = null
|
||||
|
||||
/** clear() 时递增;旧账号那次还没返回的请求 resolve 后比对一致才写 store,防跨账号数据泄漏 */
|
||||
let storeEpoch = 0
|
||||
|
||||
/** 好友通知 payload(对齐后端 BaseFriendNotification + 子类裁减后的字段) */
|
||||
export interface FriendNotificationPayload {
|
||||
operatorUserId: number
|
||||
|
|
@ -160,8 +163,13 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
if (pendingFetchFriends) {
|
||||
return pendingFetchFriends
|
||||
}
|
||||
// 快照 epoch;clear() 之后到 .then 之间触发的 epoch++ 表示账号已切,旧结果不能写入新 store
|
||||
const requestEpoch = storeEpoch
|
||||
pendingFetchFriends = apiGetMyFriendList()
|
||||
.then((list) => {
|
||||
if (requestEpoch !== storeEpoch) {
|
||||
return
|
||||
}
|
||||
this.friends = (list || []).map(convertFriend)
|
||||
this.loaded = true
|
||||
const conversationStore = useConversationStore()
|
||||
|
|
@ -178,7 +186,9 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
this.saveFriends()
|
||||
})
|
||||
.finally(() => {
|
||||
pendingFetchFriends = null
|
||||
if (requestEpoch === storeEpoch) {
|
||||
pendingFetchFriends = null
|
||||
}
|
||||
})
|
||||
return pendingFetchFriends
|
||||
},
|
||||
|
|
@ -238,15 +248,21 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
if (pendingFetchRequests) {
|
||||
return pendingFetchRequests
|
||||
}
|
||||
const requestEpoch = storeEpoch
|
||||
pendingFetchRequests = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE)
|
||||
.then((list) => {
|
||||
if (requestEpoch !== storeEpoch) {
|
||||
return
|
||||
}
|
||||
const items = (list || []).map(convertFriendRequest)
|
||||
this.friendRequests = items
|
||||
// 不足一页即没有更多;满页可能还有,等 loadMore 拉到 0 条再确定
|
||||
this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE
|
||||
})
|
||||
.finally(() => {
|
||||
pendingFetchRequests = null
|
||||
if (requestEpoch === storeEpoch) {
|
||||
pendingFetchRequests = null
|
||||
}
|
||||
})
|
||||
return pendingFetchRequests
|
||||
},
|
||||
|
|
@ -260,14 +276,20 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
if (!oldest) {
|
||||
return this.fetchFriendRequests()
|
||||
}
|
||||
const requestEpoch = storeEpoch
|
||||
pendingLoadMoreRequests = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE, oldest.id)
|
||||
.then((list) => {
|
||||
if (requestEpoch !== storeEpoch) {
|
||||
return
|
||||
}
|
||||
const items = (list || []).map(convertFriendRequest)
|
||||
this.friendRequests.push(...items)
|
||||
this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE
|
||||
})
|
||||
.finally(() => {
|
||||
pendingLoadMoreRequests = null
|
||||
if (requestEpoch === storeEpoch) {
|
||||
pendingLoadMoreRequests = null
|
||||
}
|
||||
})
|
||||
return pendingLoadMoreRequests
|
||||
},
|
||||
|
|
@ -508,12 +530,16 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
this.saveFriends()
|
||||
},
|
||||
|
||||
/** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */
|
||||
/** 清空好友内存状态,并废弃未返回请求(pending Promise 置空 + storeEpoch++) */
|
||||
clear() {
|
||||
this.friends = []
|
||||
this.friendRequests = []
|
||||
this.loaded = false
|
||||
this.hasMoreFriendRequests = true
|
||||
pendingFetchFriends = null
|
||||
pendingFetchRequests = null
|
||||
pendingLoadMoreRequests = null
|
||||
storeEpoch++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -58,6 +58,23 @@ export const FRIEND_REQUEST_PAGE_SIZE = 100
|
|||
/** 「我相关」加群申请列表的单次拉取条数 */
|
||||
export const GROUP_REQUEST_PAGE_SIZE = 100
|
||||
|
||||
// ==================== 上传安全策略 ====================
|
||||
// 数值与后端 Spring multipart 配置对齐(`spring.servlet.multipart.max-file-size = 16MB`)
|
||||
// 后端调大后这里同步调;不要单边放宽,否则用户上传完到后端 413
|
||||
|
||||
/** 图片 / 视频 / 普通文件单文件上限(MB),对齐后端 max-file-size */
|
||||
export const MESSAGE_IMAGE_MAX_MB = 16
|
||||
export const MESSAGE_VIDEO_MAX_MB = 16
|
||||
export const MESSAGE_FILE_MAX_MB = 16
|
||||
/** 语音单文件上限(MB):60s @ 64kbps 约 0.5MB,5MB 已远超录制可能产出的大小 */
|
||||
export const MESSAGE_VOICE_MAX_MB = 5
|
||||
|
||||
/** 可执行 / 脚本类扩展名黑名单;接收端点击下载后本地双击就跑,html 本地打开还能执行脚本 */
|
||||
export const DANGEROUS_FILE_EXTENSIONS = [
|
||||
'exe', 'bat', 'cmd', 'com', 'msi', 'scr', 'pif', 'vbs', 'vbe', 'wsf', 'ws',
|
||||
'js', 'jse', 'jar', 'sh', 'app', 'ps1', 'reg', 'html', 'htm'
|
||||
]
|
||||
|
||||
// ==================== 前端独有:UI 阈值 ====================
|
||||
|
||||
/** 消息之间渲染「时间分隔条」的阈值:10 分钟 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue