fix: 加强 IM 上传 URL 与 RTC 来电载荷校验

im
YunaiV 2026-05-24 23:41:46 +08:00
parent 309a4bf4d0
commit 8b06efe5ee
3 changed files with 69 additions and 8 deletions

View File

@ -1,6 +1,7 @@
import { updateFile } from '@/api/infra/file'
import { useUserStore } from '@/store/modules/user'
import { useMessage } from '@/hooks/web/useMessage'
import { isOpenableUrl } from '@/utils/url'
import { useConversationStore } from '../store/conversationStore'
import { useMessageSender } from './useMessageSender'
@ -351,6 +352,12 @@ export const useMediaUploader = () => {
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return clientMessageId
}
if (!isOpenableUrl(url)) {
console.warn(`[IM] ${handler.kind}上传返回了不支持打开的 URL`, { url })
message.warning('上传返回的文件地址不支持打开')
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return clientMessageId
}
// 3. 上传期间会话切换 / 用户登出 / 被禁言:任一情况都放弃发送,占位置 FAILED
if (!verifyMediaUploadStillAllowed(conversation, startKey, opts.type, clientMessageId)) {

View File

@ -172,8 +172,12 @@ 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 { ensureMediaSizeWithinLimit, 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 { isOpenableUrl } from '@/utils/url'
import { getConversationKey } from '@/views/im/utils/conversation'
import { ImConversationType, ImGroupMemberRole, ImMessageType } from '@/views/im/utils/constants'
import { DANGEROUS_FILE_EXTENSIONS, MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config'
@ -209,6 +213,7 @@ const {
verifyMediaUploadStillAllowed,
requireMediaHandler
} = useMediaUploader()
const muteOverlay = useMuteOverlay() // 禁言 / 封禁覆盖层
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
@ -244,6 +249,14 @@ function syncEditorState() {
syncDraftToStore(editor)
}
/** 禁言状态变化时同步发送按钮 */
watch(muteOverlay, () => {
const editor = editorRef.value
if (editor) {
applyEditorUiState(editor)
}
})
/** 把 editor 当前内容写到 draftStoreplain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */
function syncDraftToStore(editor: HTMLDivElement) {
const conversation = conversationStore.activeConversation
@ -614,11 +627,6 @@ const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
// ==================== / ====================
/** 禁言 / 封禁覆盖层handleResend 重试 / uploadAndSendMedia 上传完后也共用同一份,避免绕过 overlay */
const muteOverlay = useMuteOverlay()
/** 从 groupStore 读当前激活群的成员(切会话时由 MessagePanel 预拉) */
const groupMembers = computed<GroupMemberLite[]>(() => {
const conversation = conversationStore.activeConversation
@ -1106,8 +1114,20 @@ async function uploadAndSendVideo(file: File) {
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return
}
if (!isOpenableUrl(url)) {
console.warn('[IM] 视频上传返回了不支持打开的 URL', { url })
message.warning('上传返回的视频地址不支持打开')
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return
}
const safeCoverUrl = coverUrl && isOpenableUrl(coverUrl) ? coverUrl : undefined
if (coverUrl && !safeCoverUrl) {
console.warn('[IM] 视频封面上传返回了不支持打开的 URL', { coverUrl })
}
// 3.3 + muteOverlay useMediaUploader.uploadAndSendMedia
if (!verifyMediaUploadStillAllowed(conversation, startKey, ImMessageType.VIDEO, clientMessageId)) {
if (
!verifyMediaUploadStillAllowed(conversation, startKey, ImMessageType.VIDEO, clientMessageId)
) {
return
}
@ -1116,7 +1136,7 @@ async function uploadAndSendVideo(file: File) {
withQuotePayload(
videoHandler.build(file, url, {
videoProbe: { duration: probe.duration, width: probe.width, height: probe.height },
videoCoverUrl: coverUrl
videoCoverUrl: safeCoverUrl
}),
replyQuote
)

View File

@ -8,6 +8,7 @@ import {
ImMessageStatus,
ImMessageType,
ImConversationType,
ImRtcCallMediaType,
ImRtcParticipantStatus,
isFriendChatTip,
isFriendNotification,
@ -63,6 +64,35 @@ const isFriendDeleteWithClear = (frame: ImPrivateMessageDTO): boolean => {
}
}
const RTC_LIVEKIT_PROTOCOLS = new Set(['ws:', 'wss:', 'http:', 'https:'])
const RTC_MEDIA_TYPES = new Set<number>(Object.values(ImRtcCallMediaType))
/** 校验 LiveKit 连接地址 */
function isValidLiveKitUrl(url?: string): boolean {
if (!url) {
return false
}
try {
return RTC_LIVEKIT_PROTOCOLS.has(new URL(url).protocol)
} catch {
return false
}
}
/** 校验来电信令载荷 */
function isValidRtcInvitePayload(payload: ImRtcCallNotification): boolean {
if (!payload.room || !payload.token || !isValidLiveKitUrl(payload.livekitUrl)) {
return false
}
if (!RTC_MEDIA_TYPES.has(payload.mediaType) || !payload.inviterUserId) {
return false
}
if (payload.conversationType === ImConversationType.PRIVATE) {
return true
}
return payload.conversationType === ImConversationType.GROUP && !!payload.groupId
}
/**
* WebSocket DTO -> MessagetargetId userId
* utils/user /
@ -889,6 +919,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
}
switch (payload.status) {
case ImRtcParticipantStatus.INVITING:
if (!isValidRtcInvitePayload(payload)) {
console.warn('[IM WS] RTC_CALL invite payload 不合法', payload)
return
}
// 当前已在通话中:忽略新来电;后端层面也会拒绝,这里是兜底
if (!rtcStore.isActive) {
rtcStore.showIncoming(payload)