Merge branch 'main' into fix/http-api-security

This commit is contained in:
Ocean
2026-04-06 14:11:17 +08:00
committed by GitHub
10 changed files with 226 additions and 20 deletions

View File

@@ -4486,15 +4486,16 @@ class ChatService {
*/
private parseQuoteMessage(content: string): { content?: string; sender?: string } {
try {
const normalizedContent = this.decodeHtmlEntities(content || '')
// 提取 refermsg 部分
const referMsgStart = content.indexOf('<refermsg>')
const referMsgEnd = content.indexOf('</refermsg>')
const referMsgStart = normalizedContent.indexOf('<refermsg>')
const referMsgEnd = normalizedContent.indexOf('</refermsg>')
if (referMsgStart === -1 || referMsgEnd === -1) {
return {}
}
const referMsgXml = content.substring(referMsgStart, referMsgEnd + 11)
const referMsgXml = normalizedContent.substring(referMsgStart, referMsgEnd + 11)
// 提取发送者名称
let displayName = this.extractXmlValue(referMsgXml, 'displayname')
@@ -4511,8 +4512,8 @@ class ChatService {
let displayContent = referContent
switch (referType) {
case '1':
// 文本消息,清理可能的 wxid
displayContent = this.sanitizeQuotedContent(referContent)
// 文本消息优先取“部分引用”字段,缺失时再回退到完整 content
displayContent = this.extractPreferredQuotedText(referMsgXml)
break
case '3':
displayContent = '[图片]'
@@ -4552,6 +4553,76 @@ class ChatService {
}
}
private extractPreferredQuotedText(referMsgXml: string): string {
if (!referMsgXml) return ''
const sources = [this.decodeHtmlEntities(referMsgXml)]
const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource')
if (rawMsgSource) {
const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource)
if (decodedMsgSource) {
sources.push(decodedMsgSource)
}
}
const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content'))
const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent)
if (partialText) return partialText
const candidateTags = [
'selectedcontent',
'selectedtext',
'selectcontent',
'selecttext',
'quotecontent',
'quotetext',
'partcontent',
'parttext',
'excerpt',
'summary',
'preview'
]
for (const source of sources) {
for (const tag of candidateTags) {
const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag))
if (value) return value
}
}
return fullContent
}
private extractPartialQuotedText(xml: string, fullContent: string): string {
if (!xml || !fullContent) return ''
const startChar = this.extractXmlValue(xml, 'start')
const endChar = this.extractXmlValue(xml, 'end')
const startIndexRaw = this.extractXmlValue(xml, 'startindex')
const endIndexRaw = this.extractXmlValue(xml, 'endindex')
const startIndex = Number.parseInt(startIndexRaw, 10)
const endIndex = Number.parseInt(endIndexRaw, 10)
if (startChar && endChar) {
const startPos = fullContent.indexOf(startChar)
if (startPos !== -1) {
const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1)
if (endPos !== -1 && endPos >= startPos) {
const sliced = fullContent.slice(startPos, endPos + endChar.length).trim()
if (sliced) return sliced
}
}
}
if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) {
const chars = Array.from(fullContent)
const sliced = chars.slice(startIndex, endIndex + 1).join('').trim()
if (sliced) return sliced
}
return ''
}
/**
* 解析名片消息
* 格式: <msg username="wxid_xxx" nickname="昵称" ... />

View File

@@ -5,6 +5,13 @@ import Store from 'electron-store'
// 加密前缀标记
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
const isSafeStorageAvailable = (): boolean => {
try {
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
} catch {
return false
}
}
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
interface ConfigSchema {
@@ -257,7 +264,7 @@ export class ConfigService {
private safeEncrypt(plaintext: string): string {
if (!plaintext) return ''
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
if (!safeStorage.isEncryptionAvailable()) return plaintext
if (!isSafeStorageAvailable()) return plaintext
const encrypted = safeStorage.encryptString(plaintext)
return SAFE_PREFIX + encrypted.toString('base64')
}
@@ -265,7 +272,7 @@ export class ConfigService {
private safeDecrypt(stored: string): string {
if (!stored) return ''
if (!stored.startsWith(SAFE_PREFIX)) return stored
if (!safeStorage.isEncryptionAvailable()) return ''
if (!isSafeStorageAvailable()) return ''
try {
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
return safeStorage.decryptString(buf)

View File

@@ -254,6 +254,7 @@ async function parallelLimit<T, R>(
class ExportService {
private configService: ConfigService
private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string } | null = null
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
private inlineEmojiCache: LRUCache<string, string>
private htmlStyleCache: string | null = null
@@ -295,6 +296,10 @@ class ExportService {
return error
}
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void {
this.runtimeConfig = config
}
private normalizeSessionIds(sessionIds: string[]): string[] {
return Array.from(
new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))
@@ -1316,9 +1321,9 @@ class ExportService {
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' }
if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' }
@@ -2254,7 +2259,7 @@ class ExportService {
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
const quoteInfo = this.parseQuoteMessage(normalized)
const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '')
const quotedPreview = this.formatQuotedReferencePreview(
const quotedPreview = quoteInfo.content || this.formatQuotedReferencePreview(
this.extractXmlValue(referMsgXml, 'content'),
this.extractXmlValue(referMsgXml, 'type')
)
@@ -2960,7 +2965,7 @@ class ExportService {
switch (referType) {
case '1':
displayContent = this.sanitizeQuotedContent(referContent)
displayContent = this.extractPreferredQuotedText(referMsgXml)
break
case '3':
displayContent = '[图片]'
@@ -3001,6 +3006,76 @@ class ExportService {
}
}
private extractPreferredQuotedText(referMsgXml: string): string {
if (!referMsgXml) return ''
const sources = [this.decodeHtmlEntities(referMsgXml)]
const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource')
if (rawMsgSource) {
const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource)
if (decodedMsgSource) {
sources.push(decodedMsgSource)
}
}
const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content'))
const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent)
if (partialText) return partialText
const candidateTags = [
'selectedcontent',
'selectedtext',
'selectcontent',
'selecttext',
'quotecontent',
'quotetext',
'partcontent',
'parttext',
'excerpt',
'summary',
'preview'
]
for (const source of sources) {
for (const tag of candidateTags) {
const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag))
if (value) return value
}
}
return fullContent
}
private extractPartialQuotedText(xml: string, fullContent: string): string {
if (!xml || !fullContent) return ''
const startChar = this.extractXmlValue(xml, 'start')
const endChar = this.extractXmlValue(xml, 'end')
const startIndexRaw = this.extractXmlValue(xml, 'startindex')
const endIndexRaw = this.extractXmlValue(xml, 'endindex')
const startIndex = Number.parseInt(startIndexRaw, 10)
const endIndex = Number.parseInt(endIndexRaw, 10)
if (startChar && endChar) {
const startPos = fullContent.indexOf(startChar)
if (startPos !== -1) {
const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1)
if (endPos !== -1 && endPos >= startPos) {
const sliced = fullContent.slice(startPos, endPos + endChar.length).trim()
if (sliced) return sliced
}
}
}
if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) {
const chars = Array.from(fullContent)
const sliced = chars.slice(startIndex, endIndex + 1).join('').trim()
if (sliced) return sliced
}
return ''
}
private extractChatLabReplyToMessageId(content: string): string | undefined {
try {
const normalized = this.normalizeAppMessageContent(content || '')