fix(xss+perf): SearchView XSS消除(无v-html)+room_id索引+pool_recycle
- SearchView.vue: 用 highlightSegments() 返回 {text,hit} 数组替代 v-html+escapeHtml+regex,模板用 <template v-for> + <mark>/<span> 渲染,从结构上消除 XSS 风险(4 处 v-html 全部移除: 消息片段/客户昵称/realName/手机号)
- models.py: Message 新增 ix_message_channel_owner_room_time(channel,owner_account_id,room_id,timestamp),群聊查询过滤 room_id 而非 talker_id,原 talker 索引无法覆盖
- migrations/d8eee96cd017: 新增 room_id 索引的 upgrade/downgrade (offline SQL 已验证)
- base.py + scheduler.py + cli.py: 所有生产 create_async_engine 增加 pool_recycle=3600,在 MySQL wait_timeout(默认 8h)断连前主动回收
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -79,33 +79,35 @@ function imageCell(row: Customer, camelKey: string): string | undefined {
|
||||
const hasSearched = computed(() => submittedQ.value.length > 0)
|
||||
|
||||
/**
|
||||
* Highlight occurrences of the keyword inside `text`. Returns HTML with
|
||||
* matches wrapped in `<mark class="yk-search__hit">`. Escapes the surrounding
|
||||
* text so user content can't break out of the snippet. Empty / no-match input
|
||||
* returns the escaped plain text.
|
||||
* Split `text` into segments, marking ones that match the active keyword.
|
||||
* The template renders each segment via `<mark>` / `<span>` — NO v-html, so
|
||||
* user content cannot break out of the snippet (XSS-safe by construction).
|
||||
* Empty needle → single non-hit segment. Matching is case-insensitive; the
|
||||
* original case is preserved in the output text.
|
||||
*/
|
||||
function highlight(text: string | null | undefined): string {
|
||||
const needle = submittedQ.value.trim()
|
||||
const body = text ?? ''
|
||||
if (!body) return ''
|
||||
const escaped = escapeHtml(body)
|
||||
if (!needle) return escaped
|
||||
const needleEsc = escapeHtml(needle)
|
||||
// Escape regex metacharacters in the needle so a literal search is performed.
|
||||
const needleRegex = needleEsc.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return escaped.replace(
|
||||
new RegExp(needleRegex, 'gi'),
|
||||
(m) => `<mark class="yk-search__hit">${m}</mark>`,
|
||||
)
|
||||
interface HighlightSegment {
|
||||
text: string
|
||||
hit: boolean
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
function highlightSegments(text: string | null | undefined): HighlightSegment[] {
|
||||
const body = text ?? ''
|
||||
if (!body) return []
|
||||
const needle = submittedQ.value.trim()
|
||||
if (!needle) return [{ text: body, hit: false }]
|
||||
const lower = body.toLowerCase()
|
||||
const needleLower = needle.toLowerCase()
|
||||
const segments: HighlightSegment[] = []
|
||||
let lastIdx = 0
|
||||
let idx = lower.indexOf(needleLower)
|
||||
while (idx >= 0) {
|
||||
if (idx > lastIdx) segments.push({ text: body.slice(lastIdx, idx), hit: false })
|
||||
segments.push({ text: body.slice(idx, idx + needle.length), hit: true })
|
||||
lastIdx = idx + needle.length
|
||||
idx = lower.indexOf(needleLower, lastIdx)
|
||||
}
|
||||
if (lastIdx < body.length) segments.push({ text: body.slice(lastIdx), hit: false })
|
||||
return segments
|
||||
}
|
||||
|
||||
/** Snippet preview window around the first match (or the start of text). */
|
||||
@@ -364,10 +366,12 @@ nextTick(() => {
|
||||
</span>
|
||||
<span class="yk-search__row-time">{{ formatTime(m.timestamp) }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="yk-search__row-snippet"
|
||||
v-html="highlight(snippet(m.text))"
|
||||
/>
|
||||
<div class="yk-search__row-snippet">
|
||||
<template v-for="(seg, i) in highlightSegments(snippet(m.text))">
|
||||
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
|
||||
<span v-else :key="`s${i}`">{{ seg.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -415,18 +419,24 @@ nextTick(() => {
|
||||
</el-avatar>
|
||||
<div class="yk-search__row-body">
|
||||
<div class="yk-search__row-top">
|
||||
<span
|
||||
class="yk-search__row-sender"
|
||||
v-html="highlight(c.friend_nickname ?? c.friend_id)"
|
||||
/>
|
||||
<span class="yk-search__row-sender">
|
||||
<template v-for="(seg, i) in highlightSegments(c.friend_nickname ?? c.friend_id)">
|
||||
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
|
||||
<span v-else :key="`s${i}`">{{ seg.text }}</span>
|
||||
</template>
|
||||
</span>
|
||||
<el-tag :type="channelTagType(c.channel)" size="small" effect="plain">
|
||||
{{ channelLabel(c.channel) }}
|
||||
</el-tag>
|
||||
<span
|
||||
v-if="cellText(c, 'realName') !== '--'"
|
||||
class="yk-search__row-target"
|
||||
v-html="highlight(cellText(c, 'realName'))"
|
||||
/>
|
||||
>
|
||||
<template v-for="(seg, i) in highlightSegments(cellText(c, 'realName'))">
|
||||
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
|
||||
<span v-else :key="`s${i}`">{{ seg.text }}</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="yk-search__row-time">
|
||||
最近聊天 {{ formatTime(c.last_chat_time) }}
|
||||
</span>
|
||||
@@ -434,7 +444,10 @@ nextTick(() => {
|
||||
<div class="yk-search__row-meta">
|
||||
<span v-if="cellText(c, 'phone') !== '--'">
|
||||
手机:
|
||||
<span v-html="highlight(cellText(c, 'phone'))" />
|
||||
<template v-for="(seg, i) in highlightSegments(cellText(c, 'phone'))">
|
||||
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
|
||||
<span v-else :key="`s${i}`">{{ seg.text }}</span>
|
||||
</template>
|
||||
</span>
|
||||
<span v-if="cellText(c, 'friendWxAlias') !== '--'">
|
||||
· 微信号:{{ cellText(c, 'friendWxAlias') }}
|
||||
@@ -684,7 +697,9 @@ nextTick(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Match highlight — uses :deep so v-html <mark> picks it up despite `scoped`. */
|
||||
/* Match highlight — `<mark>` elements are rendered in this template, so they
|
||||
* carry the scoped attribute; `:deep` is retained so the rule still applies if
|
||||
* the markup ever moves into a child component. */
|
||||
.yk-search__list :deep(.yk-search__hit) {
|
||||
background: var(--el-color-warning-light-7, #fdf6ec);
|
||||
color: var(--el-color-warning-dark-2, #b88230);
|
||||
|
||||
Reference in New Issue
Block a user