@sidleo3/dsh-chat-weixin 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/host/media.mjs ADDED
@@ -0,0 +1,408 @@
1
+ /**
2
+ * 微信 iLink 入站媒体:图片/文件的 CDN 下载与 AES-128-ECB 解密。
3
+ *
4
+ * **出处**:iLink 没有公开文档,本文件的行为(`aeskey` 的两种编码、`media.aes_key`
5
+ * 的 base64 形态、CDN 地址校验规则、AES-128-ECB 解密)来自 `xmanrui/dsh-im`(MIT)
6
+ * 的 `src/channels/weixin/weixin-api.mjs`(`parseWeixinImageAesKey` /
7
+ * `decryptWeixinImage` / `weixinImageDownloadUrl` / `extractWeixinImages` /
8
+ * `extractWeixinFiles`)与 `src/channels/shared/image-prompt.mjs`(`fetchImageBuffer`)。
9
+ * 本文件是按本项目接口**重写**的收窄版:入站"下载并解密成 Buffer",出站
10
+ * "加密并上传 CDN";去掉上游的 i18n 与 artifact 错误分类。许可与出处见
11
+ * 仓库 `THIRD_PARTY_NOTICES.md`。
12
+ *
13
+ * 安全约定:下载地址必须落在 `novac2c.cdn.weixin.qq.com` 且为 https——服务端返回的
14
+ * 地址不能让我们去连任意主机;响应体一律**限额读取**,超限即中止。
15
+ *
16
+ * @module dsh-chat-weixin/media
17
+ */
18
+
19
+ import { createCipheriv, createDecipheriv } from 'node:crypto';
20
+
21
+ /** 微信 CDN(媒体文件的中转站)。 */
22
+ export const MEDIA_CDN_HOST = 'novac2c.cdn.weixin.qq.com';
23
+
24
+ const MEDIA_CDN_BASE_URL = `https://${MEDIA_CDN_HOST}/c2c`;
25
+
26
+ /** 图片大小上限(与上游默认一致:超过就请用户压缩)。 */
27
+ export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
28
+
29
+ /** 文件大小上限(与 hub 主动投递的出站上限对齐)。 */
30
+ export const MAX_FILE_BYTES = 30 * 1024 * 1024;
31
+
32
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
33
+
34
+ /** 上传:分片与"长时间没有进展"的超时(与上游一致:64KB 一片,60s 无进展即判死)。 */
35
+ const UPLOAD_CHUNK_BYTES = 64 * 1024;
36
+ const UPLOAD_IDLE_TIMEOUT_MS = 60_000;
37
+ const UPLOAD_RETRIES = 3;
38
+
39
+ /** CDN 上传路径(服务端返回的 upload_full_url 也必须落在这里)。 */
40
+ const MEDIA_CDN_UPLOAD_PATH = '/c2c/upload';
41
+
42
+ /** 媒体错误:带稳定 code,便于上层给出可读回复。 */
43
+ export class WeixinMediaError extends Error {
44
+ constructor(code, message, options = {}) {
45
+ super(message, options);
46
+ this.name = 'WeixinMediaError';
47
+ this.code = code;
48
+ }
49
+ }
50
+
51
+ function nonEmptyString(value) {
52
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
53
+ }
54
+
55
+ function strictBase64(value) {
56
+ const text = nonEmptyString(value);
57
+ if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return null;
58
+ return Buffer.from(text, 'base64');
59
+ }
60
+
61
+ /**
62
+ * 解开 iLink 媒体项里的 AES 密钥(16 字节)。
63
+ *
64
+ * 图片项的 `aeskey` 是 32 位十六进制;文件项的密钥在 `media.aes_key` 上,
65
+ * 是 base64——既可能是 16 字节原文,也可能是"32 位十六进制字符串"的 base64。
66
+ *
67
+ * @param item - `image_item` 或 `file_item`。
68
+ * @returns 16 字节密钥 Buffer。
69
+ */
70
+ export function parseMediaAesKey(item) {
71
+ const directHex = nonEmptyString(item?.aeskey);
72
+ if (directHex) {
73
+ if (!/^[0-9a-fA-F]{32}$/.test(directHex)) {
74
+ throw new WeixinMediaError('invalid-media-key', '这条微信消息的加密密钥无效。');
75
+ }
76
+ return Buffer.from(directHex, 'hex');
77
+ }
78
+
79
+ const encoded = strictBase64(item?.media?.aes_key);
80
+ if (encoded?.length === 16) return encoded;
81
+ if (encoded?.length === 32 && /^[0-9a-fA-F]{32}$/.test(encoded.toString('ascii'))) {
82
+ return Buffer.from(encoded.toString('ascii'), 'hex');
83
+ }
84
+ throw new WeixinMediaError('invalid-media-key', '这条微信消息的加密密钥无效。');
85
+ }
86
+
87
+ /**
88
+ * AES-128-ECB 解密(iLink 的媒体一律这个模式,无 IV)。
89
+ *
90
+ * @param ciphertext - 密文。
91
+ * @param key - 16 字节密钥。
92
+ * @returns 明文 Buffer。
93
+ */
94
+ export function decryptMedia(ciphertext, key) {
95
+ const encrypted = Buffer.from(ciphertext);
96
+ const aesKey = Buffer.from(key);
97
+ if (aesKey.length !== 16 || encrypted.length === 0 || encrypted.length % 16 !== 0) {
98
+ throw new WeixinMediaError('invalid-media-ciphertext', '这条微信消息的加密数据无效。');
99
+ }
100
+ try {
101
+ const decipher = createDecipheriv('aes-128-ecb', aesKey, null);
102
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]);
103
+ } catch (cause) {
104
+ throw new WeixinMediaError('media-decryption-failed', '微信媒体解密失败。', { cause });
105
+ }
106
+ }
107
+
108
+ /**
109
+ * 由 `media` 描述得到可信的下载地址。
110
+ *
111
+ * 优先用 `encrypt_query_param` 自己拼 CDN 地址;只有在没有它时才用服务端给的
112
+ * `full_url`——且必须逐项校验协议/主机/端口/路径,防止被引去任意主机。
113
+ *
114
+ * @param media - `image_item.media` / `file_item.media`。
115
+ * @returns 下载 URL 字符串。
116
+ */
117
+ export function mediaDownloadUrl(media) {
118
+ const query = nonEmptyString(media?.encrypt_query_param);
119
+ if (query) {
120
+ return `${MEDIA_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(query)}`;
121
+ }
122
+
123
+ const fullUrl = nonEmptyString(media?.full_url);
124
+ if (!fullUrl) throw new WeixinMediaError('missing-media-url', '这条微信消息没有可用的下载地址。');
125
+ let url;
126
+ try {
127
+ url = new URL(fullUrl);
128
+ } catch {
129
+ throw new WeixinMediaError('invalid-media-url', '这条微信消息的下载地址无效。');
130
+ }
131
+ if (url.protocol !== 'https:' || url.hostname !== MEDIA_CDN_HOST
132
+ || (url.port && url.port !== '443') || !url.pathname.startsWith('/c2c/')) {
133
+ throw new WeixinMediaError('untrusted-media-url', '这条微信消息的下载地址不受信任。');
134
+ }
135
+ url.username = '';
136
+ url.password = '';
137
+ url.hash = '';
138
+ return url.toString();
139
+ }
140
+
141
+ /** 限额读取响应体(不信任 content-length,边读边数)。 */
142
+ async function readBodyLimited(response, maxBytes) {
143
+ const declared = Number(response?.headers?.get?.('content-length'));
144
+ if (Number.isFinite(declared) && declared > maxBytes) {
145
+ await response?.body?.cancel?.().catch?.(() => undefined);
146
+ throw new WeixinMediaError('media-too-large', `内容超过上限(${Math.round(maxBytes / 1024 / 1024)} MB)。`);
147
+ }
148
+ if (!response?.body?.[Symbol.asyncIterator]) {
149
+ const data = Buffer.from(await response.arrayBuffer());
150
+ if (data.length > maxBytes) {
151
+ throw new WeixinMediaError('media-too-large', `内容超过上限(${Math.round(maxBytes / 1024 / 1024)} MB)。`);
152
+ }
153
+ return data;
154
+ }
155
+ const chunks = [];
156
+ let size = 0;
157
+ for await (const chunk of response.body) {
158
+ const data = Buffer.from(chunk);
159
+ size += data.length;
160
+ if (size > maxBytes) {
161
+ await response.body.cancel?.().catch?.(() => undefined);
162
+ throw new WeixinMediaError('media-too-large', `内容超过上限(${Math.round(maxBytes / 1024 / 1024)} MB)。`);
163
+ }
164
+ chunks.push(data);
165
+ }
166
+ return Buffer.concat(chunks, size);
167
+ }
168
+
169
+ /**
170
+ * 下载并解密一项媒体。
171
+ *
172
+ * @param item - `image_item` 或 `file_item`。
173
+ * @param options - { signal, maxBytes, fetchImpl }。
174
+ * @returns 明文 Buffer。
175
+ */
176
+ export async function downloadMedia(item, {
177
+ signal, maxBytes = MAX_IMAGE_BYTES, fetchImpl = fetch,
178
+ } = {}) {
179
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl 必须是函数。');
180
+ signal?.throwIfAborted();
181
+ const key = parseMediaAesKey(item);
182
+ const url = mediaDownloadUrl(item?.media);
183
+
184
+ // 超时与调用方取消**同时**生效:任何一条都不允许无限等。
185
+ const timeout = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
186
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
187
+
188
+ let response;
189
+ try {
190
+ response = await fetchImpl(new URL(url), { method: 'GET', redirect: 'manual', signal: combined });
191
+ } catch (cause) {
192
+ if (signal?.aborted) signal.throwIfAborted();
193
+ throw new WeixinMediaError('media-download-failed', `微信媒体下载失败:${cause?.message ?? cause}`, { cause });
194
+ }
195
+ if (Number.isInteger(response?.status) && response.status >= 300 && response.status < 400) {
196
+ await response.body?.cancel?.().catch?.(() => undefined);
197
+ throw new WeixinMediaError('media-redirect-blocked', '微信媒体下载地址发生了重定向,已中止。');
198
+ }
199
+ if (!response?.ok) {
200
+ await response?.body?.cancel?.().catch?.(() => undefined);
201
+ throw new WeixinMediaError(
202
+ 'media-download-failed',
203
+ `微信媒体下载失败(HTTP ${response?.status ?? 'unknown'})。`,
204
+ );
205
+ }
206
+
207
+ // 密文是"填充后的明文大小",因此限额要放宽一个 AES 块。
208
+ const ciphertext = await readBodyLimited(response, maxBytes + 16);
209
+ signal?.throwIfAborted();
210
+ return decryptMedia(ciphertext, key);
211
+ }
212
+
213
+ /** 按魔数认图片类型(不信任扩展名;与飞书渠道同一套判定)。 */
214
+ export function sniffImageMediaType(bytes, contentType) {
215
+ const supported = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
216
+ const declared = String(contentType ?? '').split(';')[0].trim().toLowerCase();
217
+ if (supported.has(declared)) return declared;
218
+ const head = bytes.subarray(0, 12);
219
+ if (head.length >= 8 && head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e) return 'image/png';
220
+ if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
221
+ if (head.length >= 6 && head.subarray(0, 4).toString('latin1') === 'GIF8') return 'image/gif';
222
+ if (head.length >= 12 && head.subarray(0, 4).toString('latin1') === 'RIFF'
223
+ && head.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp';
224
+ return null;
225
+ }
226
+
227
+ /**
228
+ * 从入站消息里挑出图片与文件(纯解析,不下载)。
229
+ *
230
+ * 两种媒体都可能是"文字 + 图片"混排的一条消息,因此这里与 `extractText` 互不排斥。
231
+ *
232
+ * @param message - iLink 消息。
233
+ * @returns `{ images:[{name,item}], files:[{name,size,item}] }`。
234
+ */
235
+ export function extractInboundMedia(message) {
236
+ const images = [];
237
+ const files = [];
238
+ for (const item of message?.item_list ?? []) {
239
+ if (item?.image_item && typeof item.image_item === 'object') {
240
+ images.push({
241
+ name: images.length === 0 ? 'weixin-image' : `weixin-image-${images.length + 1}`,
242
+ item: item.image_item,
243
+ });
244
+ continue;
245
+ }
246
+ if (item?.file_item && typeof item.file_item === 'object') {
247
+ const declaredSize = Number(item.file_item.len);
248
+ files.push({
249
+ name: nonEmptyString(item.file_item.file_name)
250
+ ?? (files.length === 0 ? 'weixin-file' : `weixin-file-${files.length + 1}`),
251
+ ...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
252
+ item: item.file_item,
253
+ });
254
+ }
255
+ }
256
+ return { images, files };
257
+ }
258
+
259
+ // ── 出站:加密并上传到 CDN ───────────────────────────────────────────────────
260
+
261
+ /**
262
+ * AES-128-ECB 的 PKCS#7 填充后长度(服务端要按它校验 `filesize`)。
263
+ *
264
+ * @param size - 原始字节数。
265
+ * @returns 填充后的字节数。
266
+ */
267
+ export function aesEcbPaddedSize(size) {
268
+ return Math.ceil((size + 1) / 16) * 16;
269
+ }
270
+
271
+ /**
272
+ * 校验服务端返回的 CDN 上传地址。
273
+ *
274
+ * @param value - 地址字符串。
275
+ * @returns URL 对象。
276
+ */
277
+ export function trustedUploadUrl(value) {
278
+ let url;
279
+ try {
280
+ url = new URL(value);
281
+ } catch {
282
+ throw new WeixinMediaError('invalid-upload-url', '微信服务返回了无效的文件上传地址。');
283
+ }
284
+ if (url.protocol !== 'https:' || url.hostname !== MEDIA_CDN_HOST
285
+ || (url.port && url.port !== '443') || url.pathname !== MEDIA_CDN_UPLOAD_PATH
286
+ || url.username || url.password) {
287
+ throw new WeixinMediaError('untrusted-upload-url', '微信服务返回了不受信任的文件上传地址。');
288
+ }
289
+ url.hash = '';
290
+ return url;
291
+ }
292
+
293
+ /**
294
+ * 由 `getuploadurl` 的响应拼出上传地址。
295
+ *
296
+ * 优先用服务端给的 `upload_full_url`(仍要过 `trustedUploadUrl`),否则用
297
+ * `upload_param` 自己拼——两条路都不允许指向别的主机。
298
+ *
299
+ * @param response - `ilink/bot/getuploadurl` 的响应。
300
+ * @param fileKey - 本次上传的 filekey。
301
+ * @returns URL 对象。
302
+ */
303
+ export function mediaUploadUrl(response, fileKey) {
304
+ const fullUrl = nonEmptyString(response?.upload_full_url);
305
+ if (fullUrl) return trustedUploadUrl(fullUrl);
306
+ const uploadParam = nonEmptyString(response?.upload_param);
307
+ if (!uploadParam) throw new WeixinMediaError('missing-upload-url', '微信服务没有返回文件上传地址。');
308
+ const url = new URL(`${MEDIA_CDN_BASE_URL}/upload`);
309
+ url.searchParams.set('encrypted_query_param', uploadParam);
310
+ url.searchParams.set('filekey', fileKey);
311
+ return trustedUploadUrl(url.toString());
312
+ }
313
+
314
+ /** 分片加密(不让密文再整份复制一遍)。 */
315
+ async function* encryptChunks(bytes, key, { signal, onProgress }) {
316
+ const cipher = createCipheriv('aes-128-ecb', key, null);
317
+ for (let offset = 0; offset < bytes.byteLength; offset += UPLOAD_CHUNK_BYTES) {
318
+ signal?.throwIfAborted();
319
+ const chunk = cipher.update(bytes.subarray(offset, offset + UPLOAD_CHUNK_BYTES));
320
+ onProgress();
321
+ if (chunk.byteLength) yield chunk;
322
+ }
323
+ signal?.throwIfAborted();
324
+ onProgress();
325
+ yield cipher.final();
326
+ }
327
+
328
+ /**
329
+ * 加密并上传到微信 CDN,返回写进消息里的 `encrypt_query_param`。
330
+ *
331
+ * 上传是"边加密边推流",服务端按 `content-length`(填充后长度)收;成功时下载参数
332
+ * 在响应头 `x-encrypted-param` 上。可重试:4xx 与被拒是确定性失败,直接抛。
333
+ *
334
+ * 实测:CDN 会拒绝**极小**的图片(79 字节的 2×2 PNG 稳定返回 HTTP 500,同样字节按
335
+ * `media_type=3` 当文件上传却成功),所以图片上传拿到 500 不一定是网络问题——
336
+ * 先确认图片本身是不是过小。
337
+ *
338
+ * @param options - { url, bytes, key, signal, fetchImpl }。
339
+ * @returns 下载参数(写进 `media.encrypt_query_param`)。
340
+ */
341
+ export async function uploadMediaToCdn({
342
+ url, bytes, key, signal, fetchImpl = fetch,
343
+ }) {
344
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl 必须是函数。');
345
+ const target = url instanceof URL ? url : trustedUploadUrl(url);
346
+ let lastError;
347
+ for (let attempt = 1; attempt <= UPLOAD_RETRIES; attempt += 1) {
348
+ signal?.throwIfAborted();
349
+ const idle = new AbortController();
350
+ const uploadSignal = signal ? AbortSignal.any([signal, idle.signal]) : idle.signal;
351
+ let timer;
352
+ let active = true;
353
+ // "长时间没有进展"就判死:每产出一片就重置计时器。
354
+ const onProgress = () => {
355
+ if (!active) return;
356
+ clearTimeout(timer);
357
+ timer = setTimeout(() => idle.abort(new WeixinMediaError(
358
+ 'upload-timeout', '微信文件上传长时间没有进展,已超时。',
359
+ )), UPLOAD_IDLE_TIMEOUT_MS);
360
+ };
361
+ const body = encryptChunks(bytes, key, { signal: uploadSignal, onProgress });
362
+ let response;
363
+ onProgress();
364
+ try {
365
+ response = await fetchImpl(target, {
366
+ method: 'POST',
367
+ headers: {
368
+ 'content-type': 'application/octet-stream',
369
+ 'content-length': String(aesEcbPaddedSize(bytes.byteLength)),
370
+ },
371
+ body,
372
+ duplex: 'half',
373
+ redirect: 'error',
374
+ signal: uploadSignal,
375
+ });
376
+ uploadSignal.throwIfAborted();
377
+ if (response.status >= 400 && response.status < 500) {
378
+ throw new WeixinMediaError('upload-rejected', `微信文件上传被拒绝(HTTP ${response.status})。`);
379
+ }
380
+ if (response.status !== 200) {
381
+ throw new WeixinMediaError('upload-failed', `微信文件上传失败(HTTP ${response.status})。`);
382
+ }
383
+ const downloadParam = nonEmptyString(response.headers?.get?.('x-encrypted-param'));
384
+ if (!downloadParam) {
385
+ throw new WeixinMediaError('invalid-upload-response', '微信文件上传响应缺少下载参数。');
386
+ }
387
+ return downloadParam;
388
+ } catch (cause) {
389
+ if (signal?.aborted) signal.throwIfAborted();
390
+ const failure = idle.signal.aborted ? idle.signal.reason : cause;
391
+ lastError = failure;
392
+ // 4xx / 被拒 = 确定性失败,重试没有意义。
393
+ if (failure instanceof WeixinMediaError
394
+ && (failure.code === 'upload-rejected' || failure.code === 'upload-timeout'
395
+ || failure.code === 'invalid-upload-response')) {
396
+ throw failure;
397
+ }
398
+ } finally {
399
+ active = false;
400
+ clearTimeout(timer);
401
+ await body.return?.();
402
+ await response?.body?.cancel?.().catch?.(() => undefined);
403
+ }
404
+ }
405
+ throw lastError instanceof WeixinMediaError
406
+ ? lastError
407
+ : new WeixinMediaError('upload-failed', '微信文件上传失败。', { cause: lastError });
408
+ }