mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-02 22:10:50 +08:00
improvement(media): 聊天媒体支持 file_id 兜底定位
- 图片/视频消息无 MD5 时,解析并下发 file_id,用于本地资源兜底定位与展示 - 后端 chat_media/open_folder 支持 md5/file_id;视频优先可 Range 的文件响应,并在需要时解密落盘 - 前端聊天页与 API 调用适配 file_id;补充媒体 URL 可用性判断 - 解密页补充“获取密钥”提示,支持手动输入/保存密钥;README 同步说明;更新音频图标资源
This commit is contained in:
@@ -62,6 +62,7 @@
|
|||||||
- **Web界面**: 提供现代化的Web操作界面
|
- **Web界面**: 提供现代化的Web操作界面
|
||||||
- **聊天记录查看**: 支持查看解密后的聊天记录(基础功能)
|
- **聊天记录查看**: 支持查看解密后的聊天记录(基础功能)
|
||||||
- **图片资源解密**: 支持批量解密微信图片(.dat文件),按MD5哈希存储便于快速访问
|
- **图片资源解密**: 支持批量解密微信图片(.dat文件),按MD5哈希存储便于快速访问
|
||||||
|
- **聊天图片展示**: 支持部分版本图片消息无MD5时通过 file_id 兜底定位本地资源
|
||||||
|
|
||||||
### 开发计划
|
### 开发计划
|
||||||
|
|
||||||
@@ -183,6 +184,8 @@ curl http://localhost:8000/api/media/keys
|
|||||||
curl "http://localhost:8000/api/media/keys?force_extract=true"
|
curl "http://localhost:8000/api/media/keys?force_extract=true"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 提示:部分版本的 AES 密钥可能需要微信触发过图片加载/解密后才会出现在进程内存中。可尝试:完全退出微信 → 重新启动并登录 → 打开朋友圈图片并点开大图 2-3 次 → 立刻回到工具获取密钥。
|
||||||
|
|
||||||
返回示例:
|
返回示例:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const useApi = () => {
|
|||||||
if (params && params.username) query.set('username', params.username)
|
if (params && params.username) query.set('username', params.username)
|
||||||
if (params && params.kind) query.set('kind', params.kind)
|
if (params && params.kind) query.set('kind', params.kind)
|
||||||
if (params && params.md5) query.set('md5', params.md5)
|
if (params && params.md5) query.set('md5', params.md5)
|
||||||
|
if (params && params.file_id) query.set('file_id', params.file_id)
|
||||||
if (params && params.server_id != null) query.set('server_id', String(params.server_id))
|
if (params && params.server_id != null) query.set('server_id', String(params.server_id))
|
||||||
const url = '/chat/media/open_folder' + (query.toString() ? `?${query.toString()}` : '')
|
const url = '/chat/media/open_folder' + (query.toString() ? `?${query.toString()}` : '')
|
||||||
return await request(url, { method: 'POST' })
|
return await request(url, { method: 'POST' })
|
||||||
|
|||||||
@@ -591,14 +591,14 @@ const openMediaContextMenu = (e, message, kind) => {
|
|||||||
} else if (kind === 'file') {
|
} else if (kind === 'file') {
|
||||||
disabled = !message?.fileMd5
|
disabled = !message?.fileMd5
|
||||||
} else if (kind === 'image') {
|
} else if (kind === 'image') {
|
||||||
disabled = !message?.imageMd5
|
disabled = !(message?.imageMd5 || message?.imageFileId)
|
||||||
} else if (kind === 'emoji') {
|
} else if (kind === 'emoji') {
|
||||||
disabled = !message?.emojiMd5
|
disabled = !message?.emojiMd5
|
||||||
} else if (kind === 'video') {
|
} else if (kind === 'video') {
|
||||||
if (message?.videoMd5) {
|
if (message?.videoMd5 || message?.videoFileId) {
|
||||||
disabled = false
|
disabled = false
|
||||||
actualKind = 'video'
|
actualKind = 'video'
|
||||||
} else if (message?.videoThumbMd5) {
|
} else if (message?.videoThumbMd5 || message?.videoThumbFileId) {
|
||||||
disabled = false
|
disabled = false
|
||||||
actualKind = 'video_thumb'
|
actualKind = 'video_thumb'
|
||||||
} else {
|
} else {
|
||||||
@@ -637,13 +637,16 @@ const onOpenFolderClick = async () => {
|
|||||||
} else if (kind === 'file') {
|
} else if (kind === 'file') {
|
||||||
params.md5 = m.fileMd5
|
params.md5 = m.fileMd5
|
||||||
} else if (kind === 'image') {
|
} else if (kind === 'image') {
|
||||||
params.md5 = m.imageMd5
|
if (m.imageMd5) params.md5 = m.imageMd5
|
||||||
|
else if (m.imageFileId) params.file_id = m.imageFileId
|
||||||
} else if (kind === 'emoji') {
|
} else if (kind === 'emoji') {
|
||||||
params.md5 = m.emojiMd5
|
params.md5 = m.emojiMd5
|
||||||
} else if (kind === 'video') {
|
} else if (kind === 'video') {
|
||||||
params.md5 = m.videoMd5
|
params.md5 = m.videoMd5
|
||||||
|
if (m.videoFileId) params.file_id = m.videoFileId
|
||||||
} else if (kind === 'video_thumb') {
|
} else if (kind === 'video_thumb') {
|
||||||
params.md5 = m.videoThumbMd5
|
params.md5 = m.videoThumbMd5
|
||||||
|
if (m.videoThumbFileId) params.file_id = m.videoThumbFileId
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.openChatMediaFolder(params)
|
await api.openChatMediaFolder(params)
|
||||||
@@ -1054,11 +1057,47 @@ const normalizeMessage = (msg) => {
|
|||||||
const fallbackAvatar = (!isSent && !selectedContact.value?.isGroup) ? (selectedContact.value?.avatar || null) : null
|
const fallbackAvatar = (!isSent && !selectedContact.value?.isGroup) ? (selectedContact.value?.avatar || null) : null
|
||||||
|
|
||||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||||
|
const normalizeMaybeUrl = (u) => (typeof u === 'string' ? u.trim() : '')
|
||||||
|
const isUsableMediaUrl = (u) => {
|
||||||
|
const v = normalizeMaybeUrl(u)
|
||||||
|
if (!v) return false
|
||||||
|
return (
|
||||||
|
/^https?:\/\//i.test(v)
|
||||||
|
|| /^blob:/i.test(v)
|
||||||
|
|| /^data:/i.test(v)
|
||||||
|
|| /^\/api\/chat\/media\//i.test(v)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const localEmojiUrl = msg.emojiMd5 ? `${mediaBase}/api/chat/media/emoji?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : ''
|
const localEmojiUrl = msg.emojiMd5 ? `${mediaBase}/api/chat/media/emoji?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : ''
|
||||||
const normalizedImageUrl = msg.imageUrl || (msg.imageMd5 ? `${mediaBase}/api/chat/media/image?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.imageMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : '')
|
const localImageByMd5 = msg.imageMd5 ? `${mediaBase}/api/chat/media/image?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.imageMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : ''
|
||||||
|
const localImageByFileId = msg.imageFileId ? `${mediaBase}/api/chat/media/image?account=${encodeURIComponent(selectedAccount.value || '')}&file_id=${encodeURIComponent(msg.imageFileId)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : ''
|
||||||
|
const normalizedImageUrl = msg.imageUrl || localImageByMd5 || localImageByFileId || ''
|
||||||
const normalizedEmojiUrl = msg.emojiUrl || localEmojiUrl
|
const normalizedEmojiUrl = msg.emojiUrl || localEmojiUrl
|
||||||
const normalizedVideoThumbUrl = msg.videoThumbUrl || (msg.videoThumbMd5 ? `${mediaBase}/api/chat/media/video_thumb?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.videoThumbMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : '')
|
const localVideoThumbUrl = (() => {
|
||||||
const normalizedVideoUrl = msg.videoUrl || (msg.videoMd5 ? `${mediaBase}/api/chat/media/video?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.videoMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : '')
|
if (!msg.videoThumbMd5 && !msg.videoThumbFileId) return ''
|
||||||
|
const parts = [
|
||||||
|
`account=${encodeURIComponent(selectedAccount.value || '')}`,
|
||||||
|
msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '',
|
||||||
|
msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '',
|
||||||
|
`username=${encodeURIComponent(selectedContact.value?.username || '')}`,
|
||||||
|
].filter(Boolean)
|
||||||
|
return `${mediaBase}/api/chat/media/video_thumb?${parts.join('&')}`
|
||||||
|
})()
|
||||||
|
|
||||||
|
const localVideoUrl = (() => {
|
||||||
|
if (!msg.videoMd5 && !msg.videoFileId) return ''
|
||||||
|
const parts = [
|
||||||
|
`account=${encodeURIComponent(selectedAccount.value || '')}`,
|
||||||
|
msg.videoMd5 ? `md5=${encodeURIComponent(msg.videoMd5)}` : '',
|
||||||
|
msg.videoFileId ? `file_id=${encodeURIComponent(msg.videoFileId)}` : '',
|
||||||
|
`username=${encodeURIComponent(selectedContact.value?.username || '')}`,
|
||||||
|
].filter(Boolean)
|
||||||
|
return `${mediaBase}/api/chat/media/video?${parts.join('&')}`
|
||||||
|
})()
|
||||||
|
|
||||||
|
const normalizedVideoThumbUrl = (isUsableMediaUrl(msg.videoThumbUrl) ? normalizeMaybeUrl(msg.videoThumbUrl) : '') || localVideoThumbUrl
|
||||||
|
const normalizedVideoUrl = (isUsableMediaUrl(msg.videoUrl) ? normalizeMaybeUrl(msg.videoUrl) : '') || localVideoUrl
|
||||||
const normalizedVoiceUrl = msg.voiceUrl || (msg.serverId ? `${mediaBase}/api/chat/media/voice?account=${encodeURIComponent(selectedAccount.value || '')}&server_id=${encodeURIComponent(String(msg.serverId))}` : '')
|
const normalizedVoiceUrl = msg.voiceUrl || (msg.serverId ? `${mediaBase}/api/chat/media/voice?account=${encodeURIComponent(selectedAccount.value || '')}&server_id=${encodeURIComponent(String(msg.serverId))}` : '')
|
||||||
|
|
||||||
const remoteFromServer = (
|
const remoteFromServer = (
|
||||||
@@ -1110,9 +1149,11 @@ const normalizeMessage = (msg) => {
|
|||||||
isSent,
|
isSent,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
renderType: msg.renderType || 'text',
|
renderType: msg.renderType || 'text',
|
||||||
|
voipType: msg.voipType || '',
|
||||||
title: msg.title || '',
|
title: msg.title || '',
|
||||||
url: msg.url || '',
|
url: msg.url || '',
|
||||||
imageMd5: msg.imageMd5 || '',
|
imageMd5: msg.imageMd5 || '',
|
||||||
|
imageFileId: msg.imageFileId || '',
|
||||||
emojiMd5: msg.emojiMd5 || '',
|
emojiMd5: msg.emojiMd5 || '',
|
||||||
emojiUrl: normalizedEmojiUrl || '',
|
emojiUrl: normalizedEmojiUrl || '',
|
||||||
emojiLocalUrl: localEmojiUrl || '',
|
emojiLocalUrl: localEmojiUrl || '',
|
||||||
@@ -1122,6 +1163,8 @@ const normalizeMessage = (msg) => {
|
|||||||
imageUrl: normalizedImageUrl || '',
|
imageUrl: normalizedImageUrl || '',
|
||||||
videoMd5: msg.videoMd5 || '',
|
videoMd5: msg.videoMd5 || '',
|
||||||
videoThumbMd5: msg.videoThumbMd5 || '',
|
videoThumbMd5: msg.videoThumbMd5 || '',
|
||||||
|
videoFileId: msg.videoFileId || '',
|
||||||
|
videoThumbFileId: msg.videoThumbFileId || '',
|
||||||
videoThumbUrl: normalizedVideoThumbUrl || '',
|
videoThumbUrl: normalizedVideoThumbUrl || '',
|
||||||
videoUrl: normalizedVideoUrl || '',
|
videoUrl: normalizedVideoUrl || '',
|
||||||
quoteTitle: msg.quoteTitle || '',
|
quoteTitle: msg.quoteTitle || '',
|
||||||
|
|||||||
@@ -128,6 +128,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 获取密钥说明 -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="w-5 h-5 text-blue-600 mr-2 mt-0.5 flex-shrink-0" 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>
|
||||||
|
<div class="text-sm text-blue-800">
|
||||||
|
<div class="font-medium mb-2">获取密钥小提示</div>
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<li><strong>AES 密钥</strong>仅在部分图片(V4-V2)解密时需要;仅有 XOR 也可以先继续下一步,失败原因会提示。</li>
|
||||||
|
<li>获取 AES 需要微信正在运行;部分环境需<strong>以管理员身份运行后端</strong>(否则可能无法读取微信进程内存)。</li>
|
||||||
|
<li>若一直获取不到 AES:完全退出微信 → 重新启动并登录 → 打开朋友圈图片并点开大图 2-3 次 → 回到本页点<strong>强制重新提取</strong>。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 密钥信息显示 -->
|
<!-- 密钥信息显示 -->
|
||||||
<div class="space-y-4 mb-6">
|
<div class="space-y-4 mb-6">
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
@@ -172,6 +191,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 手动输入密钥(自动获取失败时) -->
|
||||||
|
<div 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-3 bg-gray-50 rounded-lg p-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-[#000000e6] mb-2">XOR 密钥 <span class="text-red-500">*</span></label>
|
||||||
|
<input
|
||||||
|
v-model="manualKeys.xor_key"
|
||||||
|
type="text"
|
||||||
|
placeholder="例如:0xA5 或 A5"
|
||||||
|
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
|
||||||
|
/>
|
||||||
|
<p v-if="manualKeyErrors.xor_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.xor_key }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-[#000000e6] mb-2">AES 密钥(可选)</label>
|
||||||
|
<input
|
||||||
|
v-model="manualKeys.aes_key"
|
||||||
|
type="text"
|
||||||
|
placeholder="16 个字符(V4-V2 需要)"
|
||||||
|
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
|
||||||
|
/>
|
||||||
|
<p v-if="manualKeyErrors.aes_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.aes_key }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="applyManualKeys({ save: false })"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-white text-[#10AEEF] border border-[#10AEEF] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200"
|
||||||
|
>
|
||||||
|
使用手动密钥
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="applyManualKeys({ save: true })"
|
||||||
|
:disabled="manualSaving"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-[#10AEEF] text-white rounded-lg font-medium hover:bg-[#0D9BD9] transition-all duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ manualSaving ? '保存中...' : '保存并使用' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="clearManualKeys"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-white text-[#7F7F7F] border border-[#EDEDED] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-[#7F7F7F] mt-3">
|
||||||
|
<p>说明:</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1 mt-1">
|
||||||
|
<li>XOR 是 1 字节十六进制(00-FF)。</li>
|
||||||
|
<li>AES 仅在部分图片(V4-V2)解密时需要;输入任意 16 个字符即可(会自动截取前 16 位)。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div class="flex gap-3 justify-center pt-4 border-t border-[#EDEDED]">
|
<div class="flex gap-3 justify-center pt-4 border-t border-[#EDEDED]">
|
||||||
<button
|
<button
|
||||||
@@ -196,7 +281,6 @@
|
|||||||
强制重新提取
|
强制重新提取
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="mediaKeys.xor_key"
|
|
||||||
@click="goToStep(2)"
|
@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"
|
class="inline-flex items-center px-6 py-3 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-all duration-200"
|
||||||
>
|
>
|
||||||
@@ -319,7 +403,7 @@
|
|||||||
<p class="mb-2">可能的失败原因:</p>
|
<p class="mb-2">可能的失败原因:</p>
|
||||||
<ul class="list-disc list-inside space-y-1">
|
<ul class="list-disc list-inside space-y-1">
|
||||||
<li><strong>解密后非有效图片</strong>:文件不是图片格式(如视频缩略图损坏)</li>
|
<li><strong>解密后非有效图片</strong>:文件不是图片格式(如视频缩略图损坏)</li>
|
||||||
<li><strong>V4-V2版本需要AES密钥</strong>:需要微信运行,且部分环境需以管理员身份运行后端才能提取</li>
|
<li><strong>V4-V2版本需要AES密钥</strong>:需要微信运行,且部分环境需以管理员身份运行后端才能提取;可尝试打开朋友圈图片并点开大图 2-3 次后再提取</li>
|
||||||
<li><strong>未知加密版本</strong>:新版微信使用了不支持的加密方式</li>
|
<li><strong>未知加密版本</strong>:新版微信使用了不支持的加密方式</li>
|
||||||
<li><strong>文件为空</strong>:原始文件损坏或为空文件</li>
|
<li><strong>文件为空</strong>:原始文件损坏或为空文件</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -398,7 +482,7 @@
|
|||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
|
|
||||||
const { decryptDatabase, getMediaKeys, decryptAllMedia } = useApi()
|
const { decryptDatabase, getMediaKeys, decryptAllMedia, saveMediaKeys } = useApi()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -433,6 +517,81 @@ const mediaLoading = ref(false)
|
|||||||
const copyMessage = ref('')
|
const copyMessage = ref('')
|
||||||
let copyMessageTimer = null
|
let copyMessageTimer = null
|
||||||
|
|
||||||
|
// 手动输入密钥(自动获取失败时使用)
|
||||||
|
const manualKeys = reactive({
|
||||||
|
xor_key: '',
|
||||||
|
aes_key: ''
|
||||||
|
})
|
||||||
|
const manualKeyErrors = reactive({
|
||||||
|
xor_key: '',
|
||||||
|
aes_key: ''
|
||||||
|
})
|
||||||
|
const manualSaving = ref(false)
|
||||||
|
|
||||||
|
const normalizeXorKey = (value) => {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw) return { ok: false, value: '', message: '请输入 XOR 密钥' }
|
||||||
|
const hex = raw.toLowerCase().replace(/^0x/, '')
|
||||||
|
if (!/^[0-9a-f]{1,2}$/.test(hex)) return { ok: false, value: '', message: 'XOR 密钥格式无效(如 0xA5 或 A5)' }
|
||||||
|
const n = parseInt(hex, 16)
|
||||||
|
if (!Number.isFinite(n) || n < 0 || n > 255) return { ok: false, value: '', message: 'XOR 密钥必须在 0x00-0xFF 范围' }
|
||||||
|
return { ok: true, value: `0x${n.toString(16).toUpperCase().padStart(2, '0')}`, message: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeAesKey = (value) => {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw) return { ok: true, value: '', message: '' }
|
||||||
|
if (raw.length < 16) return { ok: false, value: '', message: 'AES 密钥长度不足(至少 16 个字符)' }
|
||||||
|
return { ok: true, value: raw.slice(0, 16), message: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyManualKeys = async (options = { save: false }) => {
|
||||||
|
manualKeyErrors.xor_key = ''
|
||||||
|
manualKeyErrors.aes_key = ''
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
const xor = normalizeXorKey(manualKeys.xor_key)
|
||||||
|
if (!xor.ok) {
|
||||||
|
manualKeyErrors.xor_key = xor.message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const aes = normalizeAesKey(manualKeys.aes_key)
|
||||||
|
if (!aes.ok) {
|
||||||
|
manualKeyErrors.aes_key = aes.message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaKeys.xor_key = xor.value
|
||||||
|
mediaKeys.aes_key = aes.value
|
||||||
|
mediaKeys.message = options?.save ? '已保存并使用手动密钥' : '已使用手动密钥(仅本次)'
|
||||||
|
|
||||||
|
if (!options?.save) return
|
||||||
|
if (!aes.value) {
|
||||||
|
mediaKeys.message = '已使用手动密钥(未保存:AES 为空)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
manualSaving.value = true
|
||||||
|
await saveMediaKeys({
|
||||||
|
xor_key: xor.value,
|
||||||
|
aes_key: aes.value
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
mediaKeys.message = '已使用手动密钥(保存失败,可继续解密)'
|
||||||
|
} finally {
|
||||||
|
manualSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearManualKeys = () => {
|
||||||
|
manualKeys.xor_key = ''
|
||||||
|
manualKeys.aes_key = ''
|
||||||
|
manualKeyErrors.xor_key = ''
|
||||||
|
manualKeyErrors.aes_key = ''
|
||||||
|
}
|
||||||
|
|
||||||
// 图片解密相关
|
// 图片解密相关
|
||||||
const mediaDecryptResult = ref(null)
|
const mediaDecryptResult = ref(null)
|
||||||
const mediaDecrypting = ref(false)
|
const mediaDecrypting = ref(false)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 739 B |
@@ -1,5 +1,6 @@
|
|||||||
import ctypes
|
import ctypes
|
||||||
import datetime
|
import datetime
|
||||||
|
import glob
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -371,7 +372,7 @@ def _resolve_media_path_from_hardlink(
|
|||||||
quoted = _quote_ident(table_name)
|
quoted = _quote_ident(table_name)
|
||||||
try:
|
try:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
f"SELECT dir1, dir2, file_name FROM {quoted} WHERE md5 = ? ORDER BY modify_time DESC LIMIT 1",
|
f"SELECT dir1, dir2, file_name, modify_time FROM {quoted} WHERE md5 = ? ORDER BY modify_time DESC LIMIT 1",
|
||||||
(md5,),
|
(md5,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -383,6 +384,132 @@ def _resolve_media_path_from_hardlink(
|
|||||||
if not file_name:
|
if not file_name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if kind_key in {"video", "video_thumb"}:
|
||||||
|
roots: list[Path] = []
|
||||||
|
for r in [wxid_dir] + (extra_roots or []):
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rr = r.resolve()
|
||||||
|
except Exception:
|
||||||
|
rr = r
|
||||||
|
if rr not in roots:
|
||||||
|
roots.append(rr)
|
||||||
|
|
||||||
|
def _iter_video_base_dirs(r: Path) -> list[Path]:
|
||||||
|
bases: list[Path] = []
|
||||||
|
try:
|
||||||
|
if r.exists() and r.is_dir():
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return bases
|
||||||
|
except Exception:
|
||||||
|
return bases
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
r / "msg" / "video",
|
||||||
|
r / "video",
|
||||||
|
r if str(r.name).lower() == "video" else None,
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if not c:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if c.exists() and c.is_dir():
|
||||||
|
bases.append(c)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# de-dup while keeping order
|
||||||
|
seen: set[str] = set()
|
||||||
|
uniq: list[Path] = []
|
||||||
|
for b in bases:
|
||||||
|
try:
|
||||||
|
k = str(b.resolve())
|
||||||
|
except Exception:
|
||||||
|
k = str(b)
|
||||||
|
if k in seen:
|
||||||
|
continue
|
||||||
|
seen.add(k)
|
||||||
|
uniq.append(b)
|
||||||
|
return uniq
|
||||||
|
|
||||||
|
modify_time = None
|
||||||
|
try:
|
||||||
|
if row["modify_time"] is not None:
|
||||||
|
modify_time = int(row["modify_time"])
|
||||||
|
except Exception:
|
||||||
|
modify_time = None
|
||||||
|
|
||||||
|
guessed_month: Optional[str] = None
|
||||||
|
if modify_time and modify_time > 0:
|
||||||
|
try:
|
||||||
|
dt = datetime.datetime.fromtimestamp(int(modify_time))
|
||||||
|
guessed_month = f"{dt.year:04d}-{dt.month:02d}"
|
||||||
|
except Exception:
|
||||||
|
guessed_month = None
|
||||||
|
|
||||||
|
stem = Path(file_name).stem
|
||||||
|
if kind_key == "video":
|
||||||
|
file_variants = [file_name]
|
||||||
|
else:
|
||||||
|
# Prefer real thumbnails when possible.
|
||||||
|
file_variants = [
|
||||||
|
f"{stem}_thumb.jpg",
|
||||||
|
f"{stem}_thumb.jpeg",
|
||||||
|
f"{stem}_thumb.png",
|
||||||
|
f"{stem}_thumb.webp",
|
||||||
|
f"{stem}.jpg",
|
||||||
|
f"{stem}.jpeg",
|
||||||
|
f"{stem}.png",
|
||||||
|
f"{stem}.gif",
|
||||||
|
f"{stem}.webp",
|
||||||
|
f"{stem}.dat",
|
||||||
|
file_name,
|
||||||
|
]
|
||||||
|
|
||||||
|
for root in roots:
|
||||||
|
for base_dir in _iter_video_base_dirs(root):
|
||||||
|
dirs_to_check: list[Path] = []
|
||||||
|
if guessed_month:
|
||||||
|
dirs_to_check.append(base_dir / guessed_month)
|
||||||
|
dirs_to_check.append(base_dir)
|
||||||
|
for d in dirs_to_check:
|
||||||
|
try:
|
||||||
|
if not d.exists() or not d.is_dir():
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for fv in file_variants:
|
||||||
|
p = d / fv
|
||||||
|
try:
|
||||||
|
if p.exists() and p.is_file():
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fallback: scan within the month directory for the exact file_name.
|
||||||
|
if guessed_month:
|
||||||
|
try:
|
||||||
|
for p in d.rglob(file_name):
|
||||||
|
try:
|
||||||
|
if p.is_file():
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Final fallback: locate by name under msg/video and cache.
|
||||||
|
for base in _iter_video_base_dirs(wxid_dir):
|
||||||
|
try:
|
||||||
|
for p in base.rglob(file_name):
|
||||||
|
if p.is_file():
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
if kind_key == "file":
|
if kind_key == "file":
|
||||||
try:
|
try:
|
||||||
full_row = conn.execute(
|
full_row = conn.execute(
|
||||||
@@ -973,20 +1100,38 @@ def _get_wechat_v2_ciphertext(weixin_root: Path, most_common_last2: bytes) -> Op
|
|||||||
|
|
||||||
template_files.sort(key=_extract_yyyymm_for_sort, reverse=True)
|
template_files.sort(key=_extract_yyyymm_for_sort, reverse=True)
|
||||||
sig = b"\x07\x08V2\x08\x07"
|
sig = b"\x07\x08V2\x08\x07"
|
||||||
for file in template_files:
|
|
||||||
|
def try_read_ct(file: Path, require_last2: bool) -> Optional[bytes]:
|
||||||
try:
|
try:
|
||||||
with open(file, "rb") as f:
|
with open(file, "rb") as f:
|
||||||
if f.read(6) != sig:
|
if f.read(6) != sig:
|
||||||
continue
|
return None
|
||||||
|
if require_last2 and most_common_last2 and len(most_common_last2) == 2:
|
||||||
|
try:
|
||||||
f.seek(-2, 2)
|
f.seek(-2, 2)
|
||||||
if f.read(2) != most_common_last2:
|
if f.read(2) != most_common_last2:
|
||||||
continue
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
f.seek(0xF)
|
f.seek(0xF)
|
||||||
ct = f.read(16)
|
ct = f.read(16)
|
||||||
if ct and len(ct) == 16:
|
if ct and len(ct) == 16:
|
||||||
return ct
|
return ct
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Prefer matching last2 bytes (older heuristic), but fall back to any V2 template like wx_key.
|
||||||
|
if most_common_last2 and len(most_common_last2) == 2:
|
||||||
|
for file in template_files:
|
||||||
|
ct = try_read_ct(file, require_last2=True)
|
||||||
|
if ct:
|
||||||
|
return ct
|
||||||
|
|
||||||
|
for file in template_files:
|
||||||
|
ct = try_read_ct(file, require_last2=False)
|
||||||
|
if ct:
|
||||||
|
return ct
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -1120,8 +1265,6 @@ def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]:
|
|||||||
PROCESS_QUERY_INFORMATION = 0x0400
|
PROCESS_QUERY_INFORMATION = 0x0400
|
||||||
MEM_COMMIT = 0x1000
|
MEM_COMMIT = 0x1000
|
||||||
MEM_PRIVATE = 0x20000
|
MEM_PRIVATE = 0x20000
|
||||||
MEM_MAPPED = 0x40000
|
|
||||||
MEM_IMAGE = 0x1000000
|
|
||||||
|
|
||||||
PAGE_NOACCESS = 0x01
|
PAGE_NOACCESS = 0x01
|
||||||
PAGE_READONLY = 0x02
|
PAGE_READONLY = 0x02
|
||||||
@@ -1172,7 +1315,9 @@ def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]:
|
|||||||
return False
|
return False
|
||||||
return bool(protect & readable_mask)
|
return bool(protect & readable_mask)
|
||||||
|
|
||||||
pattern = re.compile(rb"(?i)(?<![0-9a-z])([0-9a-z]{16}|[0-9a-z]{32})(?![0-9a-z])")
|
# Keep pattern consistent with wx_key: search for 16/32 lower/upper alpha-num strings with word-boundary-like guards.
|
||||||
|
# (Using 32 first reduces false positives in some builds.)
|
||||||
|
pattern = re.compile(rb"(?i)(?<![0-9a-z])([0-9a-z]{32}|[0-9a-z]{16})(?![0-9a-z])")
|
||||||
|
|
||||||
def scan_pid(pid: int) -> Optional[bytes]:
|
def scan_pid(pid: int) -> Optional[bytes]:
|
||||||
handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid)
|
handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid)
|
||||||
@@ -1198,14 +1343,18 @@ def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]:
|
|||||||
to_read = min(chunk, region_size - offset)
|
to_read = min(chunk, region_size - offset)
|
||||||
b = read_mem(base + offset, int(to_read))
|
b = read_mem(base + offset, int(to_read))
|
||||||
if not b:
|
if not b:
|
||||||
return None
|
# Don't abort the whole region on a single read failure (wx_key keeps scanning).
|
||||||
|
offset += to_read
|
||||||
|
tail = b""
|
||||||
|
continue
|
||||||
data = tail + b
|
data = tail + b
|
||||||
for m in pattern.finditer(data):
|
for m in pattern.finditer(data):
|
||||||
cand = m.group(1)
|
cand = m.group(1)
|
||||||
if len(cand) == 16:
|
if len(cand) == 32:
|
||||||
candidates = [cand]
|
# wx_key uses key[:16] to validate; keep that but also try the second half for compatibility.
|
||||||
else:
|
|
||||||
candidates = [cand[:16], cand[16:]]
|
candidates = [cand[:16], cand[16:]]
|
||||||
|
else:
|
||||||
|
candidates = [cand]
|
||||||
for cand16 in candidates:
|
for cand16 in candidates:
|
||||||
if _verify_wechat_aes_key(ciphertext, cand16):
|
if _verify_wechat_aes_key(ciphertext, cand16):
|
||||||
return cand16
|
return cand16
|
||||||
@@ -1219,12 +1368,14 @@ def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]:
|
|||||||
try:
|
try:
|
||||||
while VirtualQueryEx(handle, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
while VirtualQueryEx(handle, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||||
try:
|
try:
|
||||||
if int(mbi.State) == MEM_COMMIT and int(mbi.Type) in {MEM_PRIVATE, MEM_MAPPED, MEM_IMAGE}:
|
if int(mbi.State) == MEM_COMMIT and int(mbi.Type) == MEM_PRIVATE:
|
||||||
protect = int(mbi.Protect)
|
protect = int(mbi.Protect)
|
||||||
if is_readable(protect):
|
if is_readable(protect):
|
||||||
base = int(mbi.BaseAddress)
|
base = int(mbi.BaseAddress)
|
||||||
size = int(mbi.RegionSize)
|
size = int(mbi.RegionSize)
|
||||||
if size > 0:
|
if size > 0:
|
||||||
|
# Skip extremely large regions to keep runtime bounded (same idea as wx_key).
|
||||||
|
if size <= 100 * 1024 * 1024:
|
||||||
regions.append((base, size))
|
regions.append((base, size))
|
||||||
addr = int(mbi.BaseAddress) + int(mbi.RegionSize)
|
addr = int(mbi.BaseAddress) + int(mbi.RegionSize)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1250,6 +1401,100 @@ def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4096)
|
||||||
|
def _fallback_search_media_by_file_id(
|
||||||
|
weixin_root_str: str,
|
||||||
|
file_id: str,
|
||||||
|
kind: str = "",
|
||||||
|
username: str = "",
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""在微信数据目录里按文件名(file_id)兜底查找媒体文件。
|
||||||
|
|
||||||
|
一些微信版本的图片消息不再直接提供 32 位 MD5,而是提供形如 `cdnthumburl` 的长串标识,
|
||||||
|
本函数用于按文件名/前缀在 msg/attach、cache 等目录中定位对应的 .dat 资源文件。
|
||||||
|
"""
|
||||||
|
if not weixin_root_str or not file_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
root = Path(weixin_root_str)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
kind_key = str(kind or "").lower().strip()
|
||||||
|
fid = str(file_id or "").strip()
|
||||||
|
if not fid:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 优先在当前会话的 attach 子目录中查找(显著减少扫描范围)
|
||||||
|
search_dirs: list[Path] = []
|
||||||
|
if username:
|
||||||
|
try:
|
||||||
|
chat_hash = hashlib.md5(str(username).encode()).hexdigest()
|
||||||
|
search_dirs.append(root / "msg" / "attach" / chat_hash)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if kind_key == "file":
|
||||||
|
search_dirs.extend([root / "msg" / "file"])
|
||||||
|
elif kind_key == "video" or kind_key == "video_thumb":
|
||||||
|
search_dirs.extend([root / "msg" / "video", root / "cache"])
|
||||||
|
else:
|
||||||
|
search_dirs.extend([root / "msg" / "attach", root / "cache", root / "msg" / "file", root / "msg" / "video"])
|
||||||
|
|
||||||
|
# de-dup while keeping order
|
||||||
|
seen: set[str] = set()
|
||||||
|
uniq_dirs: list[Path] = []
|
||||||
|
for d in search_dirs:
|
||||||
|
try:
|
||||||
|
k = str(d.resolve())
|
||||||
|
except Exception:
|
||||||
|
k = str(d)
|
||||||
|
if k in seen:
|
||||||
|
continue
|
||||||
|
seen.add(k)
|
||||||
|
uniq_dirs.append(d)
|
||||||
|
|
||||||
|
base = glob.escape(fid)
|
||||||
|
has_suffix = bool(Path(fid).suffix)
|
||||||
|
|
||||||
|
patterns: list[str] = []
|
||||||
|
if has_suffix:
|
||||||
|
patterns.append(base)
|
||||||
|
else:
|
||||||
|
patterns.extend(
|
||||||
|
[
|
||||||
|
f"{base}_h.dat",
|
||||||
|
f"{base}_t.dat",
|
||||||
|
f"{base}.dat",
|
||||||
|
f"{base}*.dat",
|
||||||
|
f"{base}.jpg",
|
||||||
|
f"{base}.jpeg",
|
||||||
|
f"{base}.png",
|
||||||
|
f"{base}.gif",
|
||||||
|
f"{base}.webp",
|
||||||
|
f"{base}*",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for d in uniq_dirs:
|
||||||
|
try:
|
||||||
|
if not d.exists() or not d.is_dir():
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for pat in patterns:
|
||||||
|
try:
|
||||||
|
for p in d.rglob(pat):
|
||||||
|
try:
|
||||||
|
if p.is_file():
|
||||||
|
return str(p)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _save_media_keys(account_dir: Path, xor_key: int, aes_key16: bytes) -> None:
|
def _save_media_keys(account_dir: Path, xor_key: int, aes_key16: bytes) -> None:
|
||||||
try:
|
try:
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
@@ -323,8 +323,11 @@ async def list_chat_messages(
|
|||||||
emoji_url = ""
|
emoji_url = ""
|
||||||
thumb_url = ""
|
thumb_url = ""
|
||||||
image_url = ""
|
image_url = ""
|
||||||
|
image_file_id = ""
|
||||||
video_md5 = ""
|
video_md5 = ""
|
||||||
video_thumb_md5 = ""
|
video_thumb_md5 = ""
|
||||||
|
video_file_id = ""
|
||||||
|
video_thumb_file_id = ""
|
||||||
video_url = ""
|
video_url = ""
|
||||||
video_thumb_url = ""
|
video_thumb_url = ""
|
||||||
voice_length = ""
|
voice_length = ""
|
||||||
@@ -337,6 +340,7 @@ async def list_chat_messages(
|
|||||||
transfer_status = ""
|
transfer_status = ""
|
||||||
file_md5 = ""
|
file_md5 = ""
|
||||||
transfer_id = ""
|
transfer_id = ""
|
||||||
|
voip_type = ""
|
||||||
|
|
||||||
if local_type == 10000:
|
if local_type == 10000:
|
||||||
render_type = "system"
|
render_type = "system"
|
||||||
@@ -396,14 +400,40 @@ async def list_chat_messages(
|
|||||||
quote_content = str(parsed.get("quoteContent") or "")
|
quote_content = str(parsed.get("quoteContent") or "")
|
||||||
elif local_type == 3:
|
elif local_type == 3:
|
||||||
render_type = "image"
|
render_type = "image"
|
||||||
image_md5 = _extract_xml_attr(raw_text, "md5")
|
# 先尝试从 XML 中提取 md5(不同版本字段可能不同)
|
||||||
# Extract CDN URL and validate it looks like a proper URL
|
image_md5 = _extract_xml_attr(raw_text, "md5") or _extract_xml_tag_text(raw_text, "md5")
|
||||||
_cdn_url = (
|
if not image_md5:
|
||||||
|
for k in [
|
||||||
|
"cdnthumbmd5",
|
||||||
|
"cdnthumd5",
|
||||||
|
"cdnmidimgmd5",
|
||||||
|
"cdnbigimgmd5",
|
||||||
|
"hdmd5",
|
||||||
|
"hevc_mid_md5",
|
||||||
|
"hevc_md5",
|
||||||
|
"imgmd5",
|
||||||
|
"filemd5",
|
||||||
|
]:
|
||||||
|
image_md5 = _extract_xml_attr(raw_text, k) or _extract_xml_tag_text(raw_text, k)
|
||||||
|
if image_md5:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract CDN URL (some versions store a non-HTTP "file id" string here)
|
||||||
|
_cdn_url_or_id = (
|
||||||
_extract_xml_attr(raw_text, "cdnthumburl")
|
_extract_xml_attr(raw_text, "cdnthumburl")
|
||||||
|
or _extract_xml_attr(raw_text, "cdnthumurl")
|
||||||
or _extract_xml_attr(raw_text, "cdnmidimgurl")
|
or _extract_xml_attr(raw_text, "cdnmidimgurl")
|
||||||
or _extract_xml_attr(raw_text, "cdnbigimgurl")
|
or _extract_xml_attr(raw_text, "cdnbigimgurl")
|
||||||
|
or _extract_xml_tag_text(raw_text, "cdnthumburl")
|
||||||
|
or _extract_xml_tag_text(raw_text, "cdnthumurl")
|
||||||
|
or _extract_xml_tag_text(raw_text, "cdnmidimgurl")
|
||||||
|
or _extract_xml_tag_text(raw_text, "cdnbigimgurl")
|
||||||
)
|
)
|
||||||
image_url = _cdn_url if _cdn_url.startswith(("http://", "https://")) else ""
|
_cdn_url_or_id = str(_cdn_url_or_id or "").strip()
|
||||||
|
image_url = _cdn_url_or_id if _cdn_url_or_id.startswith(("http://", "https://")) else ""
|
||||||
|
if (not image_url) and _cdn_url_or_id:
|
||||||
|
image_file_id = _cdn_url_or_id
|
||||||
|
|
||||||
if (not image_md5) and resource_conn is not None:
|
if (not image_md5) and resource_conn is not None:
|
||||||
image_md5 = _lookup_resource_md5(
|
image_md5 = _lookup_resource_md5(
|
||||||
resource_conn,
|
resource_conn,
|
||||||
@@ -423,8 +453,25 @@ async def list_chat_messages(
|
|||||||
render_type = "video"
|
render_type = "video"
|
||||||
video_md5 = _extract_xml_attr(raw_text, "md5")
|
video_md5 = _extract_xml_attr(raw_text, "md5")
|
||||||
video_thumb_md5 = _extract_xml_attr(raw_text, "cdnthumbmd5")
|
video_thumb_md5 = _extract_xml_attr(raw_text, "cdnthumbmd5")
|
||||||
video_thumb_url = _extract_xml_attr(raw_text, "cdnthumburl")
|
video_thumb_url_or_id = _extract_xml_attr(raw_text, "cdnthumburl") or _extract_xml_tag_text(
|
||||||
video_url = _extract_xml_attr(raw_text, "cdnvideourl")
|
raw_text, "cdnthumburl"
|
||||||
|
)
|
||||||
|
video_url_or_id = _extract_xml_attr(raw_text, "cdnvideourl") or _extract_xml_tag_text(
|
||||||
|
raw_text, "cdnvideourl"
|
||||||
|
)
|
||||||
|
|
||||||
|
video_thumb_url = (
|
||||||
|
video_thumb_url_or_id
|
||||||
|
if str(video_thumb_url_or_id or "").strip().lower().startswith(("http://", "https://"))
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
video_url = (
|
||||||
|
video_url_or_id
|
||||||
|
if str(video_url_or_id or "").strip().lower().startswith(("http://", "https://"))
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
video_thumb_file_id = "" if video_thumb_url else (str(video_thumb_url_or_id or "").strip() or "")
|
||||||
|
video_file_id = "" if video_url else (str(video_url_or_id or "").strip() or "")
|
||||||
if (not video_thumb_md5) and resource_conn is not None:
|
if (not video_thumb_md5) and resource_conn is not None:
|
||||||
video_thumb_md5 = _lookup_resource_md5(
|
video_thumb_md5 = _lookup_resource_md5(
|
||||||
resource_conn,
|
resource_conn,
|
||||||
@@ -453,6 +500,29 @@ async def list_chat_messages(
|
|||||||
create_time=create_time,
|
create_time=create_time,
|
||||||
)
|
)
|
||||||
content_text = "[表情]"
|
content_text = "[表情]"
|
||||||
|
elif local_type == 50:
|
||||||
|
render_type = "voip"
|
||||||
|
try:
|
||||||
|
import re
|
||||||
|
|
||||||
|
block = raw_text
|
||||||
|
m_voip = re.search(
|
||||||
|
r"(<VoIPBubbleMsg[^>]*>.*?</VoIPBubbleMsg>)",
|
||||||
|
raw_text,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if m_voip:
|
||||||
|
block = m_voip.group(1) or raw_text
|
||||||
|
room_type = str(_extract_xml_tag_text(block, "room_type") or "").strip()
|
||||||
|
if room_type == "0":
|
||||||
|
voip_type = "video"
|
||||||
|
elif room_type == "1":
|
||||||
|
voip_type = "audio"
|
||||||
|
|
||||||
|
voip_msg = str(_extract_xml_tag_text(block, "msg") or "").strip()
|
||||||
|
content_text = voip_msg or "通话"
|
||||||
|
except Exception:
|
||||||
|
content_text = "通话"
|
||||||
elif local_type != 1:
|
elif local_type != 1:
|
||||||
if not content_text:
|
if not content_text:
|
||||||
content_text = _infer_message_brief_by_local_type(local_type)
|
content_text = _infer_message_brief_by_local_type(local_type)
|
||||||
@@ -513,15 +583,19 @@ async def list_chat_messages(
|
|||||||
"title": title,
|
"title": title,
|
||||||
"url": url,
|
"url": url,
|
||||||
"imageMd5": image_md5,
|
"imageMd5": image_md5,
|
||||||
|
"imageFileId": image_file_id,
|
||||||
"emojiMd5": emoji_md5,
|
"emojiMd5": emoji_md5,
|
||||||
"emojiUrl": emoji_url,
|
"emojiUrl": emoji_url,
|
||||||
"thumbUrl": thumb_url,
|
"thumbUrl": thumb_url,
|
||||||
"imageUrl": image_url,
|
"imageUrl": image_url,
|
||||||
"videoMd5": video_md5,
|
"videoMd5": video_md5,
|
||||||
"videoThumbMd5": video_thumb_md5,
|
"videoThumbMd5": video_thumb_md5,
|
||||||
|
"videoFileId": video_file_id,
|
||||||
|
"videoThumbFileId": video_thumb_file_id,
|
||||||
"videoUrl": video_url,
|
"videoUrl": video_url,
|
||||||
"videoThumbUrl": video_thumb_url,
|
"videoThumbUrl": video_thumb_url,
|
||||||
"voiceLength": voice_length,
|
"voiceLength": voice_length,
|
||||||
|
"voipType": voip_type,
|
||||||
"quoteTitle": quote_title,
|
"quoteTitle": quote_title,
|
||||||
"quoteContent": quote_content,
|
"quoteContent": quote_content,
|
||||||
"amount": amount,
|
"amount": amount,
|
||||||
@@ -632,12 +706,19 @@ async def list_chat_messages(
|
|||||||
try:
|
try:
|
||||||
rt = str(m.get("renderType") or "")
|
rt = str(m.get("renderType") or "")
|
||||||
if rt == "image":
|
if rt == "image":
|
||||||
if (not str(m.get("imageUrl") or "")) and str(m.get("imageMd5") or ""):
|
if not str(m.get("imageUrl") or ""):
|
||||||
md5 = str(m.get("imageMd5") or "")
|
md5 = str(m.get("imageMd5") or "").strip()
|
||||||
|
file_id = str(m.get("imageFileId") or "").strip()
|
||||||
|
if md5:
|
||||||
m["imageUrl"] = (
|
m["imageUrl"] = (
|
||||||
base_url
|
base_url
|
||||||
+ f"/api/chat/media/image?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
|
+ f"/api/chat/media/image?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
|
||||||
)
|
)
|
||||||
|
elif file_id:
|
||||||
|
m["imageUrl"] = (
|
||||||
|
base_url
|
||||||
|
+ f"/api/chat/media/image?account={quote(account_dir.name)}&file_id={quote(file_id)}&username={quote(username)}"
|
||||||
|
)
|
||||||
elif rt == "emoji":
|
elif rt == "emoji":
|
||||||
md5 = str(m.get("emojiMd5") or "")
|
md5 = str(m.get("emojiMd5") or "")
|
||||||
if md5:
|
if md5:
|
||||||
@@ -667,17 +748,36 @@ async def list_chat_messages(
|
|||||||
+ f"/api/chat/media/emoji?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
|
+ f"/api/chat/media/emoji?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
|
||||||
)
|
)
|
||||||
elif rt == "video":
|
elif rt == "video":
|
||||||
if (not str(m.get("videoThumbUrl") or "")) and str(m.get("videoThumbMd5") or ""):
|
video_thumb_url = str(m.get("videoThumbUrl") or "").strip()
|
||||||
md5 = str(m.get("videoThumbMd5") or "")
|
video_thumb_md5 = str(m.get("videoThumbMd5") or "").strip()
|
||||||
|
video_thumb_file_id = str(m.get("videoThumbFileId") or "").strip()
|
||||||
|
if (not video_thumb_url) or (
|
||||||
|
not video_thumb_url.lower().startswith(("http://", "https://"))
|
||||||
|
):
|
||||||
|
if video_thumb_md5:
|
||||||
m["videoThumbUrl"] = (
|
m["videoThumbUrl"] = (
|
||||||
base_url
|
base_url
|
||||||
+ f"/api/chat/media/video_thumb?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
|
+ f"/api/chat/media/video_thumb?account={quote(account_dir.name)}&md5={quote(video_thumb_md5)}&username={quote(username)}"
|
||||||
)
|
)
|
||||||
if (not str(m.get("videoUrl") or "")) and str(m.get("videoMd5") or ""):
|
elif video_thumb_file_id:
|
||||||
md5 = str(m.get("videoMd5") or "")
|
m["videoThumbUrl"] = (
|
||||||
|
base_url
|
||||||
|
+ f"/api/chat/media/video_thumb?account={quote(account_dir.name)}&file_id={quote(video_thumb_file_id)}&username={quote(username)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
video_url = str(m.get("videoUrl") or "").strip()
|
||||||
|
video_md5 = str(m.get("videoMd5") or "").strip()
|
||||||
|
video_file_id = str(m.get("videoFileId") or "").strip()
|
||||||
|
if (not video_url) or (not video_url.lower().startswith(("http://", "https://"))):
|
||||||
|
if video_md5:
|
||||||
m["videoUrl"] = (
|
m["videoUrl"] = (
|
||||||
base_url
|
base_url
|
||||||
+ f"/api/chat/media/video?account={quote(account_dir.name)}&md5={quote(md5)}&username={quote(username)}"
|
+ f"/api/chat/media/video?account={quote(account_dir.name)}&md5={quote(video_md5)}&username={quote(username)}"
|
||||||
|
)
|
||||||
|
elif video_file_id:
|
||||||
|
m["videoUrl"] = (
|
||||||
|
base_url
|
||||||
|
+ f"/api/chat/media/video?account={quote(account_dir.name)}&file_id={quote(video_file_id)}&username={quote(username)}"
|
||||||
)
|
)
|
||||||
elif rt == "voice":
|
elif rt == "voice":
|
||||||
if str(m.get("serverId") or ""):
|
if str(m.get("serverId") or ""):
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ..media_helpers import (
|
|||||||
_detect_image_extension,
|
_detect_image_extension,
|
||||||
_detect_image_media_type,
|
_detect_image_media_type,
|
||||||
_ensure_decrypted_resource_for_md5,
|
_ensure_decrypted_resource_for_md5,
|
||||||
|
_fallback_search_media_by_file_id,
|
||||||
_fallback_search_media_by_md5,
|
_fallback_search_media_by_md5,
|
||||||
_get_decrypted_resource_path,
|
_get_decrypted_resource_path,
|
||||||
_get_resource_dir,
|
_get_resource_dir,
|
||||||
@@ -238,13 +239,19 @@ async def download_chat_emoji(req: EmojiDownloadRequest):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/chat/media/image", summary="获取图片消息资源")
|
@router.get("/api/chat/media/image", summary="获取图片消息资源")
|
||||||
async def get_chat_image(md5: str, account: Optional[str] = None, username: Optional[str] = None):
|
async def get_chat_image(
|
||||||
if not md5:
|
md5: Optional[str] = None,
|
||||||
raise HTTPException(status_code=400, detail="Missing md5.")
|
file_id: Optional[str] = None,
|
||||||
|
account: Optional[str] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
):
|
||||||
|
if (not md5) and (not file_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Missing md5/file_id.")
|
||||||
account_dir = _resolve_account_dir(account)
|
account_dir = _resolve_account_dir(account)
|
||||||
|
|
||||||
# 优先从解密资源目录读取(更快)
|
# md5 模式:优先从解密资源目录读取(更快)
|
||||||
decrypted_path = _try_find_decrypted_resource(account_dir, md5.lower())
|
if md5:
|
||||||
|
decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower())
|
||||||
if decrypted_path:
|
if decrypted_path:
|
||||||
data = decrypted_path.read_bytes()
|
data = decrypted_path.read_bytes()
|
||||||
media_type = _detect_image_media_type(data[:32])
|
media_type = _detect_image_media_type(data[:32])
|
||||||
@@ -254,13 +261,10 @@ async def get_chat_image(md5: str, account: Optional[str] = None, username: Opti
|
|||||||
media_type = guessed
|
media_type = guessed
|
||||||
return Response(content=data, media_type=media_type)
|
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] = []
|
|
||||||
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
|
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
|
||||||
if db_storage_dir:
|
|
||||||
extra_roots.append(db_storage_dir)
|
|
||||||
|
|
||||||
roots: list[Path] = []
|
roots: list[Path] = []
|
||||||
if wxid_dir:
|
if wxid_dir:
|
||||||
@@ -271,11 +275,16 @@ async def get_chat_image(md5: str, account: Optional[str] = None, username: Opti
|
|||||||
roots.append(wxid_dir / "cache")
|
roots.append(wxid_dir / "cache")
|
||||||
if db_storage_dir:
|
if db_storage_dir:
|
||||||
roots.append(db_storage_dir)
|
roots.append(db_storage_dir)
|
||||||
|
|
||||||
if not roots:
|
if not roots:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
|
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
p: Optional[Path] = None
|
||||||
|
|
||||||
|
if md5:
|
||||||
p = _resolve_media_path_from_hardlink(
|
p = _resolve_media_path_from_hardlink(
|
||||||
hardlink_db_path,
|
hardlink_db_path,
|
||||||
roots[0],
|
roots[0],
|
||||||
@@ -285,16 +294,28 @@ async def get_chat_image(md5: str, account: Optional[str] = None, username: Opti
|
|||||||
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="image")
|
||||||
if hit:
|
if hit:
|
||||||
p = Path(hit)
|
p = Path(hit)
|
||||||
|
elif file_id:
|
||||||
|
# 一些版本图片消息无 MD5,仅提供 cdnthumburl 等“文件标识”
|
||||||
|
for r in [wxid_dir, db_storage_dir]:
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
|
hit = _fallback_search_media_by_file_id(str(r), str(file_id), kind="image", username=str(username or ""))
|
||||||
|
if hit:
|
||||||
|
p = Path(hit)
|
||||||
|
break
|
||||||
|
|
||||||
if not p:
|
if not p:
|
||||||
raise HTTPException(status_code=404, detail="Image not found.")
|
raise HTTPException(status_code=404, detail="Image not found.")
|
||||||
|
|
||||||
logger.info(f"chat_image: md5={md5} resolved_source={p}")
|
logger.info(f"chat_image: md5={md5} file_id={file_id} resolved_source={p}")
|
||||||
|
|
||||||
data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir)
|
data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir)
|
||||||
if media_type.startswith("image/"):
|
|
||||||
|
# 仅在 md5 有效时缓存到 resource 目录;file_id 可能非常长,避免写入超长文件名
|
||||||
|
if md5 and media_type.startswith("image/"):
|
||||||
try:
|
try:
|
||||||
out_md5 = str(md5).lower()
|
out_md5 = str(md5).lower()
|
||||||
ext = _detect_image_extension(data)
|
ext = _detect_image_extension(data)
|
||||||
@@ -304,7 +325,8 @@ async def get_chat_image(md5: str, account: Optional[str] = None, username: Opti
|
|||||||
out_path.write_bytes(data)
|
out_path.write_bytes(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
logger.info(f"chat_image: md5={md5} media_type={media_type} bytes={len(data)}")
|
|
||||||
|
logger.info(f"chat_image: md5={md5} file_id={file_id} media_type={media_type} bytes={len(data)}")
|
||||||
return Response(content=data, media_type=media_type)
|
return Response(content=data, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
@@ -345,13 +367,19 @@ async def get_chat_emoji(md5: str, account: Optional[str] = None, username: Opti
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/chat/media/video_thumb", summary="获取视频缩略图资源")
|
@router.get("/api/chat/media/video_thumb", summary="获取视频缩略图资源")
|
||||||
async def get_chat_video_thumb(md5: str, account: Optional[str] = None, username: Optional[str] = None):
|
async def get_chat_video_thumb(
|
||||||
if not md5:
|
md5: Optional[str] = None,
|
||||||
raise HTTPException(status_code=400, detail="Missing md5.")
|
file_id: Optional[str] = None,
|
||||||
|
account: Optional[str] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
):
|
||||||
|
if (not md5) and (not file_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Missing md5/file_id.")
|
||||||
account_dir = _resolve_account_dir(account)
|
account_dir = _resolve_account_dir(account)
|
||||||
|
|
||||||
# 优先从解密资源目录读取(更快)
|
# 优先从解密资源目录读取(更快)
|
||||||
decrypted_path = _try_find_decrypted_resource(account_dir, md5.lower())
|
if md5:
|
||||||
|
decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower())
|
||||||
if decrypted_path:
|
if decrypted_path:
|
||||||
data = decrypted_path.read_bytes()
|
data = decrypted_path.read_bytes()
|
||||||
media_type = _detect_image_media_type(data[:32])
|
media_type = _detect_image_media_type(data[:32])
|
||||||
@@ -375,6 +403,8 @@ async def get_chat_video_thumb(md5: str, account: Optional[str] = None, username
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
|
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
|
||||||
)
|
)
|
||||||
|
p: Optional[Path] = None
|
||||||
|
if md5:
|
||||||
p = _resolve_media_path_from_hardlink(
|
p = _resolve_media_path_from_hardlink(
|
||||||
hardlink_db_path,
|
hardlink_db_path,
|
||||||
roots[0],
|
roots[0],
|
||||||
@@ -384,9 +414,17 @@ async def get_chat_video_thumb(md5: str, account: Optional[str] = None, username
|
|||||||
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="video_thumb")
|
||||||
if hit:
|
if hit:
|
||||||
p = Path(hit)
|
p = Path(hit)
|
||||||
|
if (not p) and file_id:
|
||||||
|
for r in [wxid_dir, db_storage_dir]:
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
|
hit = _fallback_search_media_by_file_id(str(r), str(file_id), kind="video_thumb", username=str(username or ""))
|
||||||
|
if hit:
|
||||||
|
p = Path(hit)
|
||||||
|
break
|
||||||
if not p:
|
if not p:
|
||||||
raise HTTPException(status_code=404, detail="Video thumbnail not found.")
|
raise HTTPException(status_code=404, detail="Video thumbnail not found.")
|
||||||
|
|
||||||
@@ -395,10 +433,24 @@ async def get_chat_video_thumb(md5: str, account: Optional[str] = None, username
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/chat/media/video", summary="获取视频资源")
|
@router.get("/api/chat/media/video", summary="获取视频资源")
|
||||||
async def get_chat_video(md5: str, account: Optional[str] = None, username: Optional[str] = None):
|
async def get_chat_video(
|
||||||
if not md5:
|
md5: Optional[str] = None,
|
||||||
raise HTTPException(status_code=400, detail="Missing md5.")
|
file_id: Optional[str] = None,
|
||||||
|
account: Optional[str] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
):
|
||||||
|
if (not md5) and (not file_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Missing md5/file_id.")
|
||||||
account_dir = _resolve_account_dir(account)
|
account_dir = _resolve_account_dir(account)
|
||||||
|
md5_norm = str(md5 or "").strip().lower() if md5 else ""
|
||||||
|
|
||||||
|
if md5_norm:
|
||||||
|
# 优先从解密资源目录读取(更快,且支持 Range)
|
||||||
|
decrypted_path = _try_find_decrypted_resource(account_dir, md5_norm)
|
||||||
|
if decrypted_path:
|
||||||
|
mt = _guess_media_type_by_path(decrypted_path, fallback="video/mp4")
|
||||||
|
return FileResponse(str(decrypted_path), media_type=mt)
|
||||||
|
|
||||||
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] = []
|
||||||
@@ -416,22 +468,61 @@ async def get_chat_video(md5: str, account: Optional[str] = None, username: Opti
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
|
detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.",
|
||||||
)
|
)
|
||||||
|
p: Optional[Path] = None
|
||||||
|
if md5_norm:
|
||||||
p = _resolve_media_path_from_hardlink(
|
p = _resolve_media_path_from_hardlink(
|
||||||
hardlink_db_path,
|
hardlink_db_path,
|
||||||
roots[0],
|
roots[0],
|
||||||
md5=str(md5),
|
md5=md5_norm,
|
||||||
kind="video",
|
kind="video",
|
||||||
username=username,
|
username=username,
|
||||||
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), md5_norm, kind="video")
|
||||||
if hit:
|
if hit:
|
||||||
p = Path(hit)
|
p = Path(hit)
|
||||||
|
if (not p) and file_id:
|
||||||
|
for r in [wxid_dir, db_storage_dir]:
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
|
hit = _fallback_search_media_by_file_id(str(r), str(file_id), kind="video", username=str(username or ""))
|
||||||
|
if hit:
|
||||||
|
p = Path(hit)
|
||||||
|
break
|
||||||
if not p:
|
if not p:
|
||||||
raise HTTPException(status_code=404, detail="Video not found.")
|
raise HTTPException(status_code=404, detail="Video not found.")
|
||||||
|
|
||||||
|
# 直接可播放的 MP4:直接 FileResponse(支持 Range)
|
||||||
|
try:
|
||||||
|
with open(p, "rb") as f:
|
||||||
|
head = f.read(8)
|
||||||
|
if len(head) >= 8 and head[4:8] == b"ftyp":
|
||||||
media_type = _guess_media_type_by_path(p, fallback="video/mp4")
|
media_type = _guess_media_type_by_path(p, fallback="video/mp4")
|
||||||
return FileResponse(str(p), media_type=media_type)
|
return FileResponse(str(p), media_type=media_type)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 尝试解密/去前缀并落盘(避免一次性返回大文件 bytes)
|
||||||
|
if md5_norm:
|
||||||
|
try:
|
||||||
|
materialized = _ensure_decrypted_resource_for_md5(
|
||||||
|
account_dir,
|
||||||
|
md5=md5_norm,
|
||||||
|
source_path=p,
|
||||||
|
weixin_root=wxid_dir,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
materialized = None
|
||||||
|
if materialized:
|
||||||
|
media_type = _guess_media_type_by_path(materialized, fallback="video/mp4")
|
||||||
|
return FileResponse(str(materialized), media_type=media_type)
|
||||||
|
|
||||||
|
# 最后兜底:直接返回处理后的 bytes(不支持 Range)
|
||||||
|
data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir)
|
||||||
|
if media_type == "application/octet-stream":
|
||||||
|
media_type = _guess_media_type_by_path(p, fallback="video/mp4")
|
||||||
|
return Response(content=data, media_type=media_type)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/chat/media/voice", summary="获取语音消息资源")
|
@router.get("/api/chat/media/voice", summary="获取语音消息资源")
|
||||||
@@ -482,6 +573,7 @@ async def get_chat_voice(server_id: int, account: Optional[str] = None):
|
|||||||
async def open_chat_media_folder(
|
async def open_chat_media_folder(
|
||||||
kind: str,
|
kind: str,
|
||||||
md5: Optional[str] = None,
|
md5: Optional[str] = None,
|
||||||
|
file_id: Optional[str] = None,
|
||||||
server_id: Optional[int] = None,
|
server_id: Optional[int] = None,
|
||||||
account: Optional[str] = None,
|
account: Optional[str] = None,
|
||||||
username: Optional[str] = None,
|
username: Optional[str] = None,
|
||||||
@@ -528,15 +620,36 @@ async def open_chat_media_folder(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to export voice: {e}")
|
raise HTTPException(status_code=500, detail=f"Failed to export voice: {e}")
|
||||||
else:
|
else:
|
||||||
if not md5:
|
if not md5 and not file_id:
|
||||||
raise HTTPException(status_code=400, detail="Missing md5.")
|
raise HTTPException(status_code=400, detail="Missing md5/file_id.")
|
||||||
|
|
||||||
|
if md5 and (not file_id) and (not _is_valid_md5(str(md5))):
|
||||||
|
file_id = str(md5)
|
||||||
|
md5 = None
|
||||||
|
|
||||||
|
if md5:
|
||||||
p = _resolve_media_path_for_kind(account_dir, kind=kind_key, md5=str(md5), username=username)
|
p = _resolve_media_path_for_kind(account_dir, kind=kind_key, md5=str(md5), username=username)
|
||||||
|
if (not p) and file_id:
|
||||||
|
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||||
|
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
|
||||||
|
for r in [wxid_dir, db_storage_dir]:
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
|
hit = _fallback_search_media_by_file_id(
|
||||||
|
str(r),
|
||||||
|
str(file_id),
|
||||||
|
kind=str(kind_key),
|
||||||
|
username=str(username or ""),
|
||||||
|
)
|
||||||
|
if hit:
|
||||||
|
p = Path(hit)
|
||||||
|
break
|
||||||
|
|
||||||
resolved_before_materialize = p
|
resolved_before_materialize = p
|
||||||
materialized_ok = False
|
materialized_ok = False
|
||||||
opened_kind = "resolved"
|
opened_kind = "resolved"
|
||||||
|
|
||||||
if p and kind_key in {"image", "emoji", "video_thumb"}:
|
if p and kind_key in {"image", "emoji", "video_thumb"} and md5:
|
||||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||||
source_path = p
|
source_path = p
|
||||||
if kind_key == "emoji":
|
if kind_key == "emoji":
|
||||||
@@ -693,7 +806,7 @@ async def open_chat_media_folder(
|
|||||||
except Exception:
|
except Exception:
|
||||||
target = str(p)
|
target = str(p)
|
||||||
|
|
||||||
logger.info(f"open_folder: kind={kind_key} md5={md5} server_id={server_id} -> {target}")
|
logger.info(f"open_folder: kind={kind_key} md5={md5} file_id={file_id} server_id={server_id} -> {target}")
|
||||||
|
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
raise HTTPException(status_code=400, detail="open_folder is only supported on Windows.")
|
raise HTTPException(status_code=400, detail="open_folder is only supported on Windows.")
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ async def get_media_keys(account: Optional[str] = None, force_extract: bool = Fa
|
|||||||
# 保存密钥到缓存
|
# 保存密钥到缓存
|
||||||
_save_media_keys(account_dir, xor_key, aes_key16)
|
_save_media_keys(account_dir, xor_key, aes_key16)
|
||||||
else:
|
else:
|
||||||
aes_message = "无法从微信进程提取AES密钥(请确认微信正在运行,并尝试以管理员身份运行后端;部分新版微信可能暂不兼容)"
|
aes_message = "无法从微信进程提取AES密钥(请确认微信正在运行,并尝试以管理员身份运行后端;可尝试打开朋友圈图片并点开大图 2-3 次后再提取)"
|
||||||
else:
|
else:
|
||||||
aes_message = "未找到V2加密模板文件"
|
aes_message = "未找到V2加密模板文件"
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user