Merge pull request #42 from yimizilu/fix/http-image-media-identifiers

fix: prevent HTTP image media ID collisions across conversations
This commit is contained in:
qingmao
2026-09-15 14:57:26 +08:00
committed by GitHub
6 changed files with 241 additions and 16 deletions
+6 -4
View File
@@ -36,7 +36,7 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \
| GET | `/api/v1/chatroom` | 群聊列表 | `keyword` |
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
| GET | `/api/v1/media/{messageId}` | 获取图片消息的二进制资源 | 使用 `/chatlog` 返回的图片消息 `id` |
| GET | `/api/v1/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 |
| GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` |
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
@@ -85,9 +85,9 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
- `200`:请求成功;
- `401`:缺少、错误或已失效的 Bearer Token;
- `400`:参数或 JSON 请求体无效;
- `422`:媒体 `messageId` 无效,或目标消息不是可读取的图片;
- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`);
- `403`:浏览器 Origin 不在允许的 loopback 列表;
- `404`:端点、会话或群聊不存在;
- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在;
- `503`:数据库或 Agent Hub 尚未就绪;
- `500`:服务端处理或报告渲染失败。
@@ -102,13 +102,15 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
"media": {
"type": "image",
"available": true,
"url": "/api/v1/media/msg_xxx"
"url": "/api/v1/media/image%3A0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
}
```
当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。
`media.url` 包含当前数据库连接内的独立媒体标识,不等同于消息 `id`。不同会话的消息 `id` 可能重复,调用方应原样使用返回的地址,不自行拼接或解析。重启、重连或切换账号后须重新读取 `/chatlog` 获取新地址;旧的纯消息 ID 地址仅在无歧义时兼容。`available` 只表示消息带有图片定位信息,不保证本地图片文件仍存在或可以解密。
## 与 MCP 的关系
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
+5 -5
View File
@@ -35,7 +35,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
| GET | `/recent_chat` | 最近会话;可传 `limit` |
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 |
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
@@ -107,7 +107,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
当 `/chatlog` 返回图片消息时:
1. 如果用户只是询问图片消息是否存在,不需要获取图片。
2. 如果用户要求查看、识别、理解或分析图片,使用该消息 `media.url`(`/media/{messageId}`)获取真实图片。
2. 如果用户要求查看、识别、理解或分析图片,原样使用该消息 `media.url` 获取真实图片;不要用消息 `id` 自行拼接。媒体标识按数据库连接隔离,重启、重连或切换账号后须重新读取 `/chatlog` 获取地址。
3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。
4. 获取成功后,将图片交给当前 Agent 的视觉能力。
5. 如果图片获取失败,明确说明无法读取图片。
@@ -120,7 +120,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
1. 调用 `/health`;必要时调用 `/current_time`。
2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。
3. 调用 `/media/{messageId}`,将返回的图片交给 Vision。
3. 请求该消息的 `media.url`,将返回的图片交给 Vision。
4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。
不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。
@@ -133,7 +133,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
- `404`:先用 `/resolve` 确认会话标识。
- `422`:`messageId` 无效,或消息不是可读取的图片。
- `404`:会话查询失败时先用 `/resolve` 确认会话标识;媒体请求表示标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在。
- `422`:媒体标识格式错误,或消息不是可读取的图片(`NOT_IMAGE`)。
- `503`:用户还没有完成数据库连接或对应服务未就绪。
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 155 KiB

