mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
4 Commits
@@ -24,7 +24,7 @@
|
||||
|
||||
.wechat-link-card.wechat-link-card--disabled:hover,
|
||||
.wechat-link-card-cover.wechat-link-card--disabled:hover {
|
||||
background: #fff;
|
||||
background: var(--merged-history-bg);
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
@@ -704,7 +704,7 @@
|
||||
/* 文件消息样式 - 基于红包样式覆盖 */
|
||||
.wechat-file-card {
|
||||
width: 210px;
|
||||
background: #fff;
|
||||
background: var(--merged-history-bg);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
@@ -728,11 +728,11 @@
|
||||
left: 13px;
|
||||
right: 13px;
|
||||
height: 1.5px;
|
||||
background: #e8e8e8;
|
||||
background: var(--merged-history-divider);
|
||||
}
|
||||
|
||||
.wechat-file-card:hover {
|
||||
background: #f5f5f5;
|
||||
background: var(--merged-history-hover);
|
||||
}
|
||||
|
||||
.wechat-file-card .wechat-file-info {
|
||||
@@ -742,7 +742,7 @@
|
||||
|
||||
.wechat-file-name {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
color: var(--merged-history-title);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -753,7 +753,7 @@
|
||||
|
||||
.wechat-file-size {
|
||||
font-size: 12px;
|
||||
color: #b2b2b2;
|
||||
color: var(--merged-history-footer);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -765,12 +765,16 @@
|
||||
}
|
||||
|
||||
.wechat-file-bottom {
|
||||
border-top: 1px solid #e8e8e8;
|
||||
border-top: 1px solid var(--merged-history-divider);
|
||||
}
|
||||
|
||||
.wechat-file-bottom span {
|
||||
font-size: 12px;
|
||||
color: #b2b2b2;
|
||||
color: var(--merged-history-footer);
|
||||
}
|
||||
|
||||
.wechat-file-card :is(.text-gray-500, .text-gray-400) {
|
||||
color: var(--merged-history-preview);
|
||||
}
|
||||
|
||||
.wechat-file-logo {
|
||||
@@ -785,7 +789,7 @@
|
||||
width: 210px;
|
||||
min-width: 210px;
|
||||
max-width: 210px;
|
||||
background: #fff;
|
||||
background: var(--merged-history-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
@@ -798,7 +802,7 @@
|
||||
}
|
||||
|
||||
.wechat-link-card:hover {
|
||||
background: #f5f5f5;
|
||||
background: var(--merged-history-hover);
|
||||
}
|
||||
|
||||
.wechat-link-content {
|
||||
@@ -819,7 +823,7 @@
|
||||
|
||||
.wechat-link-title {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
color: var(--merged-history-title);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -830,7 +834,7 @@
|
||||
|
||||
.wechat-link-desc {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
color: var(--merged-history-preview);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -847,7 +851,7 @@
|
||||
flex: 0 0 auto;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: #f2f2f2;
|
||||
background: var(--app-surface-muted);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@@ -905,7 +909,7 @@
|
||||
|
||||
.wechat-link-mini-header-name {
|
||||
font-size: 13px;
|
||||
color: #7d7d7d;
|
||||
color: var(--merged-history-preview);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -916,7 +920,7 @@
|
||||
.wechat-link-mini-title {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #1a1a1a;
|
||||
color: var(--merged-history-title);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -930,12 +934,12 @@
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
background: #f2f2f2;
|
||||
background: var(--app-surface-muted);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.wechat-link-mini-preview--empty {
|
||||
background: #f7f7f7;
|
||||
background: var(--app-surface-soft);
|
||||
}
|
||||
|
||||
.wechat-link-mini-preview-img {
|
||||
@@ -964,7 +968,7 @@
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
background: var(--merged-history-divider);
|
||||
}
|
||||
|
||||
.wechat-link-mini-footer-icon {
|
||||
@@ -976,7 +980,7 @@
|
||||
|
||||
.wechat-link-mini-footer-text {
|
||||
font-size: 10px;
|
||||
color: #8c8c8c;
|
||||
color: var(--merged-history-preview);
|
||||
}
|
||||
|
||||
.wechat-link-from {
|
||||
@@ -996,7 +1000,7 @@
|
||||
left: 11px;
|
||||
right: 11px;
|
||||
height: 1.5px;
|
||||
background: #e8e8e8;
|
||||
background: var(--merged-history-divider);
|
||||
}
|
||||
|
||||
.wechat-link-from-avatar {
|
||||
@@ -1024,7 +1028,7 @@
|
||||
|
||||
.wechat-link-from-name {
|
||||
font-size: 12px;
|
||||
color: #b2b2b2;
|
||||
color: var(--merged-history-footer);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -1035,7 +1039,7 @@
|
||||
width: 137px;
|
||||
min-width: 137px;
|
||||
max-width: 137px;
|
||||
background: #fff;
|
||||
background: var(--merged-history-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
@@ -1048,7 +1052,7 @@
|
||||
}
|
||||
|
||||
.wechat-link-card-cover:hover {
|
||||
background: #f5f5f5;
|
||||
background: var(--merged-history-hover);
|
||||
}
|
||||
|
||||
.wechat-link-cover-image-wrap {
|
||||
@@ -1057,7 +1061,7 @@
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background: #f2f2f2;
|
||||
background: var(--app-surface-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1126,7 +1130,7 @@
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
line-height: 1.24;
|
||||
color: #1a1a1a;
|
||||
color: var(--merged-history-title);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
@@ -1597,6 +1597,154 @@
|
||||
background-color: var(--search-panel-bg);
|
||||
}
|
||||
|
||||
.chat-overlay-dropdown,
|
||||
.chat-context-menu,
|
||||
.chat-floating-window,
|
||||
.chat-edit-modal {
|
||||
background-color: var(--app-surface-bg);
|
||||
border: 1px solid var(--app-border);
|
||||
color: var(--app-text-primary);
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] :is(.chat-overlay-dropdown, .chat-context-menu, .chat-floating-window, .chat-edit-modal) {
|
||||
box-shadow: 0 24px 56px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.chat-overlay-dropdown input {
|
||||
background-color: var(--app-input-bg);
|
||||
border-color: var(--app-input-border);
|
||||
color: var(--app-text-primary);
|
||||
}
|
||||
|
||||
.chat-overlay-dropdown input::placeholder {
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.chat-overlay-dropdown :is(.bg-gray-200, .bg-gray-300),
|
||||
.chat-floating-window :is(.bg-gray-200, .bg-gray-300) {
|
||||
background-color: var(--app-border-soft);
|
||||
}
|
||||
|
||||
.chat-overlay-dropdown :is(.border-gray-100, .border-gray-200, .border-gray-300),
|
||||
.chat-floating-window :is(.border-gray-100, .border-gray-200, .border-gray-300) {
|
||||
border-color: var(--app-border);
|
||||
}
|
||||
|
||||
.chat-overlay-dropdown :is(.text-gray-900, .text-gray-800, .text-gray-700),
|
||||
.chat-floating-window :is(.text-gray-900, .text-gray-800, .text-gray-700) {
|
||||
color: var(--app-text-primary);
|
||||
}
|
||||
|
||||
.chat-overlay-dropdown :is(.text-gray-600, .text-gray-500, .text-gray-400),
|
||||
.chat-floating-window :is(.text-gray-600, .text-gray-500, .text-gray-400) {
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.chat-overlay-option {
|
||||
color: var(--app-text-primary);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-overlay-option:hover {
|
||||
background-color: var(--app-neutral-btn-hover);
|
||||
}
|
||||
|
||||
.chat-overlay-option--active {
|
||||
background-color: var(--app-surface-soft);
|
||||
}
|
||||
|
||||
.chat-overlay-option--active:hover {
|
||||
background-color: var(--app-neutral-btn-hover);
|
||||
}
|
||||
|
||||
.chat-context-menu__item {
|
||||
color: inherit;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-context-menu__item:hover {
|
||||
background-color: var(--app-neutral-btn-hover);
|
||||
}
|
||||
|
||||
.chat-context-menu :is(.border-gray-100, .border-gray-200, .border-gray-300) {
|
||||
border-color: var(--app-border);
|
||||
}
|
||||
|
||||
.chat-floating-window__header,
|
||||
.chat-floating-window__body,
|
||||
.chat-floating-window__row {
|
||||
background-color: var(--app-surface-soft);
|
||||
}
|
||||
|
||||
.chat-floating-window__header {
|
||||
border-bottom: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.chat-floating-window__row {
|
||||
border-bottom: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.chat-floating-window__title {
|
||||
color: var(--app-text-primary);
|
||||
}
|
||||
|
||||
.chat-floating-window__close {
|
||||
color: var(--app-text-secondary);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-floating-window__close:hover {
|
||||
background-color: var(--app-neutral-btn-hover);
|
||||
color: var(--app-text-primary);
|
||||
}
|
||||
|
||||
.chat-floating-window .bg-white {
|
||||
background-color: var(--app-surface-bg);
|
||||
}
|
||||
|
||||
.chat-floating-window .bg-gray-50 {
|
||||
background-color: var(--app-surface-soft);
|
||||
}
|
||||
|
||||
.chat-floating-window [class*='hover:bg-gray-50']:hover {
|
||||
background-color: var(--app-neutral-btn-hover);
|
||||
}
|
||||
|
||||
.chat-edit-modal :is(.border-gray-100, .border-gray-200, .border-gray-300) {
|
||||
border-color: var(--app-border);
|
||||
}
|
||||
|
||||
.chat-edit-modal .bg-white {
|
||||
background-color: var(--app-surface-bg);
|
||||
}
|
||||
|
||||
.chat-edit-modal .bg-gray-50 {
|
||||
background-color: var(--app-surface-soft);
|
||||
}
|
||||
|
||||
.chat-edit-modal :is(.text-gray-900, .text-gray-800, .text-gray-700) {
|
||||
color: var(--app-text-primary);
|
||||
}
|
||||
|
||||
.chat-edit-modal :is(.text-gray-600, .text-gray-500, .text-gray-400) {
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.chat-edit-modal :is(input:not([type='checkbox']):not([type='radio']), textarea, select) {
|
||||
background-color: var(--app-input-bg);
|
||||
border-color: var(--app-input-border);
|
||||
color: var(--app-text-primary);
|
||||
}
|
||||
|
||||
.chat-edit-modal :is(input:not([type='checkbox']):not([type='radio']), textarea, select)::placeholder {
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.chat-edit-modal [class*='hover:bg-gray-50']:hover {
|
||||
background-color: var(--app-neutral-btn-hover);
|
||||
}
|
||||
|
||||
.search-input-combined {
|
||||
background-color: var(--search-input-bg);
|
||||
border-color: var(--search-input-border);
|
||||
|
||||
@@ -267,4 +267,10 @@ const openLocation = () => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .wechat-location-card-wrap {
|
||||
--location-card-bg: var(--merged-history-bg);
|
||||
--location-card-text: var(--merged-history-title);
|
||||
--location-card-muted: var(--merged-history-preview);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -258,7 +258,7 @@
|
||||
|
||||
<div
|
||||
v-if="messageSearchSenderDropdownOpen"
|
||||
class="absolute left-0 right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-lg z-50 overflow-hidden"
|
||||
class="chat-overlay-dropdown absolute left-0 right-0 mt-1 rounded-md z-50 overflow-hidden"
|
||||
>
|
||||
<div class="p-2 border-b border-gray-100">
|
||||
<input
|
||||
@@ -274,8 +274,8 @@
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-gray-50"
|
||||
:class="!messageSearchSender ? 'bg-gray-50' : ''"
|
||||
class="chat-overlay-option w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs"
|
||||
:class="!messageSearchSender ? 'chat-overlay-option--active' : ''"
|
||||
@click="selectMessageSearchSender('')"
|
||||
>
|
||||
<span class="w-6 h-6 rounded-md overflow-hidden bg-gray-200 flex-shrink-0 flex items-center justify-center text-[10px] text-gray-500">
|
||||
@@ -298,8 +298,8 @@
|
||||
v-for="s in filteredMessageSearchSenderOptions"
|
||||
:key="s.username"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-gray-50"
|
||||
:class="messageSearchSender === s.username ? 'bg-gray-50' : ''"
|
||||
class="chat-overlay-option w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs"
|
||||
:class="messageSearchSender === s.username ? 'chat-overlay-option--active' : ''"
|
||||
@click="selectMessageSearchSender(s.username)"
|
||||
>
|
||||
<div class="w-6 h-6 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
@@ -560,29 +560,29 @@
|
||||
@mousedown="focusFloatingWindow(win.id)"
|
||||
>
|
||||
<div
|
||||
class="bg-[#f7f7f7] rounded-xl shadow-xl overflow-hidden border border-gray-200 flex flex-col"
|
||||
class="chat-floating-window rounded-xl overflow-hidden flex flex-col"
|
||||
:style="{ width: win.width + 'px', height: win.height + 'px' }"
|
||||
>
|
||||
<div
|
||||
class="px-3 py-2 bg-[#f7f7f7] border-b border-gray-200 flex items-center justify-between select-none cursor-move"
|
||||
class="chat-floating-window__header px-3 py-2 flex items-center justify-between select-none cursor-move"
|
||||
@mousedown.stop="startFloatingWindowDrag(win.id, $event)"
|
||||
@touchstart.stop="startFloatingWindowDrag(win.id, $event)"
|
||||
>
|
||||
<div class="text-sm text-[#161616] truncate min-w-0">{{ win.title || (win.kind === 'link' ? '链接' : '聊天记录') }}</div>
|
||||
<div class="chat-floating-window__title text-sm truncate min-w-0">{{ win.title || (win.kind === 'link' ? '链接' : '聊天记录') }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded hover:bg-black/5 flex-shrink-0"
|
||||
class="chat-floating-window__close p-2 rounded flex-shrink-0"
|
||||
@click.stop="closeFloatingWindow(win.id)"
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto bg-[#f7f7f7]">
|
||||
<div class="chat-floating-window__body flex-1 overflow-auto">
|
||||
<!-- Chat history window -->
|
||||
<template v-if="win.kind === 'chatHistory'">
|
||||
<div v-if="win.loading" class="text-xs text-gray-500 text-center py-2">加载中...</div>
|
||||
@@ -593,7 +593,7 @@
|
||||
<div
|
||||
v-for="(rec, idx) in win.records"
|
||||
:key="rec.id || idx"
|
||||
class="px-4 py-3 flex gap-3 border-b border-gray-100 bg-[#f7f7f7]"
|
||||
class="chat-floating-window__row px-4 py-3 flex gap-3"
|
||||
>
|
||||
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<img
|
||||
@@ -1087,19 +1087,19 @@
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
ref="contextMenuElement"
|
||||
class="fixed z-[12000] max-h-[calc(100vh-16px)] overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg text-sm"
|
||||
class="chat-context-menu fixed z-[12000] max-h-[calc(100vh-16px)] overflow-y-auto rounded-md text-sm"
|
||||
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onCopyMessageTextClick"
|
||||
>
|
||||
复制文本
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onCopyMessageJsonClick"
|
||||
>
|
||||
@@ -1107,14 +1107,14 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.message?.renderType === 'quote' && contextMenu.message?.quoteServerId"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onLocateQuotedMessageClick"
|
||||
>
|
||||
定位引用消息
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
:disabled="contextMenu.disabled"
|
||||
:class="contextMenu.disabled ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
@@ -1127,7 +1127,7 @@
|
||||
|
||||
<button
|
||||
v-if="contextMenu.message?.id"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onEditMessageClick"
|
||||
>
|
||||
@@ -1135,7 +1135,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.message?.id"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onEditMessageFieldsClick"
|
||||
>
|
||||
@@ -1143,7 +1143,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.editStatus?.modified"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100 text-red-600"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2 text-red-600"
|
||||
type="button"
|
||||
@click="onResetEditedMessageClick"
|
||||
>
|
||||
@@ -1151,7 +1151,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.message?.id"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onRepairMessageSenderAsMeClick"
|
||||
>
|
||||
@@ -1159,7 +1159,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.message?.id"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100 text-orange-600"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2 text-orange-600"
|
||||
type="button"
|
||||
@click="onFlipWechatMessageDirectionClick"
|
||||
>
|
||||
@@ -1171,7 +1171,7 @@
|
||||
<!-- 修改消息弹窗 -->
|
||||
<div v-if="messageEditModal.open" class="fixed inset-0 z-[11000] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40" @click="closeMessageEditModal"></div>
|
||||
<div class="relative w-[860px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="chat-edit-modal relative w-[860px] max-w-[95vw] rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center">
|
||||
<div class="text-base font-medium text-gray-900">{{ messageEditModal.mode === 'content' ? '修改消息' : '编辑源码' }}</div>
|
||||
<button class="ml-auto text-gray-400 hover:text-gray-600" type="button" @click="closeMessageEditModal">
|
||||
@@ -1218,7 +1218,7 @@
|
||||
<!-- 字段编辑弹窗 -->
|
||||
<div v-if="messageFieldsModal.open" class="fixed inset-0 z-[11000] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40" @click="closeMessageFieldsModal"></div>
|
||||
<div class="relative w-[920px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="chat-edit-modal relative w-[920px] max-w-[95vw] rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center">
|
||||
<div class="text-base font-medium text-gray-900">字段编辑</div>
|
||||
<button class="ml-auto text-gray-400 hover:text-gray-600" type="button" @click="closeMessageFieldsModal">
|
||||
|
||||
@@ -116,7 +116,7 @@ export default defineComponent({
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
background: 'var(--merged-history-bg)',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
@@ -167,7 +167,7 @@ export default defineComponent({
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
background: 'var(--merged-history-bg)',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
@@ -236,7 +236,7 @@ export default defineComponent({
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
background: 'var(--merged-history-bg)',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
<div
|
||||
v-if="contactProfileCardOpen && contactProfileCardMessageId === String(message.id ?? '')"
|
||||
class="absolute z-40 w-[360px] max-w-[88vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden"
|
||||
class="chat-contact-card absolute z-40 w-[360px] max-w-[88vw] rounded-lg overflow-hidden"
|
||||
:class="message.isSent ? 'right-0 top-[calc(100%+8px)]' : 'left-0 top-[calc(100%+8px)]'"
|
||||
@mouseenter="onContactCardMouseEnter"
|
||||
@mouseleave="onMessageAvatarMouseLeave"
|
||||
@@ -146,3 +146,40 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-contact-card {
|
||||
background-color: var(--app-surface-bg);
|
||||
border: 1px solid var(--app-border);
|
||||
color: var(--app-text-primary);
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .chat-contact-card {
|
||||
box-shadow: 0 24px 56px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.chat-contact-card .bg-white {
|
||||
background-color: var(--app-surface-bg);
|
||||
}
|
||||
|
||||
.chat-contact-card [class*='bg-[#F6F6F6]'] {
|
||||
background-color: var(--app-surface-soft);
|
||||
}
|
||||
|
||||
.chat-contact-card .bg-gray-200 {
|
||||
background-color: var(--app-border-soft);
|
||||
}
|
||||
|
||||
.chat-contact-card :is(.border-gray-100, .border-gray-200, .border-gray-300) {
|
||||
border-color: var(--app-border);
|
||||
}
|
||||
|
||||
.chat-contact-card :is(.text-gray-900, .text-gray-800, .text-gray-700) {
|
||||
color: var(--app-text-primary);
|
||||
}
|
||||
|
||||
.chat-contact-card :is(.text-gray-600, .text-gray-500, .text-gray-400) {
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
</svg>
|
||||
点击按钮将自动获取【数据库】与【图片】双重密钥。您也可以手动输入已知的64位密钥(使用<a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a>等工具获取)。
|
||||
</p>
|
||||
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-900">
|
||||
提示:数据库密钥跟随“账号 + 设备”下发。同一账号在另一台电脑生成的聊天记录,复制到当前设备后,通常无法在当前设备重新获取原设备对应的密钥,因此也无法直接解密。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据库路径输入 -->
|
||||
|
||||
@@ -186,6 +186,178 @@ def _sql_literal(value: Any) -> str:
|
||||
return "'" + s.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _pick_case_insensitive_value(item: Any, *keys: str) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
for key in keys:
|
||||
if key in item and item[key] is not None:
|
||||
return item[key]
|
||||
key_lc = str(key or "").strip().lower()
|
||||
for actual_key, actual_value in item.items():
|
||||
if str(actual_key or "").strip().lower() == key_lc and actual_value is not None:
|
||||
return actual_value
|
||||
return None
|
||||
|
||||
|
||||
def _table_exists_case_insensitive(conn: sqlite3.Connection, table_name: str) -> bool:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=lower(?) LIMIT 1",
|
||||
(str(table_name or "").strip(),),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_output_name2id_table(conn: sqlite3.Connection) -> bool:
|
||||
if _table_exists_case_insensitive(conn, "Name2Id"):
|
||||
return True
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Name2Id (
|
||||
user_name TEXT,
|
||||
is_session INTEGER DEFAULT 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _best_effort_upsert_output_name2id_rows(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
account_name: str,
|
||||
rows: list[dict[str, Any]],
|
||||
) -> bool:
|
||||
if not rows:
|
||||
return _table_exists_case_insensitive(conn, "Name2Id")
|
||||
if not _ensure_output_name2id_table(conn):
|
||||
return False
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO Name2Id(user_name, is_session) VALUES (?, ?)",
|
||||
(str(account_name or "").strip(), 1),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
wrote = False
|
||||
for row in rows:
|
||||
try:
|
||||
rid = int(row.get("real_sender_id") or 0)
|
||||
except Exception:
|
||||
rid = 0
|
||||
username = str(row.get("sender_username") or "").strip()
|
||||
if rid <= 0 or not username:
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO Name2Id(rowid, user_name, is_session) VALUES (?, ?, ?)",
|
||||
(rid, username, 1),
|
||||
)
|
||||
wrote = True
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if wrote:
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _sync_output_name2id_from_live(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
rt_conn: Any,
|
||||
msg_db_path_real: Path,
|
||||
) -> dict[str, Any]:
|
||||
if not _ensure_output_name2id_table(conn):
|
||||
return {"status": "missing_local_table", "rows": 0}
|
||||
|
||||
local_row = conn.execute("SELECT COUNT(1) AS c, COALESCE(MAX(rowid), 0) AS mx FROM Name2Id").fetchone()
|
||||
try:
|
||||
local_count = int((local_row["c"] if isinstance(local_row, sqlite3.Row) else local_row[0]) or 0)
|
||||
except Exception:
|
||||
local_count = 0
|
||||
try:
|
||||
local_max = int((local_row["mx"] if isinstance(local_row, sqlite3.Row) else local_row[1]) or 0)
|
||||
except Exception:
|
||||
local_max = 0
|
||||
|
||||
sql_stats = "SELECT COUNT(1) AS c, COALESCE(MAX(rowid), 0) AS mx FROM Name2Id"
|
||||
with rt_conn.lock:
|
||||
live_stats_rows = _wcdb_exec_query(rt_conn.handle, kind="message", path=str(msg_db_path_real), sql=sql_stats)
|
||||
|
||||
live_stats = live_stats_rows[0] if live_stats_rows and isinstance(live_stats_rows[0], dict) else {}
|
||||
try:
|
||||
live_count = int(_pick_case_insensitive_value(live_stats, "c", "count") or 0)
|
||||
except Exception:
|
||||
live_count = 0
|
||||
try:
|
||||
live_max = int(_pick_case_insensitive_value(live_stats, "mx", "max_rowid", "max") or 0)
|
||||
except Exception:
|
||||
live_max = 0
|
||||
|
||||
if local_count == live_count and local_max == live_max:
|
||||
return {
|
||||
"status": "up_to_date",
|
||||
"rows": int(local_count),
|
||||
"localCount": int(local_count),
|
||||
"liveCount": int(live_count),
|
||||
"localMax": int(local_max),
|
||||
"liveMax": int(live_max),
|
||||
}
|
||||
|
||||
sql_rows = "SELECT rowid AS rowid, user_name AS user_name, COALESCE(is_session, 1) AS is_session FROM Name2Id ORDER BY rowid ASC"
|
||||
with rt_conn.lock:
|
||||
live_rows = _wcdb_exec_query(rt_conn.handle, kind="message", path=str(msg_db_path_real), sql=sql_rows)
|
||||
|
||||
values: list[tuple[int, str, int]] = []
|
||||
seen_rowids: set[int] = set()
|
||||
for item in live_rows:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
rid = int(_pick_case_insensitive_value(item, "rowid") or 0)
|
||||
except Exception:
|
||||
rid = 0
|
||||
username = str(_pick_case_insensitive_value(item, "user_name", "username") or "").strip()
|
||||
try:
|
||||
is_session = int(_pick_case_insensitive_value(item, "is_session") or 0)
|
||||
except Exception:
|
||||
is_session = 0
|
||||
if rid <= 0 or not username or rid in seen_rowids:
|
||||
continue
|
||||
seen_rowids.add(rid)
|
||||
values.append((rid, username, is_session))
|
||||
|
||||
if live_count > 0 and not values:
|
||||
raise ValueError("Live Name2Id rows could not be decoded.")
|
||||
|
||||
conn.execute("DELETE FROM Name2Id")
|
||||
if values:
|
||||
conn.executemany(
|
||||
"INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (?, ?, ?)",
|
||||
values,
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"status": "refreshed",
|
||||
"rows": int(len(values)),
|
||||
"localCount": int(local_count),
|
||||
"liveCount": int(live_count),
|
||||
"localMax": int(local_max),
|
||||
"liveMax": int(live_max),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_edit_value(col: str, value: Any, *, from_snapshot: bool = False) -> Any:
|
||||
c = str(col or "").strip().lower()
|
||||
if value is None:
|
||||
@@ -1271,6 +1443,7 @@ def sync_chat_realtime_messages(
|
||||
# Some sessions may not exist in the decrypted snapshot yet; create the missing Msg_<md5> table
|
||||
# so we can insert the realtime rows and make `/api/chat/messages` work after switching off realtime.
|
||||
msg_db_path, table_name = _ensure_decrypted_message_table(account_dir, username)
|
||||
msg_db_path_real, _res_db_path_real = _resolve_db_storage_message_paths(account_dir, msg_db_path.stem)
|
||||
logger.info(
|
||||
"[%s] resolved decrypted table account=%s username=%s db=%s table=%s",
|
||||
trace_id,
|
||||
@@ -1283,6 +1456,34 @@ def sync_chat_realtime_messages(
|
||||
msg_conn = sqlite3.connect(str(msg_db_path))
|
||||
msg_conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
name2id_synced = False
|
||||
try:
|
||||
sync_t0 = time.perf_counter()
|
||||
name2id_result = _sync_output_name2id_from_live(
|
||||
msg_conn,
|
||||
rt_conn=rt_conn,
|
||||
msg_db_path_real=msg_db_path_real,
|
||||
)
|
||||
sync_ms = (time.perf_counter() - sync_t0) * 1000.0
|
||||
name2id_synced = str(name2id_result.get("status") or "") in {"up_to_date", "refreshed"}
|
||||
logger.info(
|
||||
"[%s] Name2Id sync account=%s db=%s status=%s rows=%s ms=%.1f",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
msg_db_path.stem,
|
||||
str(name2id_result.get("status") or ""),
|
||||
int(name2id_result.get("rows") or 0),
|
||||
sync_ms,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[%s] Name2Id sync failed account=%s db=%s error=%s",
|
||||
trace_id,
|
||||
account_dir.name,
|
||||
msg_db_path.stem,
|
||||
str(e),
|
||||
)
|
||||
|
||||
quoted_table = _quote_ident(table_name)
|
||||
row = msg_conn.execute(f"SELECT MAX(local_id) AS mx FROM {quoted_table}").fetchone()
|
||||
try:
|
||||
@@ -1425,42 +1626,12 @@ def sync_chat_realtime_messages(
|
||||
|
||||
inserted = 0
|
||||
backfilled = 0
|
||||
if new_rows:
|
||||
# Best-effort: keep Name2Id updated so decrypted queries can resolve sender usernames.
|
||||
# Rowid mapping is important (message.real_sender_id joins Name2Id.rowid).
|
||||
try:
|
||||
has_name2id = bool(
|
||||
msg_conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=lower('Name2Id') LIMIT 1"
|
||||
).fetchone()
|
||||
)
|
||||
except Exception:
|
||||
has_name2id = False
|
||||
|
||||
if has_name2id:
|
||||
try:
|
||||
msg_conn.execute(
|
||||
"INSERT OR IGNORE INTO Name2Id(user_name, is_session) VALUES (?, ?)",
|
||||
(str(account_dir.name), 1),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for r in new_rows:
|
||||
try:
|
||||
rid = int(r.get("real_sender_id") or 0)
|
||||
except Exception:
|
||||
rid = 0
|
||||
su = str(r.get("sender_username") or "").strip()
|
||||
if rid <= 0 or not su:
|
||||
continue
|
||||
try:
|
||||
msg_conn.execute(
|
||||
"INSERT OR IGNORE INTO Name2Id(rowid, user_name, is_session) VALUES (?, ?, ?)",
|
||||
(rid, su, 1),
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if new_rows and (not name2id_synced):
|
||||
_best_effort_upsert_output_name2id_rows(
|
||||
msg_conn,
|
||||
account_name=account_dir.name,
|
||||
rows=new_rows,
|
||||
)
|
||||
|
||||
# Insert older -> newer to keep sqlite btree locality similar to existing data.
|
||||
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
|
||||
@@ -1658,6 +1829,30 @@ def _sync_chat_realtime_messages_for_table(
|
||||
msg_conn = sqlite3.connect(str(msg_db_path))
|
||||
msg_conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
msg_db_path_real, _res_db_path_real = _resolve_db_storage_message_paths(account_dir, msg_db_path.stem)
|
||||
name2id_synced = False
|
||||
try:
|
||||
name2id_result = _sync_output_name2id_from_live(
|
||||
msg_conn,
|
||||
rt_conn=rt_conn,
|
||||
msg_db_path_real=msg_db_path_real,
|
||||
)
|
||||
name2id_synced = str(name2id_result.get("status") or "") in {"up_to_date", "refreshed"}
|
||||
logger.info(
|
||||
"[realtime] Name2Id sync account=%s db=%s status=%s rows=%s",
|
||||
account_dir.name,
|
||||
msg_db_path.stem,
|
||||
str(name2id_result.get("status") or ""),
|
||||
int(name2id_result.get("rows") or 0),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[realtime] Name2Id sync failed account=%s db=%s error=%s",
|
||||
account_dir.name,
|
||||
msg_db_path.stem,
|
||||
str(e),
|
||||
)
|
||||
|
||||
quoted_table = _quote_ident(table_name)
|
||||
row = msg_conn.execute(f"SELECT MAX(local_id) AS mx FROM {quoted_table}").fetchone()
|
||||
try:
|
||||
@@ -1796,40 +1991,12 @@ def _sync_chat_realtime_messages_for_table(
|
||||
|
||||
inserted = 0
|
||||
backfilled = 0
|
||||
if new_rows:
|
||||
try:
|
||||
has_name2id = bool(
|
||||
msg_conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=lower('Name2Id') LIMIT 1"
|
||||
).fetchone()
|
||||
)
|
||||
except Exception:
|
||||
has_name2id = False
|
||||
|
||||
if has_name2id:
|
||||
try:
|
||||
msg_conn.execute(
|
||||
"INSERT OR IGNORE INTO Name2Id(user_name, is_session) VALUES (?, ?)",
|
||||
(str(account_dir.name), 1),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for r in new_rows:
|
||||
try:
|
||||
rid = int(r.get("real_sender_id") or 0)
|
||||
except Exception:
|
||||
rid = 0
|
||||
su = str(r.get("sender_username") or "").strip()
|
||||
if rid <= 0 or not su:
|
||||
continue
|
||||
try:
|
||||
msg_conn.execute(
|
||||
"INSERT OR IGNORE INTO Name2Id(rowid, user_name, is_session) VALUES (?, ?, ?)",
|
||||
(rid, su, 1),
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if new_rows and (not name2id_synced):
|
||||
_best_effort_upsert_output_name2id_rows(
|
||||
msg_conn,
|
||||
account_name=account_dir.name,
|
||||
rows=new_rows,
|
||||
)
|
||||
|
||||
values = [tuple(r.get(c) for c in insert_cols) for r in reversed(new_rows)]
|
||||
insert_t0 = time.perf_counter()
|
||||
|
||||
@@ -14,7 +14,12 @@ from ..app_paths import get_output_databases_dir
|
||||
from ..logging_config import get_logger
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..key_store import upsert_account_keys_in_store
|
||||
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases, scan_account_databases_from_path
|
||||
from ..wechat_decrypt import (
|
||||
WeChatDatabaseDecryptor,
|
||||
build_decrypt_result_message,
|
||||
decrypt_wechat_databases,
|
||||
scan_account_databases_from_path,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -76,6 +81,7 @@ async def decrypt_databases(request: DecryptRequest):
|
||||
"message": results["message"],
|
||||
"processed_files": results["processed_files"],
|
||||
"failed_files": results["failed_files"],
|
||||
"failure_details": results.get("failure_details", []),
|
||||
"account_results": results.get("account_results", {}),
|
||||
}
|
||||
|
||||
@@ -159,6 +165,7 @@ async def decrypt_databases_stream(
|
||||
fail_count = 0
|
||||
processed_files: list[str] = []
|
||||
failed_files: list[str] = []
|
||||
failure_details: list[dict] = []
|
||||
account_results: dict = {}
|
||||
overall_current = 0
|
||||
|
||||
@@ -181,6 +188,7 @@ async def decrypt_databases_stream(
|
||||
account_success = 0
|
||||
account_processed: list[str] = []
|
||||
account_failed: list[str] = []
|
||||
account_failure_details: list[dict] = []
|
||||
|
||||
for db_info in dbs:
|
||||
if await request.is_disconnected():
|
||||
@@ -232,11 +240,20 @@ async def decrypt_databases_stream(
|
||||
status = "success"
|
||||
msg = "解密成功"
|
||||
else:
|
||||
failure_detail = {
|
||||
"account": account,
|
||||
"file": db_path,
|
||||
"name": db_name,
|
||||
"code": str(decryptor.last_error_code or "").strip(),
|
||||
"reason": str(decryptor.last_error_message or "").strip() or "解密失败",
|
||||
}
|
||||
account_failed.append(db_path)
|
||||
account_failure_details.append(failure_detail)
|
||||
failed_files.append(db_path)
|
||||
failure_details.append(failure_detail)
|
||||
fail_count += 1
|
||||
status = "fail"
|
||||
msg = "解密失败"
|
||||
msg = failure_detail["reason"]
|
||||
|
||||
yield _sse(
|
||||
{
|
||||
@@ -261,6 +278,7 @@ async def decrypt_databases_stream(
|
||||
"output_dir": str(account_output_dir),
|
||||
"processed_files": account_processed,
|
||||
"failed_files": account_failed,
|
||||
"failure_details": account_failure_details,
|
||||
}
|
||||
|
||||
# Build cache table (keep behavior consistent with the POST endpoint).
|
||||
@@ -307,9 +325,15 @@ async def decrypt_databases_stream(
|
||||
"success_count": success_count,
|
||||
"failure_count": total_databases - success_count,
|
||||
"output_directory": str(base_output_dir.absolute()),
|
||||
"message": f"解密完成: 成功 {success_count}/{total_databases}",
|
||||
"message": build_decrypt_result_message(
|
||||
total_databases=total_databases,
|
||||
success_count=success_count,
|
||||
failed_count=total_databases - success_count,
|
||||
failure_details=failure_details,
|
||||
),
|
||||
"processed_files": processed_files,
|
||||
"failed_files": failed_files,
|
||||
"failure_details": failure_details,
|
||||
"account_results": account_results,
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
from .app_paths import get_output_databases_dir
|
||||
|
||||
@@ -26,6 +26,117 @@ from .app_paths import get_output_databases_dir
|
||||
|
||||
# SQLite文件头
|
||||
SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
PAGE_SIZE = 4096
|
||||
KEY_SIZE = 32
|
||||
SALT_SIZE = 16
|
||||
IV_SIZE = 16
|
||||
HMAC_SIZE = 64
|
||||
RESERVE_SIZE = 80
|
||||
KEY_MISMATCH_GUIDANCE = (
|
||||
"请在当前设备登录该账号后重新获取密钥;"
|
||||
"如果聊天记录是从另一台设备复制过来的,当前设备通常无法获取原设备对应的密钥。"
|
||||
)
|
||||
|
||||
|
||||
def _derive_mac_key(raw_key: bytes, salt: bytes) -> bytes:
|
||||
mac_salt = bytes(b ^ 0x3A for b in salt)
|
||||
return hashlib.pbkdf2_hmac("sha512", raw_key, mac_salt, 2, dklen=KEY_SIZE)
|
||||
|
||||
|
||||
def _derive_sqlcipher_enc_key(key_material: bytes, salt: bytes) -> bytes:
|
||||
return hashlib.pbkdf2_hmac("sha512", key_material, salt, 256000, dklen=KEY_SIZE)
|
||||
|
||||
|
||||
def _resolve_page1_key_material(key_material: bytes, page1: bytes) -> tuple[bytes, bytes, str] | None:
|
||||
salt = page1[:SALT_SIZE]
|
||||
stored_page1_hmac = page1[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE]
|
||||
|
||||
candidates = [
|
||||
("raw_enc_key", key_material, _derive_mac_key(key_material, salt)),
|
||||
]
|
||||
|
||||
derived_key = _derive_sqlcipher_enc_key(key_material, salt)
|
||||
candidates.append(("sqlcipher_passphrase", derived_key, _derive_mac_key(derived_key, salt)))
|
||||
|
||||
for mode, enc_key, mac_key in candidates:
|
||||
expected_page1_hmac = _compute_page_hmac(mac_key, page1, 1)
|
||||
if stored_page1_hmac == expected_page1_hmac:
|
||||
return enc_key, mac_key, mode
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _compute_page_hmac(mac_key: bytes, page: bytes, page_num: int) -> bytes:
|
||||
offset = SALT_SIZE if page_num == 1 else 0
|
||||
data_end = PAGE_SIZE - RESERVE_SIZE + IV_SIZE
|
||||
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
|
||||
mac.update(page[offset:data_end])
|
||||
mac.update(page_num.to_bytes(4, "little"))
|
||||
return mac.digest()
|
||||
|
||||
|
||||
def _decrypt_page(raw_key: bytes, page: bytes, page_num: int) -> bytes:
|
||||
iv = page[PAGE_SIZE - RESERVE_SIZE : PAGE_SIZE - RESERVE_SIZE + IV_SIZE]
|
||||
offset = SALT_SIZE if page_num == 1 else 0
|
||||
encrypted = page[offset : PAGE_SIZE - RESERVE_SIZE]
|
||||
|
||||
cipher = Cipher(
|
||||
algorithms.AES(raw_key),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend(),
|
||||
)
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted = decryptor.update(encrypted) + decryptor.finalize()
|
||||
|
||||
if page_num == 1:
|
||||
return SQLITE_HEADER + decrypted + (b"\x00" * RESERVE_SIZE)
|
||||
return decrypted + (b"\x00" * RESERVE_SIZE)
|
||||
|
||||
|
||||
def _failure_matches_key_mismatch(detail: dict | None) -> bool:
|
||||
if not isinstance(detail, dict):
|
||||
return False
|
||||
code = str(detail.get("code") or "").strip().lower()
|
||||
reason = str(detail.get("reason") or "").strip()
|
||||
if code == "key_mismatch":
|
||||
return True
|
||||
return ("密钥" in reason and "不匹配" in reason) or ("当前数据库密钥不正确" in reason)
|
||||
|
||||
|
||||
def build_decrypt_result_message(
|
||||
total_databases: int,
|
||||
success_count: int,
|
||||
failed_count: int,
|
||||
failure_details: list[dict] | None = None,
|
||||
) -> str:
|
||||
total = max(int(total_databases or 0), 0)
|
||||
success = max(int(success_count or 0), 0)
|
||||
failed = max(int(failed_count or 0), 0)
|
||||
details = list(failure_details or [])
|
||||
|
||||
if total == 0:
|
||||
return "未找到可解密的数据库文件"
|
||||
|
||||
if failed == 0:
|
||||
return f"解密完成: 成功 {success}/{total}"
|
||||
|
||||
key_mismatch_count = sum(1 for item in details if _failure_matches_key_mismatch(item))
|
||||
|
||||
if success == 0 and failed == total:
|
||||
if key_mismatch_count == failed:
|
||||
return (
|
||||
f"解密失败:当前数据库密钥不正确,或该密钥不属于当前账号/当前设备(0/{total} 成功)。"
|
||||
+ KEY_MISMATCH_GUIDANCE
|
||||
)
|
||||
return f"解密失败:0/{total} 个数据库解密成功,请检查密钥、账号与数据库路径是否匹配。"
|
||||
|
||||
if key_mismatch_count > 0:
|
||||
return (
|
||||
f"解密完成:成功 {success}/{total},失败 {failed}/{total}。"
|
||||
"失败文件中包含密钥不匹配的数据库,请确认使用的是当前账号在当前设备上的密钥。"
|
||||
)
|
||||
|
||||
return f"解密完成:成功 {success}/{total},失败 {failed}/{total}。"
|
||||
|
||||
|
||||
def _normalize_account_name(name: str) -> str:
|
||||
@@ -221,153 +332,123 @@ class WeChatDatabaseDecryptor:
|
||||
self.key_bytes = bytes.fromhex(key_hex)
|
||||
except ValueError:
|
||||
raise ValueError("密钥必须是有效的十六进制字符串")
|
||||
self.last_error_code = ""
|
||||
self.last_error_message = ""
|
||||
|
||||
def _set_last_error(self, code: str, message: str) -> None:
|
||||
self.last_error_code = str(code or "").strip()
|
||||
self.last_error_message = str(message or "").strip()
|
||||
|
||||
def _clear_last_error(self) -> None:
|
||||
self.last_error_code = ""
|
||||
self.last_error_message = ""
|
||||
|
||||
def decrypt_database(self, db_path: str, output_path: str) -> bool:
|
||||
"""解密微信4.x版本数据库
|
||||
|
||||
使用SQLCipher 4.0参数:
|
||||
- PBKDF2-SHA512, 256000轮迭代
|
||||
- AES-256-CBC加密
|
||||
- HMAC-SHA512验证
|
||||
- 页面大小4096字节
|
||||
兼容两种输入形态:
|
||||
- raw enc_key(部分内存扫描/工具直接返回)
|
||||
- SQLCipher 口令/基础 key(需先用数据库 salt 做一轮 PBKDF2)
|
||||
"""
|
||||
from .logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
logger.info(f"开始解密数据库: {db_path}")
|
||||
|
||||
try:
|
||||
with open(db_path, 'rb') as f:
|
||||
encrypted_data = f.read()
|
||||
|
||||
logger.info(f"读取文件大小: {len(encrypted_data)} bytes")
|
||||
|
||||
if len(encrypted_data) < 4096:
|
||||
logger.warning(f"文件太小,跳过解密: {db_path}")
|
||||
tmp_output_path = ""
|
||||
self._clear_last_error()
|
||||
try:
|
||||
file_size = os.path.getsize(db_path)
|
||||
logger.info(f"读取文件大小: {file_size} bytes")
|
||||
|
||||
if file_size < PAGE_SIZE:
|
||||
message = f"数据库文件过小,无法解密: {db_path}"
|
||||
self._set_last_error("file_too_small", message)
|
||||
logger.warning(message)
|
||||
return False
|
||||
|
||||
output_dir = Path(output_path).parent
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(db_path, "rb") as source:
|
||||
page1 = source.read(PAGE_SIZE)
|
||||
|
||||
if len(page1) < PAGE_SIZE:
|
||||
message = f"数据库首页大小不足,无法解密: {db_path}"
|
||||
self._set_last_error("page_too_small", message)
|
||||
logger.warning(message)
|
||||
return False
|
||||
|
||||
# 检查是否已经是解密的数据库
|
||||
if encrypted_data.startswith(SQLITE_HEADER):
|
||||
if page1.startswith(SQLITE_HEADER):
|
||||
logger.info(f"文件已是SQLite格式,直接复制: {db_path}")
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(encrypted_data)
|
||||
fd, tmp_output_path = tempfile.mkstemp(
|
||||
prefix=f".{Path(output_path).name}.",
|
||||
suffix=".tmp",
|
||||
dir=str(output_dir),
|
||||
)
|
||||
os.close(fd)
|
||||
with open(db_path, "rb") as src, open(tmp_output_path, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
||||
os.replace(tmp_output_path, output_path)
|
||||
tmp_output_path = ""
|
||||
return True
|
||||
|
||||
# 提取salt (前16字节)
|
||||
salt = encrypted_data[:16]
|
||||
|
||||
# 计算mac_salt (salt XOR 0x3a)
|
||||
mac_salt = bytes(b ^ 0x3a for b in salt)
|
||||
|
||||
# 使用PBKDF2-SHA512派生密钥
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA512(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=256000,
|
||||
backend=default_backend()
|
||||
)
|
||||
derived_key = kdf.derive(self.key_bytes)
|
||||
|
||||
# 派生MAC密钥
|
||||
mac_kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA512(),
|
||||
length=32,
|
||||
salt=mac_salt,
|
||||
iterations=2,
|
||||
backend=default_backend()
|
||||
)
|
||||
mac_key = mac_kdf.derive(derived_key)
|
||||
|
||||
# 解密数据
|
||||
decrypted_data = bytearray()
|
||||
decrypted_data.extend(SQLITE_HEADER)
|
||||
|
||||
page_size = 4096
|
||||
iv_size = 16
|
||||
hmac_size = 64 # SHA512的HMAC是64字节
|
||||
|
||||
# 计算保留区域大小 (对齐到AES块大小)
|
||||
reserve_size = iv_size + hmac_size
|
||||
if reserve_size % 16 != 0:
|
||||
reserve_size = ((reserve_size // 16) + 1) * 16
|
||||
|
||||
total_pages = len(encrypted_data) // page_size
|
||||
|
||||
resolved_key_material = _resolve_page1_key_material(self.key_bytes, page1)
|
||||
if resolved_key_material is None:
|
||||
message = f"当前数据库密钥不正确,或该密钥不属于当前账号/当前设备: {db_path}"
|
||||
self._set_last_error("key_mismatch", message)
|
||||
logger.error(f"页面 1 HMAC验证失败,密钥与数据库不匹配: {db_path}")
|
||||
return False
|
||||
enc_key, mac_key, key_mode = resolved_key_material
|
||||
logger.info(f"页面 1 HMAC验证通过: mode={key_mode} path={db_path}")
|
||||
|
||||
total_pages = (file_size + PAGE_SIZE - 1) // PAGE_SIZE
|
||||
successful_pages = 0
|
||||
failed_pages = 0
|
||||
|
||||
# 逐页解密
|
||||
for cur_page in range(total_pages):
|
||||
start = cur_page * page_size
|
||||
end = start + page_size
|
||||
page = encrypted_data[start:end]
|
||||
|
||||
page_num = cur_page + 1 # 页面编号从1开始
|
||||
|
||||
if len(page) < page_size:
|
||||
logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes")
|
||||
break
|
||||
|
||||
# 确定偏移量:第一页(cur_page == 0)需要跳过salt
|
||||
offset = 16 if cur_page == 0 else 0 # SALT_SIZE = 16
|
||||
|
||||
# 提取存储的HMAC
|
||||
hmac_start = page_size - reserve_size + iv_size
|
||||
hmac_end = hmac_start + hmac_size
|
||||
stored_hmac = page[hmac_start:hmac_end]
|
||||
|
||||
# 按照wechat-dump-rs的方式验证HMAC
|
||||
data_end = page_size - reserve_size + iv_size
|
||||
hmac_data = page[offset:data_end]
|
||||
|
||||
# 分步计算HMAC:先更新数据,再更新页面编号
|
||||
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
|
||||
mac.update(hmac_data) # 包含加密数据+IV
|
||||
mac.update(page_num.to_bytes(4, 'little')) # 页面编号(小端序)
|
||||
expected_hmac = mac.digest()
|
||||
|
||||
if stored_hmac != expected_hmac:
|
||||
logger.warning(f"页面 {page_num} HMAC验证失败")
|
||||
failed_pages += 1
|
||||
continue
|
||||
|
||||
# 提取IV和加密数据用于AES解密
|
||||
iv = page[page_size - reserve_size:page_size - reserve_size + iv_size]
|
||||
encrypted_page = page[offset:page_size - reserve_size]
|
||||
|
||||
# AES-CBC解密
|
||||
try:
|
||||
cipher = Cipher(
|
||||
algorithms.AES(derived_key),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend()
|
||||
)
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted_page = decryptor.update(encrypted_page) + decryptor.finalize()
|
||||
|
||||
# 按照wechat-dump-rs的方式重组页面数据
|
||||
decrypted_data.extend(decrypted_page)
|
||||
decrypted_data.extend(page[page_size - reserve_size:]) # 保留区域
|
||||
|
||||
fd, tmp_output_path = tempfile.mkstemp(
|
||||
prefix=f".{Path(output_path).name}.",
|
||||
suffix=".tmp",
|
||||
dir=str(output_dir),
|
||||
)
|
||||
os.close(fd)
|
||||
|
||||
with open(db_path, "rb") as source, open(tmp_output_path, "wb") as target:
|
||||
for page_num in range(1, total_pages + 1):
|
||||
page = source.read(PAGE_SIZE)
|
||||
if not page:
|
||||
break
|
||||
if len(page) < PAGE_SIZE:
|
||||
logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes,自动补齐到 {PAGE_SIZE} bytes")
|
||||
page = page + (b"\x00" * (PAGE_SIZE - len(page)))
|
||||
|
||||
stored_hmac = page[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE]
|
||||
expected_hmac = _compute_page_hmac(mac_key, page, page_num)
|
||||
if stored_hmac != expected_hmac:
|
||||
message = f"数据库校验失败,文件可能损坏或密钥不匹配: {db_path}"
|
||||
self._set_last_error("page_hmac_mismatch", message)
|
||||
logger.error(f"页面 {page_num} HMAC验证失败,终止解密: {db_path}")
|
||||
return False
|
||||
|
||||
target.write(_decrypt_page(enc_key, page, page_num))
|
||||
successful_pages += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"页面 {page_num} AES解密失败: {e}")
|
||||
failed_pages += 1
|
||||
continue
|
||||
|
||||
logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 {failed_pages} 页")
|
||||
|
||||
# 写入解密后的文件
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(decrypted_data)
|
||||
|
||||
logger.info(f"解密文件大小: {len(decrypted_data)} bytes")
|
||||
logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 0 页")
|
||||
os.replace(tmp_output_path, output_path)
|
||||
tmp_output_path = ""
|
||||
logger.info(f"解密文件大小: {os.path.getsize(output_path)} bytes")
|
||||
self._clear_last_error()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._set_last_error("exception", f"解密过程中发生异常: {e}")
|
||||
logger.error(f"解密失败: {db_path}, 错误: {e}")
|
||||
return False
|
||||
finally:
|
||||
if tmp_output_path:
|
||||
try:
|
||||
os.remove(tmp_output_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> dict:
|
||||
"""
|
||||
@@ -492,6 +573,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
success_count = 0
|
||||
processed_files = []
|
||||
failed_files = []
|
||||
failure_details = []
|
||||
account_results = {}
|
||||
|
||||
for account_name, databases in account_databases.items():
|
||||
@@ -523,6 +605,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
account_success = 0
|
||||
account_processed = []
|
||||
account_failed = []
|
||||
account_failure_details = []
|
||||
|
||||
for db_info in databases:
|
||||
db_path = db_info['path']
|
||||
@@ -542,7 +625,16 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
else:
|
||||
account_failed.append(db_path)
|
||||
failed_files.append(db_path)
|
||||
logger.error(f"解密失败: {account_name}/{db_name}")
|
||||
failure_detail = {
|
||||
"account": account_name,
|
||||
"file": db_path,
|
||||
"name": db_name,
|
||||
"code": str(decryptor.last_error_code or "").strip(),
|
||||
"reason": str(decryptor.last_error_message or "").strip() or "解密失败",
|
||||
}
|
||||
account_failure_details.append(failure_detail)
|
||||
failure_details.append(failure_detail)
|
||||
logger.error(f"解密失败: {account_name}/{db_name} reason={failure_detail['reason']}")
|
||||
|
||||
# 记录账号解密结果
|
||||
account_results[account_name] = {
|
||||
@@ -551,7 +643,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
"failed": len(databases) - account_success,
|
||||
"output_dir": str(account_output_dir),
|
||||
"processed_files": account_processed,
|
||||
"failed_files": account_failed
|
||||
"failed_files": account_failed,
|
||||
"failure_details": account_failure_details,
|
||||
}
|
||||
|
||||
# 构建“会话最后一条消息”缓存表:把耗时挪到解密阶段,后续会话列表直接查表
|
||||
@@ -575,15 +668,23 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
logger.info(f"账号 {account_name} 解密完成: 成功 {account_success}/{len(databases)}")
|
||||
|
||||
# 返回结果
|
||||
failed_count = total_databases - success_count
|
||||
message = build_decrypt_result_message(
|
||||
total_databases=total_databases,
|
||||
success_count=success_count,
|
||||
failed_count=failed_count,
|
||||
failure_details=failure_details,
|
||||
)
|
||||
result = {
|
||||
"status": "success" if success_count > 0 else "error",
|
||||
"message": f"解密完成: 成功 {success_count}/{total_databases}",
|
||||
"message": message,
|
||||
"total_databases": total_databases,
|
||||
"successful_count": success_count,
|
||||
"failed_count": total_databases - success_count,
|
||||
"failed_count": failed_count,
|
||||
"output_directory": str(base_output_dir.absolute()),
|
||||
"processed_files": processed_files,
|
||||
"failed_files": failed_files,
|
||||
"failure_details": failure_details,
|
||||
"account_results": account_results, # 新增:按账号的详细结果
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
@@ -591,8 +692,9 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
logger.info("=" * 60)
|
||||
logger.info("解密任务完成!")
|
||||
logger.info(f"成功: {success_count}/{total_databases}")
|
||||
logger.info(f"失败: {total_databases - success_count}/{total_databases}")
|
||||
logger.info(f"失败: {failed_count}/{total_databases}")
|
||||
logger.info(f"输出目录: {base_output_dir.absolute()}")
|
||||
logger.info(f"结果说明: {message}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class _DummyConn:
|
||||
def __init__(self) -> None:
|
||||
self.handle = 1
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
class TestChatRealtimeName2IdSync(unittest.TestCase):
|
||||
def test_sync_repairs_name2id_even_without_new_messages(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
username = "wxid_friend"
|
||||
table_name = f"Msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
msg_db_path = account_dir / "message_0.db"
|
||||
|
||||
conn = sqlite3.connect(str(msg_db_path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (user_name TEXT, is_session INTEGER DEFAULT 1)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE "{table_name}" (
|
||||
local_id INTEGER PRIMARY KEY,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB,
|
||||
packed_info_data BLOB
|
||||
)
|
||||
""".format(table_name=table_name)
|
||||
)
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (1, ?, 1)", ("acc",))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (2, ?, 1)", ("wxid_old",))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (5, ?, 1)", ("wxid_gap_tail",))
|
||||
conn.execute(
|
||||
f'INSERT INTO "{table_name}" '
|
||||
"(local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content, packed_info_data) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(10, 10010, 1, 10, 3, 1710000010, "hello", None, None),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
live_rows = [
|
||||
{"rowid": 1, "user_name": "acc", "is_session": 1},
|
||||
{"rowid": 2, "user_name": "wxid_old", "is_session": 1},
|
||||
{"rowid": 3, "user_name": "wxid_missing_a", "is_session": 1},
|
||||
{"rowid": 4, "user_name": "wxid_missing_b", "is_session": 1},
|
||||
{"rowid": 5, "user_name": "wxid_gap_tail", "is_session": 1},
|
||||
]
|
||||
|
||||
def _fake_exec_query(_handle, *, kind, path, sql):
|
||||
self.assertEqual(kind, "message")
|
||||
self.assertTrue(str(path).endswith("message_0.db"))
|
||||
if "COUNT(1)" in sql:
|
||||
return [{"c": len(live_rows), "mx": 5}]
|
||||
if "ORDER BY rowid ASC" in sql:
|
||||
return list(live_rows)
|
||||
raise AssertionError(f"Unexpected SQL: {sql}")
|
||||
|
||||
with (
|
||||
patch.object(chat_router, "_resolve_db_storage_message_paths", return_value=(Path(td) / "live_message_0.db", Path(td) / "message_resource.db")),
|
||||
patch.object(chat_router, "_wcdb_exec_query", side_effect=_fake_exec_query),
|
||||
patch.object(chat_router, "_wcdb_get_messages", return_value=[]),
|
||||
):
|
||||
result = chat_router._sync_chat_realtime_messages_for_table(
|
||||
account_dir=account_dir,
|
||||
rt_conn=_DummyConn(),
|
||||
username=username,
|
||||
msg_db_path=msg_db_path,
|
||||
table_name=table_name,
|
||||
max_scan=50,
|
||||
backfill_limit=0,
|
||||
)
|
||||
|
||||
self.assertEqual(result.get("inserted"), 0)
|
||||
|
||||
conn = sqlite3.connect(str(msg_db_path))
|
||||
try:
|
||||
rows = conn.execute("SELECT rowid, user_name FROM Name2Id ORDER BY rowid ASC").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
self.assertEqual(
|
||||
rows,
|
||||
[
|
||||
(1, "acc"),
|
||||
(2, "wxid_old"),
|
||||
(3, "wxid_missing_a"),
|
||||
(4, "wxid_missing_b"),
|
||||
(5, "wxid_gap_tail"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,14 +3,44 @@ import os
|
||||
import sys
|
||||
import unittest
|
||||
import importlib
|
||||
import hashlib
|
||||
import hmac
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def _encrypt_page(raw_key: bytes, plain_page: bytes, page_num: int, salt: bytes, iv: bytes) -> bytes:
|
||||
from wechat_decrypt_tool.wechat_decrypt import PAGE_SIZE, RESERVE_SIZE, SALT_SIZE, _derive_mac_key
|
||||
|
||||
if page_num == 1:
|
||||
encrypted_input = plain_page[SALT_SIZE : PAGE_SIZE - RESERVE_SIZE]
|
||||
prefix = salt
|
||||
else:
|
||||
encrypted_input = plain_page[: PAGE_SIZE - RESERVE_SIZE]
|
||||
prefix = b""
|
||||
|
||||
cipher = Cipher(
|
||||
algorithms.AES(raw_key),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend(),
|
||||
)
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted = encryptor.update(encrypted_input) + encryptor.finalize()
|
||||
|
||||
page_without_hmac = prefix + encrypted + iv
|
||||
mac = hmac.new(_derive_mac_key(raw_key, salt), digestmod=hashlib.sha512)
|
||||
mac.update(page_without_hmac[SALT_SIZE if page_num == 1 else 0 :])
|
||||
mac.update(page_num.to_bytes(4, "little"))
|
||||
return page_without_hmac + mac.digest()
|
||||
|
||||
|
||||
class TestDecryptStreamSSE(unittest.TestCase):
|
||||
def test_decrypt_stream_reports_progress(self):
|
||||
from fastapi import FastAPI
|
||||
@@ -85,6 +115,76 @@ class TestDecryptStreamSSE(unittest.TestCase):
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache
|
||||
|
||||
def test_decrypt_stream_reports_key_scope_error_for_wrong_key(self):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wechat_decrypt_tool.wechat_decrypt import PAGE_SIZE, RESERVE_SIZE, SQLITE_HEADER
|
||||
|
||||
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
|
||||
bad_key = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
|
||||
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
|
||||
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
|
||||
plain_page = SQLITE_HEADER + (b"A" * (PAGE_SIZE - RESERVE_SIZE - len(SQLITE_HEADER))) + (b"\x00" * RESERVE_SIZE)
|
||||
encrypted_db = _encrypt_page(good_key, plain_page, 1, salt, iv1)
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
|
||||
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
prev_build_cache = os.environ.get("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = "0"
|
||||
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.routers.decrypt as decrypt_router
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(decrypt_router)
|
||||
|
||||
db_storage = root / "xwechat_files" / "wxid_wrong_key_user" / "db_storage"
|
||||
db_storage.mkdir(parents=True, exist_ok=True)
|
||||
(db_storage / "MSG0.db").write_bytes(encrypted_db)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(decrypt_router.router)
|
||||
client = TestClient(app)
|
||||
|
||||
events: list[dict] = []
|
||||
with client.stream(
|
||||
"GET",
|
||||
"/api/decrypt_stream",
|
||||
params={"key": bad_key, "db_storage_path": str(db_storage)},
|
||||
) as resp:
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if isinstance(line, bytes):
|
||||
line = line.decode("utf-8", errors="ignore")
|
||||
line = str(line)
|
||||
if line.startswith(":") or not line.startswith("data: "):
|
||||
continue
|
||||
payload = json.loads(line[len("data: ") :])
|
||||
events.append(payload)
|
||||
if payload.get("type") in {"complete", "error"}:
|
||||
break
|
||||
|
||||
self.assertEqual(events[-1].get("type"), "complete")
|
||||
self.assertEqual(events[-1].get("status"), "failed")
|
||||
self.assertIn("当前数据库密钥不正确", events[-1].get("message", ""))
|
||||
self.assertIn("另一台设备复制", events[-1].get("message", ""))
|
||||
finally:
|
||||
if prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
|
||||
if prev_build_cache is None:
|
||||
os.environ.pop("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.wechat_decrypt import (
|
||||
PAGE_SIZE,
|
||||
RESERVE_SIZE,
|
||||
SALT_SIZE,
|
||||
SQLITE_HEADER,
|
||||
WeChatDatabaseDecryptor,
|
||||
_derive_mac_key,
|
||||
_derive_sqlcipher_enc_key,
|
||||
decrypt_wechat_databases,
|
||||
)
|
||||
|
||||
|
||||
def _encrypt_page(
|
||||
raw_key: bytes,
|
||||
plain_page: bytes,
|
||||
page_num: int,
|
||||
salt: bytes,
|
||||
iv: bytes,
|
||||
*,
|
||||
sqlcipher_passphrase: bool = False,
|
||||
) -> bytes:
|
||||
enc_key = _derive_sqlcipher_enc_key(raw_key, salt) if sqlcipher_passphrase else raw_key
|
||||
|
||||
if page_num == 1:
|
||||
encrypted_input = plain_page[SALT_SIZE : PAGE_SIZE - RESERVE_SIZE]
|
||||
prefix = salt
|
||||
else:
|
||||
encrypted_input = plain_page[: PAGE_SIZE - RESERVE_SIZE]
|
||||
prefix = b""
|
||||
|
||||
cipher = Cipher(
|
||||
algorithms.AES(enc_key),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend(),
|
||||
)
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted = encryptor.update(encrypted_input) + encryptor.finalize()
|
||||
|
||||
page_without_hmac = prefix + encrypted + iv
|
||||
mac = hmac.new(_derive_mac_key(enc_key, salt), digestmod=hashlib.sha512)
|
||||
mac.update(page_without_hmac[SALT_SIZE if page_num == 1 else 0 :])
|
||||
mac.update(page_num.to_bytes(4, "little"))
|
||||
return page_without_hmac + mac.digest()
|
||||
|
||||
|
||||
def _build_plain_page(body_byte: int, *, first_page: bool) -> bytes:
|
||||
if first_page:
|
||||
payload = SQLITE_HEADER + bytes([body_byte]) * (PAGE_SIZE - RESERVE_SIZE - len(SQLITE_HEADER))
|
||||
else:
|
||||
payload = bytes([body_byte]) * (PAGE_SIZE - RESERVE_SIZE)
|
||||
return payload + (b"\x00" * RESERVE_SIZE)
|
||||
|
||||
|
||||
class WeChatDecryptRawKeyTests(unittest.TestCase):
|
||||
def test_decrypt_database_uses_raw_enc_key(self):
|
||||
raw_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
|
||||
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
|
||||
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
|
||||
iv2 = bytes.fromhex("1112131415161718191a1b1c1d1e1f20")
|
||||
|
||||
page1 = _build_plain_page(0x41, first_page=True)
|
||||
page2 = _build_plain_page(0x42, first_page=False)
|
||||
encrypted_db = _encrypt_page(raw_key, page1, 1, salt, iv1) + _encrypt_page(raw_key, page2, 2, salt, iv2)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src = Path(tmpdir) / "source.db"
|
||||
dst = Path(tmpdir) / "out.db"
|
||||
src.write_bytes(encrypted_db)
|
||||
|
||||
decryptor = WeChatDatabaseDecryptor(raw_key.hex())
|
||||
self.assertTrue(decryptor.decrypt_database(str(src), str(dst)))
|
||||
self.assertEqual(dst.read_bytes(), page1 + page2)
|
||||
|
||||
def test_decrypt_database_falls_back_to_sqlcipher_passphrase_mode(self):
|
||||
passphrase_key = bytes.fromhex("9f5dd0d3b6d0477ea5045c9e380ee272e53927993eb548dd98a022e842d5f7bd")
|
||||
salt = bytes.fromhex("50f4090ef6897e146f94109f13743e34")
|
||||
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
|
||||
iv2 = bytes.fromhex("1112131415161718191a1b1c1d1e1f20")
|
||||
|
||||
page1 = _build_plain_page(0x41, first_page=True)
|
||||
page2 = _build_plain_page(0x42, first_page=False)
|
||||
encrypted_db = _encrypt_page(
|
||||
passphrase_key,
|
||||
page1,
|
||||
1,
|
||||
salt,
|
||||
iv1,
|
||||
sqlcipher_passphrase=True,
|
||||
) + _encrypt_page(
|
||||
passphrase_key,
|
||||
page2,
|
||||
2,
|
||||
salt,
|
||||
iv2,
|
||||
sqlcipher_passphrase=True,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src = Path(tmpdir) / "source.db"
|
||||
dst = Path(tmpdir) / "out.db"
|
||||
src.write_bytes(encrypted_db)
|
||||
|
||||
decryptor = WeChatDatabaseDecryptor(passphrase_key.hex())
|
||||
self.assertTrue(decryptor.decrypt_database(str(src), str(dst)))
|
||||
self.assertEqual(dst.read_bytes(), page1 + page2)
|
||||
|
||||
def test_decrypt_database_keeps_existing_output_on_hmac_failure(self):
|
||||
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
|
||||
bad_key_hex = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
|
||||
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
|
||||
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
|
||||
|
||||
page1 = _build_plain_page(0x41, first_page=True)
|
||||
encrypted_db = _encrypt_page(good_key, page1, 1, salt, iv1)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src = Path(tmpdir) / "source.db"
|
||||
dst = Path(tmpdir) / "out.db"
|
||||
src.write_bytes(encrypted_db)
|
||||
dst.write_bytes(b"keep-existing-output")
|
||||
|
||||
decryptor = WeChatDatabaseDecryptor(bad_key_hex)
|
||||
self.assertFalse(decryptor.decrypt_database(str(src), str(dst)))
|
||||
self.assertEqual(dst.read_bytes(), b"keep-existing-output")
|
||||
|
||||
def test_decrypt_wechat_databases_reports_key_scope_message(self):
|
||||
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
|
||||
bad_key_hex = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
|
||||
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
|
||||
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
|
||||
|
||||
page1 = _build_plain_page(0x41, first_page=True)
|
||||
encrypted_db = _encrypt_page(good_key, page1, 1, salt, iv1)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
db_storage = root / "xwechat_files" / "wxid_scope_user" / "db_storage"
|
||||
db_storage.mkdir(parents=True, exist_ok=True)
|
||||
(db_storage / "MSG0.db").write_bytes(encrypted_db)
|
||||
|
||||
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
result = decrypt_wechat_databases(str(db_storage), bad_key_hex)
|
||||
finally:
|
||||
if prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
|
||||
|
||||
self.assertEqual(result["status"], "error")
|
||||
self.assertIn("当前数据库密钥不正确", result["message"])
|
||||
self.assertIn("账号/当前设备", result["message"])
|
||||
self.assertIn("另一台设备复制", result["message"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user