@qzsy/dsh-bridge 2.18.0 → 3.0.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +12 -1098
  2. package/LICENSE +21 -21
  3. package/README.md +72 -476
  4. package/client/build.mjs +1 -3
  5. package/client/client.js +381 -9747
  6. package/client/index.js +242 -6828
  7. package/client/locales.mjs +99 -233
  8. package/cordis.patch.yml +5 -3
  9. package/lib/config.js +93 -0
  10. package/lib/data-dir.js +16 -0
  11. package/lib/dsh-cookie.js +111 -0
  12. package/lib/gate.js +382 -0
  13. package/lib/identity.js +23 -0
  14. package/lib/index.js +302 -3496
  15. package/lib/saas/tunnel-client.mjs +529 -467
  16. package/locale/en.json +3 -3
  17. package/locale/zh.json +3 -3
  18. package/package.json +15 -70
  19. package/README.en.md +0 -488
  20. package/client/host-families.js +0 -442
  21. package/client/mobile-styles.js +0 -1299
  22. package/client/mobile-yield.js +0 -219
  23. package/client/panel-locales.mjs +0 -777
  24. package/client/picker-yield.js +0 -77
  25. package/client/resource-url-compat.js +0 -90
  26. package/client/settings-nav-icon.js +0 -105
  27. package/client/theme-tokens.js +0 -49
  28. package/client/tunnel-guard.js +0 -46
  29. package/client/unlock-manager.js +0 -144
  30. package/lib/auth/dsh-native-cookie.js +0 -148
  31. package/lib/auth/login-template.js +0 -516
  32. package/lib/auth/manager.js +0 -596
  33. package/lib/bridge-rpc-constants.js +0 -66
  34. package/lib/bridge-rpc.js +0 -722
  35. package/lib/cloudflared-manager.mjs +0 -1277
  36. package/lib/cloudflared-network-args.js +0 -99
  37. package/lib/cloudflared-process-scan.js +0 -153
  38. package/lib/compat.js +0 -172
  39. package/lib/connection-compat.js +0 -115
  40. package/lib/feishu/comment.js +0 -50
  41. package/lib/feishu/gateway.js +0 -644
  42. package/lib/feishu/index.js +0 -432
  43. package/lib/feishu/lark-bundled.mjs +0 -131929
  44. package/lib/feishu/markdown.js +0 -148
  45. package/lib/feishu/node.js +0 -1152
  46. package/lib/feishu/slash-commands.js +0 -255
  47. package/lib/platform/account-ops.js +0 -248
  48. package/lib/platform/activity.js +0 -169
  49. package/lib/platform/ask-user.js +0 -293
  50. package/lib/platform/base.js +0 -147
  51. package/lib/platform/changes-digest.js +0 -93
  52. package/lib/platform/commands.js +0 -267
  53. package/lib/platform/config-restore.js +0 -78
  54. package/lib/platform/conversation-bridge.js +0 -1544
  55. package/lib/platform/dsh-storage.js +0 -222
  56. package/lib/platform/index.js +0 -10
  57. package/lib/platform/instances.js +0 -458
  58. package/lib/platform/manager.js +0 -130
  59. package/lib/platform/message-split.js +0 -229
  60. package/lib/platform/session-catalog.js +0 -405
  61. package/lib/platform/session-config.js +0 -52
  62. package/lib/platform/stream-slices.js +0 -21
  63. package/lib/qq/gateway.js +0 -1107
  64. package/lib/qq/index.js +0 -561
  65. package/lib/qq/node.js +0 -1153
  66. package/lib/qq/qqbot-bundled.mjs +0 -8273
  67. package/lib/restart-helper.mjs +0 -133
  68. package/lib/security/log-sanitize.js +0 -50
  69. package/lib/security/path-validator.js +0 -195
  70. package/lib/security/rate-limiter.js +0 -93
  71. package/lib/session-strip.js +0 -100
  72. package/lib/sse-keepalive.js +0 -115
  73. package/lib/telegram/gateway.js +0 -643
  74. package/lib/telegram/index.js +0 -255
  75. package/lib/telegram/node.js +0 -410
  76. package/lib/tunnel-client.mjs +0 -537
  77. package/lib/wechat/gateway.js +0 -1000
  78. package/lib/wechat/index.js +0 -282
  79. package/lib/wechat/media.js +0 -302
  80. package/lib/wechat/node.js +0 -360
  81. package/llms.txt +0 -36
