mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
11 Commits
@@ -575,8 +575,14 @@ export const useApi = () => {
|
||||
}
|
||||
|
||||
// 获取图片密钥
|
||||
const getImageKey = async () => {
|
||||
return await request('/get_image_key')
|
||||
const getImageKey = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.db_storage_path) query.set('db_storage_path', params.db_storage_path)
|
||||
if (params && params.wxid_dir) query.set('wxid_dir', params.wxid_dir)
|
||||
const url = '/get_image_key' + (query.toString() ? `?${query.toString()}` : '')
|
||||
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 枚举服务号信息
|
||||
|
||||
@@ -227,6 +227,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
|
||||
_quoteThumbError: false,
|
||||
amount: msg.amount || '',
|
||||
coverUrl: msg.coverUrl || '',
|
||||
objectId: String(msg.objectId || '').trim(),
|
||||
objectNonceId: String(msg.objectNonceId || '').trim(),
|
||||
fileSize: msg.fileSize || '',
|
||||
fileMd5: msg.fileMd5 || '',
|
||||
paySubType: msg.paySubType || '',
|
||||
|
||||
+250
-70
@@ -58,7 +58,7 @@
|
||||
<svg v-else class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ isGettingDbKey ? '获取中...' : '一键获取全部密钥' }}
|
||||
{{ isGettingDbKey ? '获取中...' : '一键获取数据库密钥' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="formErrors.key" class="mt-1 text-sm text-red-600 flex items-center">
|
||||
@@ -71,7 +71,7 @@
|
||||
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
点击按钮将自动获取【数据库】与【图片】双重密钥。您也可以手动输入已知的64位密钥(使用<a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a>等工具获取)。
|
||||
点击按钮将自动获取【数据库解密密钥】。您也可以手动输入已知的64位密钥。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
如果您在第一步使用了“一键获取”或触发了云端解析,下方输入框已被自动填充。您也可可以使用<a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a>等工具手动获取。
|
||||
系统已为您尝试通过【本地算法】或【云端解析】自动获取图片密钥。如果输入框为空,请手动填写。
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -272,7 +272,7 @@
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
|
||||
<div
|
||||
class="h-2.5 rounded-full transition-all duration-300 ease-out"
|
||||
:class="decryptProgress.status === 'complete' ? 'bg-[#07C160]' : 'bg-[#91D300]'"
|
||||
:class="decryptProgress.status === 'complete' ? 'bg-[#07C160]' : decryptProgress.status === 'cancelled' ? 'bg-[#FAAD14]' : 'bg-[#91D300]'"
|
||||
:style="{ width: progressPercent + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
@@ -366,6 +366,13 @@
|
||||
</svg>
|
||||
{{ mediaDecrypting ? '解密中...' : (mediaDecryptResult ? '重新解密' : '开始解密图片') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="mediaDecrypting"
|
||||
@click="cancelMediaDecrypt"
|
||||
class="inline-flex items-center px-6 py-3 bg-[#FA5151] text-white rounded-lg font-medium hover:bg-[#E54D4D] transition-all duration-200"
|
||||
>
|
||||
停止解密
|
||||
</button>
|
||||
<button
|
||||
@click="skipToChat"
|
||||
:disabled="mediaDecrypting"
|
||||
@@ -441,6 +448,7 @@ const error = ref('')
|
||||
const warning = ref('') // 警告,用于密钥提示
|
||||
const currentStep = ref(0)
|
||||
const mediaAccount = ref('')
|
||||
const activeKeyAccount = ref('')
|
||||
const isGettingDbKey = ref(false)
|
||||
|
||||
// 步骤定义
|
||||
@@ -478,6 +486,46 @@ const manualKeyErrors = reactive({
|
||||
aes_key: ''
|
||||
})
|
||||
|
||||
const normalizeAccountId = (value) => String(value || '').trim()
|
||||
const summarizeAesForLog = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
if (raw.length <= 8) return raw
|
||||
return `${raw.slice(0, 4)}...${raw.slice(-4)}(len=${raw.length})`
|
||||
}
|
||||
const summarizeKeyStateForLog = (xorKey, aesKey) => ({
|
||||
xor_key: String(xorKey || '').trim(),
|
||||
aes_key: summarizeAesForLog(aesKey),
|
||||
has_xor: !!String(xorKey || '').trim(),
|
||||
has_aes: !!String(aesKey || '').trim()
|
||||
})
|
||||
const formatLogError = (error) => {
|
||||
if (!error) return ''
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: String(error.name || 'Error'),
|
||||
message: String(error.message || ''),
|
||||
stack: String(error.stack || '')
|
||||
}
|
||||
}
|
||||
if (typeof error === 'object') {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(error))
|
||||
} catch {}
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
const logDecryptDebug = (phase, details = {}) => {
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.('decrypt-page', phase, details)
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
console.info(`[decrypt-page] ${phase}`, details)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const normalizeXorKey = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return { ok: false, value: '', message: '请输入 XOR 密钥' }
|
||||
@@ -496,8 +544,9 @@ const normalizeAesKey = (value) => {
|
||||
}
|
||||
|
||||
const prefillKeysForAccount = async (account) => {
|
||||
const acc = String(account || '').trim()
|
||||
const acc = normalizeAccountId(account)
|
||||
if (!acc) return
|
||||
logDecryptDebug('prefill:start', { account: acc })
|
||||
try {
|
||||
const resp = await getSavedKeys({ account: acc })
|
||||
if (!resp || resp.status !== 'success') return
|
||||
@@ -517,11 +566,88 @@ const prefillKeysForAccount = async (account) => {
|
||||
if (aesKey && !String(manualKeys.aes_key || '').trim()) {
|
||||
manualKeys.aes_key = aesKey
|
||||
}
|
||||
logDecryptDebug('prefill:done', {
|
||||
request_account: acc,
|
||||
response_account: String(resp.account || '').trim(),
|
||||
db_key_present: !!dbKey,
|
||||
...summarizeKeyStateForLog(
|
||||
String(keys.image_xor_key || '').trim(),
|
||||
String(keys.image_aes_key || '').trim()
|
||||
),
|
||||
applied: summarizeKeyStateForLog(manualKeys.xor_key, manualKeys.aes_key)
|
||||
})
|
||||
} catch (e) {
|
||||
// ignore
|
||||
logDecryptDebug('prefill:error', { account: acc, error: formatLogError(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const tryAutoFetchImageKeys = async (account) => {
|
||||
const acc = normalizeAccountId(account)
|
||||
if (!acc) return
|
||||
if (String(manualKeys.xor_key || '').trim() || String(manualKeys.aes_key || '').trim()) {
|
||||
logDecryptDebug('auto-fetch:skip-existing', {
|
||||
account: acc,
|
||||
keys: summarizeKeyStateForLog(manualKeys.xor_key, manualKeys.aes_key)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
warning.value = '正在通过云端/本地算法自动获取图片密钥,请稍候...'
|
||||
logDecryptDebug('auto-fetch:start', { account: acc })
|
||||
try {
|
||||
const imgRes = await getImageKey({
|
||||
account: acc,
|
||||
db_storage_path: String(formData.db_storage_path || '').trim()
|
||||
})
|
||||
logDecryptDebug('auto-fetch:response', {
|
||||
account: acc,
|
||||
status: imgRes?.status,
|
||||
errmsg: String(imgRes?.errmsg || ''),
|
||||
data_account: String(imgRes?.data?.account || '').trim(),
|
||||
keys: summarizeKeyStateForLog(imgRes?.data?.xor_key, imgRes?.data?.aes_key)
|
||||
})
|
||||
|
||||
if (imgRes && imgRes.status === 0) {
|
||||
if (imgRes.data?.xor_key) manualKeys.xor_key = imgRes.data.xor_key
|
||||
if (imgRes.data?.aes_key) manualKeys.aes_key = imgRes.data.aes_key
|
||||
warning.value = '已通过云端成功获取图片密钥!'
|
||||
setTimeout(() => { if (warning.value.includes('成功获取')) warning.value = '' }, 3000)
|
||||
} else {
|
||||
warning.value = '云端获取图片密钥失败,您可以尝试手动填写。'
|
||||
}
|
||||
} catch (e) {
|
||||
warning.value = '网络请求失败,请手动填写图片密钥。'
|
||||
logDecryptDebug('auto-fetch:error', { account: acc, error: formatLogError(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const ensureKeysForAccount = async (account) => {
|
||||
const acc = normalizeAccountId(account)
|
||||
if (!acc) return
|
||||
|
||||
logDecryptDebug('ensure-keys:start', {
|
||||
account: acc,
|
||||
previous_account: activeKeyAccount.value,
|
||||
current_manual: summarizeKeyStateForLog(manualKeys.xor_key, manualKeys.aes_key)
|
||||
})
|
||||
if (activeKeyAccount.value && activeKeyAccount.value !== acc) {
|
||||
logDecryptDebug('ensure-keys:switch-account', {
|
||||
from: activeKeyAccount.value,
|
||||
to: acc,
|
||||
cleared_keys: summarizeKeyStateForLog(manualKeys.xor_key, manualKeys.aes_key)
|
||||
})
|
||||
clearManualKeys()
|
||||
}
|
||||
|
||||
activeKeyAccount.value = acc
|
||||
await prefillKeysForAccount(acc)
|
||||
await tryAutoFetchImageKeys(acc)
|
||||
logDecryptDebug('ensure-keys:done', {
|
||||
account: acc,
|
||||
manual: summarizeKeyStateForLog(manualKeys.xor_key, manualKeys.aes_key)
|
||||
})
|
||||
}
|
||||
|
||||
const handleGetDbKey = async () => {
|
||||
if (isGettingDbKey.value) return
|
||||
isGettingDbKey.value = true
|
||||
@@ -535,7 +661,7 @@ const handleGetDbKey = async () => {
|
||||
const wxStatus = statusRes?.wx_status
|
||||
|
||||
if (wxStatus?.is_running) {
|
||||
warning.value = '检测到微信正在运行,5秒后将终止进程并重启以获取全套密钥!'
|
||||
warning.value = '检测到微信正在运行,5秒后将终止进程并重启以获取数据库密钥!'
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
}
|
||||
|
||||
@@ -547,15 +673,7 @@ const handleGetDbKey = async () => {
|
||||
if (res.data?.db_key) {
|
||||
formData.key = res.data.db_key
|
||||
}
|
||||
// 直接把图片密钥也存好
|
||||
if (res.data?.xor_key) {
|
||||
manualKeys.xor_key = res.data.xor_key
|
||||
}
|
||||
if (res.data?.aes_key) {
|
||||
manualKeys.aes_key = res.data.aes_key
|
||||
}
|
||||
warning.value = '🎉 数据库与图片密钥均已获取成功!'
|
||||
// 3秒后清除成功提示,保持 UI 干净
|
||||
warning.value = '数据库解密密钥已获取成功!'
|
||||
setTimeout(() => { if(warning.value.includes('获取成功')) warning.value = '' }, 3000)
|
||||
} else {
|
||||
error.value = '获取失败: ' + (res?.errmsg || '未知错误')
|
||||
@@ -570,7 +688,6 @@ const handleGetDbKey = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const applyManualKeys = () => {
|
||||
manualKeyErrors.xor_key = ''
|
||||
manualKeyErrors.aes_key = ''
|
||||
@@ -601,12 +718,18 @@ const applyManualKeys = () => {
|
||||
}
|
||||
|
||||
const clearManualKeys = () => {
|
||||
logDecryptDebug('keys:clear', {
|
||||
active_account: activeKeyAccount.value,
|
||||
manual: summarizeKeyStateForLog(manualKeys.xor_key, manualKeys.aes_key),
|
||||
applied: summarizeKeyStateForLog(mediaKeys.xor_key, mediaKeys.aes_key)
|
||||
})
|
||||
manualKeys.xor_key = ''
|
||||
manualKeys.aes_key = ''
|
||||
manualKeyErrors.xor_key = ''
|
||||
manualKeyErrors.aes_key = ''
|
||||
mediaKeys.xor_key = ''
|
||||
mediaKeys.aes_key = ''
|
||||
activeKeyAccount.value = ''
|
||||
}
|
||||
|
||||
// 图片解密相关
|
||||
@@ -678,6 +801,18 @@ const validateForm = () => {
|
||||
}
|
||||
|
||||
let dbDecryptEventSource = null
|
||||
let mediaDecryptEventSource = null
|
||||
|
||||
const closeMediaDecryptEventSource = () => {
|
||||
try {
|
||||
if (mediaDecryptEventSource) mediaDecryptEventSource.close()
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
mediaDecryptEventSource = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
try {
|
||||
if (dbDecryptEventSource) dbDecryptEventSource.close()
|
||||
@@ -686,6 +821,8 @@ onBeforeUnmount(() => {
|
||||
} finally {
|
||||
dbDecryptEventSource = null
|
||||
}
|
||||
|
||||
closeMediaDecryptEventSource()
|
||||
})
|
||||
|
||||
const resetDbDecryptProgress = () => {
|
||||
@@ -698,12 +835,27 @@ const resetDbDecryptProgress = () => {
|
||||
dbDecryptProgress.message = ''
|
||||
}
|
||||
|
||||
const resetMediaDecryptProgress = () => {
|
||||
decryptProgress.current = 0
|
||||
decryptProgress.total = 0
|
||||
decryptProgress.success_count = 0
|
||||
decryptProgress.skip_count = 0
|
||||
decryptProgress.fail_count = 0
|
||||
decryptProgress.current_file = ''
|
||||
decryptProgress.fileStatus = ''
|
||||
decryptProgress.status = ''
|
||||
}
|
||||
|
||||
// 处理解密
|
||||
const handleDecrypt = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
logDecryptDebug('decrypt:start', {
|
||||
db_storage_path: String(formData.db_storage_path || '').trim(),
|
||||
db_key_length: String(formData.key || '').trim().length
|
||||
})
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
@@ -727,28 +879,20 @@ const handleDecrypt = async () => {
|
||||
}
|
||||
try {
|
||||
const accounts = Object.keys(result.account_results || {})
|
||||
if (accounts.length > 0) mediaAccount.value = accounts[0]
|
||||
if (accounts.length > 0) {
|
||||
mediaAccount.value = accounts[0]
|
||||
} else {
|
||||
const match = formData.db_storage_path.match(/(wxid_[a-zA-Z0-9]+)/)
|
||||
if (match) mediaAccount.value = match[1]
|
||||
}
|
||||
} catch (e) {}
|
||||
logDecryptDebug('decrypt:completed-fallback', {
|
||||
media_account: mediaAccount.value,
|
||||
accounts: Object.keys(result.account_results || {})
|
||||
})
|
||||
|
||||
currentStep.value = 1
|
||||
await prefillKeysForAccount(mediaAccount.value)
|
||||
|
||||
if (!manualKeys.xor_key && !manualKeys.aes_key) {
|
||||
warning.value = '正在通过云端备选方案自动获取图片密钥,请稍候...'
|
||||
try {
|
||||
const imgRes = await getImageKey({ account: mediaAccount.value })
|
||||
if (imgRes && imgRes.status === 0) {
|
||||
if (imgRes.data?.xor_key) manualKeys.xor_key = imgRes.data.xor_key
|
||||
if (imgRes.data?.aes_key) manualKeys.aes_key = imgRes.data.aes_key
|
||||
warning.value = '已通过云端成功获取图片密钥!'
|
||||
setTimeout(() => { if(warning.value.includes('成功获取')) warning.value = '' }, 3000)
|
||||
} else {
|
||||
warning.value = '云端获取图片密钥失败,您可以尝试手动填写。'
|
||||
}
|
||||
} catch (e) {
|
||||
warning.value = '网络请求失败,请手动填写图片密钥。'
|
||||
}
|
||||
}
|
||||
await ensureKeysForAccount(mediaAccount.value)
|
||||
|
||||
} else if (result.status === 'failed') {
|
||||
if (result.failure_count > 0 && result.success_count === 0) {
|
||||
@@ -817,8 +961,17 @@ const handleDecrypt = async () => {
|
||||
|
||||
try {
|
||||
const accounts = Object.keys(data.account_results || {})
|
||||
if (accounts.length > 0) mediaAccount.value = accounts[0]
|
||||
if (accounts.length > 0) {
|
||||
mediaAccount.value = accounts[0]
|
||||
} else {
|
||||
const match = formData.db_storage_path.match(/(wxid_[a-zA-Z0-9]+)/)
|
||||
if (match) mediaAccount.value = match[1]
|
||||
}
|
||||
} catch (e) {}
|
||||
logDecryptDebug('decrypt:completed-sse', {
|
||||
media_account: mediaAccount.value,
|
||||
accounts: Object.keys(data.account_results || {})
|
||||
})
|
||||
|
||||
try {
|
||||
eventSource.close()
|
||||
@@ -828,25 +981,7 @@ const handleDecrypt = async () => {
|
||||
|
||||
if (data.status === 'completed') {
|
||||
currentStep.value = 1
|
||||
await prefillKeysForAccount(mediaAccount.value)
|
||||
|
||||
// 【重点】如果刚才没有通过双 Hook 拿到图片密钥,触发云端 API 备用方案自动获取
|
||||
if (!manualKeys.xor_key && !manualKeys.aes_key) {
|
||||
warning.value = '正在通过云端备选方案自动获取图片密钥,请稍候...'
|
||||
try {
|
||||
const imgRes = await getImageKey({ account: mediaAccount.value })
|
||||
if (imgRes && imgRes.status === 0) {
|
||||
if (imgRes.data?.xor_key) manualKeys.xor_key = imgRes.data.xor_key
|
||||
if (imgRes.data?.aes_key) manualKeys.aes_key = imgRes.data.aes_key
|
||||
warning.value = '已通过云端成功获取图片密钥!'
|
||||
setTimeout(() => { if(warning.value.includes('成功获取')) warning.value = '' }, 3000)
|
||||
} else {
|
||||
warning.value = '云端获取图片密钥失败,您可以尝试手动填写。'
|
||||
}
|
||||
} catch (e) {
|
||||
warning.value = '网络请求失败,请手动填写图片密钥。'
|
||||
}
|
||||
}
|
||||
await ensureKeysForAccount(mediaAccount.value)
|
||||
} else if (data.status === 'failed') {
|
||||
error.value = data.message || '所有文件解密失败'
|
||||
} else {
|
||||
@@ -884,20 +1019,18 @@ const handleDecrypt = async () => {
|
||||
|
||||
// 批量解密所有图片(使用SSE实时进度)
|
||||
const decryptAllImages = async () => {
|
||||
closeMediaDecryptEventSource()
|
||||
mediaDecrypting.value = true
|
||||
mediaDecryptResult.value = null
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
logDecryptDebug('media-decrypt:start', {
|
||||
account: mediaAccount.value,
|
||||
keys: summarizeKeyStateForLog(mediaKeys.xor_key, mediaKeys.aes_key)
|
||||
})
|
||||
|
||||
// 重置进度
|
||||
decryptProgress.current = 0
|
||||
decryptProgress.total = 0
|
||||
decryptProgress.success_count = 0
|
||||
decryptProgress.skip_count = 0
|
||||
decryptProgress.fail_count = 0
|
||||
decryptProgress.current_file = ''
|
||||
decryptProgress.fileStatus = ''
|
||||
decryptProgress.status = ''
|
||||
resetMediaDecryptProgress()
|
||||
|
||||
try {
|
||||
// 构建SSE URL
|
||||
@@ -910,8 +1043,11 @@ const decryptAllImages = async () => {
|
||||
|
||||
// 使用EventSource接收SSE
|
||||
const eventSource = new EventSource(url)
|
||||
mediaDecryptEventSource = eventSource
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
if (mediaDecryptEventSource !== eventSource) return
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
@@ -935,12 +1071,23 @@ const decryptAllImages = async () => {
|
||||
decryptProgress.skip_count = data.skip_count
|
||||
decryptProgress.fail_count = data.fail_count
|
||||
mediaDecryptResult.value = data
|
||||
eventSource.close()
|
||||
mediaDecrypting.value = false
|
||||
logDecryptDebug('media-decrypt:complete', {
|
||||
account: mediaAccount.value,
|
||||
total: data.total,
|
||||
success_count: data.success_count,
|
||||
skip_count: data.skip_count,
|
||||
fail_count: data.fail_count
|
||||
})
|
||||
closeMediaDecryptEventSource()
|
||||
} else if (data.type === 'error') {
|
||||
error.value = data.message
|
||||
eventSource.close()
|
||||
logDecryptDebug('media-decrypt:error-event', {
|
||||
account: mediaAccount.value,
|
||||
message: data.message
|
||||
})
|
||||
mediaDecrypting.value = false
|
||||
closeMediaDecryptEventSource()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析SSE消息失败:', e)
|
||||
@@ -948,8 +1095,10 @@ const decryptAllImages = async () => {
|
||||
}
|
||||
|
||||
eventSource.onerror = (e) => {
|
||||
if (mediaDecryptEventSource !== eventSource) return
|
||||
|
||||
console.error('SSE连接错误:', e)
|
||||
eventSource.close()
|
||||
closeMediaDecryptEventSource()
|
||||
if (mediaDecrypting.value) {
|
||||
error.value = 'SSE连接中断,请重试'
|
||||
mediaDecrypting.value = false
|
||||
@@ -958,28 +1107,49 @@ const decryptAllImages = async () => {
|
||||
} catch (err) {
|
||||
error.value = err.message || '图片解密过程中发生错误'
|
||||
mediaDecrypting.value = false
|
||||
closeMediaDecryptEventSource()
|
||||
}
|
||||
}
|
||||
|
||||
const cancelMediaDecrypt = () => {
|
||||
if (!mediaDecrypting.value) return
|
||||
|
||||
decryptProgress.status = 'cancelled'
|
||||
mediaDecrypting.value = false
|
||||
warning.value = '已停止图片解密,已完成的图片会保留。'
|
||||
closeMediaDecryptEventSource()
|
||||
}
|
||||
|
||||
// 从密钥步骤进入图片解密步骤
|
||||
const goToMediaDecryptStep = async () => {
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
// 校验并应用(未填写则允许直接进入,后端会使用已保存密钥或报错提示)
|
||||
const ok = applyManualKeys()
|
||||
logDecryptDebug('media-step:apply-manual', {
|
||||
account: mediaAccount.value,
|
||||
ok,
|
||||
manual: summarizeKeyStateForLog(manualKeys.xor_key, manualKeys.aes_key),
|
||||
applied: summarizeKeyStateForLog(mediaKeys.xor_key, mediaKeys.aes_key),
|
||||
errors: { ...manualKeyErrors }
|
||||
})
|
||||
if (!ok || manualKeyErrors.xor_key || manualKeyErrors.aes_key) return
|
||||
|
||||
// 用户已输入 XOR 时,自动保存一次,避免下次重复输入(失败不影响继续)
|
||||
if (mediaKeys.xor_key) {
|
||||
try {
|
||||
const aesVal = String(mediaKeys.aes_key || '').trim()
|
||||
logDecryptDebug('media-step:save-keys', {
|
||||
account: mediaAccount.value,
|
||||
keys: summarizeKeyStateForLog(mediaKeys.xor_key, aesVal)
|
||||
})
|
||||
await saveMediaKeys({
|
||||
account: mediaAccount.value || null,
|
||||
xor_key: mediaKeys.xor_key,
|
||||
aes_key: aesVal ? aesVal : null
|
||||
})
|
||||
} catch (e) {
|
||||
// ignore
|
||||
logDecryptDebug('media-step:save-keys-error', { account: mediaAccount.value, error: formatLogError(e) })
|
||||
}
|
||||
}
|
||||
currentStep.value = 2
|
||||
@@ -991,6 +1161,10 @@ const skipToChat = async () => {
|
||||
const ok = applyManualKeys()
|
||||
if (ok && mediaKeys.xor_key) {
|
||||
const aesVal = String(mediaKeys.aes_key || '').trim()
|
||||
logDecryptDebug('skip-chat:save-keys', {
|
||||
account: mediaAccount.value,
|
||||
keys: summarizeKeyStateForLog(mediaKeys.xor_key, aesVal)
|
||||
})
|
||||
await saveMediaKeys({
|
||||
account: mediaAccount.value || null,
|
||||
xor_key: mediaKeys.xor_key,
|
||||
@@ -998,7 +1172,7 @@ const skipToChat = async () => {
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
logDecryptDebug('skip-chat:save-keys-error', { account: mediaAccount.value, error: formatLogError(e) })
|
||||
}
|
||||
navigateTo('/chat')
|
||||
}
|
||||
@@ -1007,6 +1181,7 @@ const skipToChat = async () => {
|
||||
onMounted(async () => {
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
const selectedAccount = sessionStorage.getItem('selectedAccount')
|
||||
logDecryptDebug('mounted:selected-account-raw', { raw: selectedAccount || '' })
|
||||
if (selectedAccount) {
|
||||
try {
|
||||
const account = JSON.parse(selectedAccount)
|
||||
@@ -1019,9 +1194,14 @@ onMounted(async () => {
|
||||
}
|
||||
// 清除sessionStorage
|
||||
sessionStorage.removeItem('selectedAccount')
|
||||
await prefillKeysForAccount(mediaAccount.value)
|
||||
logDecryptDebug('mounted:selected-account-parsed', {
|
||||
account_name: String(account.account_name || '').trim(),
|
||||
data_dir: String(account.data_dir || '').trim()
|
||||
})
|
||||
await ensureKeysForAccount(mediaAccount.value)
|
||||
} catch (e) {
|
||||
console.error('解析账户信息失败:', e)
|
||||
logDecryptDebug('mounted:selected-account-error', { error: formatLogError(e) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,19 @@
|
||||
<svg v-if="!loading" class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
<svg v-else class="animate-spin w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg v-else class="w-4 h-4 mr-2 animate-spin" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="18"
|
||||
stroke="currentColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="28 72"
|
||||
pathLength="100"
|
||||
transform="rotate(-90 24 24)"
|
||||
></circle>
|
||||
</svg>
|
||||
{{ loading ? '检测中...' : '手动选择目录检测' }}
|
||||
</button>
|
||||
@@ -53,9 +63,19 @@
|
||||
<div>
|
||||
<!-- 检测中状态 -->
|
||||
<div v-if="loading" class="absolute inset-0 bg-white/80 backdrop-blur-sm z-20 rounded-2xl flex flex-col items-center justify-center border border-[#EDEDED]">
|
||||
<svg class="w-16 h-16 animate-spin text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg class="w-16 h-16 animate-spin text-[#07C160]" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<circle class="opacity-20" cx="24" cy="24" r="18" stroke="currentColor" stroke-width="6"></circle>
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="18"
|
||||
stroke="currentColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="28 72"
|
||||
pathLength="100"
|
||||
transform="rotate(-90 24 24)"
|
||||
></circle>
|
||||
</svg>
|
||||
<p class="mt-4 text-lg text-[#7F7F7F]">正在检测微信数据...</p>
|
||||
</div>
|
||||
@@ -395,4 +415,4 @@ onMounted(() => {
|
||||
}
|
||||
startDetection()
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"pilk>=0.2.4",
|
||||
"pypinyin>=0.53.0",
|
||||
"jieba>=0.42.1",
|
||||
"wx_key>=1.1.0",
|
||||
"wx_key>=2.0.0",
|
||||
"packaging",
|
||||
"httpx",
|
||||
]
|
||||
|
||||
@@ -3435,6 +3435,8 @@ def _parse_message_for_export(
|
||||
quote_voice_length = ""
|
||||
quote_title = ""
|
||||
quote_content = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
amount = ""
|
||||
cover_url = ""
|
||||
file_size = ""
|
||||
@@ -3472,6 +3474,8 @@ def _parse_message_for_export(
|
||||
from_username = str(parsed.get("fromUsername") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
record_item = str(parsed.get("recordItem") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
@@ -3699,6 +3703,8 @@ def _parse_message_for_export(
|
||||
from_username = str(parsed.get("fromUsername") or from_username)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
record_item = str(parsed.get("recordItem") or record_item)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
|
||||
@@ -3765,6 +3771,8 @@ def _parse_message_for_export(
|
||||
"fromUsername": from_username,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"recordItem": record_item,
|
||||
"thumbUrl": thumb_url,
|
||||
"imageMd5": image_md5,
|
||||
|
||||
@@ -1238,6 +1238,14 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
or (_extract_xml_tag_text(finder_feed, "username") if finder_feed else "")
|
||||
or (_extract_xml_tag_text(finder_feed, "finderusername") if finder_feed else "")
|
||||
)
|
||||
object_id = (
|
||||
(_extract_xml_tag_or_attr(finder_feed, "objectid") if finder_feed else "")
|
||||
or _extract_xml_tag_or_attr(text, "objectid")
|
||||
)
|
||||
object_nonce_id = (
|
||||
(_extract_xml_tag_or_attr(finder_feed, "objectnonceid") if finder_feed else "")
|
||||
or _extract_xml_tag_or_attr(text, "objectnonceid")
|
||||
)
|
||||
|
||||
thumb_url = _normalize_xml_url(
|
||||
_extract_xml_tag_or_attr(text, "thumburl")
|
||||
@@ -1277,6 +1285,8 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
"fromUsername": from_u,
|
||||
"linkType": "finder",
|
||||
"linkStyle": "finder",
|
||||
"objectId": str(object_id or "").strip(),
|
||||
"objectNonceId": str(object_nonce_id or "").strip(),
|
||||
}
|
||||
|
||||
if app_type in (33, 36):
|
||||
@@ -2418,6 +2428,8 @@ def _row_to_search_hit(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
amount = ""
|
||||
pay_sub_type = ""
|
||||
transfer_status = ""
|
||||
@@ -2441,6 +2453,8 @@ def _row_to_search_hit(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
amount = str(parsed.get("amount") or "")
|
||||
pay_sub_type = str(parsed.get("paySubType") or "")
|
||||
@@ -2526,6 +2540,8 @@ def _row_to_search_hit(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
@@ -2567,6 +2583,8 @@ def _row_to_search_hit(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"quoteUsername": quote_username,
|
||||
"quoteTitle": quote_title,
|
||||
"quoteContent": quote_content,
|
||||
|
||||
@@ -30,83 +30,92 @@ from .media_helpers import _resolve_account_dir, _resolve_account_wxid_dir
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ====================== 以下是hook逻辑 ======================================
|
||||
def _summarize_aes_key(value: Any) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if len(raw) <= 8:
|
||||
return raw
|
||||
return f"{raw[:4]}...{raw[-4:]}(len={len(raw)})"
|
||||
|
||||
@dataclass
|
||||
class HookConfig:
|
||||
min_version: str
|
||||
pattern: str
|
||||
mask: str
|
||||
offset: int
|
||||
md5_pattern: str = ""
|
||||
md5_mask: str = ""
|
||||
md5_offset: int = 0
|
||||
|
||||
def _summarize_key_payload(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
payload = payload or {}
|
||||
return {
|
||||
"wxid": str(payload.get("wxid") or "").strip(),
|
||||
"xor_key": str(payload.get("xor_key") or "").strip(),
|
||||
"aes_key": _summarize_aes_key(payload.get("aes_key")),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_wxid_dir_for_image_key(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Path:
|
||||
explicit_wxid_dir = str(wxid_dir or "").strip()
|
||||
if explicit_wxid_dir:
|
||||
candidate = Path(explicit_wxid_dir).expanduser()
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
logger.info("[image_key] 使用显式 wxid_dir: %s", str(candidate))
|
||||
return candidate
|
||||
raise FileNotFoundError(f"指定的 wxid_dir 不存在或不是目录: {candidate}")
|
||||
|
||||
explicit_db_storage_path = str(db_storage_path or "").strip()
|
||||
if explicit_db_storage_path:
|
||||
db_storage_dir = Path(explicit_db_storage_path).expanduser()
|
||||
if db_storage_dir.exists() and db_storage_dir.is_dir():
|
||||
if db_storage_dir.name.lower() == "db_storage":
|
||||
candidate = db_storage_dir.parent
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
logger.info(
|
||||
"[image_key] 通过 db_storage_path 反推出 wxid_dir: db_storage_path=%s wxid_dir=%s",
|
||||
str(db_storage_dir),
|
||||
str(candidate),
|
||||
)
|
||||
return candidate
|
||||
nested_db_storage = db_storage_dir / "db_storage"
|
||||
if nested_db_storage.exists() and nested_db_storage.is_dir():
|
||||
logger.info(
|
||||
"[image_key] db_storage_path 指向 wxid_dir,自动使用其子目录: wxid_dir=%s",
|
||||
str(db_storage_dir),
|
||||
)
|
||||
return db_storage_dir
|
||||
logger.info(
|
||||
"[image_key] 提供的 db_storage_path 无法解析 wxid_dir: %s",
|
||||
explicit_db_storage_path,
|
||||
)
|
||||
|
||||
if account:
|
||||
try:
|
||||
account_dir = _resolve_account_dir(account)
|
||||
wx_id_dir = _resolve_account_wxid_dir(account_dir)
|
||||
if wx_id_dir:
|
||||
logger.info(
|
||||
"[image_key] 通过已解密账号目录解析 wxid_dir: account=%s account_dir=%s wxid_dir=%s",
|
||||
str(account).strip(),
|
||||
str(account_dir),
|
||||
str(wx_id_dir),
|
||||
)
|
||||
return wx_id_dir
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"[image_key] 无法通过已解密账号目录解析 wxid_dir: account=%s error=%s",
|
||||
str(account).strip(),
|
||||
str(e),
|
||||
)
|
||||
|
||||
raise FileNotFoundError("无法定位该账号的 wxid_dir,请传入有效的 db_storage_path 或先完成数据库解密")
|
||||
|
||||
|
||||
# ====================== 以下是hook逻辑 ======================================
|
||||
|
||||
class WeChatKeyFetcher:
|
||||
def __init__(self):
|
||||
self.process_name = "Weixin.exe"
|
||||
self.timeout_seconds = 60
|
||||
|
||||
@staticmethod
|
||||
def _hex_array_to_str(hex_array: List[int]) -> str:
|
||||
return " ".join([f"{b:02X}" for b in hex_array])
|
||||
|
||||
def _get_hook_config(self, version_str: str) -> Optional[HookConfig]:
|
||||
try:
|
||||
v_curr = pkg_version.parse(version_str)
|
||||
except Exception as e:
|
||||
logger.error(f"版本号解析失败: {version_str} || {e}")
|
||||
return None
|
||||
|
||||
|
||||
if v_curr > pkg_version.parse("4.1.6.14"):
|
||||
return HookConfig(
|
||||
min_version=">4.1.6.14",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x50, 0x48, 0xC7, 0x45, 0x00, 0xFE, 0xFF, 0xFF, 0xFF,
|
||||
0x44, 0x89, 0xCF, 0x44, 0x89, 0xC3, 0x49, 0x89, 0xD6, 0x48,
|
||||
0x89, 0xCE, 0x48, 0x89
|
||||
]),
|
||||
mask="xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
offset=-3,
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
if pkg_version.parse("4.1.4") <= v_curr <= pkg_version.parse("4.1.6.14"):
|
||||
return HookConfig(
|
||||
min_version="4.1.4-4.1.6.14",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74,
|
||||
0x00, 0x18, 0x48, 0x89, 0x7c, 0x00, 0x20, 0x41, 0x56, 0x48,
|
||||
0x83, 0xec, 0x50, 0x41
|
||||
]),
|
||||
mask="xxxxxxxxxx?xxxx?xxxxxxxx",
|
||||
offset=-3,
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
if v_curr < pkg_version.parse("4.1.4"):
|
||||
"""图片密钥可能是错的,版本过低没有测试"""
|
||||
return HookConfig(
|
||||
min_version="<4.1.4",
|
||||
pattern=self._hex_array_to_str([
|
||||
0x24, 0x50, 0x48, 0xc7, 0x45, 0x00, 0xfe, 0xff, 0xff, 0xff,
|
||||
0x44, 0x89, 0xcf, 0x44, 0x89, 0xc3, 0x49, 0x89, 0xd6, 0x48,
|
||||
0x89, 0xce, 0x48, 0x89
|
||||
]),
|
||||
mask="xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
offset=-15, # -0xf
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def kill_wechat(self):
|
||||
"""检测并查杀微信进程"""
|
||||
killed = False
|
||||
@@ -125,9 +134,7 @@ class WeChatKeyFetcher:
|
||||
def launch_wechat(self, exe_path: str) -> int:
|
||||
"""启动微信并返回 PID"""
|
||||
try:
|
||||
|
||||
process = subprocess.Popen(exe_path)
|
||||
|
||||
time.sleep(2)
|
||||
candidates = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'create_time']):
|
||||
@@ -135,7 +142,6 @@ class WeChatKeyFetcher:
|
||||
candidates.append(proc)
|
||||
|
||||
if candidates:
|
||||
|
||||
candidates.sort(key=lambda x: x.info['create_time'], reverse=True)
|
||||
target_pid = candidates[0].info['pid']
|
||||
return target_pid
|
||||
@@ -146,8 +152,8 @@ class WeChatKeyFetcher:
|
||||
logger.error(f"启动微信失败: {e}")
|
||||
raise RuntimeError(f"无法启动微信: {e}")
|
||||
|
||||
def fetch_key(self) -> dict:
|
||||
"""调用 wx_key 获取双密钥"""
|
||||
def fetch_db_key(self) -> dict:
|
||||
"""调用 wx_key 仅获取数据库密钥 (Hook 模式)"""
|
||||
if wx_key is None:
|
||||
raise RuntimeError("wx_key 模块未安装或加载失败")
|
||||
|
||||
@@ -160,36 +166,26 @@ class WeChatKeyFetcher:
|
||||
|
||||
logger.info(f"Detect WeChat: {version} at {exe_path}")
|
||||
|
||||
config = self._get_hook_config(version)
|
||||
if not config:
|
||||
raise RuntimeError(f"原生获取失败:当前微信版本 ({version}) 过低,为保证稳定性,仅支持 4.1.5 及以上版本使用原生获取。")
|
||||
|
||||
self.kill_wechat()
|
||||
pid = self.launch_wechat(exe_path)
|
||||
logger.info(f"WeChat launched, PID: {pid}")
|
||||
|
||||
if not wx_key.initialize_hook(pid, "", config.pattern, config.mask, config.offset,
|
||||
config.md5_pattern, config.md5_mask, config.md5_offset):
|
||||
# 仅传入 PID,触发数据库密钥自动 Hook
|
||||
if not wx_key.initialize_hook(pid):
|
||||
err = wx_key.get_last_error_msg()
|
||||
raise RuntimeError(f"Hook初始化失败: {err}")
|
||||
raise RuntimeError(f"数据库 Hook 初始化失败: {err}")
|
||||
|
||||
start_time = time.time()
|
||||
found_db_key = None
|
||||
found_md5_data = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
if time.time() - start_time > self.timeout_seconds:
|
||||
raise TimeoutError("获取密钥超时 (60s),请确保在弹出的微信中完成登录。")
|
||||
raise TimeoutError("获取数据库密钥超时 (60s),请确保在弹出的微信中完成登录。")
|
||||
|
||||
key_data = wx_key.poll_key_data()
|
||||
if key_data:
|
||||
if 'key' in key_data:
|
||||
found_db_key = key_data['key']
|
||||
if 'md5' in key_data:
|
||||
found_md5_data = key_data['md5']
|
||||
|
||||
if found_db_key and found_md5_data:
|
||||
if key_data and 'key' in key_data:
|
||||
found_db_key = key_data['key']
|
||||
break
|
||||
|
||||
while True:
|
||||
@@ -204,22 +200,13 @@ class WeChatKeyFetcher:
|
||||
logger.info("Cleaning up hook...")
|
||||
wx_key.cleanup_hook()
|
||||
|
||||
aes_key = None # gemini !!! ???
|
||||
xor_key = None
|
||||
|
||||
if found_md5_data and "|" in found_md5_data:
|
||||
aes_key, xor_key_dec = found_md5_data.split("|")
|
||||
xor_key = f"0x{int(xor_key_dec):02X}"
|
||||
|
||||
return {
|
||||
"db_key": found_db_key,
|
||||
"aes_key": aes_key,
|
||||
"xor_key": xor_key
|
||||
"db_key": found_db_key
|
||||
}
|
||||
|
||||
def get_db_key_workflow():
|
||||
fetcher = WeChatKeyFetcher()
|
||||
return fetcher.fetch_key()
|
||||
return fetcher.fetch_db_key()
|
||||
|
||||
|
||||
# ============================== 以下是图片密钥逻辑 =====================================
|
||||
@@ -232,22 +219,159 @@ def get_wechat_internal_global_config(wx_dir: Path, file_name1) -> bytes:
|
||||
return Path(target_path).read_bytes()
|
||||
|
||||
|
||||
def try_get_local_image_keys() -> List[Dict[str, Any]]:
|
||||
"""尝试通过本地算法提取图片密钥 (无需 Hook)"""
|
||||
if wx_key is None or not hasattr(wx_key, 'get_image_key'):
|
||||
logger.info("[image_key] 本地算法不可用:wx_key.get_image_key 缺失")
|
||||
return []
|
||||
|
||||
try:
|
||||
res_json = wx_key.get_image_key()
|
||||
if not res_json:
|
||||
logger.info("[image_key] 本地算法返回空结果")
|
||||
return []
|
||||
|
||||
data = json.loads(res_json)
|
||||
accounts = data.get('accounts', [])
|
||||
results = []
|
||||
for acc in accounts:
|
||||
wxid = acc.get('wxid')
|
||||
keys = acc.get('keys', [])
|
||||
for k in keys:
|
||||
xor_key = k.get('xorKey')
|
||||
aes_key = k.get('aesKey')
|
||||
if xor_key is not None:
|
||||
results.append({
|
||||
"wxid": wxid,
|
||||
"xor_key": f"0x{int(xor_key):02X}",
|
||||
"aes_key": aes_key
|
||||
})
|
||||
logger.info(
|
||||
"[image_key] 本地算法完成:accounts=%s results=%s",
|
||||
len(accounts),
|
||||
[_summarize_key_payload(item) for item in results],
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"本地提取图片密钥失败: {e}")
|
||||
return []
|
||||
|
||||
async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str, Any]:
|
||||
account_dir = _resolve_account_dir(account)
|
||||
wx_id_dir = _resolve_account_wxid_dir(account_dir)
|
||||
|
||||
async def get_image_key_integrated_workflow(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
集成图片密钥获取流程:
|
||||
1. 优先尝试本地算法提取
|
||||
2. 如果本地提取失败或未匹配到指定账号,尝试远程 API 解析
|
||||
"""
|
||||
# 1. 尝试本地提取
|
||||
local_keys = try_get_local_image_keys()
|
||||
|
||||
target_account_wxid = None
|
||||
if account or wxid_dir or db_storage_path:
|
||||
try:
|
||||
resolved_wxid_dir = _resolve_wxid_dir_for_image_key(
|
||||
account,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_path=db_storage_path,
|
||||
)
|
||||
target_account_wxid = resolved_wxid_dir.name
|
||||
except Exception:
|
||||
target_account_wxid = account
|
||||
target_account_wxid = str(target_account_wxid or "").strip().lower()
|
||||
logger.info(
|
||||
"[image_key] 开始集成流程:request_account=%s target_wxid=%s local_key_count=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
target_account_wxid,
|
||||
len(local_keys),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
|
||||
if local_keys:
|
||||
# 如果指定了账号,尝试在本地结果中找匹配的
|
||||
if target_account_wxid:
|
||||
for k in local_keys:
|
||||
local_wxid = str(k.get("wxid") or "").strip().lower()
|
||||
if local_wxid and local_wxid == target_account_wxid:
|
||||
logger.info(
|
||||
"[image_key] 本地算法精确匹配成功:target_wxid=%s payload=%s",
|
||||
target_account_wxid,
|
||||
_summarize_key_payload(k),
|
||||
)
|
||||
upsert_account_keys_in_store(
|
||||
account=str(k.get("wxid") or "").strip(),
|
||||
image_xor_key=k['xor_key'],
|
||||
image_aes_key=k['aes_key']
|
||||
)
|
||||
return k
|
||||
logger.info(
|
||||
"[image_key] 本地算法未匹配到目标账号:target_wxid=%s local_wxids=%s",
|
||||
target_account_wxid,
|
||||
[str(item.get("wxid") or "").strip() for item in local_keys],
|
||||
)
|
||||
else:
|
||||
# 如果没指定账号,返回第一个发现的并存入 store (如果有的话)
|
||||
k = local_keys[0]
|
||||
logger.info(
|
||||
"[image_key] 未指定账号,返回本地首个结果:payload=%s",
|
||||
_summarize_key_payload(k),
|
||||
)
|
||||
upsert_account_keys_in_store(
|
||||
account=k['wxid'],
|
||||
image_xor_key=k['xor_key'],
|
||||
image_aes_key=k['aes_key']
|
||||
)
|
||||
return k
|
||||
|
||||
# 2. 本地提取失败或不匹配,尝试远程解析
|
||||
logger.info("[image_key] 本地算法未命中,尝试远程 API 解析")
|
||||
return await fetch_and_save_remote_keys(
|
||||
account,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_path=db_storage_path,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_and_save_remote_keys(
|
||||
account: Optional[str] = None,
|
||||
*,
|
||||
wxid_dir: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
wx_id_dir = _resolve_wxid_dir_for_image_key(
|
||||
account,
|
||||
wxid_dir=wxid_dir,
|
||||
db_storage_path=db_storage_path,
|
||||
)
|
||||
wxid = wx_id_dir.name
|
||||
|
||||
url = "https://view.free.c3o.re/api/key"
|
||||
data = {"weixinIDFolder": wxid}
|
||||
|
||||
logger.info(f"正在为账号 {wxid} 获取云端备选图片密钥...")
|
||||
logger.info(
|
||||
"[image_key] 准备请求远程密钥:request_account=%s resolved_account=%s wxid_dir=%s db_storage_path=%s",
|
||||
str(account or "").strip(),
|
||||
wxid,
|
||||
str(wx_id_dir),
|
||||
str(db_storage_path or "").strip(),
|
||||
)
|
||||
|
||||
try:
|
||||
blob1_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1="global_config")
|
||||
blob2_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1="global_config.crc")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取微信内部文件失败: {e}")
|
||||
logger.info(
|
||||
"[image_key] 远程请求输入文件已读取:wxid=%s global_config_bytes=%s crc_bytes=%s",
|
||||
wxid,
|
||||
len(blob1_bytes),
|
||||
len(blob2_bytes),
|
||||
)
|
||||
|
||||
files = {
|
||||
'fileBytes': ('file', blob1_bytes, 'application/octet-stream'),
|
||||
@@ -255,7 +379,7 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
logger.info("向云端 API 发送请求...")
|
||||
logger.info("[image_key] 向云端 API 发送请求:url=%s wxid=%s", url, wxid)
|
||||
response = await client.post(url, data=data, files=files)
|
||||
|
||||
if response.status_code != 200:
|
||||
@@ -264,6 +388,15 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
config = response.json()
|
||||
if not config:
|
||||
raise RuntimeError("云端解析失败: 返回数据为空")
|
||||
logger.info(
|
||||
"[image_key] 收到远程响应:status_code=%s keys=%s nick_name=%s",
|
||||
response.status_code,
|
||||
{
|
||||
"xor_key": str(config.get("xorKey", config.get("xor_key", ""))),
|
||||
"aes_key": _summarize_aes_key(config.get("aesKey", config.get("aes_key", ""))),
|
||||
},
|
||||
str(config.get("nickName", config.get("nick_name", ""))),
|
||||
)
|
||||
|
||||
# 新 API 的字段兼容处理
|
||||
xor_raw = str(config.get("xorKey", config.get("xor_key", "")))
|
||||
@@ -283,10 +416,16 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
image_xor_key=xor_hex_str,
|
||||
image_aes_key=aes_val
|
||||
)
|
||||
logger.info(
|
||||
"[image_key] 远程密钥已保存:account=%s xor_key=%s aes_key=%s",
|
||||
wxid,
|
||||
xor_hex_str,
|
||||
_summarize_aes_key(aes_val),
|
||||
)
|
||||
|
||||
return {
|
||||
"wxid": wxid,
|
||||
"xor_key": xor_hex_str,
|
||||
"aes_key": aes_val,
|
||||
"nick_name": config.get("nickName", config.get("nick_name", ""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3049,6 +3049,8 @@ def _append_full_messages_from_rows(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -3082,6 +3084,8 @@ def _append_full_messages_from_rows(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -3324,6 +3328,8 @@ def _append_full_messages_from_rows(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -3382,6 +3388,8 @@ def _append_full_messages_from_rows(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -4584,6 +4592,8 @@ def _collect_chat_messages(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -4617,6 +4627,8 @@ def _collect_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -4838,6 +4850,8 @@ def _collect_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -4901,6 +4915,8 @@ def _collect_chat_messages(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -5502,6 +5518,8 @@ def list_chat_messages(
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
object_id = ""
|
||||
object_nonce_id = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -5531,6 +5549,8 @@ def list_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
object_id = str(parsed.get("objectId") or "")
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -5736,6 +5756,8 @@ def list_chat_messages(
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
object_id = str(parsed.get("objectId") or object_id)
|
||||
object_nonce_id = str(parsed.get("objectNonceId") or object_nonce_id)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -5788,6 +5810,8 @@ def list_chat_messages(
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"objectId": object_id,
|
||||
"objectNonceId": object_nonce_id,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -7796,6 +7820,8 @@ async def resolve_app_message(
|
||||
"fromUsername": str(parsed.get("fromUsername") or "").strip(),
|
||||
"linkType": str(parsed.get("linkType") or "").strip(),
|
||||
"linkStyle": str(parsed.get("linkStyle") or "").strip(),
|
||||
"objectId": str(parsed.get("objectId") or "").strip(),
|
||||
"objectNonceId": str(parsed.get("objectNonceId") or "").strip(),
|
||||
"size": str(parsed.get("size") or "").strip(),
|
||||
"baseUrl": base_url,
|
||||
}
|
||||
|
||||
@@ -2,12 +2,23 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..logging_config import get_logger
|
||||
from ..key_store import get_account_keys_from_store
|
||||
from ..key_service import get_db_key_workflow, fetch_and_save_remote_keys
|
||||
from ..key_service import get_db_key_workflow, get_image_key_integrated_workflow
|
||||
from ..media_helpers import _load_media_keys, _resolve_account_dir
|
||||
from ..path_fix import PathFixRoute
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _summarize_aes_key(value: str) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if len(raw) <= 8:
|
||||
return raw
|
||||
return f"{raw[:4]}...{raw[-4:]}(len={len(raw)})"
|
||||
|
||||
|
||||
@router.get("/api/keys", summary="获取账号已保存的密钥")
|
||||
@@ -23,6 +34,13 @@ async def get_saved_keys(account: Optional[str] = None):
|
||||
# 账号可能尚未解密;仍允许从全局 store 读取(如果传入了 account)
|
||||
account_name = str(account or "").strip() or None
|
||||
|
||||
logger.info(
|
||||
"[keys] get_saved_keys start: request_account=%s resolved_account=%s account_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(account_name or ""),
|
||||
str(account_dir) if account_dir else "",
|
||||
)
|
||||
|
||||
keys: dict = {}
|
||||
if account_name:
|
||||
keys = get_account_keys_from_store(account_name)
|
||||
@@ -45,6 +63,14 @@ async def get_saved_keys(account: Optional[str] = None):
|
||||
"image_aes_key": str(keys.get("image_aes_key") or "").strip(),
|
||||
"updated_at": str(keys.get("updated_at") or "").strip(),
|
||||
}
|
||||
logger.info(
|
||||
"[keys] get_saved_keys done: account=%s db_key_present=%s xor_key=%s aes_key=%s updated_at=%s",
|
||||
str(account_name or ""),
|
||||
bool(result["db_key"]),
|
||||
result["image_xor_key"],
|
||||
_summarize_aes_key(result["image_aes_key"]),
|
||||
result["updated_at"],
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -87,7 +113,11 @@ async def get_wechat_db_key():
|
||||
|
||||
|
||||
@router.get("/api/get_image_key", summary="获取并保存微信图片密钥")
|
||||
async def get_image_key(account: Optional[str] = None):
|
||||
async def get_image_key(
|
||||
account: Optional[str] = None,
|
||||
db_storage_path: Optional[str] = None,
|
||||
wxid_dir: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
通过模拟 Next.js Server Action 协议,利用本地微信配置文件换取 AES/XOR 密钥。
|
||||
|
||||
@@ -97,7 +127,24 @@ async def get_image_key(account: Optional[str] = None):
|
||||
4. 解析返回流,自动存入本地数据库
|
||||
"""
|
||||
try:
|
||||
result = await fetch_and_save_remote_keys(account)
|
||||
logger.info(
|
||||
"[keys] get_image_key start: request_account=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
result = await get_image_key_integrated_workflow(
|
||||
account,
|
||||
db_storage_path=db_storage_path,
|
||||
wxid_dir=wxid_dir,
|
||||
)
|
||||
logger.info(
|
||||
"[keys] get_image_key done: request_account=%s response_account=%s xor_key=%s aes_key=%s",
|
||||
str(account or "").strip(),
|
||||
str(result.get("wxid") or "").strip(),
|
||||
str(result.get("xor_key") or "").strip(),
|
||||
_summarize_aes_key(str(result.get("aes_key") or "").strip()),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
@@ -105,11 +152,17 @@ async def get_image_key(account: Optional[str] = None):
|
||||
"data": {
|
||||
"xor_key": result["xor_key"],
|
||||
"aes_key": result["aes_key"],
|
||||
"nick_name": result.get("nick_name"),
|
||||
"account": result["wxid"]
|
||||
"nick_name": result.get("nick_name", ""),
|
||||
"account": result.get("wxid", "")
|
||||
}
|
||||
}
|
||||
except FileNotFoundError as e:
|
||||
logger.exception(
|
||||
"[keys] get_image_key file missing: request_account=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": f"文件缺失: {str(e)}",
|
||||
@@ -118,6 +171,12 @@ async def get_image_key(account: Optional[str] = None):
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.exception(
|
||||
"[keys] get_image_key failed: request_account=%s db_storage_path=%s wxid_dir=%s",
|
||||
str(account or "").strip(),
|
||||
str(db_storage_path or "").strip(),
|
||||
str(wxid_dir or "").strip(),
|
||||
)
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": f"获取失败: {str(e)}",
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -27,6 +27,26 @@ logger = get_logger(__name__)
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
|
||||
def _summarize_aes_key(value: Optional[str]) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if len(raw) <= 8:
|
||||
return raw
|
||||
return f"{raw[:4]}...{raw[-4:]}(len={len(raw)})"
|
||||
|
||||
|
||||
def _summarize_media_keys(*, xor_key: Optional[str] = None, aes_key: Optional[str] = None) -> dict:
|
||||
xor_str = str(xor_key or "").strip()
|
||||
aes_str = str(aes_key or "").strip()
|
||||
return {
|
||||
"xor_key": xor_str,
|
||||
"aes_key": _summarize_aes_key(aes_str),
|
||||
"has_xor": bool(xor_str),
|
||||
"has_aes": bool(aes_str),
|
||||
}
|
||||
|
||||
|
||||
class MediaKeysSaveRequest(BaseModel):
|
||||
"""媒体密钥保存请求模型(用户手动提供)"""
|
||||
|
||||
@@ -52,6 +72,12 @@ async def save_media_keys_api(request: MediaKeysSaveRequest):
|
||||
- aes_key: AES密钥(可选,至少16个字符;V4-V2需要)
|
||||
"""
|
||||
account_dir = _resolve_account_dir(request.account)
|
||||
logger.info(
|
||||
"[media] save_media_keys start: request_account=%s resolved_account=%s keys=%s",
|
||||
str(request.account or "").strip(),
|
||||
account_dir.name,
|
||||
_summarize_media_keys(xor_key=request.xor_key, aes_key=request.aes_key),
|
||||
)
|
||||
|
||||
# 解析XOR密钥
|
||||
try:
|
||||
@@ -76,6 +102,11 @@ async def save_media_keys_api(request: MediaKeysSaveRequest):
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"[media] save_media_keys done: account=%s keys=%s",
|
||||
account_dir.name,
|
||||
_summarize_media_keys(xor_key=f"0x{xor_int:02X}", aes_key=aes_str[:16] if aes_str else ""),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -99,6 +130,12 @@ async def decrypt_all_media(request: MediaDecryptRequest):
|
||||
"""
|
||||
account_dir = _resolve_account_dir(request.account)
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
logger.info(
|
||||
"[media] decrypt_all start: request_account=%s resolved_account=%s provided_keys=%s",
|
||||
str(request.account or "").strip(),
|
||||
account_dir.name,
|
||||
_summarize_media_keys(xor_key=request.xor_key, aes_key=request.aes_key),
|
||||
)
|
||||
|
||||
if not wxid_dir:
|
||||
raise HTTPException(
|
||||
@@ -125,12 +162,28 @@ async def decrypt_all_media(request: MediaDecryptRequest):
|
||||
# 如果未提供密钥,尝试从缓存加载
|
||||
if xor_key_int is None or aes_key16 is None:
|
||||
cached = _load_media_keys(account_dir)
|
||||
logger.info(
|
||||
"[media] decrypt_all cache lookup: account=%s cached_keys=%s",
|
||||
account_dir.name,
|
||||
_summarize_media_keys(
|
||||
xor_key=f"0x{int(cached.get('xor')):02X}" if cached.get("xor") is not None else "",
|
||||
aes_key=str(cached.get("aes") or "").strip(),
|
||||
),
|
||||
)
|
||||
if xor_key_int is None:
|
||||
xor_key_int = cached.get("xor")
|
||||
if aes_key16 is None:
|
||||
aes_str = str(cached.get("aes") or "").strip()
|
||||
if len(aes_str) >= 16:
|
||||
aes_key16 = aes_str[:16].encode("ascii", errors="ignore")
|
||||
logger.info(
|
||||
"[media] decrypt_all effective_keys: account=%s keys=%s",
|
||||
account_dir.name,
|
||||
_summarize_media_keys(
|
||||
xor_key=f"0x{int(xor_key_int):02X}" if xor_key_int is not None else "",
|
||||
aes_key=(aes_key16 or b"").decode("ascii", errors="ignore") if aes_key16 else "",
|
||||
),
|
||||
)
|
||||
|
||||
if xor_key_int is None:
|
||||
raise HTTPException(
|
||||
@@ -226,6 +279,7 @@ async def get_decrypted_resource(md5: str, account: Optional[str] = None):
|
||||
|
||||
@router.get("/api/media/decrypt_all_stream", summary="批量解密所有图片资源(SSE实时进度)")
|
||||
async def decrypt_all_media_stream(
|
||||
request: Request,
|
||||
account: Optional[str] = None,
|
||||
xor_key: Optional[str] = None,
|
||||
aes_key: Optional[str] = None,
|
||||
@@ -252,10 +306,26 @@ async def decrypt_all_media_stream(
|
||||
- 解密后非有效图片格式
|
||||
"""
|
||||
|
||||
async def is_client_disconnected() -> bool:
|
||||
try:
|
||||
return await request.is_disconnected()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def generate_progress():
|
||||
try:
|
||||
if await is_client_disconnected():
|
||||
logger.info("[SSE] 客户端已断开,取消图片解密任务")
|
||||
return
|
||||
|
||||
account_dir = _resolve_account_dir(account)
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
logger.info(
|
||||
"[media] decrypt_all_stream start: request_account=%s resolved_account=%s provided_keys=%s",
|
||||
str(account or "").strip(),
|
||||
account_dir.name,
|
||||
_summarize_media_keys(xor_key=xor_key, aes_key=aes_key),
|
||||
)
|
||||
|
||||
if not wxid_dir:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': '未找到微信数据目录'})}\n\n"
|
||||
@@ -281,12 +351,28 @@ async def decrypt_all_media_stream(
|
||||
# 如果未提供密钥,尝试从缓存加载
|
||||
if xor_key_int is None or aes_key16 is None:
|
||||
cached = _load_media_keys(account_dir)
|
||||
logger.info(
|
||||
"[media] decrypt_all_stream cache lookup: account=%s cached_keys=%s",
|
||||
account_dir.name,
|
||||
_summarize_media_keys(
|
||||
xor_key=f"0x{int(cached.get('xor')):02X}" if cached.get("xor") is not None else "",
|
||||
aes_key=str(cached.get("aes") or "").strip(),
|
||||
),
|
||||
)
|
||||
if xor_key_int is None:
|
||||
xor_key_int = cached.get("xor")
|
||||
if aes_key16 is None:
|
||||
aes_str = str(cached.get("aes") or "").strip()
|
||||
if len(aes_str) >= 16:
|
||||
aes_key16 = aes_str[:16].encode("ascii", errors="ignore")
|
||||
logger.info(
|
||||
"[media] decrypt_all_stream effective_keys: account=%s keys=%s",
|
||||
account_dir.name,
|
||||
_summarize_media_keys(
|
||||
xor_key=f"0x{int(xor_key_int):02X}" if xor_key_int is not None else "",
|
||||
aes_key=(aes_key16 or b"").decode("ascii", errors="ignore") if aes_key16 else "",
|
||||
),
|
||||
)
|
||||
|
||||
if xor_key_int is None:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': '未找到XOR密钥,请先使用 wx_key 获取并保存/填写'})}\n\n"
|
||||
@@ -301,6 +387,10 @@ async def decrypt_all_media_stream(
|
||||
total_files = len(dat_files)
|
||||
logger.info(f"[SSE] 共发现 {total_files} 个.dat文件(仅图片)")
|
||||
|
||||
if await is_client_disconnected():
|
||||
logger.info("[SSE] 扫描完成后客户端已断开,停止图片解密任务")
|
||||
return
|
||||
|
||||
if total_files == 0:
|
||||
yield f"data: {json.dumps({'type': 'complete', 'message': '未发现需要解密的图片文件', 'total': 0, 'success_count': 0, 'skip_count': 0, 'fail_count': 0})}\n\n"
|
||||
return
|
||||
@@ -319,6 +409,10 @@ async def decrypt_all_media_stream(
|
||||
resource_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for i, (dat_path, md5) in enumerate(dat_files):
|
||||
if await is_client_disconnected():
|
||||
logger.info("[SSE] 客户端已断开,停止图片解密任务")
|
||||
return
|
||||
|
||||
current = i + 1
|
||||
file_name = dat_path.name
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -128,6 +130,10 @@ _loaded_wcdb_api_dll: Optional[Path] = None
|
||||
_preloaded_native_libs: list[ctypes.CDLL] = []
|
||||
_protection_checked = False
|
||||
_protection_result: Optional[tuple[int, str]] = None
|
||||
_AUTO_SIDECAR_LOCK = threading.Lock()
|
||||
_AUTO_SIDECAR_PROC: Optional[subprocess.Popen] = None
|
||||
_AUTO_SIDECAR_URL = ""
|
||||
_AUTO_SIDECAR_TOKEN = ""
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
@@ -238,6 +244,197 @@ def _sidecar_enabled() -> bool:
|
||||
return bool(_sidecar_url())
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _source_sidecar_assets() -> tuple[Path, Path, Path] | None:
|
||||
if getattr(sys, "frozen", False):
|
||||
return None
|
||||
|
||||
repo_root = _repo_root()
|
||||
electron_exe = repo_root / "desktop" / "node_modules" / "electron" / "dist" / "electron.exe"
|
||||
sidecar_script = repo_root / "desktop" / "src" / "wcdb-sidecar.cjs"
|
||||
koffi_dir = repo_root / "desktop" / "vendor" / "koffi"
|
||||
|
||||
try:
|
||||
if electron_exe.is_file() and sidecar_script.is_file() and koffi_dir.exists():
|
||||
return electron_exe, sidecar_script, koffi_dir
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _auto_sidecar_started_here() -> bool:
|
||||
with _AUTO_SIDECAR_LOCK:
|
||||
return bool(_AUTO_SIDECAR_URL and _AUTO_SIDECAR_TOKEN)
|
||||
|
||||
|
||||
def _parse_port(value: object) -> Optional[int]:
|
||||
try:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
port = int(raw, 10)
|
||||
except Exception:
|
||||
return None
|
||||
if 1 <= port <= 65535:
|
||||
return port
|
||||
return None
|
||||
|
||||
|
||||
def _pick_free_port() -> int:
|
||||
requested = _parse_port(os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_PORT"))
|
||||
if requested is not None:
|
||||
return requested
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(1)
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _build_auto_sidecar_resource_paths(wcdb_api_dll: Path) -> list[str]:
|
||||
items: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(path: str | Path | None) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
resolved = Path(path).resolve()
|
||||
except Exception:
|
||||
resolved = Path(path)
|
||||
key = str(resolved).replace("/", "\\").rstrip("\\").lower()
|
||||
if not key or key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
items.append(str(resolved))
|
||||
|
||||
repo_root = _repo_root()
|
||||
dll_dir = wcdb_api_dll.parent
|
||||
add(dll_dir)
|
||||
add(dll_dir.parent)
|
||||
add(repo_root)
|
||||
add(repo_root / "resources")
|
||||
|
||||
data_dir = str(os.environ.get("WECHAT_TOOL_DATA_DIR", "") or "").strip()
|
||||
if data_dir:
|
||||
add(data_dir)
|
||||
add(Path(data_dir) / "resources")
|
||||
else:
|
||||
add(Path.cwd())
|
||||
add(Path.cwd() / "resources")
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _stop_auto_sidecar() -> None:
|
||||
global _AUTO_SIDECAR_PROC, _AUTO_SIDECAR_URL, _AUTO_SIDECAR_TOKEN
|
||||
|
||||
with _AUTO_SIDECAR_LOCK:
|
||||
proc = _AUTO_SIDECAR_PROC
|
||||
owned_url = _AUTO_SIDECAR_URL
|
||||
owned_token = _AUTO_SIDECAR_TOKEN
|
||||
_AUTO_SIDECAR_PROC = None
|
||||
_AUTO_SIDECAR_URL = ""
|
||||
_AUTO_SIDECAR_TOKEN = ""
|
||||
|
||||
if owned_url and os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_URL") == owned_url:
|
||||
os.environ.pop("WECHAT_TOOL_WCDB_SIDECAR_URL", None)
|
||||
if owned_token and os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_TOKEN") == owned_token:
|
||||
os.environ.pop("WECHAT_TOOL_WCDB_SIDECAR_TOKEN", None)
|
||||
|
||||
if proc is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5.0)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _maybe_start_auto_sidecar() -> bool:
|
||||
global _AUTO_SIDECAR_PROC, _AUTO_SIDECAR_URL, _AUTO_SIDECAR_TOKEN
|
||||
|
||||
if _sidecar_enabled() or not _is_windows():
|
||||
return False
|
||||
|
||||
assets = _source_sidecar_assets()
|
||||
if not assets:
|
||||
return False
|
||||
|
||||
wcdb_api_dll = _resolve_wcdb_api_dll_path()
|
||||
try:
|
||||
if not wcdb_api_dll.exists():
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
electron_exe, sidecar_script, koffi_dir = assets
|
||||
repo_root = _repo_root()
|
||||
|
||||
with _AUTO_SIDECAR_LOCK:
|
||||
proc = _AUTO_SIDECAR_PROC
|
||||
if proc is not None and proc.poll() is None and _AUTO_SIDECAR_URL and _AUTO_SIDECAR_TOKEN:
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_URL"] = _AUTO_SIDECAR_URL
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_TOKEN"] = _AUTO_SIDECAR_TOKEN
|
||||
return True
|
||||
|
||||
if proc is not None and proc.poll() is not None:
|
||||
_AUTO_SIDECAR_PROC = None
|
||||
_AUTO_SIDECAR_URL = ""
|
||||
_AUTO_SIDECAR_TOKEN = ""
|
||||
|
||||
port = _pick_free_port()
|
||||
token = os.urandom(24).hex()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"ELECTRON_RUN_AS_NODE": "1",
|
||||
"WECHAT_TOOL_WCDB_SIDECAR_HOST": "127.0.0.1",
|
||||
"WECHAT_TOOL_WCDB_SIDECAR_PORT": str(port),
|
||||
"WECHAT_TOOL_WCDB_SIDECAR_TOKEN": token,
|
||||
"WECHAT_TOOL_WCDB_API_DLL_PATH": str(wcdb_api_dll),
|
||||
"WECHAT_TOOL_WCDB_DLL_DIR": str(wcdb_api_dll.parent),
|
||||
"WECHAT_TOOL_WCDB_RESOURCE_PATHS": json.dumps(
|
||||
_build_auto_sidecar_resource_paths(wcdb_api_dll), ensure_ascii=False
|
||||
),
|
||||
"WECHAT_TOOL_KOFFI_DIR": str(koffi_dir),
|
||||
}
|
||||
)
|
||||
|
||||
creationflags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0)
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[str(electron_exe), str(sidecar_script)],
|
||||
cwd=str(repo_root),
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[wcdb] auto sidecar start failed: %s", exc)
|
||||
return False
|
||||
|
||||
_AUTO_SIDECAR_PROC = proc
|
||||
_AUTO_SIDECAR_URL = url
|
||||
_AUTO_SIDECAR_TOKEN = token
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_URL"] = url
|
||||
os.environ["WECHAT_TOOL_WCDB_SIDECAR_TOKEN"] = token
|
||||
|
||||
logger.info("[wcdb] auto-started electron sidecar url=%s dll=%s", _AUTO_SIDECAR_URL, wcdb_api_dll)
|
||||
return True
|
||||
|
||||
|
||||
def _sidecar_call(action: str, payload: Optional[dict[str, Any]] = None, *, timeout: float = 30.0) -> dict[str, Any]:
|
||||
base_url = _sidecar_url()
|
||||
if not base_url:
|
||||
@@ -476,30 +673,37 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
||||
|
||||
def _ensure_initialized() -> None:
|
||||
global _initialized, _loaded_wcdb_api_dll, _protection_result
|
||||
_maybe_start_auto_sidecar()
|
||||
if _sidecar_enabled():
|
||||
with _lib_lock:
|
||||
if _initialized:
|
||||
return
|
||||
result = _sidecar_call("init", timeout=30.0)
|
||||
dll_path = str(result.get("dllPath") or "").strip()
|
||||
if dll_path:
|
||||
try:
|
||||
_loaded_wcdb_api_dll = Path(dll_path)
|
||||
except Exception:
|
||||
pass
|
||||
protection = result.get("protection")
|
||||
if isinstance(protection, list):
|
||||
for item in protection:
|
||||
if isinstance(item, dict) and "rc" in item:
|
||||
try:
|
||||
_protection_result = (int(item.get("rc")), str(item.get("path") or ""))
|
||||
if int(item.get("rc")) == 0:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
with _lib_lock:
|
||||
_initialized = True
|
||||
return
|
||||
try:
|
||||
result = _sidecar_call("init", timeout=30.0)
|
||||
dll_path = str(result.get("dllPath") or "").strip()
|
||||
if dll_path:
|
||||
try:
|
||||
_loaded_wcdb_api_dll = Path(dll_path)
|
||||
except Exception:
|
||||
pass
|
||||
protection = result.get("protection")
|
||||
if isinstance(protection, list):
|
||||
for item in protection:
|
||||
if isinstance(item, dict) and "rc" in item:
|
||||
try:
|
||||
_protection_result = (int(item.get("rc")), str(item.get("path") or ""))
|
||||
if int(item.get("rc")) == 0:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
with _lib_lock:
|
||||
_initialized = True
|
||||
return
|
||||
except Exception:
|
||||
if not _auto_sidecar_started_here():
|
||||
raise
|
||||
logger.warning("[wcdb] auto sidecar init failed, fallback to in-process wcdb")
|
||||
_stop_auto_sidecar()
|
||||
|
||||
lib = _load_wcdb_lib()
|
||||
with _lib_lock:
|
||||
@@ -1188,13 +1392,15 @@ def shutdown() -> None:
|
||||
global _initialized
|
||||
if _sidecar_enabled():
|
||||
with _lib_lock:
|
||||
if not _initialized:
|
||||
return
|
||||
should_shutdown = bool(_initialized)
|
||||
try:
|
||||
_sidecar_call("shutdown", timeout=5.0)
|
||||
if should_shutdown:
|
||||
_sidecar_call("shutdown", timeout=5.0)
|
||||
finally:
|
||||
with _lib_lock:
|
||||
_initialized = False
|
||||
if _auto_sidecar_started_here():
|
||||
_stop_auto_sidecar()
|
||||
return
|
||||
|
||||
lib = _load_wcdb_lib()
|
||||
@@ -1205,6 +1411,8 @@ def shutdown() -> None:
|
||||
lib.wcdb_shutdown()
|
||||
finally:
|
||||
_initialized = False
|
||||
if _auto_sidecar_started_here():
|
||||
_stop_auto_sidecar()
|
||||
|
||||
|
||||
def _resolve_session_db_path(db_storage_dir: Path) -> Path:
|
||||
|
||||
@@ -16,6 +16,37 @@ from datetime import datetime
|
||||
from .database_filters import should_skip_source_database
|
||||
|
||||
|
||||
COMMON_WECHAT_PATTERNS = [
|
||||
"WeChat Files",
|
||||
"Weixin Files",
|
||||
"wechat_files",
|
||||
"xwechat_files",
|
||||
"wechatMSG",
|
||||
"WeChat",
|
||||
"微信",
|
||||
"Weixin",
|
||||
"wechat",
|
||||
]
|
||||
|
||||
SYSTEM_SCAN_SKIP_NAMES = {
|
||||
"$recycle.bin",
|
||||
"$winreagent",
|
||||
"config.msi",
|
||||
"documents and settings",
|
||||
"intel",
|
||||
"onedrivetemp",
|
||||
"perflogs",
|
||||
"program files",
|
||||
"program files (x86)",
|
||||
"programdata",
|
||||
"recovery",
|
||||
"system volume information",
|
||||
"windows",
|
||||
"windows.old",
|
||||
"windows.old(1)",
|
||||
}
|
||||
|
||||
|
||||
def get_wx_db(msg_dir: str = None,
|
||||
db_types: Union[List[str], str] = None,
|
||||
wxids: Union[List[str], str] = None) -> List[dict]:
|
||||
@@ -285,6 +316,87 @@ def get_process_list():
|
||||
return process_list
|
||||
|
||||
|
||||
def _is_wechat_dir_candidate_name(name: str) -> bool:
|
||||
normalized = str(name or "").strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
return any(pattern.lower() in normalized for pattern in COMMON_WECHAT_PATTERNS)
|
||||
|
||||
|
||||
def _safe_iter_subdirs(directory: str) -> List[tuple[str, str]]:
|
||||
items: List[tuple[str, str]] = []
|
||||
try:
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_dir():
|
||||
items.append((entry.name, entry.path))
|
||||
except OSError:
|
||||
continue
|
||||
except (PermissionError, OSError):
|
||||
return []
|
||||
return items
|
||||
|
||||
|
||||
def _append_detected_dir(detected_dirs: List[str], candidate: str) -> None:
|
||||
if not candidate:
|
||||
return
|
||||
normalized = os.path.normpath(candidate)
|
||||
if normalized not in detected_dirs:
|
||||
detected_dirs.append(normalized)
|
||||
|
||||
|
||||
def _build_auto_detect_scan_paths() -> List[str]:
|
||||
scan_paths: List[str] = []
|
||||
seen_paths = set()
|
||||
|
||||
def add(path_value: str | None) -> None:
|
||||
raw = str(path_value or "").strip()
|
||||
if not raw:
|
||||
return
|
||||
normalized = os.path.normpath(raw)
|
||||
key = normalized.lower()
|
||||
if key in seen_paths:
|
||||
return
|
||||
seen_paths.add(key)
|
||||
scan_paths.append(normalized)
|
||||
|
||||
home_dir = str(Path.home())
|
||||
add(home_dir)
|
||||
add(os.path.join(home_dir, "Documents"))
|
||||
add(os.path.join(home_dir, "Desktop"))
|
||||
add(os.path.join(home_dir, "Downloads"))
|
||||
|
||||
user_profile = str(os.environ.get("USERPROFILE") or "").strip()
|
||||
if user_profile:
|
||||
add(user_profile)
|
||||
add(os.path.join(user_profile, "Documents"))
|
||||
add(os.path.join(user_profile, "Desktop"))
|
||||
add(os.path.join(user_profile, "Downloads"))
|
||||
|
||||
for drive in ("C:", "D:", "E:", "F:"):
|
||||
drive_root = drive + os.sep
|
||||
if not os.path.exists(drive_root):
|
||||
continue
|
||||
|
||||
add(drive_root)
|
||||
|
||||
for child_name, child_path in _safe_iter_subdirs(drive_root):
|
||||
if child_name.strip().lower() in SYSTEM_SCAN_SKIP_NAMES:
|
||||
continue
|
||||
add(child_path)
|
||||
|
||||
users_dir = os.path.join(drive_root, "Users")
|
||||
add(users_dir)
|
||||
for _user_name, user_dir in _safe_iter_subdirs(users_dir):
|
||||
add(user_dir)
|
||||
add(os.path.join(user_dir, "Documents"))
|
||||
add(os.path.join(user_dir, "Desktop"))
|
||||
add(os.path.join(user_dir, "Downloads"))
|
||||
|
||||
return scan_paths
|
||||
|
||||
|
||||
def auto_detect_wechat_data_dirs():
|
||||
"""
|
||||
自动检测微信数据目录 - 多策略组合检测
|
||||
@@ -292,52 +404,27 @@ def auto_detect_wechat_data_dirs():
|
||||
"""
|
||||
detected_dirs = []
|
||||
|
||||
# 策略1:注册表检测已移除
|
||||
|
||||
# 策略2和策略3:注册表相关检测已移除
|
||||
|
||||
# 策略1:常见驱动器扫描微信相关目录
|
||||
common_wechat_patterns = [
|
||||
"WeChat Files", "wechat_files", "xwechat_files", "wechatMSG",
|
||||
"WeChat", "微信", "Weixin", "wechat"
|
||||
]
|
||||
|
||||
# 扫描常见驱动器
|
||||
drives = ['C:', 'D:', 'E:', 'F:']
|
||||
for drive in drives:
|
||||
if not os.path.exists(drive):
|
||||
# 策略1:常见驱动器 / 用户目录 / 自定义目录的浅层扫描。
|
||||
# 这里既检查扫描根目录本身,也检查其直接子目录,兼容:
|
||||
# - C:\Users\<user>\Documents\WeChat Files
|
||||
# - D:\wechatMSG\xwechat_files
|
||||
# - D:\abc\wechatMSG\xwechat_files
|
||||
for scan_path in _build_auto_detect_scan_paths():
|
||||
if not os.path.exists(scan_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
# 扫描驱动器根目录和常见目录
|
||||
scan_paths = [
|
||||
drive + os.sep,
|
||||
os.path.join(drive + os.sep, "Users"),
|
||||
]
|
||||
scan_name = os.path.basename(os.path.normpath(scan_path))
|
||||
if _is_wechat_dir_candidate_name(scan_name) and has_wxid_directories(scan_path):
|
||||
_append_detected_dir(detected_dirs, scan_path)
|
||||
print(f"[DEBUG] 目录扫描检测成功: {scan_path}")
|
||||
|
||||
for scan_path in scan_paths:
|
||||
if not os.path.exists(scan_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
for item in os.listdir(scan_path):
|
||||
item_path = os.path.join(scan_path, item)
|
||||
if not os.path.isdir(item_path):
|
||||
continue
|
||||
|
||||
# 检查是否匹配微信目录模式
|
||||
for pattern in common_wechat_patterns:
|
||||
if pattern.lower() in item.lower():
|
||||
# 检查是否包含wxid目录
|
||||
if has_wxid_directories(item_path):
|
||||
if item_path not in detected_dirs:
|
||||
detected_dirs.append(item_path)
|
||||
print(f"[DEBUG] 目录扫描检测成功: {item_path}")
|
||||
break
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
for item_name, item_path in _safe_iter_subdirs(scan_path):
|
||||
if not _is_wechat_dir_candidate_name(item_name):
|
||||
continue
|
||||
if not has_wxid_directories(item_path):
|
||||
continue
|
||||
_append_detected_dir(detected_dirs, item_path)
|
||||
print(f"[DEBUG] 目录扫描检测成功: {item_path}")
|
||||
|
||||
# 策略2:进程内存分析(简化版)
|
||||
try:
|
||||
@@ -361,12 +448,11 @@ def auto_detect_wechat_data_dirs():
|
||||
break
|
||||
|
||||
for parent_dir in parent_dirs:
|
||||
for pattern in common_wechat_patterns:
|
||||
for pattern in COMMON_WECHAT_PATTERNS:
|
||||
potential_dir = os.path.join(parent_dir, pattern)
|
||||
if os.path.exists(potential_dir) and has_wxid_directories(potential_dir):
|
||||
if potential_dir not in detected_dirs:
|
||||
detected_dirs.append(potential_dir)
|
||||
print(f"[DEBUG] 进程分析检测成功: {potential_dir}")
|
||||
_append_detected_dir(detected_dirs, potential_dir)
|
||||
print(f"[DEBUG] 进程分析检测成功: {potential_dir}")
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
import wechat_decrypt_tool.key_service as key_service
|
||||
|
||||
|
||||
class TestKeyServiceImageKeyAccountMatch(unittest.TestCase):
|
||||
def test_local_image_keys_do_not_match_by_substring(self) -> None:
|
||||
remote_result = {
|
||||
"wxid": "wxid_demo_extra",
|
||||
"xor_key": "0x8A",
|
||||
"aes_key": "BBBBBBBBBBBBBBBB",
|
||||
}
|
||||
|
||||
with mock.patch.object(
|
||||
key_service,
|
||||
"try_get_local_image_keys",
|
||||
return_value=[
|
||||
{"wxid": "wxid_demo", "xor_key": "0x01", "aes_key": "AAAAAAAAAAAAAAAA"},
|
||||
],
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_dir",
|
||||
return_value=Path("D:/tmp/output/databases/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_wxid_dir",
|
||||
return_value=Path("D:/tmp/xwechat_files/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"upsert_account_keys_in_store",
|
||||
) as upsert_mock, mock.patch.object(
|
||||
key_service,
|
||||
"fetch_and_save_remote_keys",
|
||||
new=mock.AsyncMock(return_value=remote_result),
|
||||
) as remote_mock:
|
||||
result = asyncio.run(key_service.get_image_key_integrated_workflow("wxid_demo_extra"))
|
||||
|
||||
self.assertEqual(result, remote_result)
|
||||
remote_mock.assert_awaited_once_with("wxid_demo_extra", wxid_dir=None, db_storage_path=None)
|
||||
upsert_mock.assert_not_called()
|
||||
|
||||
def test_local_image_keys_require_exact_account_match(self) -> None:
|
||||
with mock.patch.object(
|
||||
key_service,
|
||||
"try_get_local_image_keys",
|
||||
return_value=[
|
||||
{"wxid": "wxid_demo", "xor_key": "0x01", "aes_key": "AAAAAAAAAAAAAAAA"},
|
||||
{"wxid": "wxid_demo_extra", "xor_key": "0x8A", "aes_key": "BBBBBBBBBBBBBBBB"},
|
||||
],
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_dir",
|
||||
return_value=Path("D:/tmp/output/databases/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_wxid_dir",
|
||||
return_value=Path("D:/tmp/xwechat_files/wxid_demo_extra"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"upsert_account_keys_in_store",
|
||||
) as upsert_mock, mock.patch.object(
|
||||
key_service,
|
||||
"fetch_and_save_remote_keys",
|
||||
new=mock.AsyncMock(side_effect=AssertionError("remote should not be called")),
|
||||
):
|
||||
result = asyncio.run(key_service.get_image_key_integrated_workflow("wxid_demo_extra"))
|
||||
|
||||
self.assertEqual(result["wxid"], "wxid_demo_extra")
|
||||
self.assertEqual(result["xor_key"], "0x8A")
|
||||
self.assertEqual(result["aes_key"], "BBBBBBBBBBBBBBBB")
|
||||
upsert_mock.assert_called_once_with(
|
||||
account="wxid_demo_extra",
|
||||
image_xor_key="0x8A",
|
||||
image_aes_key="BBBBBBBBBBBBBBBB",
|
||||
)
|
||||
|
||||
def test_fetch_remote_keys_can_use_db_storage_path_without_decrypted_output(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
wxid_dir = Path(temp_dir) / "xwechat_files" / "wxid_v4mbduwqtzpt22"
|
||||
db_storage_dir = wxid_dir / "db_storage"
|
||||
db_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
class _FakeResponse:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {
|
||||
"xorKey": "138",
|
||||
"aesKey": "c3f3366e23628242",
|
||||
"nickName": "demo",
|
||||
}
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def post(self, url, data=None, files=None):
|
||||
self.last_url = url
|
||||
self.last_data = data
|
||||
self.last_files = files
|
||||
return _FakeResponse()
|
||||
|
||||
with mock.patch.object(
|
||||
key_service,
|
||||
"_resolve_account_dir",
|
||||
side_effect=AssertionError("should not require decrypted account dir"),
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"get_wechat_internal_global_config",
|
||||
side_effect=[b"global-config", b"crc-bytes"],
|
||||
), mock.patch.object(
|
||||
key_service.httpx,
|
||||
"AsyncClient",
|
||||
_FakeAsyncClient,
|
||||
), mock.patch.object(
|
||||
key_service,
|
||||
"upsert_account_keys_in_store",
|
||||
) as upsert_mock:
|
||||
result = asyncio.run(
|
||||
key_service.fetch_and_save_remote_keys(
|
||||
"wxid_v4mbduwqtzpt22",
|
||||
db_storage_path=str(db_storage_dir),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(result["wxid"], "wxid_v4mbduwqtzpt22")
|
||||
self.assertEqual(result["xor_key"], "0x8A")
|
||||
self.assertEqual(result["aes_key"], "c3f3366e23628242")
|
||||
upsert_mock.assert_called_once_with(
|
||||
account="wxid_v4mbduwqtzpt22",
|
||||
image_xor_key="0x8A",
|
||||
image_aes_key="c3f3366e23628242",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,74 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.routers import media as media_router # noqa: E402 pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
class _FakeDisconnectingRequest:
|
||||
def __init__(self, disconnect_after: int):
|
||||
self._disconnect_after = disconnect_after
|
||||
self._calls = 0
|
||||
|
||||
async def is_disconnected(self):
|
||||
self._calls += 1
|
||||
return self._calls >= self._disconnect_after
|
||||
|
||||
|
||||
class TestMediaDecryptStreamCancel(unittest.TestCase):
|
||||
def test_stream_stops_processing_when_client_disconnects(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account_dir = root / "account"
|
||||
wxid_dir = root / "wxid"
|
||||
dat_path = wxid_dir / "image.dat"
|
||||
resource_dir = account_dir / "resource"
|
||||
wxid_dir.mkdir(parents=True, exist_ok=True)
|
||||
dat_path.write_bytes(b"encrypted")
|
||||
|
||||
request = _FakeDisconnectingRequest(disconnect_after=3)
|
||||
decrypt_mock = mock.Mock(return_value=(True, "ok"))
|
||||
|
||||
with mock.patch.object(media_router, "_resolve_account_dir", return_value=account_dir):
|
||||
with mock.patch.object(media_router, "_resolve_account_wxid_dir", return_value=wxid_dir):
|
||||
with mock.patch.object(media_router, "_load_media_keys", return_value={"xor": 0xA5, "aes": ""}):
|
||||
with mock.patch.object(media_router, "_collect_all_dat_files", return_value=[(dat_path, "abc123")]):
|
||||
with mock.patch.object(media_router, "_get_resource_dir", return_value=resource_dir):
|
||||
with mock.patch.object(media_router, "_try_find_decrypted_resource", return_value=None):
|
||||
with mock.patch.object(media_router, "_decrypt_and_save_resource", decrypt_mock):
|
||||
response = asyncio.run(
|
||||
media_router.decrypt_all_media_stream(
|
||||
request=request,
|
||||
account="wxid_demo",
|
||||
)
|
||||
)
|
||||
|
||||
async def _read_chunks():
|
||||
chunks = []
|
||||
async for chunk in response.body_iterator:
|
||||
chunks.append(chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk))
|
||||
return chunks
|
||||
|
||||
chunks = asyncio.run(_read_chunks())
|
||||
|
||||
events = []
|
||||
for chunk in chunks:
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("data: "):
|
||||
events.append(json.loads(line[len("data: ") :]))
|
||||
|
||||
self.assertEqual([event.get("type") for event in events], ["scanning", "start"])
|
||||
decrypt_mock.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -148,6 +148,26 @@ class TestParseAppMessage(unittest.TestCase):
|
||||
self.assertEqual(parsed.get("thumbUrl"), "https://finder.video.qq.com/cover.jpg")
|
||||
self.assertEqual(parsed.get("url"), "https://channels.weixin.qq.com/web/pages/feed?feedid=abc")
|
||||
|
||||
def test_finder_type_51_exposes_object_fields(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>当前版本不支持展示该内容,请升级至最新版本。</title>'
|
||||
'<des></des>'
|
||||
'<type>51</type>'
|
||||
'<finderFeed>'
|
||||
'<nickname><![CDATA[央视新闻]]></nickname>'
|
||||
'<objectId><![CDATA[1234567890]]></objectId>'
|
||||
'<objectNonceId><![CDATA[nonce-abc]]></objectNonceId>'
|
||||
'</finderFeed>'
|
||||
'</appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("linkType"), "finder")
|
||||
self.assertEqual(parsed.get("objectId"), "1234567890")
|
||||
self.assertEqual(parsed.get("objectNonceId"), "nonce-abc")
|
||||
|
||||
def test_quote_type_5_nested_xml_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestWechatDetectionAutoDetect(unittest.TestCase):
|
||||
def test_detect_wechat_installation_finds_nested_custom_data_root(self):
|
||||
from wechat_decrypt_tool import wechat_detection as wd
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
nested_scan_root = Path(td) / "abc"
|
||||
wechat_parent = nested_scan_root / "wechatMSG"
|
||||
xwechat_root = wechat_parent / "xwechat_files"
|
||||
|
||||
login_dir = xwechat_root / "all_users" / "login" / "wxid_demo"
|
||||
login_dir.mkdir(parents=True, exist_ok=True)
|
||||
(login_dir / "key_info.db").write_bytes(b"demo")
|
||||
|
||||
account_dir = xwechat_root / "wxid_demo_nested"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
(account_dir / "contact.db").write_bytes(b"demo")
|
||||
|
||||
with (
|
||||
patch.object(wd, "_build_auto_detect_scan_paths", return_value=[str(nested_scan_root)]),
|
||||
patch.object(wd, "get_process_list", return_value=[]),
|
||||
):
|
||||
detected_dirs = wd.auto_detect_wechat_data_dirs()
|
||||
result = wd.detect_wechat_installation()
|
||||
|
||||
self.assertEqual(detected_dirs, [str(wechat_parent)])
|
||||
self.assertEqual(result["total_accounts"], 1)
|
||||
self.assertEqual(result["accounts"][0]["account_name"], "wxid_demo")
|
||||
self.assertEqual(result["accounts"][0]["data_dir"], str(account_dir))
|
||||
self.assertEqual(result["total_databases"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
@@ -919,7 +919,7 @@ requires-dist = [
|
||||
{ name = "requests", specifier = ">=2.32.4" },
|
||||
{ name = "typing-extensions", specifier = ">=4.8.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
||||
{ name = "wx-key", specifier = ">=1.1.0" },
|
||||
{ name = "wx-key", specifier = ">=2.0.0" },
|
||||
{ name = "zstandard", specifier = ">=0.23.0" },
|
||||
]
|
||||
provides-extras = ["build"]
|
||||
@@ -935,13 +935,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "wx-key"
|
||||
version = "1.1.0"
|
||||
version = "2.0.0"
|
||||
source = { registry = "tools/key_wheels" }
|
||||
wheels = [
|
||||
{ path = "wx_key-1.1.0-cp311-cp311-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp312-cp312-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp313-cp313-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp314-cp314-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp311-cp311-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp312-cp312-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp313-cp313-win_amd64.whl" },
|
||||
{ path = "wx_key-2.0.0-cp314-cp314-win_amd64.whl" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user