+29 -2
View File
@@ -1,3 +1,4 @@
import { createHash, randomUUID } from 'node:crypto'
import { WechatDb, WechatMessage } from '../wechat-db'
import {
parseImageBufferDataUrlFromRow,
@@ -156,7 +157,9 @@ export interface ImageMessageReference {
}
const imageMessageReferences = new Map<string, ImageMessageReference | null>()
let imageReferenceScope = randomUUID()
/** Replace the active database and invalidate connection-scoped lookup caches. */
export function setChatDb(db: WechatDb | null): boolean {
if (shutdownRequested) {
db?.close()
@@ -166,6 +169,7 @@ export function setChatDb(db: WechatDb | null): boolean {
dbRef = db
contactSearchIndexCache = null
imageMessageReferences.clear()
imageReferenceScope = randomUUID()
return true
}
@@ -367,6 +371,7 @@ export async function getContactAvatars(
return client.getAvatarUrlsAsync(normalized)
}
/** Format source rows and register image handles without merging recall archives. */
function listSourceMessages(
userMd5: string,
startTime?: number,
@@ -556,11 +561,28 @@ function listSourceMessages(
: msg.mesLocalID || Math.random().toString()
)
const imageContent = contentData?.type === 'image' ? contentData : undefined
// Local ids repeat across conversations. Scope media handles to this database
// connection and image without changing the message id used by other clients.
const mediaId = imageContent
? `image:${createHash('sha256')
.update(
JSON.stringify([
imageReferenceScope,
userMd5,
messageId,
String(msg.serverId || ''),
createTime,
imageContent.md5 || '',
imageContent.datName || ''
])
)
.digest('hex')}`
: ''
const media = imageContent
? {
type: 'image' as const,
available: Boolean(imageContent.md5 || imageContent.datName),
url: `/api/v1/media/${encodeURIComponent(messageId)}`
url: `/api/v1/media/${encodeURIComponent(mediaId)}`
}
: undefined
if (imageContent && media) {
@@ -571,6 +593,8 @@ function listSourceMessages(
imageDatName: imageContent.datName,
createTime
}
imageMessageReferences.set(mediaId, reference)
// Keep old bare-id URLs working only while they are unambiguous.
const previous = imageMessageReferences.get(messageId)
if (
previous &&
@@ -598,7 +622,10 @@ function listSourceMessages(
senderId,
sessionId: username,
localId,
serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined,
serverId:
typeof msg.serverId === 'string' || typeof msg.serverId === 'bigint'
? String(msg.serverId)
: undefined,
createTime,
recoveredFromRecallJournal,
contentData,
+49 -5
View File
@@ -198,20 +198,64 @@ describe('Local API authentication', () => {
expect(body.messages[0].contentData).not.toHaveProperty('aeskey')
})
it('maps media lookup failures to stable API statuses', async () => {
it('serves opaque media handles while preserving the chatlog message id', async () => {
const mediaId = `image:${'a'.repeat(64)}`
const previousUrl = fixture.chatlogMessages[0].media.url
fixture.chatlogMessages[0].media.url = `/api/v1/media/${encodeURIComponent(mediaId)}`
try {
const provider = vi.fn(async (id: string) => {
expect(id).toBe(mediaId)
return { buffer: Buffer.from([0xff, 0xd8, 0xff, 0xd9]), mimeType: 'image/jpeg' }
})
const handle = await startFixtureServer(() => VALID_TOKEN, provider)
const headers = { Authorization: `Bearer ${VALID_TOKEN}` }
const chatlog = await fetch(`${baseUrl(handle)}/api/v1/chatlog?talker=fixture`, { headers })
const body = await chatlog.json()
expect(body.messages[0].id).toBe('message:1')
expect(body.messages[0].media.url).not.toContain('message')
const response = await fetch(`${baseUrl(handle)}${body.messages[0].media.url}`, { headers })
expect(response.status).toBe(200)
expect(Buffer.from(await response.arrayBuffer())).toEqual(
Buffer.from([0xff, 0xd8, 0xff, 0xd9])
)
expect(provider).toHaveBeenCalledOnce()
} finally {
fixture.chatlogMessages[0].media.url = previousUrl
}
})
it.each([
['NOT_FOUND', '未找到图片消息', 404],
['NOT_FOUND', '图片文件不存在', 404],
['NOT_IMAGE', '消息不是可读取的图片消息', 422]
] as const)('maps %s (%s) to HTTP %s', async (code, message, status) => {
const handle = await startFixtureServer(
() => VALID_TOKEN,
async () => {
throw new HttpMediaError('NOT_IMAGE', '消息不是可读取的图片消息')
throw new HttpMediaError(code, message)
}
)
const response = await fetch(`${baseUrl(handle)}/api/v1/media/message-1`, {
const response = await fetch(`${baseUrl(handle)}/api/v1/media/image%3Aunresolved`, {
headers: { Authorization: `Bearer ${VALID_TOKEN}` }
})
expect(response.status).toBe(422)
await expect(response.json()).resolves.toMatchObject({ status: 422 })
expect(response.status).toBe(status)
await expect(response.json()).resolves.toMatchObject({ status, error: message })
})
it.each(['%ZZ', 'image%2Finvalid', 'image%5Cinvalid'])(
'rejects malformed media identifiers (%s) before lookup',
async (identifier) => {
const provider = vi.fn(async () => ({ buffer: Buffer.from('image'), mimeType: 'image/png' }))
const handle = await startFixtureServer(() => VALID_TOKEN, provider)
const response = await fetch(`${baseUrl(handle)}/api/v1/media/${identifier}`, {
headers: { Authorization: `Bearer ${VALID_TOKEN}` }
})
expect(response.status).toBe(422)
await expect(response.json()).resolves.toMatchObject({ status: 422 })
expect(provider).not.toHaveBeenCalled()
}
)
it.each(['Basic xxx', 'Bearer', 'bearer xxx', 'Bearer xxx', 'xxx'])(
'rejects the invalid Authorization format %s',
async (authorization) => {
+152
View File
@@ -0,0 +1,152 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WechatDb, WechatMessage } from '../../src/main/wechat-db'
vi.mock('../../src/main/services/recall-archive-service', () => ({
recordRecallArchiveMessages: vi.fn(),
mergeRecallArchiveMessages: (_md5: string, messages: unknown[]) => messages
}))
import {
getImageMessageReference,
listMessages,
listMessagesAsync,
setChatDb,
type FormattedMessage
} from '../../src/main/services/chat-service'
const IMAGE_A = 'a'.repeat(32)
const IMAGE_B = 'b'.repeat(32)
/** Create a synthetic image row with an intentionally reusable local message ID. */
function image(md5: string, overrides: Partial<WechatMessage> = {}): WechatMessage {
return {
mesLocalID: '56',
mesDes: 1,
messageType: '3',
msgCreateTime: '1756000000',
msgContent: `<msg><img md5="${md5}" /></msg>`,
serverId: '9007199254740993123',
...overrides
}
}
/** Attach an in-memory fixture database without reading a real account. */
function connect(messages: Record<string, WechatMessage[]>): void {
const client = { getUsernameByMd5: (md5: string) => `wxid_${md5}` }
setChatDb({
close: vi.fn(),
getWcdb4Client: () => client,
getUserMessages: (md5: string) => messages[md5] || [],
getUserMessagesAsync: async (md5: string) => messages[md5] || []
} as unknown as WechatDb)
}
/** Extract the opaque handle after checking the formatted image metadata. */
function mediaId(message: FormattedMessage): string {
expect(message.media).toMatchObject({ type: 'image', available: true })
return decodeURIComponent(message.media!.url.slice('/api/v1/media/'.length))
}
describe('chat service image media handles', () => {
afterEach(() => setChatDb(null))
it('keeps colliding local ids readable across conversations and repeated reads', async () => {
connect({ first: [image(IMAGE_A)], second: [image(IMAGE_B)] })
const first = listMessages('first')[0]
const firstId = mediaId(first)
expect(first.id).toBe('56')
expect(firstId).toMatch(/^image:[a-f0-9]{64}$/)
expect(firstId).not.toContain('wxid_first')
expect(getImageMessageReference('56')?.imageMd5).toBe(IMAGE_A)
const second = (await listMessagesAsync('second'))[0]
const secondId = mediaId(second)
expect(second.id).toBe(first.id)
expect(secondId).not.toBe(firstId)
expect(getImageMessageReference(firstId)).toMatchObject({
sessionId: 'wxid_first',
imageMd5: IMAGE_A
})
expect(getImageMessageReference(secondId)).toMatchObject({
sessionId: 'wxid_second',
imageMd5: IMAGE_B
})
expect(getImageMessageReference('56')).toBeNull()
expect(mediaId(listMessages('first')[0])).toBe(firstId)
expect(mediaId((await listMessagesAsync('second'))[0])).toBe(secondId)
expect(getImageMessageReference(firstId)?.imageMd5).toBe(IMAGE_A)
expect(getImageMessageReference(secondId)?.imageMd5).toBe(IMAGE_B)
expect(getImageMessageReference('56')).toBeNull()
})
it('distinguishes images with the same local id within one conversation', () => {
connect({ first: [image(IMAGE_A), image(IMAGE_B, { serverId: '9007199254740993124' })] })
const [first, second] = listMessages('first').map(mediaId)
expect(first).not.toBe(second)
expect(getImageMessageReference(first)?.imageMd5).toBe(IMAGE_A)
expect(getImageMessageReference(second)?.imageMd5).toBe(IMAGE_B)
expect(getImageMessageReference('56')).toBeNull()
})
it('does not reuse media handles after reconnecting or switching accounts', () => {
const messages = { first: [image(IMAGE_A)] }
connect(messages)
const previousId = mediaId(listMessages('first')[0])
setChatDb(null)
expect(getImageMessageReference(previousId)).toBeNull()
connect(messages)
const currentId = mediaId(listMessages('first')[0])
expect(currentId).not.toBe(previousId)
expect(getImageMessageReference(previousId)).toBeNull()
expect(getImageMessageReference(currentId)?.imageMd5).toBe(IMAGE_A)
})
it('normalizes native server ids without losing integer precision', () => {
const message = image(IMAGE_A, { serverId: 9007199254740993123n })
connect({ first: [message] })
const first = listMessages('first')[0]
const firstId = mediaId(first)
expect(first.serverId).toBe('9007199254740993123')
expect(JSON.parse(JSON.stringify(first)).serverId).toBe('9007199254740993123')
message.serverId = '9007199254740993123'
const reread = listMessages('first')[0]
expect(reread.serverId).toBe('9007199254740993123')
expect(mediaId(reread)).toBe(firstId)
})
it.each([9007199254740992, null, undefined, false])(
'omits unsupported server ID values (%s)',
(serverId) => {
connect({ first: [image(IMAGE_A, { serverId })] })
const message = listMessages('first')[0]
expect(message.serverId).toBeUndefined()
expect(JSON.parse(JSON.stringify(message))).not.toHaveProperty('serverId')
}
)
it('scopes recovered images and supports images identified only by dat name', () => {
connect({
first: [image(IMAGE_A, { _wxe_recovered: true })],
second: [image(IMAGE_B, { _wxe_recovered: true })],
third: [image('', { msgContent: JSON.stringify({ imageDatName: IMAGE_A }) })]
})
const first = listMessages('first')[0]
const second = listMessages('second')[0]
expect(first.id).toBe('recovered:56')
expect(mediaId(second)).not.toBe(mediaId(first))
expect(getImageMessageReference(mediaId(first))?.imageMd5).toBe(IMAGE_A)
expect(getImageMessageReference(mediaId(second))?.imageMd5).toBe(IMAGE_B)
expect(getImageMessageReference('recovered:56')).toBeNull()
const third = listMessages('third')[0]
expect(getImageMessageReference(mediaId(third))?.imageDatName).toBe(IMAGE_A)
})
it('does not add media handles to text messages', () => {
connect({ first: [image(IMAGE_A, { messageType: '1', msgContent: 'text' })] })
const message = listMessages('first')[0]
expect(message.id).toBe('56')
expect(message.media).toBeUndefined()
expect(getImageMessageReference('56')).toBeNull()
})
})