diff --git a/resources/js/chat-room.js b/resources/js/chat-room.js index 5766794..2c547b1 100644 --- a/resources/js/chat-room.js +++ b/resources/js/chat-room.js @@ -2,7 +2,7 @@ /** * 模块引用说明: - * - html.js:提供聊天内容通用 HTML 转义工具。 + * - html.js:提供聊天内容通用 HTML 转义和安全链接规整工具。 * - appointment-announcement.js:处理任命/撤销公告的大卡片和系统消息。 * - banner.js:提供 window.chatBanner 居中大卡片通知。 * - chat-bot.js:处理 AI 小班长发送消息和清空上下文。 @@ -54,10 +54,11 @@ * - reward-modal.js:处理职务奖励金币弹窗入口。 * - red-packet-panel.js:处理礼包红包发包、抢包、倒计时和广播监听。 * - message-queue.js:提供聊天消息分批渲染队列。 + * - message-utils.js:提供图片消息过期等消息渲染辅助判断。 */ // 统一转发各子模块导出,方便测试或后续模块继续复用同一组工具。 -export { escapeHtml, escapeHtmlWithLineBreaks } from "./chat-room/html.js"; +export { escapeHtml, escapeHtmlWithLineBreaks, normalizeSafeChatUrl } from "./chat-room/html.js"; export { bindAppointmentAnnouncementControls, showAppointmentBanner } from "./chat-room/appointment-announcement.js"; export { bindChatBanner } from "./chat-room/banner.js"; export { bindChatBotControls, clearChatBotContext, sendToChatBot } from "./chat-room/chat-bot.js"; @@ -241,8 +242,9 @@ export { updateRedPacketClaimsUI, } from "./chat-room/red-packet-panel.js"; export { createMessageQueue } from "./chat-room/message-queue.js"; +export { isExpiredChatImageMessage } from "./chat-room/message-utils.js"; -import { escapeHtml, escapeHtmlWithLineBreaks } from "./chat-room/html.js"; +import { escapeHtml, escapeHtmlWithLineBreaks, normalizeSafeChatUrl } from "./chat-room/html.js"; import { bindAppointmentAnnouncementControls, showAppointmentBanner } from "./chat-room/appointment-announcement.js"; import { bindChatBanner } from "./chat-room/banner.js"; import { bindChatBotControls, clearChatBotContext, sendToChatBot } from "./chat-room/chat-bot.js"; @@ -426,12 +428,14 @@ import { updateRedPacketClaimsUI, } from "./chat-room/red-packet-panel.js"; import { createMessageQueue } from "./chat-room/message-queue.js"; +import { isExpiredChatImageMessage } from "./chat-room/message-utils.js"; if (typeof window !== "undefined") { // 保留聚合入口,给新迁移模块、测试和仍在 Blade 内的存量脚本统一读取工具。 window.ChatRoomTools = { escapeHtml, escapeHtmlWithLineBreaks, + normalizeSafeChatUrl, bindAppointmentAnnouncementControls, showAppointmentBanner, bindChatBanner, @@ -643,10 +647,14 @@ if (typeof window !== "undefined") { showRedPacketModal, updateRedPacketClaimsUI, createMessageQueue, + isExpiredChatImageMessage, }; // 直接挂载只服务暂未迁移的 Blade 调用点;新代码优先通过模块导入或 ChatRoomTools 复用。 window.closeChatImageLightbox = closeChatImageLightbox; + window.escapeHtml = escapeHtml; + window.isExpiredChatImageMessage = isExpiredChatImageMessage; + window.normalizeSafeChatUrl = normalizeSafeChatUrl; window.openChatImageLightbox = openChatImageLightbox; window.closeFriendPanel = closeFriendPanel; window.friendSearch = friendSearch; diff --git a/resources/js/chat-room/html.js b/resources/js/chat-room/html.js index 63120f0..f504bb9 100644 --- a/resources/js/chat-room/html.js +++ b/resources/js/chat-room/html.js @@ -27,3 +27,28 @@ export function escapeHtml(value) { export function escapeHtmlWithLineBreaks(value) { return escapeHtml(value).replace(/\n/g, "
"); } + +/** + * 规整广播携带的链接,只允许当前站点的 http(s) 地址进入 innerHTML。 + * + * @param {string|null|undefined} url 原始链接 + * @param {string} fallback 回退链接 + * @returns {string} + */ +export function normalizeSafeChatUrl(url, fallback) { + try { + const parsedUrl = new URL(url || fallback, window.location.origin); + + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + return fallback; + } + + if (parsedUrl.origin !== window.location.origin) { + return fallback; + } + + return parsedUrl.toString(); + } catch (error) { + return fallback; + } +} diff --git a/resources/js/chat-room/message-utils.js b/resources/js/chat-room/message-utils.js new file mode 100644 index 0000000..0f62b36 --- /dev/null +++ b/resources/js/chat-room/message-utils.js @@ -0,0 +1,40 @@ +// 聊天消息工具函数,承接主消息渲染脚本里可独立复用的判断逻辑。 + +/** + * 判断图片消息是否已经超过前端允许展示的保留期。 + * + * @param {Record|null|undefined} message 聊天消息 + * @param {number} retentionDays 图片保留天数 + * @param {number} nowTimestamp 当前时间戳 + * @returns {boolean} + */ +export function isExpiredChatImageMessage( + message, + retentionDays = Number.parseInt(window.chatContext?.chatImageRetentionDays || 3, 10), + nowTimestamp = Date.now(), +) { + if (!message) { + return false; + } + + if (message.message_type === "expired_image") { + return true; + } + + if (message.message_type !== "image") { + return false; + } + + if (!message.image_url || !message.image_thumb_url) { + return true; + } + + const sentAtText = String(message.sent_at || "").replace(" ", "T"); + const sentAt = sentAtText ? new Date(sentAtText) : null; + + if (!sentAt || Number.isNaN(sentAt.getTime())) { + return false; + } + + return nowTimestamp >= sentAt.getTime() + retentionDays * 24 * 60 * 60 * 1000; +} diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index ecfd02c..43af87d 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -59,6 +59,55 @@ const CHAT_MESSAGE_FLUSH_BATCH_SIZE = 8; const PUBLIC_MESSAGE_NODE_LIMIT = 600; const PRIVATE_MESSAGE_NODE_LIMIT = 300; + // Vite 模块稍后会覆盖这些全局工具;这里保留极小同步兜底,避免经典脚本早期事件取不到同名函数。 + window.escapeHtml = window.escapeHtml || ((text) => { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }); + window.normalizeSafeChatUrl = window.normalizeSafeChatUrl || ((url, fallback) => { + try { + const parsedUrl = new URL(url || fallback, window.location.origin); + + if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.origin !== window.location.origin) { + return fallback; + } + + return parsedUrl.toString(); + } catch (error) { + return fallback; + } + }); + window.isExpiredChatImageMessage = window.isExpiredChatImageMessage || ((msg) => { + if (!msg) { + return false; + } + + if (msg.message_type === 'expired_image') { + return true; + } + + if (msg.message_type !== 'image') { + return false; + } + + if (!msg.image_url || !msg.image_thumb_url) { + return true; + } + + const retentionDays = parseInt(window.chatContext?.chatImageRetentionDays || 3, 10); + const sentAtText = String(msg.sent_at || '').replace(' ', 'T'); + const sentAt = sentAtText ? new Date(sentAtText) : null; + + if (!sentAt || Number.isNaN(sentAt.getTime())) { + return false; + } + + return Date.now() >= sentAt.getTime() + retentionDays * 24 * 60 * 60 * 1000; + }); + const escapeHtml = (...args) => window.escapeHtml(...args); + const normalizeSafeChatUrl = (...args) => window.normalizeSafeChatUrl(...args); + const isExpiredChatImageMessage = (...args) => window.isExpiredChatImageMessage(...args); const initialChatPreferences = normalizeChatPreferences(window.chatContext?.chatPreferences || {}); let blockedSystemSenders = new Set(initialChatPreferences.blocked_system_senders); @@ -1349,37 +1398,6 @@ window.showVipPresenceBanner = showVipPresenceBanner; - /** - * 判断图片消息是否已经超过前端允许展示的保留期。 - */ - function isExpiredChatImageMessage(msg) { - if (!msg) { - return false; - } - - if (msg.message_type === 'expired_image') { - return true; - } - - if (msg.message_type !== 'image') { - return false; - } - - if (!msg.image_url || !msg.image_thumb_url) { - return true; - } - - const retentionDays = parseInt(window.chatContext?.chatImageRetentionDays || 3, 10); - const sentAtText = String(msg.sent_at || '').replace(' ', 'T'); - const sentAt = sentAtText ? new Date(sentAtText) : null; - - if (!sentAt || Number.isNaN(sentAt.getTime())) { - return false; - } - - return Date.now() >= sentAt.getTime() + retentionDays * 24 * 60 * 60 * 1000; - } - /** * 构建普通聊天消息的正文区域,支持缩略图与过期占位渲染。 */ @@ -3832,33 +3850,4 @@ setInterval(() => saveExp(true), HEARTBEAT_INTERVAL); setTimeout(() => saveExp(true), 10000); - - /** - * HTML 转义函数,防止 XSS - */ - function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - /** - * 规整广播携带的链接,只允许当前站点的 http(s) 地址进入 innerHTML。 - */ - function normalizeSafeChatUrl(url, fallback) { - try { - const parsedUrl = new URL(url || fallback, window.location.origin); - if (!['http:', 'https:'].includes(parsedUrl.protocol)) { - return fallback; - } - - if (parsedUrl.origin !== window.location.origin) { - return fallback; - } - - return parsedUrl.toString(); - } catch (error) { - return fallback; - } - }