secure-upload-fastify-sdk 1.0.4 → 1.0.6
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/README.md +181 -1
- package/dist/index.js +103 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +103 -4
- package/dist/index.mjs.map +1 -1
- package/dist/server/fastify-plugin.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +103 -4
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +103 -4
- package/dist/server/index.mjs.map +1 -1
- package/dist/server/routes/multipart.d.ts.map +1 -1
- package/dist/server/types.d.ts +74 -1
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/utils/repository/index.js +51 -1
- package/dist/server/utils/repository/index.js.map +1 -1
- package/dist/server/utils/repository/index.mjs +51 -1
- package/dist/server/utils/repository/index.mjs.map +1 -1
- package/dist/server/utils/repository/memory.d.ts +7 -1
- package/dist/server/utils/repository/memory.d.ts.map +1 -1
- package/dist/server/utils/repository/prisma.d.ts +8 -1
- package/dist/server/utils/repository/prisma.d.ts.map +1 -1
- package/dist/server/utils/repository/types.d.ts +38 -2
- package/dist/server/utils/repository/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
- **后台任务** — 过期 `uploadId` 清理 + 孤儿文件扫描,全部可关闭
|
|
19
19
|
- **严格秒传隔离(v1.0.0+)** — 按 `(contentHash, isPublic, userId)` 三元组匹配,公开池/私有池/不同用户之间完全隔离
|
|
20
20
|
- **可配置 /smart/init 批量上限(v1.0.2+)** — `rateLimit.smartMaxFilesPerInit` 动态生效,工厂函数生成 zod schema,业务校验 + 启动日志同源
|
|
21
|
+
- **complete 业务钩子(v1.0.6+)** — `onComplete` 在文件落库后被调用,可读写已合并 buffer、可返回 patch 升级 file record(典型用法:自动水印、转码、敏感扫描),失败不阻塞主流程,幂等命中跳过
|
|
21
22
|
|
|
22
23
|
---
|
|
23
24
|
|
|
@@ -215,11 +216,125 @@ await app.register(secureUploadPlugin, {
|
|
|
215
216
|
```
|
|
216
217
|
|
|
217
218
|
> 默认 `sessionValidator` 只 base64-decode JWT payload 并校验 `exp` + 读 `sub`,不做 HMAC 验签(SDK 不持有 `appSecret`)。
|
|
219
|
+
>
|
|
220
|
+
> **详细 onComplete 钩子契约、类型签名、失败隔离说明、自动水印示例**:见本文档 **"🪝 onComplete 业务钩子(v1.0.6+)"** 章节。
|
|
218
221
|
> 完整安全链路:gateway 已做 JWT 验签,业务服务信任透传的 session;若业务服务要自己验签,通过 `options.sessionValidator` 注入即可。
|
|
219
222
|
> 校验失败应抛 `SecureError(ErrorCode.BINARY_AUTH_REQUIRED / BINARY_AUTH_FAILED)`,SDK 会自动转成 401 响应。
|
|
220
223
|
|
|
221
224
|
---
|
|
222
225
|
|
|
226
|
+
## 🪝 onComplete 业务钩子(v1.0.6+)
|
|
227
|
+
|
|
228
|
+
> **核心场景**:在文件落到 storage、写到 `File` 表之后,**在 `multipart/complete` 响应返回前**,给你一个机会"加工"这个文件——典型用例:
|
|
229
|
+
> - **自动水印**(upload-service 的 `autoWatermarkOnComplete`):读明文 → 嵌入可见/数字水印 → put 新文件 → 升级 file record;complete 响应的 url 直接是水印后文件
|
|
230
|
+
> - **图片转码**(WebP / AVIF 压缩)
|
|
231
|
+
> - **敏感词/版权扫描**
|
|
232
|
+
> - **异步转存 / CDN 预热**
|
|
233
|
+
|
|
234
|
+
### 1. 类型签名
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import type {
|
|
238
|
+
OnCompleteHook,
|
|
239
|
+
OnCompleteContext,
|
|
240
|
+
OnCompletePatch,
|
|
241
|
+
} from 'secure-upload-fastify-sdk/server';
|
|
242
|
+
|
|
243
|
+
export type OnCompletePatch = Partial<{
|
|
244
|
+
storageKey: string; // 替换指向新文件
|
|
245
|
+
size: number; // 新文件字节数
|
|
246
|
+
contentType: string; // 新 mime(如 image/webp)
|
|
247
|
+
watermarked: boolean; // 标记是否已水印
|
|
248
|
+
isOriginal: boolean; // 标记"是不是原图"
|
|
249
|
+
retentionExpiresAt: Date; // 自定义保留期
|
|
250
|
+
meta: Record<string, unknown>;// 附加元数据
|
|
251
|
+
}>;
|
|
252
|
+
|
|
253
|
+
export type OnCompleteContext = {
|
|
254
|
+
file: FileRecord; // 刚写进库的 file record
|
|
255
|
+
storage: StorageAdapter; // storage 实例(读/写 / delete)
|
|
256
|
+
multipart: MultipartUploadRecord;
|
|
257
|
+
log: FastifyBaseLogger;
|
|
258
|
+
getOriginalBuffer: () => Promise<Buffer>; // ★ 读已合并文件明文(走 storage.get)
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export type OnCompleteHook = (ctx: OnCompleteContext) => Promise<OnCompletePatch | void | undefined>;
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### 2. 使用示例(自动水印精简版)
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import sharp from 'sharp';
|
|
268
|
+
import type { OnCompleteHook } from 'secure-upload-fastify-sdk/server';
|
|
269
|
+
|
|
270
|
+
const autoWatermark: OnCompleteHook = async (ctx) => {
|
|
271
|
+
const { file, storage, getOriginalBuffer, log } = ctx;
|
|
272
|
+
|
|
273
|
+
// 1) 判条件(图片 + 私有 + 大小允许)
|
|
274
|
+
if (!file.userId) return; // 公开上传跳过数字水印
|
|
275
|
+
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.contentType)) return;
|
|
276
|
+
|
|
277
|
+
// 2) 读原图
|
|
278
|
+
const buf = await getOriginalBuffer();
|
|
279
|
+
const png = await sharp(buf).png().toBuffer();
|
|
280
|
+
|
|
281
|
+
// 3) put 水印后文件,新 storageKey
|
|
282
|
+
const newKey = file.storageKey.replace(/(\.\w+)?$/, '.wm.png');
|
|
283
|
+
await storage.put(newKey, png, { contentType: 'image/png' });
|
|
284
|
+
|
|
285
|
+
// 4) 返回 patch,SDK 会把 file record 升级为水印后版本
|
|
286
|
+
return {
|
|
287
|
+
storageKey: newKey,
|
|
288
|
+
size: png.length,
|
|
289
|
+
contentType: 'image/png',
|
|
290
|
+
watermarked: true,
|
|
291
|
+
isOriginal: false,
|
|
292
|
+
retentionExpiresAt: new Date(Date.now() + 30 * 86400 * 1000),
|
|
293
|
+
meta: { autoWatermark: true, sourceFileId: file.id },
|
|
294
|
+
};
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
await app.register(secureUploadPlugin, {
|
|
298
|
+
storage: { adapter: 'local', options: { baseDir } },
|
|
299
|
+
repository: { kind: 'prisma', prisma },
|
|
300
|
+
onComplete: autoWatermark, // ← 一行接入
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### 3. SDK 调用契约(务必读完)
|
|
305
|
+
|
|
306
|
+
| 场景 | SDK 行为 |
|
|
307
|
+
|------|---------|
|
|
308
|
+
| 钩子返回 `undefined` / `void` | file record 不动,complete 正常 200 返回 |
|
|
309
|
+
| 钩子返回 `OnCompletePatch` | SDK 调 `fileRepo.update(id, patch)`;原 storage 文件**保留**(如需清理可钩子里自己 `storage.delete(oldKey)`) |
|
|
310
|
+
| 钩子**抛错** | **不阻塞**主流程,file record 不变,complete 仍 200;记 `log.warn({ err, fileId }, 'onComplete hook failed')` |
|
|
311
|
+
| 钩子**超时**(若外部包了超时) | 同上:不阻塞,不污染 file record |
|
|
312
|
+
| **idempotent 命中**(同一 uploadId 第二次 complete) | **不调**钩子,直接返回原 fileId(idempotent: true) |
|
|
313
|
+
| `getOriginalBuffer()` | 内部走 `storage.get(file.storageKey)`,**会读完整文件到内存**,大文件注意 |
|
|
314
|
+
| 钩子里再调 `storage` / `fileRepo` | ✅ 安全,但要注意:调 `fileRepo.update` 会覆盖前一个钩子的 patch |
|
|
315
|
+
|
|
316
|
+
### 4. 失败隔离(设计哲学)
|
|
317
|
+
|
|
318
|
+
> 上传是核心路径,**任何**钩子失败都不能让用户重新传文件。
|
|
319
|
+
>
|
|
320
|
+
> - 钩子抛错 → SDK catch → log.warn → 仍返回成功响应(原文件 url)
|
|
321
|
+
> - 钩子返回无效 patch(如 storageKey 已存在) → SDK 把 patch 当"无效",file record 不变,记 warn
|
|
322
|
+
> - 测试可注入"100% 失败"钩子验证主流程不受影响
|
|
323
|
+
|
|
324
|
+
### 5. 端到端测试覆盖
|
|
325
|
+
|
|
326
|
+
`tests/hook.test.ts` 用 `InMemoryMultipartRepository` + `InMemoryFileRepository` 覆盖:
|
|
327
|
+
|
|
328
|
+
1. **钩子在 complete 成功路径调用一次**(不是两次)
|
|
329
|
+
2. **patch 应用** → file record 字段、complete 响应字段都被更新
|
|
330
|
+
3. **`undefined`** → file record 不变
|
|
331
|
+
4. **钩子抛错** → 主流程仍 200,file record 不变
|
|
332
|
+
5. **idempotent 命中** → 不重复调钩子
|
|
333
|
+
6. **`getOriginalBuffer()`** → 内容与上传文件 byte-for-byte 一致
|
|
334
|
+
7. **不传 `onComplete`** → 完全向后兼容(旧代码不受影响)
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
223
338
|
## 🔐 Secure-Crypto 模式
|
|
224
339
|
|
|
225
340
|
默认情况下,SDK 从 `req.body` 和 `req.headers['x-secure-raw-base64']` 读数据(`raw` 模式)。
|
|
@@ -438,7 +553,7 @@ if (body.files.length > rt.config.smartMaxFiles) {
|
|
|
438
553
|
| `ttlSeconds` | `number` | `86400` (24h) | `uploadId` 有效期 |
|
|
439
554
|
| `suggestedClientConcurrency` | `number` | `3` | 建议客户端并发 part 数 |
|
|
440
555
|
| `rateLimit.initPerMin` | `number` | `30` | `/multipart/init` 限流 |
|
|
441
|
-
| `rateLimit.partPerMin` | `number` | `600` | `/multipart/part` 限流 |
|
|
556
|
+
| `rateLimit.partPerMin` | `number` | `600` | `/multipart/part` 限流(**v1.0.5+ 按 `uploadId` 维度**,每个文件独立 600/min 配额,不再按 sessionKey 共享;详见 Changelog) |
|
|
442
557
|
| `rateLimit.smartMaxFilesPerInit` | `number` | `50` | `/smart/init` 单次最多文件数(**v1.0.2+ 可配置**,从 `.env UPLOAD_SMART_MAX_FILES_PER_INIT` 透传,详见下文"配置链路"章节) |
|
|
443
558
|
| `pathPrefix.multipart` | `string` | `'/multipart'` | 分片路由挂载前缀 |
|
|
444
559
|
| `pathPrefix.smart` | `string` | `'/smart'` | smart 路由挂载前缀 |
|
|
@@ -452,6 +567,7 @@ if (body.files.length > rt.config.smartMaxFiles) {
|
|
|
452
567
|
| `orphanScan.intervalSeconds` | `number` | `300` | 扫描间隔(秒) |
|
|
453
568
|
| `orphanScan.lookbackDays` | `number` | `7` | 回溯天数 |
|
|
454
569
|
| `orphanScan.batchSize` | `number` | `2000` | 单次扫描条数 |
|
|
570
|
+
| `onComplete` | `OnCompleteHook` | `undefined` | **v1.0.6+** complete 后业务钩子(详见下文"onComplete 业务钩子"章节) |
|
|
455
571
|
|
|
456
572
|
---
|
|
457
573
|
|
|
@@ -473,6 +589,7 @@ pnpm test:e2e # SDK 独立启动 e2e(server-only,不依赖 MySQL/Redi
|
|
|
473
589
|
| `memory-repo.test.ts` | `InMemoryMultipartRepository` / `InMemoryFileRepository` |
|
|
474
590
|
| `plugin.test.ts` | 端到端 init / part / complete / abort / status / smart |
|
|
475
591
|
| `dedup.test.ts` | 秒传命中 + 隔离(可见性 / userId 不同时不命中) |
|
|
592
|
+
| `hook.test.ts` | **v1.0.6+** `onComplete` 钩子:调用顺序 / patch 应用 / 失败隔离 / idempotent 跳过 / `getOriginalBuffer` 内容正确 / 无钩子向后兼容 |
|
|
476
593
|
| `e2e-with-prefix.mjs` / `standalone-e2e.*` | 启动完整 Fastify 实例,跑通 5 个集成流程 |
|
|
477
594
|
|
|
478
595
|
---
|
|
@@ -561,6 +678,69 @@ HTTP 状态码映射由 `getHttpStatus(code)` 给出,用户业务侧可直接用
|
|
|
561
678
|
|
|
562
679
|
## 📝 Changelog
|
|
563
680
|
|
|
681
|
+
### v1.0.6 — `onComplete` 业务钩子(自动水印/转码/敏感扫描通用入口)
|
|
682
|
+
|
|
683
|
+
**🎉 Feature**
|
|
684
|
+
|
|
685
|
+
- **`SecureUploadOptions.onComplete?: OnCompleteHook`**
|
|
686
|
+
在 multipart/smart complete 完成(file record 已落库)、响应返回前**调用一次**,给你一个机会"加工"这个文件:
|
|
687
|
+
- **读**原 buffer:`ctx.getOriginalBuffer()`(走 `storage.get`)
|
|
688
|
+
- **改**file record:返回 `OnCompletePatch` → SDK 调 `fileRepo.update(id, patch)`
|
|
689
|
+
- **失败隔离**:钩子抛错 → SDK catch → log.warn → complete 仍返回 200(file record 不变)
|
|
690
|
+
- **幂等跳过**:同一 uploadId 第二次 complete(命中 idempotent)→ 不调钩子
|
|
691
|
+
|
|
692
|
+
- **典型用例:upload-service 的自动水印**
|
|
693
|
+
```ts
|
|
694
|
+
onComplete: autoWatermarkOnComplete // 后端代码 src/watermark/auto.ts
|
|
695
|
+
```
|
|
696
|
+
私有 PNG/JPEG/WebP 走分片上传 → complete 时自动:
|
|
697
|
+
1. 读明文 → `processWatermark` (LSB 数字 + 可见)
|
|
698
|
+
2. put 新文件 → 返回 `storageKey/size/contentType/watermarked/isOriginal/retentionExpiresAt/meta` patch
|
|
699
|
+
3. 前端拿到的 complete url 直接是水印后文件,**无二次请求**
|
|
700
|
+
|
|
701
|
+
- **新增类型导出**:`OnCompleteHook` / `OnCompleteContext` / `OnCompletePatch`
|
|
702
|
+
|
|
703
|
+
**🧪 Tests**
|
|
704
|
+
|
|
705
|
+
- `tests/hook.test.ts`(7 用例):钩子调用顺序 / patch 应用 / 失败隔离 / idempotent 跳过 / `getOriginalBuffer` 内容 / 无钩子向后兼容
|
|
706
|
+
|
|
707
|
+
**⚙️ 升级**
|
|
708
|
+
|
|
709
|
+
```bash
|
|
710
|
+
cd secure-upload-fastify-sdk && pnpm build
|
|
711
|
+
cd ../backend && pnpm install
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
完全向后兼容(不传 `onComplete` 时行为不变)。
|
|
715
|
+
|
|
716
|
+
### v1.0.5 — `/multipart/part` 限流维度从 sessionKey 改为 uploadId
|
|
717
|
+
|
|
718
|
+
**🛠 Fix**
|
|
719
|
+
|
|
720
|
+
- **`/multipart/part` 限流 key 改为按 `uploadId` 维度**
|
|
721
|
+
之前 key 是 `rl:mp:part:${sessionKey.slice(0, 64)}`(**单用户共享 600 part/min 配额**),单用户并发 N 个文件会互相挤兑。
|
|
722
|
+
实测 60 个文件 × 16 part = 960 part,60s 内必爆,第 42 号文件全部 part 报 `1402 part 上传过于频繁`。
|
|
723
|
+
改为 `rl:mp:part:${uploadId}` 后,**每个文件独立 600 part/min**,多文件并发互不影响。
|
|
724
|
+
|
|
725
|
+
**⚙️ 影响面**
|
|
726
|
+
|
|
727
|
+
| 组件 | 行为 | 是否需要改动 |
|
|
728
|
+
|------|------|-------------|
|
|
729
|
+
| 单文件上传 | 行为不变(单文件本身就是 1 个 uploadId) | ❌ |
|
|
730
|
+
| 单用户并发多文件 | 60+ 文件不再互相挤兑配额 | ❌(自动生效) |
|
|
731
|
+
| 旧 key `rl:mp:part:{sessionKey}` | 立即失效,等 60s 滑动窗口自然过期 | 无需手动清理 |
|
|
732
|
+
| `rateLimit.partPerMin` 默认值 | 不变(600) | ❌ |
|
|
733
|
+
|
|
734
|
+
**🔄 升级步骤**
|
|
735
|
+
|
|
736
|
+
```bash
|
|
737
|
+
cd secure-upload-fastify-sdk && pnpm build # 重建 dist
|
|
738
|
+
cd ../backend && pnpm install # workspace 重链接
|
|
739
|
+
pnpm dev # 重启 upload-service
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
---
|
|
743
|
+
|
|
564
744
|
### v1.0.2 — /smart/init 批量上限可配置化
|
|
565
745
|
|
|
566
746
|
**🛠 Fix / Feature**
|
package/dist/index.js
CHANGED
|
@@ -345,7 +345,12 @@ function toFileRecord(row) {
|
|
|
345
345
|
meta: row.meta && typeof row.meta === "object" ? row.meta : null,
|
|
346
346
|
createdAt: row.createdAt,
|
|
347
347
|
updatedAt: row.updatedAt,
|
|
348
|
-
deletedAt: row.deletedAt
|
|
348
|
+
deletedAt: row.deletedAt,
|
|
349
|
+
// v1.5.1+:水印溯源字段(可选;旧 schema/老库里 row 可能没有,需要兜底)
|
|
350
|
+
watermarked: row.watermarked ?? false,
|
|
351
|
+
isOriginal: row.isOriginal ?? true,
|
|
352
|
+
parentId: row.parentId ?? null,
|
|
353
|
+
retentionExpiresAt: row.retentionExpiresAt ?? null
|
|
349
354
|
};
|
|
350
355
|
}
|
|
351
356
|
var PrismaMultipartRepository = class {
|
|
@@ -511,6 +516,27 @@ var PrismaFileRepository = class {
|
|
|
511
516
|
});
|
|
512
517
|
return toFileRecord(row);
|
|
513
518
|
}
|
|
519
|
+
/**
|
|
520
|
+
* v1.5.1: 钩子 onComplete 返回 patch 后 SDK 调这个方法
|
|
521
|
+
* - 只更新传入的字段;其他字段保留原值
|
|
522
|
+
* - 文件不存在 → 抛错(由调用方决定是否捕获)
|
|
523
|
+
* - 业务字段 watermarked/isOriginal/parentId/retentionExpiresAt 需要 schema v1.4.0+
|
|
524
|
+
*/
|
|
525
|
+
async update(id, patch) {
|
|
526
|
+
const data = {};
|
|
527
|
+
if (patch.storageKey !== void 0) data.storageKey = patch.storageKey;
|
|
528
|
+
if (patch.size !== void 0) data.size = BigInt(patch.size);
|
|
529
|
+
if (patch.contentType !== void 0) data.contentType = patch.contentType;
|
|
530
|
+
if (patch.meta !== void 0) data.meta = patch.meta;
|
|
531
|
+
if (patch.watermarked !== void 0) data.watermarked = patch.watermarked;
|
|
532
|
+
if (patch.isOriginal !== void 0) data.isOriginal = patch.isOriginal;
|
|
533
|
+
if (patch.parentId !== void 0) data.parentId = patch.parentId;
|
|
534
|
+
if (patch.retentionExpiresAt !== void 0) {
|
|
535
|
+
data.retentionExpiresAt = patch.retentionExpiresAt;
|
|
536
|
+
}
|
|
537
|
+
const row = await this.prisma.file.update({ where: { id }, data });
|
|
538
|
+
return toFileRecord(row);
|
|
539
|
+
}
|
|
514
540
|
async softDelete(id, metaPatch) {
|
|
515
541
|
if (metaPatch) {
|
|
516
542
|
const cur = await this.prisma.file.findUnique({ where: { id } });
|
|
@@ -737,6 +763,30 @@ var InMemoryFileRepository = class {
|
|
|
737
763
|
};
|
|
738
764
|
this.store.set(id, next);
|
|
739
765
|
}
|
|
766
|
+
/**
|
|
767
|
+
* v1.5.1: 钩子 onComplete 返回 patch 后 SDK 调这个方法
|
|
768
|
+
* - 只更新传入的字段;其他字段保留原值
|
|
769
|
+
* - 文件不存在 → 抛错(由调用方决定是否捕获)
|
|
770
|
+
*/
|
|
771
|
+
async update(id, patch) {
|
|
772
|
+
const rec = this.store.get(id);
|
|
773
|
+
if (!rec) throw new Error("NOT_FOUND");
|
|
774
|
+
const now = /* @__PURE__ */ new Date();
|
|
775
|
+
const next = {
|
|
776
|
+
...rec,
|
|
777
|
+
storageKey: patch.storageKey ?? rec.storageKey,
|
|
778
|
+
size: patch.size !== void 0 ? BigInt(patch.size) : rec.size,
|
|
779
|
+
contentType: patch.contentType ?? rec.contentType,
|
|
780
|
+
meta: patch.meta === void 0 ? rec.meta : patch.meta,
|
|
781
|
+
watermarked: patch.watermarked ?? rec.watermarked,
|
|
782
|
+
isOriginal: patch.isOriginal ?? rec.isOriginal,
|
|
783
|
+
parentId: patch.parentId === void 0 ? rec.parentId : patch.parentId,
|
|
784
|
+
retentionExpiresAt: patch.retentionExpiresAt === void 0 ? rec.retentionExpiresAt : patch.retentionExpiresAt,
|
|
785
|
+
updatedAt: now
|
|
786
|
+
};
|
|
787
|
+
this.store.set(id, next);
|
|
788
|
+
return next;
|
|
789
|
+
}
|
|
740
790
|
async listOrphans(opts) {
|
|
741
791
|
const sinceMs = opts.sinceDays * 24 * 3600 * 1e3;
|
|
742
792
|
const list = [];
|
|
@@ -1184,6 +1234,47 @@ async function lock(rt, key, ttlMs, fn, retries = 0) {
|
|
|
1184
1234
|
}
|
|
1185
1235
|
return await memoryWithLock(key, fn, retries);
|
|
1186
1236
|
}
|
|
1237
|
+
async function streamToBuffer(stream) {
|
|
1238
|
+
const chunks = [];
|
|
1239
|
+
for await (const chunk of stream) {
|
|
1240
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1241
|
+
}
|
|
1242
|
+
return Buffer.concat(chunks);
|
|
1243
|
+
}
|
|
1244
|
+
async function runOnComplete(rt, file, log) {
|
|
1245
|
+
if (!rt.onComplete) return file;
|
|
1246
|
+
try {
|
|
1247
|
+
const patch = await rt.onComplete({
|
|
1248
|
+
file,
|
|
1249
|
+
storage: rt.storage,
|
|
1250
|
+
log,
|
|
1251
|
+
getOriginalBuffer: async () => {
|
|
1252
|
+
const stream = await Promise.resolve(rt.storage.get(file.storageKey));
|
|
1253
|
+
return streamToBuffer(stream);
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
if (!patch || Object.keys(patch).length === 0) {
|
|
1257
|
+
return file;
|
|
1258
|
+
}
|
|
1259
|
+
const updated = await rt.fileRepo.update(file.id, patch);
|
|
1260
|
+
log.info(
|
|
1261
|
+
{
|
|
1262
|
+
fileId: file.id,
|
|
1263
|
+
patchKeys: Object.keys(patch),
|
|
1264
|
+
newStorageKey: patch.storageKey,
|
|
1265
|
+
watermarked: patch.watermarked
|
|
1266
|
+
},
|
|
1267
|
+
"\u2705 onComplete \u94A9\u5B50\u5DF2\u5E94\u7528 patch"
|
|
1268
|
+
);
|
|
1269
|
+
return updated;
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
log.warn(
|
|
1272
|
+
{ err, fileId: file.id, uploadId: file.uploadId },
|
|
1273
|
+
"\u26A0\uFE0F onComplete \u94A9\u5B50\u6267\u884C\u5931\u8D25,\u5DF2\u8DF3\u8FC7\u4E1A\u52A1\u540E\u5904\u7406(file record \u4FDD\u6301\u539F\u72B6)"
|
|
1274
|
+
);
|
|
1275
|
+
return file;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1187
1278
|
async function registerMultipartRoutes(app, rt) {
|
|
1188
1279
|
if (!rt.config.enabled) {
|
|
1189
1280
|
app.log.warn("\u26A0\uFE0F multipart \u4E0A\u4F20\u5DF2\u7981\u7528 (enabled=false)");
|
|
@@ -1489,8 +1580,14 @@ async function registerMultipartRoutes(app, rt) {
|
|
|
1489
1580
|
contentHash: finalHash,
|
|
1490
1581
|
meta: rec.meta ?? void 0
|
|
1491
1582
|
});
|
|
1583
|
+
const finalFile = await runOnComplete(rt, file2, req.log);
|
|
1492
1584
|
await rt.multipartRepo.markCompleted(rec.id, fileId);
|
|
1493
|
-
return {
|
|
1585
|
+
return {
|
|
1586
|
+
idempotent: false,
|
|
1587
|
+
fileId: finalFile.id,
|
|
1588
|
+
size: Number(finalFile.size),
|
|
1589
|
+
finalHash
|
|
1590
|
+
};
|
|
1494
1591
|
}
|
|
1495
1592
|
);
|
|
1496
1593
|
if (!result.idempotent) {
|
|
@@ -1503,7 +1600,8 @@ async function registerMultipartRoutes(app, rt) {
|
|
|
1503
1600
|
uploadId: body.uploadId,
|
|
1504
1601
|
fileId: result.fileId,
|
|
1505
1602
|
size: result.size,
|
|
1506
|
-
userId: rec.userId
|
|
1603
|
+
userId: rec.userId,
|
|
1604
|
+
watermarkHook: rt.onComplete ? "on" : "off"
|
|
1507
1605
|
},
|
|
1508
1606
|
"\u2705 multipart: complete"
|
|
1509
1607
|
);
|
|
@@ -1979,7 +2077,8 @@ async function secureUploadPluginImpl(app, options) {
|
|
|
1979
2077
|
...options.strategy ?? {}
|
|
1980
2078
|
}
|
|
1981
2079
|
},
|
|
1982
|
-
validateSession: options.sessionValidator ?? defaultSessionValidator
|
|
2080
|
+
validateSession: options.sessionValidator ?? defaultSessionValidator,
|
|
2081
|
+
onComplete: options.onComplete ?? null
|
|
1983
2082
|
};
|
|
1984
2083
|
app.decorate("secureUpload", runtime);
|
|
1985
2084
|
await registerMultipartRoutes(app, runtime);
|