mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-02 22:10:50 +08:00
feat(media): 添加图片资源解密与SSE进度
- 后端新增图片解密密钥获取/保存接口(/api/media/keys)
- 新增批量解密接口与输出结构:resource/{md5前2位}/{md5}.{ext}
- 新增资源直读接口(/api/media/resource/{md5}),自动识别媒体类型返回
- 新增SSE实时进度接口(/api/media/decrypt_all_stream),前端可实时展示进度
- 前端解密页增加图片解密步骤与进度条/统计/失败说明,并对接相关API
- README 补充图片资源解密使用说明
This commit is contained in:
43
README.md
43
README.md
@@ -49,6 +49,7 @@
|
|||||||
- **API接口**: 提供RESTful API接口进行数据库解密操作
|
- **API接口**: 提供RESTful API接口进行数据库解密操作
|
||||||
- **Web界面**: 提供现代化的Web操作界面
|
- **Web界面**: 提供现代化的Web操作界面
|
||||||
- **聊天记录查看**: 支持查看解密后的聊天记录(基础功能)
|
- **聊天记录查看**: 支持查看解密后的聊天记录(基础功能)
|
||||||
|
- **图片资源解密**: 支持批量解密微信图片(.dat文件),按MD5哈希存储便于快速访问
|
||||||
|
|
||||||
### 开发计划
|
### 开发计划
|
||||||
|
|
||||||
@@ -156,6 +157,48 @@ uv run analyze_wechat_databases.py
|
|||||||
- `{数据库名}/README.md`:该数据库概览
|
- `{数据库名}/README.md`:该数据库概览
|
||||||
- `{数据库名}/{表名}.md`:各表详细结构、索引、外键、示例数据与建表语句
|
- `{数据库名}/{表名}.md`:各表详细结构、索引、外键、示例数据与建表语句
|
||||||
|
|
||||||
|
### 图片资源解密
|
||||||
|
|
||||||
|
微信的图片文件(.dat)是加密存储的,需要解密后才能正常显示。本工具提供了API接口进行批量解密。
|
||||||
|
|
||||||
|
#### 1. 获取图片解密密钥
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# GET请求获取密钥(需要微信正在运行以提取AES密钥)
|
||||||
|
curl http://localhost:8000/api/media/keys
|
||||||
|
|
||||||
|
# 强制重新提取密钥
|
||||||
|
curl "http://localhost:8000/api/media/keys?force_extract=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回示例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"xor_key": "0xA5",
|
||||||
|
"aes_key": "xxxxxxxxxxxxxxxx",
|
||||||
|
"message": "XOR密钥提取成功。已从微信进程提取AES密钥"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 批量解密所有图片
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# POST请求批量解密所有图片到 output/databases/{账号}/resource 目录
|
||||||
|
curl -X POST http://localhost:8000/api/media/decrypt_all \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
解密后的图片按MD5哈希命名,存储在 `resource/{md5前2位}/{md5}.{ext}` 路径下,便于快速查找。
|
||||||
|
|
||||||
|
#### 3. 获取已解密的图片
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 直接通过MD5获取已解密的图片(更快)
|
||||||
|
curl http://localhost:8000/api/media/resource/{md5}
|
||||||
|
```
|
||||||
|
|
||||||
## 安全说明
|
## 安全说明
|
||||||
|
|
||||||
**重要提醒**:
|
**重要提醒**:
|
||||||
|
|||||||
75
frontend/components/Stepper.vue
Normal file
75
frontend/components/Stepper.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div v-for="(step, index) in steps" :key="index" class="flex items-center flex-1" :class="index === steps.length - 1 ? 'flex-none' : ''">
|
||||||
|
<!-- 步骤圆点 -->
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300"
|
||||||
|
:class="getStepClass(index)"
|
||||||
|
>
|
||||||
|
<!-- 已完成显示勾选 -->
|
||||||
|
<svg v-if="index < currentStep" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
<!-- 未完成显示数字 -->
|
||||||
|
<span v-else>{{ index + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 步骤标题 -->
|
||||||
|
<div
|
||||||
|
class="mt-2 text-xs font-medium whitespace-nowrap transition-colors duration-300"
|
||||||
|
:class="getTextClass(index)"
|
||||||
|
>
|
||||||
|
{{ step.title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连接线 -->
|
||||||
|
<div
|
||||||
|
v-if="index < steps.length - 1"
|
||||||
|
class="flex-1 h-0.5 mx-4 transition-colors duration-300"
|
||||||
|
:class="index < currentStep ? 'bg-[#07C160]' : 'bg-[#EDEDED]'"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
steps: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
// 每个step应该有 { title: string, description?: string }
|
||||||
|
},
|
||||||
|
currentStep: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取步骤圆点样式
|
||||||
|
const getStepClass = (index) => {
|
||||||
|
if (index < props.currentStep) {
|
||||||
|
// 已完成
|
||||||
|
return 'bg-[#07C160] text-white'
|
||||||
|
} else if (index === props.currentStep) {
|
||||||
|
// 当前步骤
|
||||||
|
return 'bg-[#07C160] text-white ring-4 ring-[#07C160]/20'
|
||||||
|
} else {
|
||||||
|
// 未开始
|
||||||
|
return 'bg-[#F7F7F7] text-[#7F7F7F] border-2 border-[#EDEDED]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文字样式
|
||||||
|
const getTextClass = (index) => {
|
||||||
|
if (index <= props.currentStep) {
|
||||||
|
return 'text-[#07C160]'
|
||||||
|
} else {
|
||||||
|
return 'text-[#7F7F7F]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -95,6 +95,37 @@ export const useApi = () => {
|
|||||||
return await request(url, { method: 'POST' })
|
return await request(url, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取图片解密密钥
|
||||||
|
const getMediaKeys = async (params = {}) => {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params && params.account) query.set('account', params.account)
|
||||||
|
if (params && params.force_extract) query.set('force_extract', 'true')
|
||||||
|
const url = '/media/keys' + (query.toString() ? `?${query.toString()}` : '')
|
||||||
|
return await request(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存图片解密密钥
|
||||||
|
const saveMediaKeys = async (params = {}) => {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params && params.account) query.set('account', params.account)
|
||||||
|
if (params && params.xor_key) query.set('xor_key', params.xor_key)
|
||||||
|
if (params && params.aes_key) query.set('aes_key', params.aes_key)
|
||||||
|
const url = '/media/keys' + (query.toString() ? `?${query.toString()}` : '')
|
||||||
|
return await request(url, { method: 'POST', body: { account: params.account, force_extract: false } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量解密所有图片
|
||||||
|
const decryptAllMedia = async (params = {}) => {
|
||||||
|
return await request('/media/decrypt_all', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
account: params.account || null,
|
||||||
|
xor_key: params.xor_key || null,
|
||||||
|
aes_key: params.aes_key || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
detectWechat,
|
detectWechat,
|
||||||
detectCurrentAccount,
|
detectCurrentAccount,
|
||||||
@@ -103,6 +134,9 @@ export const useApi = () => {
|
|||||||
listChatAccounts,
|
listChatAccounts,
|
||||||
listChatSessions,
|
listChatSessions,
|
||||||
listChatMessages,
|
listChatMessages,
|
||||||
openChatMediaFolder
|
openChatMediaFolder,
|
||||||
|
getMediaKeys,
|
||||||
|
saveMediaKeys,
|
||||||
|
decryptAllMedia
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="min-h-screen flex items-center justify-center py-8">
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto px-6 w-full">
|
<div class="max-w-4xl mx-auto px-6 w-full">
|
||||||
<!-- 解密表单 -->
|
<!-- 步骤指示器 -->
|
||||||
<div class="bg-white rounded-2xl border border-[#EDEDED]">
|
<div class="mb-8">
|
||||||
|
<Stepper :steps="steps" :current-step="currentStep" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤1: 数据库解密 -->
|
||||||
|
<div v-if="currentStep === 0" class="bg-white rounded-2xl border border-[#EDEDED]">
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
<div class="flex items-center mb-6">
|
<div class="flex items-center mb-6">
|
||||||
<div class="w-12 h-12 bg-[#07C160] rounded-lg flex items-center justify-center mr-4">
|
<div class="w-12 h-12 bg-[#07C160] rounded-lg flex items-center justify-center mr-4">
|
||||||
@@ -12,7 +17,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-[#000000e6]">解密配置</h2>
|
<h2 class="text-xl font-bold text-[#000000e6]">数据库解密</h2>
|
||||||
<p class="text-sm text-[#7F7F7F]">输入密钥和路径开始解密</p>
|
<p class="text-sm text-[#7F7F7F]">输入密钥和路径开始解密</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,15 +113,240 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤2: 图片密钥获取 -->
|
||||||
|
<div v-if="currentStep === 1" class="bg-white rounded-2xl border border-[#EDEDED]">
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="flex items-center mb-6">
|
||||||
|
<div class="w-12 h-12 bg-[#10AEEF] rounded-lg flex items-center justify-center mr-4">
|
||||||
|
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-[#000000e6]">图片密钥</h2>
|
||||||
|
<p class="text-sm text-[#7F7F7F]">获取图片解密所需的密钥</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 密钥信息显示 -->
|
||||||
|
<div class="space-y-4 mb-6">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<span class="text-sm font-medium text-[#000000e6]">XOR 密钥</span>
|
||||||
|
<span class="font-mono text-sm px-3 py-1 bg-white rounded border border-[#EDEDED]">
|
||||||
|
{{ mediaKeys.xor_key || '未获取' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm font-medium text-[#000000e6]">AES 密钥</span>
|
||||||
|
<span class="font-mono text-sm px-3 py-1 bg-white rounded border border-[#EDEDED]">
|
||||||
|
{{ mediaKeys.aes_key ? mediaKeys.aes_key.substring(0, 8) + '...' : '未获取' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mediaKeys.message" class="text-sm text-[#7F7F7F] flex items-start">
|
||||||
|
<svg class="w-4 h-4 mr-2 mt-0.5 flex-shrink-0 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>
|
||||||
|
{{ mediaKeys.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="flex gap-3 justify-center pt-4 border-t border-[#EDEDED]">
|
||||||
|
<button
|
||||||
|
@click="fetchMediaKeys(false)"
|
||||||
|
:disabled="mediaLoading"
|
||||||
|
class="inline-flex items-center px-6 py-3 bg-[#10AEEF] text-white rounded-lg font-medium hover:bg-[#0D9BD9] transition-all duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg v-if="mediaLoading" class="w-5 h-5 mr-2 animate-spin" 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 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-5 h-5 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>
|
||||||
|
{{ mediaLoading ? '获取中...' : '获取密钥' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="fetchMediaKeys(true)"
|
||||||
|
:disabled="mediaLoading"
|
||||||
|
class="inline-flex items-center px-6 py-3 bg-white text-[#10AEEF] border border-[#10AEEF] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
强制重新提取
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="mediaKeys.xor_key"
|
||||||
|
@click="goToStep(2)"
|
||||||
|
class="inline-flex items-center px-6 py-3 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-all duration-200"
|
||||||
|
>
|
||||||
|
下一步
|
||||||
|
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 跳过按钮 -->
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<button @click="skipToChat" class="text-sm text-[#7F7F7F] hover:text-[#07C160] transition-colors">
|
||||||
|
跳过图片解密,直接查看聊天记录 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤3: 批量解密图片 -->
|
||||||
|
<div v-if="currentStep === 2" class="bg-white rounded-2xl border border-[#EDEDED]">
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-12 h-12 bg-[#91D300] rounded-lg flex items-center justify-center mr-4">
|
||||||
|
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-[#000000e6]">批量解密图片</h2>
|
||||||
|
<p class="text-sm text-[#7F7F7F]">仅解密加密的图片文件(.dat),其他文件无需解密</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 进度计数 -->
|
||||||
|
<div v-if="mediaDecrypting && decryptProgress.total > 0" class="text-right">
|
||||||
|
<div class="text-lg font-bold text-[#91D300]">{{ decryptProgress.current }} / {{ decryptProgress.total }}</div>
|
||||||
|
<div class="text-xs text-[#7F7F7F]">已处理 / 总图片</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 实时进度条 -->
|
||||||
|
<div v-if="mediaDecrypting || decryptProgress.total > 0" class="mb-6">
|
||||||
|
<!-- 进度条 -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="flex justify-between text-xs text-[#7F7F7F] mb-1">
|
||||||
|
<span>解密进度</span>
|
||||||
|
<span>{{ progressPercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<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]'"
|
||||||
|
:style="{ width: progressPercent + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 当前文件名 -->
|
||||||
|
<div v-if="decryptProgress.current_file" class="flex items-center text-sm text-[#7F7F7F] mb-3">
|
||||||
|
<svg class="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14"/>
|
||||||
|
</svg>
|
||||||
|
<span class="truncate font-mono text-xs">{{ decryptProgress.current_file }}</span>
|
||||||
|
<span
|
||||||
|
class="ml-2 px-2 py-0.5 rounded text-xs"
|
||||||
|
:class="{
|
||||||
|
'bg-green-100 text-green-700': decryptProgress.fileStatus === 'success',
|
||||||
|
'bg-gray-100 text-gray-600': decryptProgress.fileStatus === 'skip',
|
||||||
|
'bg-red-100 text-red-700': decryptProgress.fileStatus === 'fail'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ decryptProgress.fileStatus === 'success' ? '解密成功' : decryptProgress.fileStatus === 'skip' ? '已存在' : decryptProgress.fileStatus === 'fail' ? '失败' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 实时统计 -->
|
||||||
|
<div class="grid grid-cols-4 gap-3 text-center bg-gray-50 rounded-lg p-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-bold text-[#10AEEF]">{{ decryptProgress.total }}</div>
|
||||||
|
<div class="text-xs text-[#7F7F7F]">总图片</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-bold text-[#07C160]">{{ decryptProgress.success_count }}</div>
|
||||||
|
<div class="text-xs text-[#7F7F7F]">成功</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-bold text-[#7F7F7F]">{{ decryptProgress.skip_count }}</div>
|
||||||
|
<div class="text-xs text-[#7F7F7F]">跳过(已解密)</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-bold text-[#FA5151]">{{ decryptProgress.fail_count }}</div>
|
||||||
|
<div class="text-xs text-[#7F7F7F]">失败</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 完成后的结果 -->
|
||||||
|
<div v-if="mediaDecryptResult && !mediaDecrypting" class="mb-6">
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-green-700">解密完成</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-green-600">
|
||||||
|
输出目录: <code class="bg-white px-2 py-1 rounded text-xs">{{ mediaDecryptResult.output_dir }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 失败原因说明 -->
|
||||||
|
<div v-if="decryptProgress.fail_count > 0" class="mb-6">
|
||||||
|
<details class="text-sm">
|
||||||
|
<summary class="cursor-pointer text-[#7F7F7F] hover:text-[#000000e6]">
|
||||||
|
<span class="ml-1">查看失败原因说明</span>
|
||||||
|
</summary>
|
||||||
|
<div class="mt-2 bg-gray-50 rounded-lg p-3 text-xs text-[#7F7F7F]">
|
||||||
|
<p class="mb-2">可能的失败原因:</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<li><strong>解密后非有效图片</strong>:文件不是图片格式(如视频缩略图损坏)</li>
|
||||||
|
<li><strong>V4-V2版本需要AES密钥</strong>:需要微信运行时才能提取AES密钥</li>
|
||||||
|
<li><strong>未知加密版本</strong>:新版微信使用了不支持的加密方式</li>
|
||||||
|
<li><strong>文件为空</strong>:原始文件损坏或为空文件</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="flex gap-3 justify-center pt-4 border-t border-[#EDEDED]">
|
||||||
|
<button
|
||||||
|
@click="decryptAllImages"
|
||||||
|
:disabled="mediaDecrypting"
|
||||||
|
class="inline-flex items-center px-6 py-3 bg-[#91D300] text-white rounded-lg font-medium hover:bg-[#82BD00] transition-all duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg v-if="mediaDecrypting" class="w-5 h-5 mr-2 animate-spin" 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 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14"/>
|
||||||
|
</svg>
|
||||||
|
{{ mediaDecrypting ? '解密中...' : (mediaDecryptResult ? '重新解密' : '开始解密图片') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="skipToChat"
|
||||||
|
:disabled="mediaDecrypting"
|
||||||
|
class="inline-flex items-center px-6 py-3 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-all duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
查看聊天记录
|
||||||
|
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 错误提示 -->
|
<!-- 错误提示 -->
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mt-6 animate-shake flex items-start">
|
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mt-6 animate-shake flex items-start">
|
||||||
<svg class="h-5 w-5 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 mr-2 flex-shrink-0 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">解密失败</p>
|
<p class="font-semibold text-red-700">操作失败</p>
|
||||||
<p class="text-sm mt-1">{{ error }}</p>
|
<p class="text-sm mt-1 text-red-600">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
@@ -146,13 +376,21 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
|
|
||||||
const { decryptDatabase } = useApi()
|
const { decryptDatabase, getMediaKeys, decryptAllMedia } = useApi()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const currentStep = ref(0)
|
||||||
|
|
||||||
|
// 步骤定义
|
||||||
|
const steps = [
|
||||||
|
{ title: '数据库解密' },
|
||||||
|
{ title: '图片密钥' },
|
||||||
|
{ title: '图片解密' }
|
||||||
|
]
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
@@ -166,6 +404,39 @@ const formErrors = reactive({
|
|||||||
db_storage_path: ''
|
db_storage_path: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 图片密钥相关
|
||||||
|
const mediaKeys = reactive({
|
||||||
|
xor_key: '',
|
||||||
|
aes_key: '',
|
||||||
|
message: ''
|
||||||
|
})
|
||||||
|
const mediaLoading = ref(false)
|
||||||
|
|
||||||
|
// 图片解密相关
|
||||||
|
const mediaDecryptResult = ref(null)
|
||||||
|
const mediaDecrypting = ref(false)
|
||||||
|
|
||||||
|
// 实时解密进度
|
||||||
|
const decryptProgress = reactive({
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
success_count: 0,
|
||||||
|
skip_count: 0,
|
||||||
|
fail_count: 0,
|
||||||
|
current_file: '',
|
||||||
|
fileStatus: '',
|
||||||
|
status: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 进度百分比
|
||||||
|
const progressPercent = computed(() => {
|
||||||
|
if (decryptProgress.total === 0) return 0
|
||||||
|
return Math.round((decryptProgress.current / decryptProgress.total) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解密结果存储
|
||||||
|
const decryptResult = ref(null)
|
||||||
|
|
||||||
// 验证表单
|
// 验证表单
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
let isValid = true
|
let isValid = true
|
||||||
@@ -209,11 +480,15 @@ const handleDecrypt = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.status === 'completed') {
|
if (result.status === 'completed') {
|
||||||
// 解密成功,跳转到结果页面
|
// 解密成功,保存结果并进入下一步
|
||||||
|
decryptResult.value = result
|
||||||
if (process.client && typeof window !== 'undefined') {
|
if (process.client && typeof window !== 'undefined') {
|
||||||
sessionStorage.setItem('decryptResult', JSON.stringify(result))
|
sessionStorage.setItem('decryptResult', JSON.stringify(result))
|
||||||
}
|
}
|
||||||
navigateTo('/decrypt-result')
|
// 进入图片密钥获取步骤
|
||||||
|
currentStep.value = 1
|
||||||
|
// 自动尝试获取图片密钥
|
||||||
|
fetchMediaKeys(false)
|
||||||
} else if (result.status === 'failed') {
|
} else if (result.status === 'failed') {
|
||||||
if (result.failure_count > 0 && result.success_count === 0) {
|
if (result.failure_count > 0 && result.success_count === 0) {
|
||||||
error.value = result.message || '所有文件解密失败'
|
error.value = result.message || '所有文件解密失败'
|
||||||
@@ -230,6 +505,115 @@ const handleDecrypt = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取图片密钥
|
||||||
|
const fetchMediaKeys = async (forceExtract = false) => {
|
||||||
|
mediaLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getMediaKeys({ force_extract: forceExtract })
|
||||||
|
|
||||||
|
if (result.status === 'success') {
|
||||||
|
mediaKeys.xor_key = result.xor_key || ''
|
||||||
|
mediaKeys.aes_key = result.aes_key || ''
|
||||||
|
mediaKeys.message = result.message || ''
|
||||||
|
} else {
|
||||||
|
error.value = result.message || '获取密钥失败'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message || '获取密钥过程中发生错误'
|
||||||
|
} finally {
|
||||||
|
mediaLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量解密所有图片(使用SSE实时进度)
|
||||||
|
const decryptAllImages = async () => {
|
||||||
|
mediaDecrypting.value = true
|
||||||
|
mediaDecryptResult.value = null
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
// 重置进度
|
||||||
|
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 = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构建SSE URL
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (mediaKeys.xor_key) params.set('xor_key', mediaKeys.xor_key)
|
||||||
|
if (mediaKeys.aes_key) params.set('aes_key', mediaKeys.aes_key)
|
||||||
|
const url = `http://localhost:8000/api/media/decrypt_all_stream?${params.toString()}`
|
||||||
|
|
||||||
|
// 使用EventSource接收SSE
|
||||||
|
const eventSource = new EventSource(url)
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
|
||||||
|
if (data.type === 'scanning') {
|
||||||
|
decryptProgress.current_file = '正在扫描文件...'
|
||||||
|
} else if (data.type === 'start') {
|
||||||
|
decryptProgress.total = data.total
|
||||||
|
} else if (data.type === 'progress') {
|
||||||
|
decryptProgress.current = data.current
|
||||||
|
decryptProgress.total = data.total
|
||||||
|
decryptProgress.success_count = data.success_count
|
||||||
|
decryptProgress.skip_count = data.skip_count
|
||||||
|
decryptProgress.fail_count = data.fail_count
|
||||||
|
decryptProgress.current_file = data.current_file
|
||||||
|
decryptProgress.fileStatus = data.status
|
||||||
|
} else if (data.type === 'complete') {
|
||||||
|
decryptProgress.status = 'complete'
|
||||||
|
decryptProgress.current = data.total
|
||||||
|
decryptProgress.total = data.total
|
||||||
|
decryptProgress.success_count = data.success_count
|
||||||
|
decryptProgress.skip_count = data.skip_count
|
||||||
|
decryptProgress.fail_count = data.fail_count
|
||||||
|
mediaDecryptResult.value = data
|
||||||
|
eventSource.close()
|
||||||
|
mediaDecrypting.value = false
|
||||||
|
} else if (data.type === 'error') {
|
||||||
|
error.value = data.message
|
||||||
|
eventSource.close()
|
||||||
|
mediaDecrypting.value = false
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析SSE消息失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onerror = (e) => {
|
||||||
|
console.error('SSE连接错误:', e)
|
||||||
|
eventSource.close()
|
||||||
|
if (mediaDecrypting.value) {
|
||||||
|
error.value = 'SSE连接中断,请重试'
|
||||||
|
mediaDecrypting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message || '图片解密过程中发生错误'
|
||||||
|
mediaDecrypting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到指定步骤
|
||||||
|
const goToStep = (step) => {
|
||||||
|
currentStep.value = step
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过图片解密,直接查看聊天记录
|
||||||
|
const skipToChat = () => {
|
||||||
|
navigateTo('/chat')
|
||||||
|
}
|
||||||
|
|
||||||
// 页面加载时检查是否有选中的账户
|
// 页面加载时检查是否有选中的账户
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (process.client && typeof window !== 'undefined') {
|
if (process.client && typeof window !== 'undefined') {
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ except Exception:
|
|||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import Response, FileResponse
|
from fastapi.responses import Response, FileResponse, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from .logging_config import setup_logging, get_logger
|
from .logging_config import setup_logging, get_logger
|
||||||
from .wechat_decrypt import decrypt_wechat_databases
|
from .wechat_decrypt import decrypt_wechat_databases
|
||||||
@@ -494,7 +495,7 @@ def _resolve_media_path_from_hardlink(
|
|||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=4096)
|
@lru_cache(maxsize=4096)
|
||||||
def _fallback_search_media_by_md5(weixin_root_str: str, md5: str) -> Optional[str]:
|
def _fallback_search_media_by_md5(weixin_root_str: str, md5: str, kind: str = "") -> Optional[str]:
|
||||||
if not weixin_root_str or not md5:
|
if not weixin_root_str or not md5:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -502,26 +503,40 @@ def _fallback_search_media_by_md5(weixin_root_str: str, md5: str) -> Optional[st
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
search_dirs = [
|
kind_key = str(kind or "").lower().strip()
|
||||||
root / "msg" / "attach",
|
|
||||||
root / "msg" / "file",
|
# 根据类型选择搜索目录
|
||||||
root / "msg" / "video",
|
if kind_key == "file":
|
||||||
root / "cache",
|
search_dirs = [root / "msg" / "file"]
|
||||||
]
|
else:
|
||||||
# 优先顺序: _h.dat (高清) > _t.dat (缩略图) > 普通 .dat > 其他格式
|
search_dirs = [
|
||||||
# 因为基础 .dat 可能是 wxgf 容器格式,而 _h.dat/_t.dat 是真正的图片
|
root / "msg" / "attach",
|
||||||
patterns = [
|
root / "msg" / "file",
|
||||||
f"{md5}_h.dat", # 高清图优先
|
root / "msg" / "video",
|
||||||
f"{md5}_t.dat", # 缩略图次之
|
root / "cache",
|
||||||
f"{md5}.dat", # 基础 dat
|
]
|
||||||
f"{md5}*.dat", # 其他 dat 变体
|
|
||||||
f"{md5}*.jpg",
|
# 根据类型选择搜索模式
|
||||||
f"{md5}*.jpeg",
|
if kind_key == "file":
|
||||||
f"{md5}*.png",
|
# 文件类型:搜索所有包含md5的文件
|
||||||
f"{md5}*.gif",
|
patterns = [
|
||||||
f"{md5}*.webp",
|
f"*{md5}*", # 任何包含md5的文件
|
||||||
f"{md5}*.mp4",
|
]
|
||||||
]
|
else:
|
||||||
|
# 优先顺序: _h.dat (高清) > _t.dat (缩略图) > 普通 .dat > 其他格式
|
||||||
|
# 因为基础 .dat 可能是 wxgf 容器格式,而 _h.dat/_t.dat 是真正的图片
|
||||||
|
patterns = [
|
||||||
|
f"{md5}_h.dat", # 高清图优先
|
||||||
|
f"{md5}_t.dat", # 缩略图次之
|
||||||
|
f"{md5}.dat", # 基础 dat
|
||||||
|
f"{md5}*.dat", # 其他 dat 变体
|
||||||
|
f"{md5}*.jpg",
|
||||||
|
f"{md5}*.jpeg",
|
||||||
|
f"{md5}*.png",
|
||||||
|
f"{md5}*.gif",
|
||||||
|
f"{md5}*.webp",
|
||||||
|
f"{md5}*.mp4",
|
||||||
|
]
|
||||||
|
|
||||||
for d in search_dirs:
|
for d in search_dirs:
|
||||||
try:
|
try:
|
||||||
@@ -882,6 +897,149 @@ def _load_media_keys(account_dir: Path) -> dict[str, Any]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ===================== 解密资源目录相关辅助函数 =====================
|
||||||
|
|
||||||
|
def _get_resource_dir(account_dir: Path) -> Path:
|
||||||
|
"""获取解密资源输出目录"""
|
||||||
|
return account_dir / "resource"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_decrypted_resource_path(account_dir: Path, md5: str, ext: str = "") -> Path:
|
||||||
|
"""根据MD5获取解密后资源的路径"""
|
||||||
|
resource_dir = _get_resource_dir(account_dir)
|
||||||
|
# 使用MD5前2位作为子目录,避免单目录文件过多
|
||||||
|
sub_dir = md5[:2] if len(md5) >= 2 else "00"
|
||||||
|
if ext:
|
||||||
|
return resource_dir / sub_dir / f"{md5}.{ext}"
|
||||||
|
return resource_dir / sub_dir / md5
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_image_extension(data: bytes) -> str:
|
||||||
|
"""根据图片数据检测文件扩展名"""
|
||||||
|
if not data:
|
||||||
|
return "dat"
|
||||||
|
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
return "png"
|
||||||
|
if data.startswith(b"\xff\xd8\xff"):
|
||||||
|
return "jpg"
|
||||||
|
if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
|
||||||
|
return "gif"
|
||||||
|
if data.startswith(b"RIFF") and len(data) >= 12 and data[8:12] == b"WEBP":
|
||||||
|
return "webp"
|
||||||
|
return "dat"
|
||||||
|
|
||||||
|
|
||||||
|
def _try_find_decrypted_resource(account_dir: Path, md5: str) -> Optional[Path]:
|
||||||
|
"""尝试在解密资源目录中查找已解密的资源"""
|
||||||
|
if not md5:
|
||||||
|
return None
|
||||||
|
resource_dir = _get_resource_dir(account_dir)
|
||||||
|
if not resource_dir.exists():
|
||||||
|
return None
|
||||||
|
sub_dir = md5[:2] if len(md5) >= 2 else "00"
|
||||||
|
target_dir = resource_dir / sub_dir
|
||||||
|
if not target_dir.exists():
|
||||||
|
return None
|
||||||
|
# 查找匹配MD5的文件(可能有不同扩展名)
|
||||||
|
for ext in ["jpg", "png", "gif", "webp", "dat"]:
|
||||||
|
p = target_dir / f"{md5}.{ext}"
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_all_dat_files(wxid_dir: Path) -> list[tuple[Path, str]]:
|
||||||
|
"""收集所有需要解密的.dat文件,返回 (文件路径, md5) 列表"""
|
||||||
|
results: list[tuple[Path, str]] = []
|
||||||
|
if not wxid_dir or not wxid_dir.exists():
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 搜索目录
|
||||||
|
search_dirs = [
|
||||||
|
wxid_dir / "msg" / "attach",
|
||||||
|
wxid_dir / "cache",
|
||||||
|
]
|
||||||
|
|
||||||
|
for search_dir in search_dirs:
|
||||||
|
if not search_dir.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
for dat_file in search_dir.rglob("*.dat"):
|
||||||
|
if not dat_file.is_file():
|
||||||
|
continue
|
||||||
|
# 从文件名提取MD5
|
||||||
|
stem = dat_file.stem
|
||||||
|
# 文件名格式可能是: md5.dat, md5_t.dat, md5_h.dat 等
|
||||||
|
md5 = stem.split("_")[0] if "_" in stem else stem
|
||||||
|
# 验证是否是有效的MD5(32位十六进制)
|
||||||
|
if len(md5) == 32 and all(c in "0123456789abcdefABCDEF" for c in md5):
|
||||||
|
results.append((dat_file, md5.lower()))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"扫描目录失败 {search_dir}: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_and_save_resource(
|
||||||
|
dat_path: Path,
|
||||||
|
md5: str,
|
||||||
|
account_dir: Path,
|
||||||
|
xor_key: int,
|
||||||
|
aes_key: Optional[bytes],
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""解密单个资源文件并保存到resource目录
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = dat_path.read_bytes()
|
||||||
|
if not data:
|
||||||
|
return False, "文件为空"
|
||||||
|
|
||||||
|
version = _detect_wechat_dat_version(data)
|
||||||
|
decrypted: Optional[bytes] = None
|
||||||
|
|
||||||
|
if version == 0:
|
||||||
|
# V3: 纯XOR解密
|
||||||
|
decrypted = _decrypt_wechat_dat_v3(data, xor_key)
|
||||||
|
elif version == 1:
|
||||||
|
# V4-V1: 使用固定AES密钥
|
||||||
|
decrypted = _decrypt_wechat_dat_v4(data, xor_key, b"cfcd208495d565ef")
|
||||||
|
elif version == 2:
|
||||||
|
# V4-V2: 需要动态AES密钥
|
||||||
|
if aes_key and len(aes_key) >= 16:
|
||||||
|
decrypted = _decrypt_wechat_dat_v4(data, xor_key, aes_key[:16])
|
||||||
|
else:
|
||||||
|
return False, "V4-V2版本需要AES密钥"
|
||||||
|
else:
|
||||||
|
# 尝试简单XOR解密
|
||||||
|
dec, mt = _try_xor_decrypt_by_magic(data)
|
||||||
|
if dec:
|
||||||
|
decrypted = dec
|
||||||
|
else:
|
||||||
|
return False, f"未知加密版本: {version}"
|
||||||
|
|
||||||
|
if not decrypted:
|
||||||
|
return False, "解密结果为空"
|
||||||
|
|
||||||
|
# 检测图片类型
|
||||||
|
ext = _detect_image_extension(decrypted)
|
||||||
|
mt = _detect_image_media_type(decrypted[:32])
|
||||||
|
if mt == "application/octet-stream":
|
||||||
|
# 解密可能失败,跳过
|
||||||
|
return False, "解密后非有效图片"
|
||||||
|
|
||||||
|
# 保存到resource目录
|
||||||
|
output_path = _get_decrypted_resource_path(account_dir, md5, ext)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_bytes(decrypted)
|
||||||
|
|
||||||
|
return True, str(output_path)
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
def _read_and_maybe_decrypt_media(path: Path, account_dir: Optional[Path] = None, weixin_root: Optional[Path] = None) -> tuple[bytes, str]:
|
def _read_and_maybe_decrypt_media(path: Path, account_dir: Optional[Path] = None, weixin_root: Optional[Path] = None) -> tuple[bytes, str]:
|
||||||
# Fast path: already a normal image
|
# Fast path: already a normal image
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
@@ -1263,14 +1421,14 @@ def _infer_transfer_status_text(
|
|||||||
if rs == "1":
|
if rs == "1":
|
||||||
return "已收款"
|
return "已收款"
|
||||||
if rs == "2":
|
if rs == "2":
|
||||||
return "已退回"
|
return "已退还"
|
||||||
if rs == "3":
|
if rs == "3":
|
||||||
return "已过期"
|
return "已过期"
|
||||||
|
|
||||||
if t == "4":
|
if t == "4":
|
||||||
return "已退回"
|
return "已退还"
|
||||||
if t == "9":
|
if t == "9":
|
||||||
return "已被退回"
|
return "已被退还"
|
||||||
if t == "10":
|
if t == "10":
|
||||||
return "已过期"
|
return "已过期"
|
||||||
|
|
||||||
@@ -1278,7 +1436,10 @@ def _infer_transfer_status_text(
|
|||||||
if t == "8":
|
if t == "8":
|
||||||
return "发起转账"
|
return "发起转账"
|
||||||
if t == "3":
|
if t == "3":
|
||||||
return "接收转账"
|
# paysubtype=3 表示收款方已收款
|
||||||
|
# 对于发起方(isSent=True)显示"已收款",表示对方已收
|
||||||
|
# 对于收款方(isSent=False)显示"已被接收",表示自己收到转账
|
||||||
|
return "已收款" if is_sent else "已被接收"
|
||||||
if t == "1":
|
if t == "1":
|
||||||
return "转账"
|
return "转账"
|
||||||
|
|
||||||
@@ -1451,6 +1612,13 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
|||||||
receivertitle = _extract_xml_tag_or_attr(text, "receivertitle")
|
receivertitle = _extract_xml_tag_or_attr(text, "receivertitle")
|
||||||
senderdes = _extract_xml_tag_or_attr(text, "senderdes")
|
senderdes = _extract_xml_tag_or_attr(text, "senderdes")
|
||||||
receiverdes = _extract_xml_tag_or_attr(text, "receiverdes")
|
receiverdes = _extract_xml_tag_or_attr(text, "receiverdes")
|
||||||
|
transferid = _extract_xml_tag_or_attr(text, "transferid")
|
||||||
|
invalidtime = _extract_xml_tag_or_attr(text, "invalidtime")
|
||||||
|
# 调试日志:记录转账消息的关键字段
|
||||||
|
logger.debug(
|
||||||
|
f"[转账解析] paysubtype={paysubtype}, receivestatus={receivestatus}, "
|
||||||
|
f"transferid={transferid}, feedesc={feedesc}"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"renderType": "transfer",
|
"renderType": "transfer",
|
||||||
"content": (pay_memo or "").strip(),
|
"content": (pay_memo or "").strip(),
|
||||||
@@ -1462,6 +1630,8 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
|||||||
"receiverTitle": receivertitle or "",
|
"receiverTitle": receivertitle or "",
|
||||||
"senderDes": senderdes or "",
|
"senderDes": senderdes or "",
|
||||||
"receiverDes": receiverdes or "",
|
"receiverDes": receiverdes or "",
|
||||||
|
"transferId": str(transferid or "").strip(),
|
||||||
|
"invalidTime": str(invalidtime or "").strip(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if app_type in (2001, 2003) or "<wcpayinfo" in text and ("redenvelope" in text.lower() or "sendertitle" in text.lower()):
|
if app_type in (2001, 2003) or "<wcpayinfo" in text and ("redenvelope" in text.lower() or "sendertitle" in text.lower()):
|
||||||
@@ -1826,6 +1996,15 @@ async def get_chat_image(md5: str, account: Optional[str] = None, username: Opti
|
|||||||
if not md5:
|
if not md5:
|
||||||
raise HTTPException(status_code=400, detail="Missing md5.")
|
raise HTTPException(status_code=400, detail="Missing md5.")
|
||||||
account_dir = _resolve_account_dir(account)
|
account_dir = _resolve_account_dir(account)
|
||||||
|
|
||||||
|
# 优先从解密资源目录读取(更快)
|
||||||
|
decrypted_path = _try_find_decrypted_resource(account_dir, md5.lower())
|
||||||
|
if decrypted_path:
|
||||||
|
data = decrypted_path.read_bytes()
|
||||||
|
media_type = _detect_image_media_type(data[:32])
|
||||||
|
return Response(content=data, media_type=media_type)
|
||||||
|
|
||||||
|
# 回退到原始逻辑:从微信数据目录实时解密
|
||||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||||
hardlink_db_path = account_dir / "hardlink.db"
|
hardlink_db_path = account_dir / "hardlink.db"
|
||||||
extra_roots: list[Path] = []
|
extra_roots: list[Path] = []
|
||||||
@@ -1868,6 +2047,15 @@ async def get_chat_emoji(md5: str, account: Optional[str] = None, username: Opti
|
|||||||
if not md5:
|
if not md5:
|
||||||
raise HTTPException(status_code=400, detail="Missing md5.")
|
raise HTTPException(status_code=400, detail="Missing md5.")
|
||||||
account_dir = _resolve_account_dir(account)
|
account_dir = _resolve_account_dir(account)
|
||||||
|
|
||||||
|
# 优先从解密资源目录读取(更快)
|
||||||
|
decrypted_path = _try_find_decrypted_resource(account_dir, md5.lower())
|
||||||
|
if decrypted_path:
|
||||||
|
data = decrypted_path.read_bytes()
|
||||||
|
media_type = _detect_image_media_type(data[:32])
|
||||||
|
return Response(content=data, media_type=media_type)
|
||||||
|
|
||||||
|
# 回退到原始逻辑
|
||||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||||
hardlink_db_path = account_dir / "hardlink.db"
|
hardlink_db_path = account_dir / "hardlink.db"
|
||||||
extra_roots: list[Path] = []
|
extra_roots: list[Path] = []
|
||||||
@@ -1906,6 +2094,15 @@ async def get_chat_video_thumb(md5: str, account: Optional[str] = None, username
|
|||||||
if not md5:
|
if not md5:
|
||||||
raise HTTPException(status_code=400, detail="Missing md5.")
|
raise HTTPException(status_code=400, detail="Missing md5.")
|
||||||
account_dir = _resolve_account_dir(account)
|
account_dir = _resolve_account_dir(account)
|
||||||
|
|
||||||
|
# 优先从解密资源目录读取(更快)
|
||||||
|
decrypted_path = _try_find_decrypted_resource(account_dir, md5.lower())
|
||||||
|
if decrypted_path:
|
||||||
|
data = decrypted_path.read_bytes()
|
||||||
|
media_type = _detect_image_media_type(data[:32])
|
||||||
|
return Response(content=data, media_type=media_type)
|
||||||
|
|
||||||
|
# 回退到原始逻辑
|
||||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||||
hardlink_db_path = account_dir / "hardlink.db"
|
hardlink_db_path = account_dir / "hardlink.db"
|
||||||
extra_roots: list[Path] = []
|
extra_roots: list[Path] = []
|
||||||
@@ -2067,6 +2264,17 @@ def _resolve_media_path_for_kind(
|
|||||||
) -> Optional[Path]:
|
) -> Optional[Path]:
|
||||||
if not md5:
|
if not md5:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
kind_key = str(kind or "").strip().lower()
|
||||||
|
|
||||||
|
# 优先查找解密后的资源目录(图片、表情、视频缩略图)
|
||||||
|
if kind_key in {"image", "emoji", "video_thumb"}:
|
||||||
|
decrypted_path = _try_find_decrypted_resource(account_dir, md5.lower())
|
||||||
|
if decrypted_path:
|
||||||
|
logger.debug(f"找到解密资源: {decrypted_path}")
|
||||||
|
return decrypted_path
|
||||||
|
|
||||||
|
# 回退到原始逻辑:从微信数据目录查找
|
||||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||||
hardlink_db_path = account_dir / "hardlink.db"
|
hardlink_db_path = account_dir / "hardlink.db"
|
||||||
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
|
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
|
||||||
@@ -2092,7 +2300,7 @@ def _resolve_media_path_for_kind(
|
|||||||
extra_roots=roots[1:],
|
extra_roots=roots[1:],
|
||||||
)
|
)
|
||||||
if (not p) and wxid_dir:
|
if (not p) and wxid_dir:
|
||||||
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5))
|
hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5), kind=kind_key)
|
||||||
if hit:
|
if hit:
|
||||||
p = Path(hit)
|
p = Path(hit)
|
||||||
return p
|
return p
|
||||||
@@ -2614,6 +2822,7 @@ async def list_chat_messages(
|
|||||||
pay_sub_type = ""
|
pay_sub_type = ""
|
||||||
transfer_status = ""
|
transfer_status = ""
|
||||||
file_md5 = ""
|
file_md5 = ""
|
||||||
|
transfer_id = ""
|
||||||
|
|
||||||
if local_type == 10000:
|
if local_type == 10000:
|
||||||
render_type = "system"
|
render_type = "system"
|
||||||
@@ -2636,8 +2845,12 @@ async def list_chat_messages(
|
|||||||
file_size = str(parsed.get("size") or "")
|
file_size = str(parsed.get("size") or "")
|
||||||
pay_sub_type = str(parsed.get("paySubType") or "")
|
pay_sub_type = str(parsed.get("paySubType") or "")
|
||||||
file_md5 = str(parsed.get("fileMd5") or "")
|
file_md5 = str(parsed.get("fileMd5") or "")
|
||||||
|
transfer_id = str(parsed.get("transferId") or "")
|
||||||
|
|
||||||
if render_type == "transfer":
|
if render_type == "transfer":
|
||||||
|
# 直接从原始 XML 提取 transferid(可能在 wcpayinfo 内)
|
||||||
|
if not transfer_id:
|
||||||
|
transfer_id = _extract_xml_tag_or_attr(raw_text, "transferid") or ""
|
||||||
transfer_status = _infer_transfer_status_text(
|
transfer_status = _infer_transfer_status_text(
|
||||||
is_sent=is_sent,
|
is_sent=is_sent,
|
||||||
paysubtype=pay_sub_type,
|
paysubtype=pay_sub_type,
|
||||||
@@ -2743,8 +2956,12 @@ async def list_chat_messages(
|
|||||||
file_size = str(parsed.get("size") or file_size)
|
file_size = str(parsed.get("size") or file_size)
|
||||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||||
file_md5 = str(parsed.get("fileMd5") or file_md5)
|
file_md5 = str(parsed.get("fileMd5") or file_md5)
|
||||||
|
transfer_id = str(parsed.get("transferId") or transfer_id)
|
||||||
|
|
||||||
if render_type == "transfer":
|
if render_type == "transfer":
|
||||||
|
# 如果 transferId 仍为空,尝试从原始 XML 提取
|
||||||
|
if not transfer_id:
|
||||||
|
transfer_id = _extract_xml_tag_or_attr(content_text, "transferid") or ""
|
||||||
transfer_status = _infer_transfer_status_text(
|
transfer_status = _infer_transfer_status_text(
|
||||||
is_sent=is_sent,
|
is_sent=is_sent,
|
||||||
paysubtype=pay_sub_type,
|
paysubtype=pay_sub_type,
|
||||||
@@ -2795,6 +3012,7 @@ async def list_chat_messages(
|
|||||||
"fileMd5": file_md5,
|
"fileMd5": file_md5,
|
||||||
"paySubType": pay_sub_type,
|
"paySubType": pay_sub_type,
|
||||||
"transferStatus": transfer_status,
|
"transferStatus": transfer_status,
|
||||||
|
"transferId": transfer_id,
|
||||||
"_rawText": raw_text if local_type == 266287972401 else "",
|
"_rawText": raw_text if local_type == 266287972401 else "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -2807,6 +3025,76 @@ async def list_chat_messages(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 后处理:关联转账消息的最终状态
|
||||||
|
# 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配
|
||||||
|
# paysubtype 含义:1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期
|
||||||
|
|
||||||
|
# 收集已退还和已收款的转账ID和金额
|
||||||
|
returned_transfer_ids: set[str] = set() # 退还状态的 transferId
|
||||||
|
received_transfer_ids: set[str] = set() # 已收款状态的 transferId
|
||||||
|
returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配
|
||||||
|
received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配
|
||||||
|
|
||||||
|
for m in merged:
|
||||||
|
if m.get("renderType") == "transfer":
|
||||||
|
pst = str(m.get("paySubType") or "")
|
||||||
|
tid = str(m.get("transferId") or "").strip()
|
||||||
|
amt = str(m.get("amount") or "")
|
||||||
|
ts = int(m.get("createTime") or 0)
|
||||||
|
|
||||||
|
if pst in ("4", "9"): # 退还状态
|
||||||
|
if tid:
|
||||||
|
returned_transfer_ids.add(tid)
|
||||||
|
if amt:
|
||||||
|
returned_amounts_with_time.append((amt, ts))
|
||||||
|
elif pst == "3": # 已收款状态
|
||||||
|
if tid:
|
||||||
|
received_transfer_ids.add(tid)
|
||||||
|
if amt:
|
||||||
|
received_amounts_with_time.append((amt, ts))
|
||||||
|
|
||||||
|
# 更新原始转账消息的状态
|
||||||
|
for m in merged:
|
||||||
|
if m.get("renderType") == "transfer":
|
||||||
|
pst = str(m.get("paySubType") or "")
|
||||||
|
# 只更新未确定状态的原始转账消息(paysubtype=1 或 8)
|
||||||
|
if pst in ("1", "8"):
|
||||||
|
tid = str(m.get("transferId") or "").strip()
|
||||||
|
amt = str(m.get("amount") or "")
|
||||||
|
ts = int(m.get("createTime") or 0)
|
||||||
|
|
||||||
|
# 优先检查退还状态(退还优先于收款)
|
||||||
|
should_mark_returned = False
|
||||||
|
should_mark_received = False
|
||||||
|
|
||||||
|
# 策略1:精确 transferId 匹配
|
||||||
|
if tid:
|
||||||
|
if tid in returned_transfer_ids:
|
||||||
|
should_mark_returned = True
|
||||||
|
elif tid in received_transfer_ids:
|
||||||
|
should_mark_received = True
|
||||||
|
|
||||||
|
# 策略2:回退到金额+时间窗口匹配(24小时内同金额)
|
||||||
|
if not should_mark_returned and not should_mark_received and amt:
|
||||||
|
for ret_amt, ret_ts in returned_amounts_with_time:
|
||||||
|
if ret_amt == amt and abs(ret_ts - ts) <= 86400:
|
||||||
|
should_mark_returned = True
|
||||||
|
break
|
||||||
|
if not should_mark_returned:
|
||||||
|
for rec_amt, rec_ts in received_amounts_with_time:
|
||||||
|
if rec_amt == amt and abs(rec_ts - ts) <= 86400:
|
||||||
|
should_mark_received = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if should_mark_returned:
|
||||||
|
m["paySubType"] = "9"
|
||||||
|
m["transferStatus"] = "已被退还"
|
||||||
|
elif should_mark_received:
|
||||||
|
m["paySubType"] = "3"
|
||||||
|
# 根据 isSent 判断:发起方显示"已收款",收款方显示"已被接收"
|
||||||
|
is_sent = m.get("isSent", False)
|
||||||
|
m["transferStatus"] = "已收款" if is_sent else "已被接收"
|
||||||
|
|
||||||
uniq_senders = list(dict.fromkeys([u for u in (sender_usernames + list(pat_usernames)) if u]))
|
uniq_senders = list(dict.fromkeys([u for u in (sender_usernames + list(pat_usernames)) if u]))
|
||||||
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
|
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
|
||||||
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
|
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
|
||||||
@@ -2891,6 +3179,426 @@ async def list_chat_messages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ===================== 图片密钥与资源解密相关 API =====================
|
||||||
|
|
||||||
|
class MediaKeysRequest(BaseModel):
|
||||||
|
"""媒体密钥请求模型"""
|
||||||
|
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
|
||||||
|
force_extract: bool = Field(False, description="是否强制从微信进程重新提取密钥")
|
||||||
|
|
||||||
|
|
||||||
|
class MediaDecryptRequest(BaseModel):
|
||||||
|
"""媒体解密请求模型"""
|
||||||
|
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
|
||||||
|
xor_key: Optional[str] = Field(None, description="XOR密钥(十六进制,如 0xA5 或 A5)")
|
||||||
|
aes_key: Optional[str] = Field(None, description="AES密钥(16字符ASCII字符串)")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/media/keys", summary="获取图片解密密钥")
|
||||||
|
async def get_media_keys(account: Optional[str] = None, force_extract: bool = False):
|
||||||
|
"""获取图片解密密钥(XOR和AES)
|
||||||
|
|
||||||
|
如果已缓存密钥且不强制提取,直接返回缓存的密钥。
|
||||||
|
否则尝试从微信进程中提取密钥。
|
||||||
|
|
||||||
|
注意:提取AES密钥需要微信进程正在运行。
|
||||||
|
"""
|
||||||
|
account_dir = _resolve_account_dir(account)
|
||||||
|
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||||
|
|
||||||
|
# 尝试加载已缓存的密钥
|
||||||
|
cached_keys = _load_media_keys(account_dir)
|
||||||
|
if cached_keys and not force_extract:
|
||||||
|
xor_key = cached_keys.get("xor")
|
||||||
|
aes_key = cached_keys.get("aes")
|
||||||
|
if xor_key is not None and aes_key:
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"source": "cache",
|
||||||
|
"xor_key": f"0x{int(xor_key):02X}",
|
||||||
|
"aes_key": str(aes_key)[:16] if aes_key else "",
|
||||||
|
"message": "已从缓存加载密钥",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not wxid_dir:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "未找到微信数据目录,请确保已正确配置 db_storage_path",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 尝试提取XOR密钥
|
||||||
|
xor_key = _find_wechat_xor_key(str(wxid_dir))
|
||||||
|
if xor_key is None:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "无法提取XOR密钥,请确保微信数据目录中存在 _t.dat 模板文件",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 尝试提取AES密钥(需要微信进程运行)
|
||||||
|
aes_key16: Optional[bytes] = None
|
||||||
|
aes_message = ""
|
||||||
|
|
||||||
|
most_common = _get_wechat_template_most_common_last2(str(wxid_dir))
|
||||||
|
if most_common:
|
||||||
|
ct = _get_wechat_v2_ciphertext(wxid_dir, most_common)
|
||||||
|
if ct:
|
||||||
|
aes_key16 = _extract_wechat_aes_key_from_process(ct)
|
||||||
|
if aes_key16:
|
||||||
|
aes_message = "已从微信进程提取AES密钥"
|
||||||
|
# 保存密钥到缓存
|
||||||
|
_save_media_keys(account_dir, xor_key, aes_key16)
|
||||||
|
else:
|
||||||
|
aes_message = "无法从微信进程提取AES密钥(微信是否正在运行?)"
|
||||||
|
else:
|
||||||
|
aes_message = "未找到V2加密模板文件"
|
||||||
|
else:
|
||||||
|
aes_message = "未找到足够的模板文件用于提取AES密钥"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"source": "extracted",
|
||||||
|
"xor_key": f"0x{xor_key:02X}",
|
||||||
|
"aes_key": aes_key16.decode("ascii", errors="ignore") if aes_key16 else "",
|
||||||
|
"message": f"XOR密钥提取成功。{aes_message}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/media/keys", summary="保存图片解密密钥")
|
||||||
|
async def save_media_keys_api(request: MediaKeysRequest, xor_key: str, aes_key: str):
|
||||||
|
"""手动保存图片解密密钥
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- xor_key: XOR密钥(十六进制格式,如 0xA5 或 A5)
|
||||||
|
- aes_key: AES密钥(16字符ASCII字符串)
|
||||||
|
"""
|
||||||
|
account_dir = _resolve_account_dir(request.account)
|
||||||
|
|
||||||
|
# 解析XOR密钥
|
||||||
|
try:
|
||||||
|
xor_hex = xor_key.strip().lower().replace("0x", "")
|
||||||
|
xor_int = int(xor_hex, 16)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="XOR密钥格式无效,请使用十六进制格式如 0xA5")
|
||||||
|
|
||||||
|
# 验证AES密钥
|
||||||
|
aes_str = aes_key.strip()
|
||||||
|
if len(aes_str) < 16:
|
||||||
|
raise HTTPException(status_code=400, detail="AES密钥长度不足,需要至少16个字符")
|
||||||
|
|
||||||
|
# 保存密钥
|
||||||
|
_save_media_keys(account_dir, xor_int, aes_str[:16].encode("ascii", errors="ignore"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "密钥已保存",
|
||||||
|
"xor_key": f"0x{xor_int:02X}",
|
||||||
|
"aes_key": aes_str[:16],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/media/decrypt_all", summary="批量解密所有图片资源")
|
||||||
|
async def decrypt_all_media(request: MediaDecryptRequest):
|
||||||
|
"""批量解密所有图片资源到 output/databases/{账号}/resource 目录
|
||||||
|
|
||||||
|
解密后的图片按MD5哈希命名,存储在 resource/{md5前2位}/{md5}.{ext} 路径下。
|
||||||
|
这样可以快速通过MD5定位资源文件。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- account: 账号目录名(可选)
|
||||||
|
- xor_key: XOR密钥(可选,不提供则从缓存读取)
|
||||||
|
- aes_key: AES密钥(可选,不提供则从缓存读取)
|
||||||
|
"""
|
||||||
|
account_dir = _resolve_account_dir(request.account)
|
||||||
|
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||||
|
|
||||||
|
if not wxid_dir:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="未找到微信数据目录,请确保已正确配置 db_storage_path",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取密钥
|
||||||
|
xor_key: Optional[int] = None
|
||||||
|
aes_key16: Optional[bytes] = None
|
||||||
|
|
||||||
|
if request.xor_key:
|
||||||
|
try:
|
||||||
|
xor_hex = request.xor_key.strip().lower().replace("0x", "")
|
||||||
|
xor_key = int(xor_hex, 16)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="XOR密钥格式无效")
|
||||||
|
|
||||||
|
if request.aes_key:
|
||||||
|
aes_str = request.aes_key.strip()
|
||||||
|
if len(aes_str) >= 16:
|
||||||
|
aes_key16 = aes_str[:16].encode("ascii", errors="ignore")
|
||||||
|
|
||||||
|
# 如果未提供密钥,尝试从缓存加载
|
||||||
|
if xor_key is None or aes_key16 is None:
|
||||||
|
cached = _load_media_keys(account_dir)
|
||||||
|
if xor_key is None:
|
||||||
|
xor_key = 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")
|
||||||
|
|
||||||
|
# 如果仍然没有XOR密钥,尝试自动提取
|
||||||
|
if xor_key is None:
|
||||||
|
xor_key = _find_wechat_xor_key(str(wxid_dir))
|
||||||
|
|
||||||
|
if xor_key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="未找到XOR密钥,请先调用 /api/media/keys 获取密钥或手动提供",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 收集所有.dat文件
|
||||||
|
logger.info(f"开始扫描 {wxid_dir} 中的.dat文件...")
|
||||||
|
dat_files = _collect_all_dat_files(wxid_dir)
|
||||||
|
total_files = len(dat_files)
|
||||||
|
logger.info(f"共发现 {total_files} 个.dat文件")
|
||||||
|
|
||||||
|
if total_files == 0:
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "未发现需要解密的.dat文件",
|
||||||
|
"total": 0,
|
||||||
|
"success_count": 0,
|
||||||
|
"skip_count": 0,
|
||||||
|
"fail_count": 0,
|
||||||
|
"output_dir": str(_get_resource_dir(account_dir)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 开始解密
|
||||||
|
success_count = 0
|
||||||
|
skip_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
failed_files: list[dict] = []
|
||||||
|
|
||||||
|
resource_dir = _get_resource_dir(account_dir)
|
||||||
|
resource_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for dat_path, md5 in dat_files:
|
||||||
|
# 检查是否已解密
|
||||||
|
existing = _try_find_decrypted_resource(account_dir, md5)
|
||||||
|
if existing:
|
||||||
|
skip_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 解密并保存
|
||||||
|
success, msg = _decrypt_and_save_resource(
|
||||||
|
dat_path, md5, account_dir, xor_key, aes_key16
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
if len(failed_files) < 100: # 只记录前100个失败
|
||||||
|
failed_files.append({
|
||||||
|
"file": str(dat_path),
|
||||||
|
"md5": md5,
|
||||||
|
"error": msg,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"解密完成: 成功={success_count}, 跳过={skip_count}, 失败={fail_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"解密完成: 成功 {success_count}, 跳过 {skip_count}, 失败 {fail_count}",
|
||||||
|
"total": total_files,
|
||||||
|
"success_count": success_count,
|
||||||
|
"skip_count": skip_count,
|
||||||
|
"fail_count": fail_count,
|
||||||
|
"output_dir": str(resource_dir),
|
||||||
|
"failed_files": failed_files[:20] if failed_files else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/media/resource/{md5}", summary="获取已解密的资源文件")
|
||||||
|
async def get_decrypted_resource(md5: str, account: Optional[str] = None):
|
||||||
|
"""直接从解密资源目录获取图片
|
||||||
|
|
||||||
|
如果资源已解密,直接返回解密后的文件。
|
||||||
|
这比实时解密更快,适合频繁访问的场景。
|
||||||
|
"""
|
||||||
|
if not md5 or len(md5) != 32:
|
||||||
|
raise HTTPException(status_code=400, detail="无效的MD5")
|
||||||
|
|
||||||
|
account_dir = _resolve_account_dir(account)
|
||||||
|
p = _try_find_decrypted_resource(account_dir, md5.lower())
|
||||||
|
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail="资源未找到,请先执行批量解密")
|
||||||
|
|
||||||
|
data = p.read_bytes()
|
||||||
|
media_type = _detect_image_media_type(data[:32])
|
||||||
|
return Response(content=data, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/media/decrypt_all_stream", summary="批量解密所有图片资源(SSE实时进度)")
|
||||||
|
async def decrypt_all_media_stream(
|
||||||
|
account: Optional[str] = None,
|
||||||
|
xor_key: Optional[str] = None,
|
||||||
|
aes_key: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""批量解密所有图片资源,通过SSE实时推送进度
|
||||||
|
|
||||||
|
返回格式为Server-Sent Events,每条消息包含:
|
||||||
|
- type: progress/complete/error
|
||||||
|
- current: 当前处理数量
|
||||||
|
- total: 总文件数
|
||||||
|
- success_count: 成功数
|
||||||
|
- skip_count: 跳过数(已解密)
|
||||||
|
- fail_count: 失败数
|
||||||
|
- current_file: 当前处理的文件名
|
||||||
|
- status: 当前文件状态(success/skip/fail)
|
||||||
|
- message: 状态消息
|
||||||
|
|
||||||
|
跳过原因:文件已经解密过
|
||||||
|
失败原因:
|
||||||
|
- 文件为空
|
||||||
|
- V4-V2版本需要AES密钥但未提供
|
||||||
|
- 未知加密版本
|
||||||
|
- 解密结果为空
|
||||||
|
- 解密后非有效图片格式
|
||||||
|
"""
|
||||||
|
async def generate_progress():
|
||||||
|
try:
|
||||||
|
account_dir = _resolve_account_dir(account)
|
||||||
|
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||||
|
|
||||||
|
if not wxid_dir:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': '未找到微信数据目录'})}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取密钥
|
||||||
|
xor_key_int: Optional[int] = None
|
||||||
|
aes_key16: Optional[bytes] = None
|
||||||
|
|
||||||
|
if xor_key:
|
||||||
|
try:
|
||||||
|
xor_hex = xor_key.strip().lower().replace("0x", "")
|
||||||
|
xor_key_int = int(xor_hex, 16)
|
||||||
|
except Exception:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': 'XOR密钥格式无效'})}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
if aes_key:
|
||||||
|
aes_str = aes_key.strip()
|
||||||
|
if len(aes_str) >= 16:
|
||||||
|
aes_key16 = aes_str[:16].encode("ascii", errors="ignore")
|
||||||
|
|
||||||
|
# 如果未提供密钥,尝试从缓存加载
|
||||||
|
if xor_key_int is None or aes_key16 is None:
|
||||||
|
cached = _load_media_keys(account_dir)
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 如果仍然没有XOR密钥,尝试自动提取
|
||||||
|
if xor_key_int is None:
|
||||||
|
xor_key_int = _find_wechat_xor_key(str(wxid_dir))
|
||||||
|
|
||||||
|
if xor_key_int is None:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': '未找到XOR密钥,请先获取密钥'})}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# 收集所有.dat文件
|
||||||
|
logger.info(f"[SSE] 开始扫描 {wxid_dir} 中的.dat文件...")
|
||||||
|
yield f"data: {json.dumps({'type': 'scanning', 'message': '正在扫描图片文件...'})}\n\n"
|
||||||
|
await asyncio.sleep(0) # 让出控制权
|
||||||
|
|
||||||
|
dat_files = _collect_all_dat_files(wxid_dir)
|
||||||
|
total_files = len(dat_files)
|
||||||
|
logger.info(f"[SSE] 共发现 {total_files} 个.dat文件(仅图片)")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 发送总数信息
|
||||||
|
yield f"data: {json.dumps({'type': 'start', 'total': total_files, 'message': f'开始解密 {total_files} 个图片文件'})}\n\n"
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# 开始解密
|
||||||
|
success_count = 0
|
||||||
|
skip_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
failed_files: list[dict] = []
|
||||||
|
|
||||||
|
resource_dir = _get_resource_dir(account_dir)
|
||||||
|
resource_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for i, (dat_path, md5) in enumerate(dat_files):
|
||||||
|
current = i + 1
|
||||||
|
file_name = dat_path.name
|
||||||
|
|
||||||
|
# 检查是否已解密
|
||||||
|
existing = _try_find_decrypted_resource(account_dir, md5)
|
||||||
|
if existing:
|
||||||
|
skip_count += 1
|
||||||
|
# 每100个跳过的文件发送一次进度,减少消息量
|
||||||
|
if skip_count % 100 == 0 or current == total_files:
|
||||||
|
yield f"data: {json.dumps({'type': 'progress', 'current': current, 'total': total_files, 'success_count': success_count, 'skip_count': skip_count, 'fail_count': fail_count, 'current_file': file_name, 'status': 'skip', 'message': '已存在'})}\n\n"
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 解密并保存
|
||||||
|
success, msg = _decrypt_and_save_resource(
|
||||||
|
dat_path, md5, account_dir, xor_key_int, aes_key16
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
success_count += 1
|
||||||
|
status = "success"
|
||||||
|
status_msg = "解密成功"
|
||||||
|
logger.debug(f"[SSE] 解密成功: {file_name}")
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
status = "fail"
|
||||||
|
status_msg = msg
|
||||||
|
logger.warning(f"[SSE] 解密失败: {file_name} - {msg}")
|
||||||
|
if len(failed_files) < 100:
|
||||||
|
failed_files.append({
|
||||||
|
"file": file_name,
|
||||||
|
"md5": md5,
|
||||||
|
"error": msg,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 每处理一个文件发送进度(成功或失败都发送)
|
||||||
|
yield f"data: {json.dumps({'type': 'progress', 'current': current, 'total': total_files, 'success_count': success_count, 'skip_count': skip_count, 'fail_count': fail_count, 'current_file': file_name, 'status': status, 'message': status_msg})}\n\n"
|
||||||
|
|
||||||
|
# 每处理10个文件让出一次控制权,避免阻塞
|
||||||
|
if current % 10 == 0:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
logger.info(f"[SSE] 解密完成: 成功={success_count}, 跳过={skip_count}, 失败={fail_count}")
|
||||||
|
|
||||||
|
# 发送完成消息
|
||||||
|
yield f"data: {json.dumps({'type': 'complete', 'total': total_files, 'success_count': success_count, 'skip_count': skip_count, 'fail_count': fail_count, 'output_dir': str(resource_dir), 'failed_files': failed_files[:20], 'message': f'解密完成: 成功 {success_count}, 跳过 {skip_count}, 失败 {fail_count}'})}\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[SSE] 解密过程出错: {e}")
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate_progress(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health", summary="健康检查端点")
|
@app.get("/api/health", summary="健康检查端点")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""健康检查端点"""
|
"""健康检查端点"""
|
||||||
|
|||||||
Reference in New Issue
Block a user