@wenbin_wb/dsh-bridge 2.8.6 → 2.9.0

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.
@@ -1,281 +1,285 @@
1
- // dsh-bridge WeChat media download/upload + AES-128-ECB encryption
2
- //
3
- // iLink 媒体项(图片/文件/语音/视频)通过 CDN 加密传输:
4
- // - 下载:CDN URL + encrypted_query_param → 下载密文 → AES-128-ECB 解密
5
- // - 上传:明文 → AES-128-ECB 加密 → POST 到 CDN → 获取 encrypted_query_param → sendmessage
6
- //
7
- // 参考实现:
8
- // - dsh-chatnode-wechat/src/gateway/media.ts(下载 + 解密)
9
- // - hermes-agent/gateway/platforms/weixin.py(上传 + 加密)
10
-
11
- import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
12
- import { createHash } from 'node:crypto'
13
-
14
- /** 腾讯微信 CDN 基础 URL(用于媒体上传下载) */
15
- export const WEIXIN_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
16
-
17
- /** CDN 白名单(SSRF 防护,只允许从这些域名下载) */
18
- export const CDN_ALLOWLIST = [
19
- 'novac2c.cdn.weixin.qq.com',
20
- 'ilinkai.weixin.qq.com',
21
- 'wx.qlogo.cn',
22
- 'thirdwx.qlogo.cn',
23
- 'res.wx.qq.com',
24
- 'mmbiz.qpic.cn',
25
- 'mmbiz.qlogo.cn',
26
- ]
27
-
28
- // ---- AES-128-ECB 加解密 + PKCS#7 填充 ----
29
-
30
- /** PKCS#7 填充到完整 AES 块(16 字节) */
31
- export function pkcs7Pad(data) {
32
- const blockSize = 16
33
- const padLen = blockSize - (data.length % blockSize)
34
- const out = Buffer.alloc(data.length + padLen)
35
- data.copy(out, 0)
36
- out.fill(padLen, data.length)
37
- return out
38
- }
39
-
40
- /** 移除 PKCS#7 填充(校验填充值,无效时返回原数据) */
41
- export function pkcs7Unpad(data) {
42
- if (data.length === 0) return data
43
- const last = data[data.length - 1]
44
- if (last >= 1 && last <= 16 && data.length >= last) {
45
- let valid = true
46
- for (let i = data.length - last; i < data.length; i++) {
47
- if (data[i] !== last) {
48
- valid = false
49
- break
50
- }
51
- }
52
- if (valid) return data.subarray(0, data.length - last)
53
- }
54
- return data
55
- }
56
-
57
- /** AES-128-ECB 加密(用于上传媒体) */
58
- export function aes128EcbEncrypt(plaintext, key) {
59
- const cipher = createCipheriv('aes-128-ecb', key, null)
60
- cipher.setAutoPadding(false) // 手动 PKCS#7 填充
61
- const padded = pkcs7Pad(plaintext)
62
- return Buffer.concat([cipher.update(padded), cipher.final()])
63
- }
64
-
65
- /** AES-128-ECB 解密(用于下载媒体) */
66
- export function aes128EcbDecrypt(ciphertext, key) {
67
- const decipher = createDecipheriv('aes-128-ecb', key, null)
68
- decipher.setAutoPadding(false)
69
- const out = Buffer.concat([decipher.update(ciphertext), decipher.final()])
70
- return pkcs7Unpad(out)
71
- }
72
-
73
- // ---- iLink aes_key 解析 ----
74
-
75
- /**
76
- * 解析 iLink 的 aes_key 字段(base64 编码)。
77
- * 支持两种格式:
78
- * - base64(16 字节原始 key) — 直接用
79
- * - base64(32 字符 hex 字符串) — 先 hex 解码再用
80
- */
81
- export function parseAesKey(aesKeyBase64) {
82
- const decoded = Buffer.from(aesKeyBase64, 'base64')
83
- if (decoded.length === 16) return decoded
84
- if (decoded.length === 32) {
85
- const text = decoded.toString('ascii')
86
- if (/^[0-9a-fA-F]{32}$/.test(text)) return Buffer.from(text, 'hex')
87
- }
88
- throw new Error(`unexpected aes_key format (${decoded.length} decoded bytes)`)
89
- }
90
-
91
- /**
92
- * 归一化媒体项的 aes_key 到 base64(原始 16 字节) 格式,供 parseAesKey/downloadMedia 使用。
93
- * iLink 各字段的 aes_key 编码不一:
94
- * - 图片 image_item.aeskey:常见为裸 hex 字符串(32 字符),须先 hex→raw→base64
95
- * - media.aes_key / file/voice/video:多为 base64(raw 或 hex 字符串)
96
- * 返回 base64 字符串;无法识别时返回 null。
97
- */
98
- export function normalizeAesKey(input) {
99
- if (!input || typeof input !== 'string') return null
100
- const trimmed = input.trim()
101
- if (!trimmed) return null
102
- // 裸 hex(32 字符 0-9a-f):hex → raw16 → base64
103
- if (/^[0-9a-fA-F]{32}$/.test(trimmed)) {
104
- return Buffer.from(trimmed, 'hex').toString('base64')
105
- }
106
- // 已经是 base64:校验能否被 parseAesKey 识别
107
- try {
108
- parseAesKey(trimmed)
109
- return trimmed
110
- } catch {
111
- // 最后尝试:若 base64 解码后是 32 字节且是 hex,parseAesKey 已覆盖;否则视为无法解析
112
- return null
113
- }
114
- }
115
-
116
- /**
117
- * 生成用于 iLink API CDNMedia 的 aes_key 字段(上传时用)。
118
- * iLink 协议规范:aes_key 必须先转 hex 字符串,再 base64 编码(base64(hex_string))。
119
- */
120
- export function encodeAesKeyForApi(keyBytes) {
121
- const hexStr = Buffer.isBuffer(keyBytes) ? keyBytes.toString('hex') : Buffer.from(keyBytes).toString('hex')
122
- return Buffer.from(hexStr, 'ascii').toString('base64')
123
- }
124
-
125
- // ---- CDN URL 构建 ----
126
-
127
- /** 构建 CDN 下载 URL */
128
- export function cdnDownloadUrl(cdnBaseUrl, encryptedQueryParam) {
129
- return `${cdnBaseUrl.replace(/\/+$/, '')}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`
130
- }
131
-
132
- /** 构建 CDN 上传 URL */
133
- export function cdnUploadUrl(cdnBaseUrl, uploadParam, filekey) {
134
- return `${cdnBaseUrl.replace(/\/+$/, '')}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${filekey}`
135
- }
136
-
137
- // ---- SSRF 防护 ----
138
-
139
- /**
140
- * 校验媒体 URL 是否在 CDN 白名单内(防 SSRF)。
141
- * @throws 如果 URL 不在白名单或非 http(s)
142
- */
143
- export function assertWeixinCdnUrl(url, allowHosts = CDN_ALLOWLIST) {
144
- let parsed
145
- try {
146
- parsed = new URL(url)
147
- } catch {
148
- throw new Error(`Unparseable media URL: ${url}`)
149
- }
150
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
151
- throw new Error(`Media URL has disallowed scheme ${parsed.protocol}; only http/https are permitted.`)
152
- }
153
- if (!allowHosts.includes(parsed.hostname)) {
154
- throw new Error(`Media URL host ${parsed.hostname} is not in the WeChat CDN allowlist. Refusing to fetch to prevent SSRF.`)
155
- }
156
- }
157
-
158
- // ---- 媒体下载(解密) ----
159
-
160
- /**
161
- * 下载并解密一个媒体项。
162
- * @param {object} opts
163
- * @param {string} [opts.cdnBaseUrl] CDN 基础 URL
164
- * @param {string} [opts.encryptedQueryParam] 加密参数(优先用)
165
- * @param {string} [opts.fullUrl] 完整 URL(回退)
166
- * @param {string} [opts.aesKeyBase64] AES key(base64)
167
- * @param {number} [opts.timeoutMs] 下载超时
168
- * @returns {Promise<Buffer>} 解密后的明文
169
- */
170
- export async function downloadMedia({
171
- cdnBaseUrl = WEIXIN_CDN_BASE_URL,
172
- encryptedQueryParam,
173
- fullUrl,
174
- aesKeyBase64,
175
- timeoutMs = 60000,
176
- }) {
177
- let url
178
- if (encryptedQueryParam) {
179
- url = cdnDownloadUrl(cdnBaseUrl, encryptedQueryParam)
180
- } else if (fullUrl) {
181
- url = fullUrl
182
- } else {
183
- throw new Error('media item had neither encrypt_query_param nor full_url')
184
- }
185
- assertWeixinCdnUrl(url)
186
-
187
- const controller = new AbortController()
188
- const timer = setTimeout(() => controller.abort(), timeoutMs)
189
- try {
190
- const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25MB
191
- const contentLength = Number(response.headers.get('content-length') || 0);
192
- if (contentLength > MAX_DOWNLOAD_BYTES) {
193
- throw new Error(`Media file exceeds maximum allowed size (${contentLength} > 25MB)`);
194
- }
195
- const raw = Buffer.from(await response.arrayBuffer())
196
- if (raw.length > MAX_DOWNLOAD_BYTES) {
197
- throw new Error(`Media file exceeds maximum allowed size (${raw.length} > 25MB)`);
198
- }
199
- if (aesKeyBase64) {
200
- const key = parseAesKey(aesKeyBase64)
201
- return aes128EcbDecrypt(raw, key)
202
- }
203
- return raw
204
- } finally {
205
- clearTimeout(timer)
206
- }
207
- }
208
-
209
- // ---- 媒体上传(加密) ----
210
-
211
- /**
212
- * 加密并上传媒体到 CDN。
213
- * @param {object} opts
214
- * @param {Buffer} opts.plaintext 明文内容
215
- * @param {string} opts.uploadUrl CDN 上传 URL(来自 getuploadurl)
216
- * @param {Buffer} opts.aesKey AES key(16 字节)
217
- * @param {number} [opts.timeoutMs] 上传超时
218
- * @returns {Promise<string>} encrypted_query_param(从响应头 x-encrypted-param)
219
- */
220
- export async function uploadMedia({
221
- plaintext,
222
- uploadUrl,
223
- aesKey,
224
- timeoutMs = 60000,
225
- }) {
226
- const ciphertext = aes128EcbEncrypt(plaintext, aesKey)
227
- const controller = new AbortController()
228
- const timer = setTimeout(() => controller.abort(), timeoutMs)
229
- try {
230
- const response = await fetch(uploadUrl, {
231
- method: 'POST',
232
- headers: { 'Content-Type': 'application/octet-stream' },
233
- body: ciphertext,
234
- signal: controller.signal,
235
- })
236
- if (!response.ok) throw new Error(`CDN upload HTTP ${response.status}`)
237
- const encryptedParam = response.headers.get('x-encrypted-param')
238
- if (!encryptedParam) throw new Error('CDN upload response missing x-encrypted-param header')
239
- return encryptedParam
240
- } finally {
241
- clearTimeout(timer)
242
- }
243
- }
244
-
245
- // ---- 辅助工具 ----
246
-
247
- /** 从文件名猜测 MIME 类型 */
248
- export function mimeFromFilename(filename) {
249
- const ext = filename.includes('.') ? filename.split('.').pop().toLowerCase() : ''
250
- const table = {
251
- jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
252
- mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
253
- mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', silk: 'audio/silk',
254
- pdf: 'application/pdf', zip: 'application/zip',
255
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
256
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
257
- pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
258
- }
259
- return table[ext] ?? 'application/octet-stream'
260
- }
261
-
262
- /** 计算文件 MD5 */
263
- export function md5(data) {
264
- return createHash('md5').update(data).digest('hex')
265
- }
266
-
267
- /** 生成随机 filekey(32 字符 hex) */
268
- export function generateFilekey() {
269
- return randomBytes(16).toString('hex')
270
- }
271
-
272
- /** 生成随机 AES key(16 字节) */
273
- export function generateAesKey() {
274
- return randomBytes(16)
275
- }
276
-
277
- /** 计算 AES 填充后的文件大小 */
278
- export function aes128PaddedSize(rawSize) {
279
- const blockSize = 16
280
- return rawSize + (blockSize - (rawSize % blockSize))
281
- }
1
+ // dsh-bridge WeChat media download/upload + AES-128-ECB encryption
2
+ //
3
+ // iLink 媒体项(图片/文件/语音/视频)通过 CDN 加密传输:
4
+ // - 下载:CDN URL + encrypted_query_param → 下载密文 → AES-128-ECB 解密
5
+ // - 上传:明文 → AES-128-ECB 加密 → POST 到 CDN → 获取 encrypted_query_param → sendmessage
6
+ //
7
+ // 参考实现:
8
+ // - dsh-chatnode-wechat/src/gateway/media.ts(下载 + 解密)
9
+ // - hermes-agent/gateway/platforms/weixin.py(上传 + 加密)
10
+
11
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
12
+ import { createHash } from 'node:crypto'
13
+
14
+ /** 腾讯微信 CDN 基础 URL(用于媒体上传下载) */
15
+ export const WEIXIN_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
16
+
17
+ /** CDN 白名单(SSRF 防护,只允许从这些域名下载) */
18
+ export const CDN_ALLOWLIST = [
19
+ 'novac2c.cdn.weixin.qq.com',
20
+ 'ilinkai.weixin.qq.com',
21
+ 'wx.qlogo.cn',
22
+ 'thirdwx.qlogo.cn',
23
+ 'res.wx.qq.com',
24
+ 'mmbiz.qpic.cn',
25
+ 'mmbiz.qlogo.cn',
26
+ ]
27
+
28
+ // ---- AES-128-ECB 加解密 + PKCS#7 填充 ----
29
+
30
+ /** PKCS#7 填充到完整 AES 块(16 字节) */
31
+ export function pkcs7Pad(data) {
32
+ const blockSize = 16
33
+ const padLen = blockSize - (data.length % blockSize)
34
+ const out = Buffer.alloc(data.length + padLen)
35
+ data.copy(out, 0)
36
+ out.fill(padLen, data.length)
37
+ return out
38
+ }
39
+
40
+ /** 移除 PKCS#7 填充(校验填充值,无效时返回原数据) */
41
+ export function pkcs7Unpad(data) {
42
+ if (data.length === 0) return data
43
+ const last = data[data.length - 1]
44
+ if (last >= 1 && last <= 16 && data.length >= last) {
45
+ let valid = true
46
+ for (let i = data.length - last; i < data.length; i++) {
47
+ if (data[i] !== last) {
48
+ valid = false
49
+ break
50
+ }
51
+ }
52
+ if (valid) return data.subarray(0, data.length - last)
53
+ }
54
+ return data
55
+ }
56
+
57
+ /** AES-128-ECB 加密(用于上传媒体) */
58
+ export function aes128EcbEncrypt(plaintext, key) {
59
+ const cipher = createCipheriv('aes-128-ecb', key, null)
60
+ cipher.setAutoPadding(false) // 手动 PKCS#7 填充
61
+ const padded = pkcs7Pad(plaintext)
62
+ return Buffer.concat([cipher.update(padded), cipher.final()])
63
+ }
64
+
65
+ /** AES-128-ECB 解密(用于下载媒体) */
66
+ export function aes128EcbDecrypt(ciphertext, key) {
67
+ const decipher = createDecipheriv('aes-128-ecb', key, null)
68
+ decipher.setAutoPadding(false)
69
+ const out = Buffer.concat([decipher.update(ciphertext), decipher.final()])
70
+ return pkcs7Unpad(out)
71
+ }
72
+
73
+ // ---- iLink aes_key 解析 ----
74
+
75
+ /**
76
+ * 解析 iLink 的 aes_key 字段(base64 编码)。
77
+ * 支持两种格式:
78
+ * - base64(16 字节原始 key) — 直接用
79
+ * - base64(32 字符 hex 字符串) — 先 hex 解码再用
80
+ */
81
+ export function parseAesKey(aesKeyBase64) {
82
+ const decoded = Buffer.from(aesKeyBase64, 'base64')
83
+ if (decoded.length === 16) return decoded
84
+ if (decoded.length === 32) {
85
+ const text = decoded.toString('ascii')
86
+ if (/^[0-9a-fA-F]{32}$/.test(text)) return Buffer.from(text, 'hex')
87
+ }
88
+ throw new Error(`unexpected aes_key format (${decoded.length} decoded bytes)`)
89
+ }
90
+
91
+ /**
92
+ * 归一化媒体项的 aes_key 到 base64(原始 16 字节) 格式,供 parseAesKey/downloadMedia 使用。
93
+ * iLink 各字段的 aes_key 编码不一:
94
+ * - 图片 image_item.aeskey:常见为裸 hex 字符串(32 字符),须先 hex→raw→base64
95
+ * - media.aes_key / file/voice/video:多为 base64(raw 或 hex 字符串)
96
+ * 返回 base64 字符串;无法识别时返回 null。
97
+ */
98
+ export function normalizeAesKey(input) {
99
+ if (!input || typeof input !== 'string') return null
100
+ const trimmed = input.trim()
101
+ if (!trimmed) return null
102
+ // 裸 hex(32 字符 0-9a-f):hex → raw16 → base64
103
+ if (/^[0-9a-fA-F]{32}$/.test(trimmed)) {
104
+ return Buffer.from(trimmed, 'hex').toString('base64')
105
+ }
106
+ // 已经是 base64:校验能否被 parseAesKey 识别
107
+ try {
108
+ parseAesKey(trimmed)
109
+ return trimmed
110
+ } catch {
111
+ // 最后尝试:若 base64 解码后是 32 字节且是 hex,parseAesKey 已覆盖;否则视为无法解析
112
+ return null
113
+ }
114
+ }
115
+
116
+ /**
117
+ * 生成用于 iLink API CDNMedia 的 aes_key 字段(上传时用)。
118
+ * iLink 协议规范:aes_key 必须先转 hex 字符串,再 base64 编码(base64(hex_string))。
119
+ */
120
+ export function encodeAesKeyForApi(keyBytes) {
121
+ const hexStr = Buffer.isBuffer(keyBytes) ? keyBytes.toString('hex') : Buffer.from(keyBytes).toString('hex')
122
+ return Buffer.from(hexStr, 'ascii').toString('base64')
123
+ }
124
+
125
+ // ---- CDN URL 构建 ----
126
+
127
+ /** 构建 CDN 下载 URL */
128
+ export function cdnDownloadUrl(cdnBaseUrl, encryptedQueryParam) {
129
+ return `${cdnBaseUrl.replace(/\/+$/, '')}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`
130
+ }
131
+
132
+ /** 构建 CDN 上传 URL */
133
+ export function cdnUploadUrl(cdnBaseUrl, uploadParam, filekey) {
134
+ return `${cdnBaseUrl.replace(/\/+$/, '')}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${filekey}`
135
+ }
136
+
137
+ // ---- SSRF 防护 ----
138
+
139
+ /**
140
+ * 校验媒体 URL 是否在 CDN 白名单内(防 SSRF)。
141
+ * @throws 如果 URL 不在白名单或非 http(s)
142
+ */
143
+ export function assertWeixinCdnUrl(url, allowHosts = CDN_ALLOWLIST) {
144
+ let parsed
145
+ try {
146
+ parsed = new URL(url)
147
+ } catch {
148
+ throw new Error(`Unparseable media URL: ${url}`)
149
+ }
150
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
151
+ throw new Error(`Media URL has disallowed scheme ${parsed.protocol}; only http/https are permitted.`)
152
+ }
153
+ if (!allowHosts.includes(parsed.hostname)) {
154
+ throw new Error(`Media URL host ${parsed.hostname} is not in the WeChat CDN allowlist. Refusing to fetch to prevent SSRF.`)
155
+ }
156
+ }
157
+
158
+ // ---- 媒体下载(解密) ----
159
+
160
+ /**
161
+ * 下载并解密一个媒体项。
162
+ * @param {object} opts
163
+ * @param {string} [opts.cdnBaseUrl] CDN 基础 URL
164
+ * @param {string} [opts.encryptedQueryParam] 加密参数(优先用)
165
+ * @param {string} [opts.fullUrl] 完整 URL(回退)
166
+ * @param {string} [opts.aesKeyBase64] AES key(base64)
167
+ * @param {number} [opts.timeoutMs] 下载超时
168
+ * @returns {Promise<Buffer>} 解密后的明文
169
+ */
170
+ export async function downloadMedia({
171
+ cdnBaseUrl = WEIXIN_CDN_BASE_URL,
172
+ encryptedQueryParam,
173
+ fullUrl,
174
+ aesKeyBase64,
175
+ timeoutMs = 60000,
176
+ }) {
177
+ let url
178
+ if (encryptedQueryParam) {
179
+ url = cdnDownloadUrl(cdnBaseUrl, encryptedQueryParam)
180
+ } else if (fullUrl) {
181
+ url = fullUrl
182
+ } else {
183
+ throw new Error('media item had neither encrypt_query_param nor full_url')
184
+ }
185
+ assertWeixinCdnUrl(url)
186
+
187
+ const controller = new AbortController()
188
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
189
+ try {
190
+ const response = await fetch(url, { signal: controller.signal })
191
+ if (!response.ok) {
192
+ throw new Error(`CDN download HTTP ${response.status}`)
193
+ }
194
+ const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25MB
195
+ const contentLength = Number(response.headers.get('content-length') || 0);
196
+ if (contentLength > MAX_DOWNLOAD_BYTES) {
197
+ throw new Error(`Media file exceeds maximum allowed size (${contentLength} > 25MB)`);
198
+ }
199
+ const raw = Buffer.from(await response.arrayBuffer())
200
+ if (raw.length > MAX_DOWNLOAD_BYTES) {
201
+ throw new Error(`Media file exceeds maximum allowed size (${raw.length} > 25MB)`);
202
+ }
203
+ if (aesKeyBase64) {
204
+ const key = parseAesKey(aesKeyBase64)
205
+ return aes128EcbDecrypt(raw, key)
206
+ }
207
+ return raw
208
+ } finally {
209
+ clearTimeout(timer)
210
+ }
211
+ }
212
+
213
+ // ---- 媒体上传(加密) ----
214
+
215
+ /**
216
+ * 加密并上传媒体到 CDN。
217
+ * @param {object} opts
218
+ * @param {Buffer} opts.plaintext 明文内容
219
+ * @param {string} opts.uploadUrl CDN 上传 URL(来自 getuploadurl)
220
+ * @param {Buffer} opts.aesKey AES key(16 字节)
221
+ * @param {number} [opts.timeoutMs] 上传超时
222
+ * @returns {Promise<string>} encrypted_query_param(从响应头 x-encrypted-param)
223
+ */
224
+ export async function uploadMedia({
225
+ plaintext,
226
+ uploadUrl,
227
+ aesKey,
228
+ timeoutMs = 60000,
229
+ }) {
230
+ const ciphertext = aes128EcbEncrypt(plaintext, aesKey)
231
+ const controller = new AbortController()
232
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
233
+ try {
234
+ const response = await fetch(uploadUrl, {
235
+ method: 'POST',
236
+ headers: { 'Content-Type': 'application/octet-stream' },
237
+ body: ciphertext,
238
+ signal: controller.signal,
239
+ })
240
+ if (!response.ok) throw new Error(`CDN upload HTTP ${response.status}`)
241
+ const encryptedParam = response.headers.get('x-encrypted-param')
242
+ if (!encryptedParam) throw new Error('CDN upload response missing x-encrypted-param header')
243
+ return encryptedParam
244
+ } finally {
245
+ clearTimeout(timer)
246
+ }
247
+ }
248
+
249
+ // ---- 辅助工具 ----
250
+
251
+ /** 从文件名猜测 MIME 类型 */
252
+ export function mimeFromFilename(filename) {
253
+ const ext = filename.includes('.') ? filename.split('.').pop().toLowerCase() : ''
254
+ const table = {
255
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
256
+ mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
257
+ mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', silk: 'audio/silk',
258
+ pdf: 'application/pdf', zip: 'application/zip',
259
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
260
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
261
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
262
+ }
263
+ return table[ext] ?? 'application/octet-stream'
264
+ }
265
+
266
+ /** 计算文件 MD5 */
267
+ export function md5(data) {
268
+ return createHash('md5').update(data).digest('hex')
269
+ }
270
+
271
+ /** 生成随机 filekey(32 字符 hex) */
272
+ export function generateFilekey() {
273
+ return randomBytes(16).toString('hex')
274
+ }
275
+
276
+ /** 生成随机 AES key(16 字节) */
277
+ export function generateAesKey() {
278
+ return randomBytes(16)
279
+ }
280
+
281
+ /** 计算 AES 填充后的文件大小 */
282
+ export function aes128PaddedSize(rawSize) {
283
+ const blockSize = 16
284
+ return rawSize + (blockSize - (rawSize % blockSize))
285
+ }