@imweapp/openclaw-imwe 2026.4.12-alpha.1
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/LICENSE +21 -0
- package/README.md +172 -0
- package/index.ts +16 -0
- package/openclaw.plugin.json +58 -0
- package/package.json +73 -0
- package/proto/PbBoxPullProto.proto +43 -0
- package/proto/PbChatAudioContent.proto +23 -0
- package/proto/PbChatDeliverMsg.proto +38 -0
- package/proto/PbChatFileMeta.proto +34 -0
- package/proto/PbChatMsg.proto +93 -0
- package/proto/PbChatRichMediaContent.proto +31 -0
- package/proto/PbChatTextContent.proto +38 -0
- package/proto/PbMarkdownContent.proto +18 -0
- package/proto/PbMsgReadStampContent.proto +11 -0
- package/proto/PbPacket.proto +61 -0
- package/proto/PbSingleChatMsg.proto +60 -0
- package/setup-entry.ts +17 -0
- package/src/accounts.ts +109 -0
- package/src/api-client.ts +740 -0
- package/src/bot-info-cache.ts +49 -0
- package/src/channel.runtime.ts +29 -0
- package/src/channel.ts +456 -0
- package/src/config-schema.ts +26 -0
- package/src/e2ee/api.ts +261 -0
- package/src/e2ee/canonical.ts +59 -0
- package/src/e2ee/errors.ts +103 -0
- package/src/e2ee/index.ts +8 -0
- package/src/e2ee/proper-lockfile.d.ts +61 -0
- package/src/e2ee/service.ts +1273 -0
- package/src/e2ee/store.ts +174 -0
- package/src/e2ee/types.ts +113 -0
- package/src/e2ee/vodozemac.ts +373 -0
- package/src/file-transfer/api.ts +364 -0
- package/src/file-transfer/concurrency.ts +77 -0
- package/src/file-transfer/download.ts +261 -0
- package/src/file-transfer/file-crypto.ts +93 -0
- package/src/file-transfer/index.ts +18 -0
- package/src/file-transfer/scheduler.ts +185 -0
- package/src/file-transfer/types.ts +195 -0
- package/src/file-transfer/upload.ts +656 -0
- package/src/markdown-detect.ts +119 -0
- package/src/media-upload.ts +338 -0
- package/src/media-utils.ts +110 -0
- package/src/monitor.ts +838 -0
- package/src/proto/codec.ts +54 -0
- package/src/proto/inbound.codec.ts +624 -0
- package/src/proto/proto-types.ts +291 -0
- package/src/proto/registry.ts +226 -0
- package/src/proto/send.codec.ts +535 -0
- package/src/recent-message-cache.ts +350 -0
- package/src/send.ts +792 -0
- package/src/setup-core.ts +62 -0
- package/src/types.ts +153 -0
- package/src/vodozemackit/index.ts +297 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm.d.ts +138 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm.js +24 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.js +1172 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm +0 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm.d.ts +109 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* upload.ts — 分片加密上传服务
|
|
3
|
+
*
|
|
4
|
+
* 与 iOS LargeAttachmentUploadOperation 对齐的三阶段分片上传流程:
|
|
5
|
+
* initMultipartUpload → 并发 PUT chunks → batchCompleteChunks
|
|
6
|
+
*
|
|
7
|
+
* 双模式策略:
|
|
8
|
+
* - Buffer 模式(≤ streamThreshold,默认 10MB):全量加载 → 分片 → 逐片加密 → 并发上传
|
|
9
|
+
* - Stream 模式(> streamThreshold):顺序读取 chunk → 加密 → 提交并发队列 → 增量 SHA256
|
|
10
|
+
*
|
|
11
|
+
* 断点续传:
|
|
12
|
+
* - 持久化 UploadState 到 JSON 文件(含 key/iv/sendContext)
|
|
13
|
+
* - 恢复时跳过已完成 chunk,刷新过期 URL
|
|
14
|
+
* - 恢复时仍顺序遍历所有 chunk 维护 SHA256 digest
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import fsp from 'node:fs/promises';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
UploadResult,
|
|
24
|
+
UploadSendContext,
|
|
25
|
+
UploadState,
|
|
26
|
+
FileTransferConfig,
|
|
27
|
+
DEFAULT_CONFIG,
|
|
28
|
+
} from './types.js';
|
|
29
|
+
import { generateFileKey, aesCtrXor } from './file-crypto.js';
|
|
30
|
+
import { runConcurrent, ConcurrentTask } from './concurrency.js';
|
|
31
|
+
import { initMultipartUpload, batchGetChunkPreSignedUrl, batchCompleteChunks } from './api.js';
|
|
32
|
+
import { inferMimeType, computeBlurhash } from '../media-utils.js';
|
|
33
|
+
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// 断点续传状态管理
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 从持久化状态恢复上传。
|
|
42
|
+
* 检查 stateDir 中是否存在未完成的 UploadState,若存在则恢复。
|
|
43
|
+
* 按 mediaUrl 精确匹配。
|
|
44
|
+
*/
|
|
45
|
+
export function loadUploadState(stateDir: string, mediaUrl: string): UploadState | null {
|
|
46
|
+
if (!stateDir) return null;
|
|
47
|
+
try {
|
|
48
|
+
const files = fs.readdirSync(stateDir);
|
|
49
|
+
for (const f of files) {
|
|
50
|
+
if (!f.startsWith('upload-') || !f.endsWith('.json')) continue;
|
|
51
|
+
const filePath = path.join(stateDir, f);
|
|
52
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
53
|
+
const state: UploadState = JSON.parse(content);
|
|
54
|
+
if (state.mediaUrl === mediaUrl) {
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// stateDir 不存在或读取失败,视为无状态
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 持久化上传状态。
|
|
66
|
+
* 每个 chunk 完成后调用,原子写入 JSON 文件。
|
|
67
|
+
*/
|
|
68
|
+
export function saveUploadState(stateDir: string, state: UploadState): void {
|
|
69
|
+
if (!stateDir) return;
|
|
70
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
71
|
+
const filePath = path.join(stateDir, `upload-${state.fileId}.json`);
|
|
72
|
+
state.updatedAt = Date.now();
|
|
73
|
+
// 原子写入:先写临时文件再 rename
|
|
74
|
+
const tmpPath = filePath + '.tmp';
|
|
75
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8');
|
|
76
|
+
fs.renameSync(tmpPath, filePath);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 清理上传状态文件(上传完成或放弃时调用)。
|
|
81
|
+
*/
|
|
82
|
+
export function clearUploadState(stateDir: string, mediaUrl: string): void {
|
|
83
|
+
if (!stateDir) return;
|
|
84
|
+
try {
|
|
85
|
+
const files = fs.readdirSync(stateDir);
|
|
86
|
+
for (const f of files) {
|
|
87
|
+
if (!f.startsWith('upload-') || !f.endsWith('.json')) continue;
|
|
88
|
+
const filePath = path.join(stateDir, f);
|
|
89
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
90
|
+
const state: UploadState = JSON.parse(content);
|
|
91
|
+
if (state.mediaUrl === mediaUrl) {
|
|
92
|
+
fs.unlinkSync(filePath);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// 清理失败不阻断
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
// 内部:媒体源解析
|
|
103
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** 解析后的媒体源(Buffer 模式) */
|
|
106
|
+
interface BufferMediaSource {
|
|
107
|
+
kind: 'buffer';
|
|
108
|
+
buffer: Buffer;
|
|
109
|
+
fileName: string;
|
|
110
|
+
mimeType: string;
|
|
111
|
+
fileSize: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 解析后的媒体源(Stream 模式 — 仅保留路径,按需读取 chunk) */
|
|
115
|
+
interface StreamMediaSource {
|
|
116
|
+
kind: 'stream';
|
|
117
|
+
filePath: string;
|
|
118
|
+
fileName: string;
|
|
119
|
+
mimeType: string;
|
|
120
|
+
fileSize: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type ResolvedMediaSource = BufferMediaSource | StreamMediaSource;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 解析媒体源,根据文件大小选择 Buffer 或 Stream 模式。
|
|
127
|
+
*/
|
|
128
|
+
async function resolveMediaSource(
|
|
129
|
+
mediaUrl: string,
|
|
130
|
+
streamThreshold: number,
|
|
131
|
+
options?: {
|
|
132
|
+
mediaLocalRoots?: readonly string[];
|
|
133
|
+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
|
134
|
+
},
|
|
135
|
+
): Promise<ResolvedMediaSource> {
|
|
136
|
+
const fileName = extractFileName(mediaUrl);
|
|
137
|
+
const mimeType = inferMimeType(fileName);
|
|
138
|
+
|
|
139
|
+
// data URI → Buffer 模式
|
|
140
|
+
if (mediaUrl.startsWith('data:')) {
|
|
141
|
+
const match = mediaUrl.match(/^data:([^;,]+)?(?:;base64)?,(.*)$/s);
|
|
142
|
+
if (!match) throw new Error(`无效的 data URI: ${mediaUrl.slice(0, 40)}...`);
|
|
143
|
+
const buffer = Buffer.from(match[2], 'base64');
|
|
144
|
+
const mime = match[1] || 'application/octet-stream';
|
|
145
|
+
return { kind: 'buffer', buffer, fileName, mimeType: mime, fileSize: buffer.length };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 远程 URL → 始终 Buffer 模式(下载到内存)
|
|
149
|
+
if (mediaUrl.startsWith('http://') || mediaUrl.startsWith('https://')) {
|
|
150
|
+
const res = await fetch(mediaUrl);
|
|
151
|
+
if (!res.ok) throw new Error(`下载远程媒体失败: HTTP ${res.status} ${mediaUrl}`);
|
|
152
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
153
|
+
const remoteMime = res.headers.get('content-type')?.split(';')[0]?.trim() || mimeType;
|
|
154
|
+
return { kind: 'buffer', buffer, fileName, mimeType: remoteMime, fileSize: buffer.length };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 本地路径:优先使用 openclaw plugin-sdk 加载
|
|
158
|
+
try {
|
|
159
|
+
const { loadWebMedia } = await import('openclaw/plugin-sdk/web-media');
|
|
160
|
+
const localRoots = options?.mediaLocalRoots?.length ? options.mediaLocalRoots : undefined;
|
|
161
|
+
const loaded = await loadWebMedia(mediaUrl, {
|
|
162
|
+
localRoots,
|
|
163
|
+
readFile: options?.mediaReadFile,
|
|
164
|
+
});
|
|
165
|
+
// loadWebMedia 返回 Buffer,判断大小决定模式
|
|
166
|
+
if (loaded.buffer.length > streamThreshold) {
|
|
167
|
+
// 大文件但已加载到内存,仍用 Buffer 模式(loadWebMedia 已全量加载)
|
|
168
|
+
return {
|
|
169
|
+
kind: 'buffer',
|
|
170
|
+
buffer: loaded.buffer,
|
|
171
|
+
fileName: loaded.fileName ?? fileName,
|
|
172
|
+
mimeType: loaded.contentType ?? mimeType,
|
|
173
|
+
fileSize: loaded.buffer.length,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
kind: 'buffer',
|
|
178
|
+
buffer: loaded.buffer,
|
|
179
|
+
fileName: loaded.fileName ?? fileName,
|
|
180
|
+
mimeType: loaded.contentType ?? mimeType,
|
|
181
|
+
fileSize: loaded.buffer.length,
|
|
182
|
+
};
|
|
183
|
+
} catch {
|
|
184
|
+
// loadWebMedia 不可用时回退到直接读取
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 自定义 mediaReadFile → Buffer 模式
|
|
188
|
+
if (options?.mediaReadFile) {
|
|
189
|
+
const buffer = await options.mediaReadFile(mediaUrl);
|
|
190
|
+
return { kind: 'buffer', buffer, fileName, mimeType, fileSize: buffer.length };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 直接读取本地文件
|
|
194
|
+
const fileStat = await fsp.stat(mediaUrl);
|
|
195
|
+
|
|
196
|
+
if (fileStat.size > streamThreshold) {
|
|
197
|
+
// 大文件 → Stream 模式(保留路径,按需读取 chunk)
|
|
198
|
+
return { kind: 'stream', filePath: mediaUrl, fileName, mimeType, fileSize: fileStat.size };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 小文件 → Buffer 模式
|
|
202
|
+
const buffer = await fsp.readFile(mediaUrl);
|
|
203
|
+
return { kind: 'buffer', buffer, fileName, mimeType, fileSize: buffer.length };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 从 URL 或路径中提取文件名 */
|
|
207
|
+
function extractFileName(mediaUrl: string): string {
|
|
208
|
+
try {
|
|
209
|
+
if (mediaUrl.startsWith('http://') || mediaUrl.startsWith('https://')) {
|
|
210
|
+
const pathname = new URL(mediaUrl).pathname;
|
|
211
|
+
return pathname.split('/').pop() || 'file';
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// ignore
|
|
215
|
+
}
|
|
216
|
+
return mediaUrl.split('/').pop()?.split('?')[0] || 'file';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
220
|
+
// 内部:分片工具
|
|
221
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/** 计算分片计划 */
|
|
224
|
+
function computeChunkPlan(fileSize: number, chunkSize: number) {
|
|
225
|
+
const totalChunks = Math.ceil(fileSize / chunkSize);
|
|
226
|
+
return { chunkSize, totalChunks, fileSize };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 从 Buffer 中提取指定 chunk 的数据 */
|
|
230
|
+
function getChunkFromBuffer(buffer: Buffer, index: number, chunkSize: number): Buffer {
|
|
231
|
+
const start = index * chunkSize;
|
|
232
|
+
const end = Math.min(start + chunkSize, buffer.length);
|
|
233
|
+
return buffer.subarray(start, end);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 从文件中读取指定 chunk 的数据(Stream 模式) */
|
|
237
|
+
async function readChunkFromFile(
|
|
238
|
+
filePath: string,
|
|
239
|
+
index: number,
|
|
240
|
+
chunkSize: number,
|
|
241
|
+
fileSize: number,
|
|
242
|
+
): Promise<Buffer> {
|
|
243
|
+
const start = index * chunkSize;
|
|
244
|
+
const end = Math.min(start + chunkSize, fileSize);
|
|
245
|
+
const length = end - start;
|
|
246
|
+
const buf = Buffer.alloc(length);
|
|
247
|
+
const fd = await fsp.open(filePath, 'r');
|
|
248
|
+
try {
|
|
249
|
+
await fd.read(buf, 0, length, start);
|
|
250
|
+
} finally {
|
|
251
|
+
await fd.close();
|
|
252
|
+
}
|
|
253
|
+
return buf;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** PUT 加密后的 chunk 到预签名 URL,返回 ETag */
|
|
257
|
+
async function putChunkToS3(
|
|
258
|
+
url: string,
|
|
259
|
+
encryptedData: Uint8Array,
|
|
260
|
+
mimeType: string,
|
|
261
|
+
): Promise<string> {
|
|
262
|
+
const res = await fetch(url, {
|
|
263
|
+
method: 'PUT',
|
|
264
|
+
headers: {
|
|
265
|
+
'Content-Type': mimeType,
|
|
266
|
+
'Content-Length': String(encryptedData.length),
|
|
267
|
+
},
|
|
268
|
+
body: encryptedData,
|
|
269
|
+
});
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
const errText = await res.text().catch(() => '');
|
|
272
|
+
throw new Error(`分片上传失败: HTTP ${res.status} ${errText}`);
|
|
273
|
+
}
|
|
274
|
+
// ETag 通常在响应头中
|
|
275
|
+
const originalEtag = res.headers.get('etag');
|
|
276
|
+
const etag = originalEtag
|
|
277
|
+
? originalEtag.slice(1, -1) // etag 取值去掉头尾一个字符
|
|
278
|
+
: crypto.randomUUID().replace(/-/g, '');
|
|
279
|
+
return etag;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
283
|
+
// Buffer 模式上传
|
|
284
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Buffer 模式:全量加载 → 分片 → 逐片加密 → 并发上传 → 流式 SHA256
|
|
288
|
+
*/
|
|
289
|
+
async function uploadBufferMode(
|
|
290
|
+
buffer: Buffer,
|
|
291
|
+
key: Uint8Array,
|
|
292
|
+
iv: Uint8Array,
|
|
293
|
+
chunkUrls: string[],
|
|
294
|
+
state: UploadState,
|
|
295
|
+
stateDir: string,
|
|
296
|
+
concurrency: number,
|
|
297
|
+
mimeType: string,
|
|
298
|
+
log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void },
|
|
299
|
+
): Promise<{ digest: string }> {
|
|
300
|
+
const { chunkSize, totalChunks } = state;
|
|
301
|
+
|
|
302
|
+
// 流式 SHA256:顺序遍历所有 chunk 维护 digest
|
|
303
|
+
const hash = crypto.createHash('sha256');
|
|
304
|
+
|
|
305
|
+
// 构建上传任务列表
|
|
306
|
+
const tasks: ConcurrentTask<string>[] = [];
|
|
307
|
+
|
|
308
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
309
|
+
const chunkData = getChunkFromBuffer(buffer, i, chunkSize);
|
|
310
|
+
const offset = i * chunkSize;
|
|
311
|
+
|
|
312
|
+
// 无论是否已完成,都要 update SHA256 digest
|
|
313
|
+
hash.update(chunkData);
|
|
314
|
+
|
|
315
|
+
if (state.chunks[i].completed) {
|
|
316
|
+
// 已完成的 chunk 跳过上传
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 加密 chunk
|
|
321
|
+
const encrypted = aesCtrXor(chunkData, key, iv, offset);
|
|
322
|
+
const url = chunkUrls[i];
|
|
323
|
+
|
|
324
|
+
tasks.push({
|
|
325
|
+
id: i,
|
|
326
|
+
execute: async () => {
|
|
327
|
+
const etag = await putChunkToS3(url, encrypted, mimeType);
|
|
328
|
+
// 更新状态
|
|
329
|
+
state.chunks[i].completed = true;
|
|
330
|
+
state.chunks[i].etag = etag;
|
|
331
|
+
saveUploadState(stateDir, state);
|
|
332
|
+
log?.info?.(`[upload] chunk ${i + 1}/${totalChunks} 上传完成`);
|
|
333
|
+
return etag;
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 并发上传未完成的 chunk
|
|
339
|
+
if (tasks.length > 0) {
|
|
340
|
+
const results = await runConcurrent(tasks, concurrency);
|
|
341
|
+
// 检查是否有失败
|
|
342
|
+
const failed = results.filter((r) => !r.success);
|
|
343
|
+
if (failed.length > 0) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`${failed.length} 个分片上传失败: ${failed.map((f) => f.error?.message).join('; ')}`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const digest = hash.digest('hex');
|
|
351
|
+
return { digest };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
355
|
+
// Stream 模式上传
|
|
356
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Stream 模式:顺序读取 chunk → 加密 → 提交并发队列 → 增量 SHA256
|
|
360
|
+
*
|
|
361
|
+
* 读取是顺序的(不将整个文件加载到内存),加密后的 chunk 并发上传。
|
|
362
|
+
* 峰值内存占用 ≈ chunkSize × concurrency。
|
|
363
|
+
*/
|
|
364
|
+
async function uploadStreamMode(
|
|
365
|
+
filePath: string,
|
|
366
|
+
fileSize: number,
|
|
367
|
+
key: Uint8Array,
|
|
368
|
+
iv: Uint8Array,
|
|
369
|
+
chunkUrls: string[],
|
|
370
|
+
state: UploadState,
|
|
371
|
+
stateDir: string,
|
|
372
|
+
concurrency: number,
|
|
373
|
+
mimeType: string,
|
|
374
|
+
log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void },
|
|
375
|
+
): Promise<{ digest: string }> {
|
|
376
|
+
const { chunkSize, totalChunks } = state;
|
|
377
|
+
|
|
378
|
+
// 流式 SHA256
|
|
379
|
+
const hash = crypto.createHash('sha256');
|
|
380
|
+
|
|
381
|
+
// 构建上传任务列表(顺序读取,收集后并发上传)
|
|
382
|
+
const tasks: ConcurrentTask<string>[] = [];
|
|
383
|
+
|
|
384
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
385
|
+
const chunkData = await readChunkFromFile(filePath, i, chunkSize, fileSize);
|
|
386
|
+
const offset = i * chunkSize;
|
|
387
|
+
|
|
388
|
+
// 无论是否已完成,都要 update SHA256 digest
|
|
389
|
+
hash.update(chunkData);
|
|
390
|
+
|
|
391
|
+
if (state.chunks[i].completed) {
|
|
392
|
+
// 已完成的 chunk 跳过上传
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 加密 chunk
|
|
397
|
+
const encrypted = aesCtrXor(chunkData, key, iv, offset);
|
|
398
|
+
const url = chunkUrls[i];
|
|
399
|
+
|
|
400
|
+
tasks.push({
|
|
401
|
+
id: i,
|
|
402
|
+
execute: async () => {
|
|
403
|
+
const etag = await putChunkToS3(url, encrypted, mimeType);
|
|
404
|
+
// 更新状态
|
|
405
|
+
state.chunks[i].completed = true;
|
|
406
|
+
state.chunks[i].etag = etag;
|
|
407
|
+
saveUploadState(stateDir, state);
|
|
408
|
+
log?.info?.(`[upload] chunk ${i + 1}/${totalChunks} 上传完成`);
|
|
409
|
+
return etag;
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 并发上传未完成的 chunk
|
|
415
|
+
if (tasks.length > 0) {
|
|
416
|
+
const results = await runConcurrent(tasks, concurrency);
|
|
417
|
+
const failed = results.filter((r) => !r.success);
|
|
418
|
+
if (failed.length > 0) {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`${failed.length} 个分片上传失败: ${failed.map((f) => f.error?.message).join('; ')}`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const digest = hash.digest('hex');
|
|
426
|
+
return { digest };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
430
|
+
// 公开 API
|
|
431
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* 加密并分片上传文件到平台存储。
|
|
435
|
+
*
|
|
436
|
+
* 双模式策略(与 iOS "顺序读取, 并发上传" 对齐):
|
|
437
|
+
* - Buffer 模式(≤ streamThreshold,默认 10MB):全量加载到内存,分片加密后并发上传
|
|
438
|
+
* - Stream 模式(> streamThreshold):顺序读取 chunk,每个 chunk 加密后提交到并发上传队列
|
|
439
|
+
*
|
|
440
|
+
* 完整流程:
|
|
441
|
+
* 1. 判断文件大小,选择 Buffer 或 Stream 模式
|
|
442
|
+
* 2. 生成 AES-256 key/iv(或从断点状态恢复)
|
|
443
|
+
* 3. 计算分片计划(chunkSize 默认 2MB)
|
|
444
|
+
* 4. initMultipartUpload 获取 fileId + 预签名 URL
|
|
445
|
+
* 5. 持久化 UploadState(fileId, key, iv, chunkPlan, sendContext)
|
|
446
|
+
* 6. 加密 + 上传各分片
|
|
447
|
+
* 7. 流式计算 SHA256(plaintext)
|
|
448
|
+
* 8. batchCompleteChunks 确认上传完成
|
|
449
|
+
* 9. 清理 UploadState 文件
|
|
450
|
+
*
|
|
451
|
+
* @param mediaUrl 待上传的媒体文件路径或 URL
|
|
452
|
+
* @param auth 鉴权信息
|
|
453
|
+
* @param apiBaseUrl API 基础地址
|
|
454
|
+
* @param options 可选参数
|
|
455
|
+
*/
|
|
456
|
+
export async function uploadEncryptedFile(
|
|
457
|
+
mediaUrl: string,
|
|
458
|
+
auth: { apiKey: string; apiSecret: string },
|
|
459
|
+
apiBaseUrl: string,
|
|
460
|
+
options?: {
|
|
461
|
+
/** 接收方 IM 主账号 ID */
|
|
462
|
+
imMainAccId?: string;
|
|
463
|
+
/** 文件传输配置 */
|
|
464
|
+
config?: FileTransferConfig;
|
|
465
|
+
/** 消息发送上下文(持久化到 UploadState,断点续传恢复后用于发送消息) */
|
|
466
|
+
sendContext?: UploadSendContext;
|
|
467
|
+
/** 允许读取的本地目录列表 */
|
|
468
|
+
mediaLocalRoots?: readonly string[];
|
|
469
|
+
/** 自定义文件读取函数 */
|
|
470
|
+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
|
471
|
+
/** 日志接口 */
|
|
472
|
+
log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
|
|
473
|
+
},
|
|
474
|
+
): Promise<UploadResult> {
|
|
475
|
+
const log = options?.log;
|
|
476
|
+
const config = { ...DEFAULT_CONFIG, ...options?.config };
|
|
477
|
+
const { chunkSize, uploadConcurrency, stateDir, streamThreshold } = config;
|
|
478
|
+
|
|
479
|
+
// 1. 解析媒体源
|
|
480
|
+
log?.info?.(`[upload] 解析媒体源: ${mediaUrl}`);
|
|
481
|
+
const media = await resolveMediaSource(mediaUrl, streamThreshold, {
|
|
482
|
+
mediaLocalRoots: options?.mediaLocalRoots,
|
|
483
|
+
mediaReadFile: options?.mediaReadFile,
|
|
484
|
+
});
|
|
485
|
+
log?.info?.(
|
|
486
|
+
`[upload] 媒体源解析完成: mode=${media.kind}, fileName=${media.fileName}, size=${media.fileSize}`,
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
// 2. 检查断点续传状态
|
|
490
|
+
let state = loadUploadState(stateDir, mediaUrl);
|
|
491
|
+
let key: Uint8Array;
|
|
492
|
+
let iv: Uint8Array;
|
|
493
|
+
let chunkUrls: string[];
|
|
494
|
+
|
|
495
|
+
if (state) {
|
|
496
|
+
// 从断点恢复
|
|
497
|
+
log?.info?.(
|
|
498
|
+
`[upload] 检测到断点续传状态: fileId=${state.fileId}, 已完成 ${state.chunks.filter((c) => c.completed).length}/${state.totalChunks} 个分片`,
|
|
499
|
+
);
|
|
500
|
+
key = Buffer.from(state.keyHex, 'hex');
|
|
501
|
+
iv = Buffer.from(state.ivHex, 'hex');
|
|
502
|
+
|
|
503
|
+
// 刷新未完成 chunk 的预签名 URL
|
|
504
|
+
const pendingIndices = state.chunks.filter((c) => !c.completed).map((c) => c.index);
|
|
505
|
+
|
|
506
|
+
if (pendingIndices.length > 0) {
|
|
507
|
+
log?.info?.(
|
|
508
|
+
`[upload] 刷新 ${pendingIndices.length} 个分片的预签名 URL, ${JSON.stringify({
|
|
509
|
+
imMainAccId: options?.imMainAccId ?? '',
|
|
510
|
+
fileId: state.fileId,
|
|
511
|
+
chunkIndices: pendingIndices,
|
|
512
|
+
})}`,
|
|
513
|
+
);
|
|
514
|
+
const refreshed = await batchGetChunkPreSignedUrl(apiBaseUrl, auth, {
|
|
515
|
+
imMainAccId: options?.imMainAccId ?? '',
|
|
516
|
+
fileId: state.fileId,
|
|
517
|
+
chunkIndices: pendingIndices,
|
|
518
|
+
});
|
|
519
|
+
// 构建完整的 URL 列表
|
|
520
|
+
chunkUrls = new Array(state.totalChunks).fill('');
|
|
521
|
+
for (const item of refreshed.chunkUrlList) {
|
|
522
|
+
chunkUrls[item.index] = item.url;
|
|
523
|
+
}
|
|
524
|
+
} else {
|
|
525
|
+
chunkUrls = new Array(state.totalChunks).fill('');
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
// 全新上传
|
|
529
|
+
const fileKey = generateFileKey();
|
|
530
|
+
key = fileKey.key;
|
|
531
|
+
iv = fileKey.iv;
|
|
532
|
+
|
|
533
|
+
// 3. 计算分片计划
|
|
534
|
+
const plan = computeChunkPlan(media.fileSize, chunkSize);
|
|
535
|
+
log?.info?.(`[upload] 分片计划: chunkSize=${chunkSize}, totalChunks=${plan.totalChunks}`);
|
|
536
|
+
|
|
537
|
+
// 4. initMultipartUpload
|
|
538
|
+
const initResult = await initMultipartUpload(apiBaseUrl, auth, {
|
|
539
|
+
imMainAccId: options?.imMainAccId ?? '',
|
|
540
|
+
fileName: media.fileName,
|
|
541
|
+
fileSize: media.fileSize,
|
|
542
|
+
chunkSize,
|
|
543
|
+
totalChunks: plan.totalChunks,
|
|
544
|
+
mimeType: media.mimeType,
|
|
545
|
+
});
|
|
546
|
+
log?.info?.(`[upload] initMultipartUpload 成功: ${JSON.stringify(initResult)}`);
|
|
547
|
+
|
|
548
|
+
chunkUrls = (initResult.chunkUrlList || []).filter(Boolean);
|
|
549
|
+
|
|
550
|
+
// 5. 创建并持久化 UploadState
|
|
551
|
+
const sendContext: UploadSendContext = options?.sendContext ?? {
|
|
552
|
+
to: '',
|
|
553
|
+
fromId: '',
|
|
554
|
+
mediaType: 'file',
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
state = {
|
|
558
|
+
fileId: initResult.fileId,
|
|
559
|
+
fileName: media.fileName,
|
|
560
|
+
mediaUrl,
|
|
561
|
+
imMainAccId: options?.imMainAccId ?? '',
|
|
562
|
+
plaintextLength: media.fileSize,
|
|
563
|
+
chunkSize,
|
|
564
|
+
totalChunks: plan.totalChunks,
|
|
565
|
+
keyHex: Buffer.from(key).toString('hex'),
|
|
566
|
+
ivHex: Buffer.from(iv).toString('hex'),
|
|
567
|
+
chunks: Array.from({ length: plan.totalChunks }, (_, i) => ({
|
|
568
|
+
index: i,
|
|
569
|
+
completed: false,
|
|
570
|
+
})),
|
|
571
|
+
sendContext,
|
|
572
|
+
expireTime: initResult.expireTime,
|
|
573
|
+
createdAt: Date.now(),
|
|
574
|
+
updatedAt: Date.now(),
|
|
575
|
+
};
|
|
576
|
+
saveUploadState(stateDir, state);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// 6. 执行加密 + 上传
|
|
580
|
+
let digest: string;
|
|
581
|
+
|
|
582
|
+
if (media.kind === 'buffer') {
|
|
583
|
+
log?.info?.(`[upload] 使用 Buffer 模式上传`);
|
|
584
|
+
const result = await uploadBufferMode(
|
|
585
|
+
media.buffer,
|
|
586
|
+
key,
|
|
587
|
+
iv,
|
|
588
|
+
chunkUrls,
|
|
589
|
+
state,
|
|
590
|
+
stateDir,
|
|
591
|
+
uploadConcurrency,
|
|
592
|
+
media.mimeType,
|
|
593
|
+
log,
|
|
594
|
+
);
|
|
595
|
+
digest = result.digest;
|
|
596
|
+
} else {
|
|
597
|
+
log?.info?.(`[upload] 使用 Stream 模式上传`);
|
|
598
|
+
const result = await uploadStreamMode(
|
|
599
|
+
media.filePath,
|
|
600
|
+
media.fileSize,
|
|
601
|
+
key,
|
|
602
|
+
iv,
|
|
603
|
+
chunkUrls,
|
|
604
|
+
state,
|
|
605
|
+
stateDir,
|
|
606
|
+
uploadConcurrency,
|
|
607
|
+
media.mimeType,
|
|
608
|
+
log,
|
|
609
|
+
);
|
|
610
|
+
digest = result.digest;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// 7. batchCompleteChunks 确认上传完成
|
|
614
|
+
const completedChunks = state.chunks.map((c) => ({
|
|
615
|
+
index: c.index,
|
|
616
|
+
etag: c.etag || '',
|
|
617
|
+
}));
|
|
618
|
+
|
|
619
|
+
log?.info?.(`[upload] 确认上传完成: fileId=${state.fileId}`);
|
|
620
|
+
const completeResult = await batchCompleteChunks(apiBaseUrl, auth, {
|
|
621
|
+
imMainAccId: options?.imMainAccId ?? '',
|
|
622
|
+
fileId: state.fileId,
|
|
623
|
+
completedChunks,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// 8. 清理 UploadState
|
|
627
|
+
clearUploadState(stateDir, mediaUrl);
|
|
628
|
+
log?.info?.(`[upload] 上传完成: fileUrl=${completeResult.fileUrl}`);
|
|
629
|
+
|
|
630
|
+
// 9. 计算 blurhash(仅 Buffer 模式图片)
|
|
631
|
+
let blurHash: string | undefined;
|
|
632
|
+
let width: number | undefined;
|
|
633
|
+
let height: number | undefined;
|
|
634
|
+
if (media.kind === 'buffer') {
|
|
635
|
+
const bh = await computeBlurhash(media.buffer, media.mimeType);
|
|
636
|
+
if (bh) {
|
|
637
|
+
blurHash = bh.blurHash;
|
|
638
|
+
width = bh.width;
|
|
639
|
+
height = bh.height;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return {
|
|
644
|
+
url: completeResult.fileUrl,
|
|
645
|
+
fileKey: key,
|
|
646
|
+
fileIv: iv,
|
|
647
|
+
fileDigest: digest,
|
|
648
|
+
plaintextLength: media.fileSize,
|
|
649
|
+
opCreds: completeResult.opCreds,
|
|
650
|
+
contentType: media.mimeType,
|
|
651
|
+
blurHash,
|
|
652
|
+
width,
|
|
653
|
+
height,
|
|
654
|
+
expireTime: state.expireTime,
|
|
655
|
+
};
|
|
656
|
+
}
|