@@ -1,282 +0,0 @@
1
- // dsh-bridge WeChat platform adapter
2
- //
3
- // 编排 WechatGateway(iLink 网关,注册为 ctx.wechat 服务)+ WechatConversationNode
4
- // (微信⇄DSH 会话桥)。作为 Platform 子类,可注册进 PlatformManager 统一管理。
5
- //
6
- // 对外暴露给 bridge-rpc 的接口(与 v1.x 完全兼容):getStatus / login / stop /
7
- // setAllowFrom / setConfig / unbind / destroy。
8
- //
9
- // 持久化策略:凭证(token/accountId/baseUrl)与配置(allowFrom/间隔)由主插件通过回调
10
- // `onPersist` 保存在 $DSH_HOME/dsh-bridge/config.json。本服务不直接碰文件系统。
11
-
12
- import { Platform } from '../platform/base.js'
13
- import { applySessionConfig, readSessionConfig } from '../platform/session-config.js'
14
- import { WechatGateway, gatewayConstants } from './gateway.js'
15
- import { WechatConversationNode } from './node.js'
16
-
17
- export class WechatService extends Platform {
18
- /**
19
- * @param {object} opts
20
- * @param {object} opts.ctx Cordis 上下文
21
- * @param {object} opts.logger 日志器
22
- * @param {object} [opts.config] 已持久化的 wechat 配置(凭证 + allowFrom + 间隔)
23
- * @param {(patch: object) => (void|Promise<void>)} opts.onPersist 主插件保存回调
24
- */
25
- constructor({ ctx, logger, config = {}, onPersist, instanceId, serviceName }) {
26
- super({ ctx, logger, config, onPersist })
27
- this.id = 'wechat'
28
- // issue #78:同类型可并行多实例;缺省回退类型 id 保持单实例旧行为
29
- this.instanceId = instanceId || this.id
30
- this.displayName = null
31
- this.name = '微信'
32
-
33
- this.gateway = new WechatGateway({
34
- ctx,
35
- logger,
36
- // issue #78:instanceId 用于事件隔离;serviceName 是唯一 cordis 服务名(D2)
37
- instanceId: this.instanceId,
38
- serviceName,
39
- config: {
40
- token: config.token ?? '',
41
- accountId: config.accountId ?? '',
42
- baseUrl: config.baseUrl ?? undefined,
43
- },
44
- })
45
-
46
- this.node = new WechatConversationNode(ctx, {
47
- allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
48
- groupAutoApprove: config.groupAutoApprove,
49
- changesDigest: config.changesDigest,
50
- steerMode: config.steerMode,
51
- digestIntervalSec: config.digestIntervalSec,
52
- approvalTimeoutSec: config.approvalTimeoutSec,
53
- maxMessageChars: config.maxMessageChars,
54
- sendChunkDelayMs: config.sendChunkDelayMs,
55
- activeSessionId: config.activeSessionId, // v0.2.1:恢复活动会话
56
- // 会话级配置:工作区 / Agent 预设 / 模型路由(构造期来自 cordis 配置,运行期由设置页写入)
57
- cwd: config.cwd,
58
- agentPreset: config.agentPreset,
59
- agentProvider: config.agentProvider,
60
- agentModel: config.agentModel,
61
- }, logger, {
62
- onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
63
- onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }), // v0.2.1:持久化活动会话
64
- gateway: this.gateway,
65
- instanceId: this.instanceId,
66
- })
67
- this.bridge = this.node
68
-
69
- if (this.gateway.configured) {
70
- void this.start().catch((err) => {
71
- this.logger.error('[dsh-bridge wechat] start failed: %s', err?.message ?? err)
72
- })
73
- }
74
- }
75
-
76
- // ---- Platform 接口 ----
77
-
78
- get configured() { return this.gateway.configured }
79
- get accountId() { return this.gateway.accountId }
80
- get baseUrl() { return this.gateway.baseUrl }
81
-
82
- get capabilities() {
83
- return {
84
- supportsGroup: false, // v0.1 不处理群消息
85
- supportsMedia: true,
86
- supportsVoice: true,
87
- supportsTyping: true,
88
- maxMessageChars: this.node.config.maxMessageChars ?? gatewayConstants.MAX_MESSAGE_CHARS,
89
- }
90
- }
91
-
92
- /** 消息抽象:委托 gateway(供 PlatformManager/未来多平台统一调用)。 */
93
- async sendText(peerId, text, opts = {}) {
94
- return this.gateway.sendText(peerId, text, opts.clientId)
95
- }
96
-
97
- async sendTyping(peerId, state) {
98
- return this.gateway.sendTyping(peerId, state)
99
- }
100
-
101
- async sendMedia(peerId, media, opts = {}) {
102
- return this.gateway.sendMedia({ ...media, to: peerId, ...opts })
103
- }
104
-
105
- async sendMediaFile(peerId, filePath, opts = {}) {
106
- return this.gateway.sendMediaFile(peerId, filePath)
107
- }
108
-
109
- /** 合并展示状态给浏览器 UI(保持 v1.x 字段结构,新增 id/name/capabilities)。 */
110
- getStatus() {
111
- const allowFrom = [...(this.node.config.allowFrom ?? [])]
112
- return {
113
- id: this.id,
114
- // issue #78:实例标识(instanceId 为全局唯一键,platformId 为平台类型)
115
- instanceId: this.instanceId,
116
- platformId: this.id,
117
- displayName: this.displayName ?? this.name,
118
- name: this.name,
119
- status: this.gateway.status,
120
- configured: this.gateway.configured,
121
- accountId: this.gateway.accountId,
122
- allowFrom,
123
- peerId: this.node.peerId,
124
- sessionId: this.node.activeSessionId,
125
- baseUrl: this.gateway.baseUrl,
126
- login: { ...this.loginState },
127
- capabilities: { ...this.capabilities },
128
- // 可编辑配置(供 UI 设置面板读取)
129
- config: {
130
- name: this.config.name ?? '',
131
- digestIntervalSec: this.node.config.digestIntervalSec,
132
- approvalTimeoutSec: this.node.config.approvalTimeoutSec,
133
- maxMessageChars: this.node.config.maxMessageChars,
134
- sendChunkDelayMs: this.node.config.sendChunkDelayMs,
135
- groupAutoApprove: this.node.config.groupAutoApprove === true,
136
- changesDigest: this.node.config.changesDigest !== false,
137
- steerMode: this.node.config.steerMode === true,
138
- ...readSessionConfig(this.node.config),
139
- },
140
- }
141
- }
142
-
143
- /** 可编辑配置(供通用 PlatformPanel 读取)。 */
144
- getEditableConfig() {
145
- return {
146
- name: this.config.name ?? '',
147
- digestIntervalSec: this.node.config.digestIntervalSec,
148
- approvalTimeoutSec: this.node.config.approvalTimeoutSec,
149
- maxMessageChars: this.node.config.maxMessageChars,
150
- sendChunkDelayMs: this.node.config.sendChunkDelayMs,
151
- groupAutoApprove: this.node.config.groupAutoApprove === true,
152
- changesDigest: this.node.config.changesDigest !== false,
153
- steerMode: this.node.config.steerMode === true,
154
- ...readSessionConfig(this.node.config),
155
- }
156
- }
157
-
158
- /** 启动网关轮询(凭证已配置时生效)。 */
159
- async start() {
160
- if (!this.gateway.configured) {
161
- this.setStatus('idle')
162
- return
163
- }
164
- await this.gateway.start()
165
- }
166
-
167
- /** 停止网关轮询(保留凭证与配置)。 */
168
- async stop() {
169
- await this.gateway.stop()
170
- }
171
-
172
- /** 完整关停(dispose 时调用)。 */
173
- async dispose() {
174
- await this.gateway.stop()
175
- this.gateway.dispose()
176
- super.dispose() // 清理 node (bridge) + disposers
177
- }
178
-
179
- /** 兼容 v1.x 的别名(主插件 ctx.effect 调用)。 */
180
- async destroy() {
181
- await this.dispose()
182
- }
183
-
184
- /**
185
- * 发起扫码登录(后台执行,状态通过 getStatus/loginState 轮询读取)。
186
- * @param {object} [opts]
187
- * @param {string} [opts.qrType] 'img' 时优先用 qrcode_img_content,否则用 scanData
188
- * @returns {Promise<{ok: boolean, error?: string}>} 立即返回(已启动)
189
- */
190
- async login({ qrType } = {}) {
191
- // 已有状态/正在登录则先清空
192
- this.loginState = { phase: 'idle', qrPayload: null, qrKind: null, error: null }
193
- void this._doLogin({ qrType })
194
- return { ok: true }
195
- }
196
-
197
- async _doLogin({ qrType } = {}) {
198
- const update = (phase, patch = {}) => {
199
- this.loginState = { ...this.loginState, phase, ...patch }
200
- }
201
- const result = await this.gateway.loginQr({
202
- onQr: (qr) => {
203
- const img = qr.imgContent
204
- if (img && /^data:/i.test(img)) {
205
- // 服务端已给图片 dataURL
206
- update('qr', { qrPayload: img, qrKind: 'img' })
207
- } else if (qrType === 'img' && img) {
208
- // 服务端给了图片内容但非 dataURL(可能是 base64)——RPC 层拼接
209
- update('qr', { qrPayload: img, qrKind: 'img' })
210
- } else {
211
- // 纯文本/短链:RPC 层用 qrcode 库渲染成 dataURL
212
- update('qr', { qrPayload: qr.scanData || qr.value, qrKind: 'text' })
213
- }
214
- },
215
- onStatus: (state) => {
216
- if (state === 'scaned' || state === 'scaned_but_redirect') update('scaned')
217
- else if (state === 'confirmed') update('confirmed')
218
- else if (state === 'expired') update('qr', { error: '二维码已过期,正在刷新…' })
219
- else if (state === 'wait') update('qr')
220
- },
221
- }).catch((err) => {
222
- this.loginState = { phase: 'error', qrPayload: null, qrKind: null, error: err?.message ?? String(err) }
223
- return null
224
- })
225
- if (!result || !result.success) {
226
- this.loginState = { phase: 'error', qrPayload: null, qrKind: null, error: result?.error ?? '登录失败或超时' }
227
- return
228
- }
229
- const creds = result.credentials
230
- await this.persist({
231
- token: creds.token,
232
- accountId: creds.accountId,
233
- baseUrl: creds.baseUrl,
234
- })
235
- void this.gateway.start().catch((err) => {
236
- this.logger.error('[dsh-bridge wechat] login start failed: %s', err?.message ?? err)
237
- })
238
- this.loginState = { phase: 'done', qrPayload: null, qrKind: null, error: null }
239
- }
240
-
241
- /** 解绑:停止网关并清除凭证(token/accountId),下次重启不再自动重连。 */
242
- async unbind() {
243
- await this.gateway.stop()
244
- this.gateway.setCredentials({ token: '', accountId: '', baseUrl: undefined })
245
- await this.persist({ token: '', accountId: '', baseUrl: '' })
246
- this.logger.info('[dsh-bridge wechat] unbound: credentials cleared')
247
- }
248
-
249
- /** 更新白名单并发起持久化。 */
250
- async setAllowFrom(list) {
251
- const clean = Array.isArray(list) ? [...new Set(list.map((x) => String(x).trim()).filter(Boolean))] : []
252
- this.node.config.allowFrom = clean
253
- await this.persist({ allowFrom: clean })
254
- }
255
-
256
- /** 更新运行时配置(心跳间隔/审批超时/每气泡字数/分块延迟/会话级配置)并持久化。 */
257
- async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, groupAutoApprove, changesDigest, steerMode, name, agentPreset, cwd, agentProvider, agentModel } = {}) {
258
- if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
259
- if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
260
- if (maxMessageChars != null) {
261
- const val = Number(maxMessageChars)
262
- this.node.config.maxMessageChars = (val >= 200) ? val : gatewayConstants.MAX_MESSAGE_CHARS
263
- }
264
- if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
265
- if (changesDigest != null) this.node.config.changesDigest = changesDigest === true
266
- if (steerMode != null) this.node.config.steerMode = steerMode === true
267
- applySessionConfig(this.node.config, { agentPreset, cwd, agentProvider, agentModel })
268
- // issue #78:账号备注名(面板回显 + displayName 来源),随 setConfig 持久化
269
- if (name !== undefined) this.config.name = String(name).trim()
270
- await this.persist({
271
- digestIntervalSec: this.node.config.digestIntervalSec,
272
- approvalTimeoutSec: this.node.config.approvalTimeoutSec,
273
- maxMessageChars: this.node.config.maxMessageChars,
274
- sendChunkDelayMs: this.node.config.sendChunkDelayMs,
275
- groupAutoApprove: this.node.config.groupAutoApprove === true,
276
- changesDigest: this.node.config.changesDigest !== false,
277
- steerMode: this.node.config.steerMode === true,
278
- ...readSessionConfig(this.node.config),
279
- ...(name !== undefined ? { name: this.config.name ?? '' } : {}),
280
- })
281
- }
282
- }
@@ -1,302 +0,0 @@
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
- /**
128
- * 去掉字符串末尾的所有斜杠(**线性时间**,不用正则)。
129
- *
130
- * 为什么不用 `replace(/\/+$/, '')`:CodeQL js/polynomial-redos 指出该正则在
131
- * 不受控输入(如 baseUrl 来自配置/远端)上是多项式复杂度——遇到大量斜杠时
132
- * 引擎需要反复回溯,可被用来做 ReDoS。这里用指针从尾部扫一遍,严格 O(n)。
133
- *
134
- * @param {string} value
135
- * @returns {string}
136
- */
137
- export function stripTrailingSlashes(value) {
138
- const text = String(value ?? '')
139
- let end = text.length
140
- while (end > 0 && text.charCodeAt(end - 1) === 0x2f /* '/' */) end -= 1
141
- return text.slice(0, end)
142
- }
143
-
144
- /** 构建 CDN 下载 URL */
145
- export function cdnDownloadUrl(cdnBaseUrl, encryptedQueryParam) {
146
- return `${stripTrailingSlashes(cdnBaseUrl)}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`
147
- }
148
-
149
- /** 构建 CDN 上传 URL */
150
- export function cdnUploadUrl(cdnBaseUrl, uploadParam, filekey) {
151
- return `${stripTrailingSlashes(cdnBaseUrl)}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${filekey}`
152
- }
153
-
154
- // ---- SSRF 防护 ----
155
-
156
- /**
157
- * 校验媒体 URL 是否在 CDN 白名单内(防 SSRF)。
158
- * @throws 如果 URL 不在白名单或非 http(s)
159
- */
160
- export function assertWeixinCdnUrl(url, allowHosts = CDN_ALLOWLIST) {
161
- let parsed
162
- try {
163
- parsed = new URL(url)
164
- } catch {
165
- throw new Error(`Unparseable media URL: ${url}`)
166
- }
167
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
168
- throw new Error(`Media URL has disallowed scheme ${parsed.protocol}; only http/https are permitted.`)
169
- }
170
- if (!allowHosts.includes(parsed.hostname)) {
171
- throw new Error(`Media URL host ${parsed.hostname} is not in the WeChat CDN allowlist. Refusing to fetch to prevent SSRF.`)
172
- }
173
- }
174
-
175
- // ---- 媒体下载(解密) ----
176
-
177
- /**
178
- * 下载并解密一个媒体项。
179
- * @param {object} opts
180
- * @param {string} [opts.cdnBaseUrl] CDN 基础 URL
181
- * @param {string} [opts.encryptedQueryParam] 加密参数(优先用)
182
- * @param {string} [opts.fullUrl] 完整 URL(回退)
183
- * @param {string} [opts.aesKeyBase64] AES key(base64)
184
- * @param {number} [opts.timeoutMs] 下载超时
185
- * @returns {Promise<Buffer>} 解密后的明文
186
- */
187
- export async function downloadMedia({
188
- cdnBaseUrl = WEIXIN_CDN_BASE_URL,
189
- encryptedQueryParam,
190
- fullUrl,
191
- aesKeyBase64,
192
- timeoutMs = 60000,
193
- }) {
194
- let url
195
- if (encryptedQueryParam) {
196
- url = cdnDownloadUrl(cdnBaseUrl, encryptedQueryParam)
197
- } else if (fullUrl) {
198
- url = fullUrl
199
- } else {
200
- throw new Error('media item had neither encrypt_query_param nor full_url')
201
- }
202
- assertWeixinCdnUrl(url)
203
-
204
- const controller = new AbortController()
205
- const timer = setTimeout(() => controller.abort(), timeoutMs)
206
- try {
207
- const response = await fetch(url, { signal: controller.signal })
208
- if (!response.ok) {
209
- throw new Error(`CDN download HTTP ${response.status}`)
210
- }
211
- const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25MB
212
- const contentLength = Number(response.headers.get('content-length') || 0);
213
- if (contentLength > MAX_DOWNLOAD_BYTES) {
214
- throw new Error(`Media file exceeds maximum allowed size (${contentLength} > 25MB)`);
215
- }
216
- const raw = Buffer.from(await response.arrayBuffer())
217
- if (raw.length > MAX_DOWNLOAD_BYTES) {
218
- throw new Error(`Media file exceeds maximum allowed size (${raw.length} > 25MB)`);
219
- }
220
- if (aesKeyBase64) {
221
- const key = parseAesKey(aesKeyBase64)
222
- return aes128EcbDecrypt(raw, key)
223
- }
224
- return raw
225
- } finally {
226
- clearTimeout(timer)
227
- }
228
- }
229
-
230
- // ---- 媒体上传(加密) ----
231
-
232
- /**
233
- * 加密并上传媒体到 CDN。
234
- * @param {object} opts
235
- * @param {Buffer} opts.plaintext 明文内容
236
- * @param {string} opts.uploadUrl CDN 上传 URL(来自 getuploadurl)
237
- * @param {Buffer} opts.aesKey AES key(16 字节)
238
- * @param {number} [opts.timeoutMs] 上传超时
239
- * @returns {Promise<string>} encrypted_query_param(从响应头 x-encrypted-param)
240
- */
241
- export async function uploadMedia({
242
- plaintext,
243
- uploadUrl,
244
- aesKey,
245
- timeoutMs = 60000,
246
- }) {
247
- const ciphertext = aes128EcbEncrypt(plaintext, aesKey)
248
- const controller = new AbortController()
249
- const timer = setTimeout(() => controller.abort(), timeoutMs)
250
- try {
251
- const response = await fetch(uploadUrl, {
252
- method: 'POST',
253
- headers: { 'Content-Type': 'application/octet-stream' },
254
- body: ciphertext,
255
- signal: controller.signal,
256
- })
257
- if (!response.ok) throw new Error(`CDN upload HTTP ${response.status}`)
258
- const encryptedParam = response.headers.get('x-encrypted-param')
259
- if (!encryptedParam) throw new Error('CDN upload response missing x-encrypted-param header')
260
- return encryptedParam
261
- } finally {
262
- clearTimeout(timer)
263
- }
264
- }
265
-
266
- // ---- 辅助工具 ----
267
-
268
- /** 从文件名猜测 MIME 类型 */
269
- export function mimeFromFilename(filename) {
270
- const ext = filename.includes('.') ? filename.split('.').pop().toLowerCase() : ''
271
- const table = {
272
- jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
273
- mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
274
- mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', silk: 'audio/silk',
275
- pdf: 'application/pdf', zip: 'application/zip',
276
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
277
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
278
- pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
279
- }
280
- return table[ext] ?? 'application/octet-stream'
281
- }
282
-
283
- /** 计算文件 MD5 */
284
- export function md5(data) {
285
- return createHash('md5').update(data).digest('hex')
286
- }
287
-
288
- /** 生成随机 filekey(32 字符 hex) */
289
- export function generateFilekey() {
290
- return randomBytes(16).toString('hex')
291
- }
292
-
293
- /** 生成随机 AES key(16 字节) */
294
- export function generateAesKey() {
295
- return randomBytes(16)
296
- }
297
-
298
- /** 计算 AES 填充后的文件大小 */
299
- export function aes128PaddedSize(rawSize) {
300
- const blockSize = 16
301
- return rawSize + (blockSize - (rawSize % blockSize))
302
- }