secure-upload-fastify-sdk 1.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 (74) hide show
  1. package/README.md +467 -0
  2. package/dist/index.d.ts +14 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +2069 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +2023 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/dist/server/adapters/secure-crypto.d.ts +28 -0
  9. package/dist/server/adapters/secure-crypto.d.ts.map +1 -0
  10. package/dist/server/errors.d.ts +48 -0
  11. package/dist/server/errors.d.ts.map +1 -0
  12. package/dist/server/errors.js +112 -0
  13. package/dist/server/errors.js.map +1 -0
  14. package/dist/server/errors.mjs +85 -0
  15. package/dist/server/errors.mjs.map +1 -0
  16. package/dist/server/fastify-plugin.d.ts +38 -0
  17. package/dist/server/fastify-plugin.d.ts.map +1 -0
  18. package/dist/server/index.d.ts +17 -0
  19. package/dist/server/index.d.ts.map +1 -0
  20. package/dist/server/index.js +2070 -0
  21. package/dist/server/index.js.map +1 -0
  22. package/dist/server/index.mjs +2024 -0
  23. package/dist/server/index.mjs.map +1 -0
  24. package/dist/server/routes/_internal.d.ts +42 -0
  25. package/dist/server/routes/_internal.d.ts.map +1 -0
  26. package/dist/server/routes/multipart.d.ts +22 -0
  27. package/dist/server/routes/multipart.d.ts.map +1 -0
  28. package/dist/server/routes/smart.d.ts +16 -0
  29. package/dist/server/routes/smart.d.ts.map +1 -0
  30. package/dist/server/session.d.ts +19 -0
  31. package/dist/server/session.d.ts.map +1 -0
  32. package/dist/server/types.d.ts +233 -0
  33. package/dist/server/types.d.ts.map +1 -0
  34. package/dist/server/utils/cleaner.d.ts +20 -0
  35. package/dist/server/utils/cleaner.d.ts.map +1 -0
  36. package/dist/server/utils/memory-rate-limit.d.ts +29 -0
  37. package/dist/server/utils/memory-rate-limit.d.ts.map +1 -0
  38. package/dist/server/utils/orphan-scanner.d.ts +34 -0
  39. package/dist/server/utils/orphan-scanner.d.ts.map +1 -0
  40. package/dist/server/utils/redis-helpers.d.ts +25 -0
  41. package/dist/server/utils/redis-helpers.d.ts.map +1 -0
  42. package/dist/server/utils/repository/index.d.ts +27 -0
  43. package/dist/server/utils/repository/index.d.ts.map +1 -0
  44. package/dist/server/utils/repository/index.js +501 -0
  45. package/dist/server/utils/repository/index.js.map +1 -0
  46. package/dist/server/utils/repository/index.mjs +469 -0
  47. package/dist/server/utils/repository/index.mjs.map +1 -0
  48. package/dist/server/utils/repository/memory.d.ts +33 -0
  49. package/dist/server/utils/repository/memory.d.ts.map +1 -0
  50. package/dist/server/utils/repository/prisma.d.ts +49 -0
  51. package/dist/server/utils/repository/prisma.d.ts.map +1 -0
  52. package/dist/server/utils/repository/types-extra.d.ts +9 -0
  53. package/dist/server/utils/repository/types-extra.d.ts.map +1 -0
  54. package/dist/server/utils/repository/types.d.ts +128 -0
  55. package/dist/server/utils/repository/types.d.ts.map +1 -0
  56. package/dist/server/utils/storage/index.d.ts +115 -0
  57. package/dist/server/utils/storage/index.d.ts.map +1 -0
  58. package/dist/server/utils/storage/index.js +285 -0
  59. package/dist/server/utils/storage/index.js.map +1 -0
  60. package/dist/server/utils/storage/index.mjs +247 -0
  61. package/dist/server/utils/storage/index.mjs.map +1 -0
  62. package/dist/server/utils/storage/local.d.ts +46 -0
  63. package/dist/server/utils/storage/local.d.ts.map +1 -0
  64. package/dist/server/utils/storage/memory.d.ts +34 -0
  65. package/dist/server/utils/storage/memory.d.ts.map +1 -0
  66. package/dist/server/utils/storage/types.d.ts +78 -0
  67. package/dist/server/utils/storage/types.d.ts.map +1 -0
  68. package/dist/server/utils/strategy.d.ts +64 -0
  69. package/dist/server/utils/strategy.d.ts.map +1 -0
  70. package/dist/server/utils/strategy.js +130 -0
  71. package/dist/server/utils/strategy.js.map +1 -0
  72. package/dist/server/utils/strategy.mjs +103 -0
  73. package/dist/server/utils/strategy.mjs.map +1 -0
  74. package/package.json +97 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,2023 @@
1
+ // src/server/fastify-plugin.ts
2
+ import fp from "fastify-plugin";
3
+
4
+ // src/server/utils/storage/local.ts
5
+ import { promises as fs, createReadStream, createWriteStream } from "fs";
6
+ import path from "path";
7
+ import { createHash } from "crypto";
8
+ var LocalStorage = class {
9
+ baseDir;
10
+ constructor(opts) {
11
+ this.baseDir = typeof opts === "string" ? opts : opts.baseDir;
12
+ }
13
+ // ============================================================
14
+ // 内部工具
15
+ // ============================================================
16
+ inferExt(filename, contentType) {
17
+ const fromName = path.extname(filename).toLowerCase();
18
+ if (fromName) return fromName;
19
+ const map = {
20
+ "image/png": ".png",
21
+ "image/jpeg": ".jpg",
22
+ "image/gif": ".gif",
23
+ "image/webp": ".webp",
24
+ "image/svg+xml": ".svg",
25
+ "application/pdf": ".pdf",
26
+ "text/plain": ".txt",
27
+ "application/json": ".json",
28
+ "application/zip": ".zip",
29
+ "video/mp4": ".mp4"
30
+ };
31
+ return map[contentType] ?? ".bin";
32
+ }
33
+ generateKey(filename, contentType, id) {
34
+ const now = /* @__PURE__ */ new Date();
35
+ const yyyy = now.getFullYear();
36
+ const mm = String(now.getMonth() + 1).padStart(2, "0");
37
+ const dd = String(now.getDate()).padStart(2, "0");
38
+ const ext = this.inferExt(filename, contentType);
39
+ return `${yyyy}/${mm}/${dd}/${id}${ext}`;
40
+ }
41
+ multipartDir(uploadId) {
42
+ return path.resolve(this.baseDir, "_multipart", uploadId);
43
+ }
44
+ partPath(uploadId, partNumber) {
45
+ if (!Number.isInteger(partNumber) || partNumber < 1) {
46
+ throw new Error(`[storage] partNumber \u975E\u6CD5: ${partNumber}`);
47
+ }
48
+ return path.join(this.multipartDir(uploadId), `part_${String(partNumber).padStart(4, "0")}.bin`);
49
+ }
50
+ // ============================================================
51
+ // StorageAdapter 实现
52
+ // ============================================================
53
+ async put(data, meta) {
54
+ const key = this.generateKey(meta.filename, meta.contentType, meta.id);
55
+ const absolutePath = path.resolve(this.baseDir, key);
56
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
57
+ await fs.writeFile(absolutePath, data);
58
+ return { key, absolutePath };
59
+ }
60
+ get(key) {
61
+ const absolutePath = path.resolve(this.baseDir, key);
62
+ return createReadStream(absolutePath);
63
+ }
64
+ async delete(key) {
65
+ const absolutePath = path.resolve(this.baseDir, key);
66
+ try {
67
+ await fs.unlink(absolutePath);
68
+ } catch (e) {
69
+ if (e.code !== "ENOENT") throw e;
70
+ }
71
+ }
72
+ async exists(key) {
73
+ const absolutePath = path.resolve(this.baseDir, key);
74
+ try {
75
+ await fs.access(absolutePath);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+ async putPart(uploadId, partNumber, data) {
82
+ const dir = this.multipartDir(uploadId);
83
+ await fs.mkdir(dir, { recursive: true });
84
+ const finalPath = this.partPath(uploadId, partNumber);
85
+ const tmpPath = `${finalPath}.tmp`;
86
+ try {
87
+ await fs.writeFile(tmpPath, data);
88
+ await fs.rename(tmpPath, finalPath);
89
+ } catch (e) {
90
+ try {
91
+ await fs.unlink(tmpPath);
92
+ } catch {
93
+ }
94
+ throw e;
95
+ }
96
+ return { size: data.length, path: finalPath };
97
+ }
98
+ async listParts(uploadId) {
99
+ const dir = this.multipartDir(uploadId);
100
+ try {
101
+ const names = await fs.readdir(dir);
102
+ const nums = [];
103
+ for (const n of names) {
104
+ const m = n.match(/^part_(\d{4,})\.bin$/);
105
+ if (m) nums.push(parseInt(m[1], 10));
106
+ }
107
+ nums.sort((a, b) => a - b);
108
+ return nums;
109
+ } catch (e) {
110
+ if (e.code === "ENOENT") return [];
111
+ throw e;
112
+ }
113
+ }
114
+ async partSize(uploadId, partNumber) {
115
+ const p = this.partPath(uploadId, partNumber);
116
+ const stat = await fs.stat(p);
117
+ return stat.size;
118
+ }
119
+ async mergeParts(uploadId, totalParts, target) {
120
+ const key = this.generateKey(target.filename, target.contentType, target.id);
121
+ const absolutePath = path.resolve(this.baseDir, key);
122
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
123
+ const tmpPath = `${absolutePath}.merge.tmp`;
124
+ const out = createWriteStream(tmpPath);
125
+ try {
126
+ for (let i = 1; i <= totalParts; i++) {
127
+ const partPath = this.partPath(uploadId, i);
128
+ try {
129
+ await fs.access(partPath);
130
+ } catch {
131
+ throw new Error(`[storage] \u7F3A\u5206\u7247 part_${i} (uploadId=${uploadId})`);
132
+ }
133
+ await new Promise((resolve, reject) => {
134
+ const rs = createReadStream(partPath);
135
+ rs.on("error", reject);
136
+ rs.on("end", resolve);
137
+ rs.pipe(out, { end: false });
138
+ });
139
+ }
140
+ out.end();
141
+ await new Promise((resolve, reject) => {
142
+ out.on("finish", () => resolve());
143
+ out.on("error", reject);
144
+ });
145
+ const stat = await fs.stat(tmpPath);
146
+ await fs.rename(tmpPath, absolutePath);
147
+ return { key, absolutePath, size: stat.size };
148
+ } catch (e) {
149
+ try {
150
+ await fs.unlink(tmpPath);
151
+ } catch {
152
+ }
153
+ throw e;
154
+ }
155
+ }
156
+ async cleanupMultipart(uploadId) {
157
+ const dir = this.multipartDir(uploadId);
158
+ try {
159
+ await fs.rm(dir, { recursive: true, force: true });
160
+ } catch (e) {
161
+ if (e.code !== "ENOENT") {
162
+ console.warn(`[storage] cleanup multipart failed: uploadId=${uploadId} err=${e.message}`);
163
+ }
164
+ }
165
+ }
166
+ async computeMergedHash(uploadId, totalParts) {
167
+ const hash = createHash("sha256");
168
+ for (let i = 1; i <= totalParts; i++) {
169
+ const partPath = this.partPath(uploadId, i);
170
+ const stream = createReadStream(partPath);
171
+ await new Promise((resolve, reject) => {
172
+ stream.on("data", (chunk) => hash.update(chunk));
173
+ stream.on("end", resolve);
174
+ stream.on("error", reject);
175
+ });
176
+ }
177
+ return hash.digest("hex");
178
+ }
179
+ };
180
+
181
+ // src/server/utils/storage/memory.ts
182
+ import { Readable } from "stream";
183
+ import { createHash as createHash2 } from "crypto";
184
+ var InMemoryStorage = class {
185
+ files = /* @__PURE__ */ new Map();
186
+ parts = /* @__PURE__ */ new Map();
187
+ async put(data, meta) {
188
+ const key = `mem/${meta.id}`;
189
+ this.files.set(key, Buffer.from(data));
190
+ return { key };
191
+ }
192
+ get(key) {
193
+ const buf = this.files.get(key);
194
+ if (!buf) throw new Error(`[memory-storage] not found: ${key}`);
195
+ return Readable.from(buf);
196
+ }
197
+ async delete(key) {
198
+ this.files.delete(key);
199
+ }
200
+ async exists(key) {
201
+ return this.files.has(key);
202
+ }
203
+ async putPart(uploadId, partNumber, data) {
204
+ let m = this.parts.get(uploadId);
205
+ if (!m) {
206
+ m = /* @__PURE__ */ new Map();
207
+ this.parts.set(uploadId, m);
208
+ }
209
+ m.set(partNumber, Buffer.from(data));
210
+ return { size: data.length };
211
+ }
212
+ async listParts(uploadId) {
213
+ const m = this.parts.get(uploadId);
214
+ if (!m) return [];
215
+ return [...m.keys()].sort((a, b) => a - b);
216
+ }
217
+ async mergeParts(uploadId, totalParts, target) {
218
+ const m = this.parts.get(uploadId);
219
+ if (!m) throw new Error(`[memory-storage] no parts for ${uploadId}`);
220
+ const parts = [];
221
+ for (let i = 1; i <= totalParts; i++) {
222
+ const p = m.get(i);
223
+ if (!p) throw new Error(`[memory-storage] missing part ${i}`);
224
+ parts.push(p);
225
+ }
226
+ const merged = Buffer.concat(parts);
227
+ const key = `mem/${target.id}`;
228
+ this.files.set(key, merged);
229
+ return { key, size: merged.length };
230
+ }
231
+ async computeMergedHash(uploadId, totalParts) {
232
+ const hash = createHash2("sha256");
233
+ const m = this.parts.get(uploadId);
234
+ if (!m) throw new Error(`[memory-storage] no parts for ${uploadId}`);
235
+ for (let i = 1; i <= totalParts; i++) {
236
+ const p = m.get(i);
237
+ if (!p) throw new Error(`[memory-storage] missing part ${i}`);
238
+ hash.update(p);
239
+ }
240
+ return hash.digest("hex");
241
+ }
242
+ async cleanupMultipart(uploadId) {
243
+ this.parts.delete(uploadId);
244
+ }
245
+ };
246
+
247
+ // src/server/utils/repository/types.ts
248
+ var MultipartStatus = {
249
+ INIT: "INIT",
250
+ UPLOADING: "UPLOADING",
251
+ COMPLETED: "COMPLETED",
252
+ ABORTED: "ABORTED",
253
+ EXPIRED: "EXPIRED",
254
+ FAILED: "FAILED"
255
+ };
256
+ var FileStatus = {
257
+ PENDING: "PENDING",
258
+ UPLOADING: "UPLOADING",
259
+ COMPLETED: "COMPLETED",
260
+ FAILED: "FAILED"
261
+ };
262
+
263
+ // src/server/utils/repository/prisma.ts
264
+ function toMultipartRecord(row) {
265
+ return {
266
+ id: row.id,
267
+ userId: row.userId,
268
+ filename: row.filename,
269
+ contentType: row.contentType,
270
+ totalSize: row.totalSize,
271
+ partSize: row.partSize,
272
+ totalParts: row.totalParts,
273
+ isPublic: row.isPublic,
274
+ status: row.status,
275
+ receivedParts: Array.isArray(row.receivedParts) ? row.receivedParts : [],
276
+ partHashes: row.partHashes && typeof row.partHashes === "object" ? row.partHashes : {},
277
+ meta: row.meta && typeof row.meta === "object" ? row.meta : null,
278
+ expectedHash: row.expectedHash,
279
+ fileId: row.fileId,
280
+ createdAt: row.createdAt,
281
+ updatedAt: row.updatedAt,
282
+ expiresAt: row.expiresAt,
283
+ completedAt: row.completedAt,
284
+ abortedAt: row.abortedAt
285
+ };
286
+ }
287
+ function toFileRecord(row) {
288
+ return {
289
+ id: row.id,
290
+ userId: row.userId,
291
+ filename: row.filename,
292
+ contentType: row.contentType,
293
+ size: row.size,
294
+ storageKey: row.storageKey,
295
+ isPublic: row.isPublic,
296
+ status: row.status,
297
+ uploadId: row.uploadId,
298
+ totalParts: row.totalParts,
299
+ contentHash: row.contentHash,
300
+ meta: row.meta && typeof row.meta === "object" ? row.meta : null,
301
+ createdAt: row.createdAt,
302
+ updatedAt: row.updatedAt,
303
+ deletedAt: row.deletedAt
304
+ };
305
+ }
306
+ var PrismaMultipartRepository = class {
307
+ constructor(prisma) {
308
+ this.prisma = prisma;
309
+ }
310
+ prisma;
311
+ async createInit(input) {
312
+ const expiresAt = new Date(Date.now() + input.ttlSeconds * 1e3);
313
+ const row = await this.prisma.multipartUpload.create({
314
+ data: {
315
+ userId: input.userId,
316
+ filename: input.filename,
317
+ contentType: input.contentType,
318
+ totalSize: BigInt(input.totalSize),
319
+ partSize: input.partSize,
320
+ totalParts: input.totalParts,
321
+ isPublic: input.isPublic,
322
+ status: MultipartStatus.INIT,
323
+ receivedParts: [],
324
+ partHashes: {},
325
+ meta: input.meta ?? void 0,
326
+ expectedHash: input.expectedHash ?? null,
327
+ expiresAt
328
+ }
329
+ });
330
+ return toMultipartRecord(row);
331
+ }
332
+ async findById(id) {
333
+ const row = await this.prisma.multipartUpload.findUnique({ where: { id } });
334
+ return row ? toMultipartRecord(row) : null;
335
+ }
336
+ async addPart(id, partNumber, partHash) {
337
+ return await this.prisma.$transaction(async (tx) => {
338
+ const row = await tx.multipartUpload.findUnique({ where: { id } });
339
+ if (!row) throw new Error("NOT_FOUND");
340
+ const received = Array.isArray(row.receivedParts) ? row.receivedParts : [];
341
+ const hashes = row.partHashes && typeof row.partHashes === "object" ? row.partHashes : {};
342
+ if (received.includes(partNumber)) {
343
+ return {
344
+ updated: false,
345
+ duplicate: true,
346
+ existingHash: hashes[String(partNumber)] ?? null,
347
+ record: toMultipartRecord(row)
348
+ };
349
+ }
350
+ const next = [...received, partNumber].sort((a, b) => a - b);
351
+ const nextHashes = { ...hashes };
352
+ if (partHash) nextHashes[String(partNumber)] = partHash;
353
+ const newStatus = row.status === MultipartStatus.INIT ? MultipartStatus.UPLOADING : row.status;
354
+ if (row.status === MultipartStatus.COMPLETED || row.status === MultipartStatus.ABORTED || row.status === MultipartStatus.EXPIRED || row.status === MultipartStatus.FAILED) {
355
+ throw new Error("STATUS_INVALID");
356
+ }
357
+ const updated = await tx.multipartUpload.update({
358
+ where: { id },
359
+ data: {
360
+ receivedParts: next,
361
+ partHashes: nextHashes,
362
+ status: newStatus
363
+ }
364
+ });
365
+ return { updated: true, record: toMultipartRecord(updated) };
366
+ });
367
+ }
368
+ async markCompleted(id, fileId) {
369
+ const row = await this.prisma.multipartUpload.updateMany({
370
+ where: { id, status: { in: [MultipartStatus.INIT, MultipartStatus.UPLOADING] } },
371
+ data: {
372
+ status: MultipartStatus.COMPLETED,
373
+ fileId,
374
+ completedAt: /* @__PURE__ */ new Date()
375
+ }
376
+ });
377
+ if (row.count === 0) {
378
+ const cur = await this.prisma.multipartUpload.findUnique({ where: { id } });
379
+ if (!cur) throw new Error("NOT_FOUND");
380
+ throw new Error("STATUS_INVALID");
381
+ }
382
+ const fresh = await this.prisma.multipartUpload.findUnique({ where: { id } });
383
+ return toMultipartRecord(fresh);
384
+ }
385
+ async markAborted(id) {
386
+ const row = await this.prisma.multipartUpload.updateMany({
387
+ where: { id, status: { in: [MultipartStatus.INIT, MultipartStatus.UPLOADING] } },
388
+ data: {
389
+ status: MultipartStatus.ABORTED,
390
+ abortedAt: /* @__PURE__ */ new Date()
391
+ }
392
+ });
393
+ if (row.count === 0) {
394
+ const cur = await this.prisma.multipartUpload.findUnique({ where: { id } });
395
+ if (!cur) throw new Error("NOT_FOUND");
396
+ if (cur.status === MultipartStatus.COMPLETED) throw new Error("ALREADY_COMPLETED");
397
+ throw new Error("STATUS_INVALID");
398
+ }
399
+ const fresh = await this.prisma.multipartUpload.findUnique({ where: { id } });
400
+ return toMultipartRecord(fresh);
401
+ }
402
+ async markExpired(id) {
403
+ await this.prisma.multipartUpload.updateMany({
404
+ where: { id, status: { in: [MultipartStatus.INIT, MultipartStatus.UPLOADING] } },
405
+ data: {
406
+ status: MultipartStatus.EXPIRED,
407
+ abortedAt: /* @__PURE__ */ new Date()
408
+ }
409
+ });
410
+ }
411
+ async listExpired(limit) {
412
+ const rows = await this.prisma.multipartUpload.findMany({
413
+ where: {
414
+ status: { in: [MultipartStatus.INIT, MultipartStatus.UPLOADING] },
415
+ expiresAt: { lt: /* @__PURE__ */ new Date() }
416
+ },
417
+ take: limit,
418
+ orderBy: { expiresAt: "asc" }
419
+ });
420
+ return rows.map(toMultipartRecord);
421
+ }
422
+ };
423
+ var PrismaFileRepository = class {
424
+ constructor(prisma) {
425
+ this.prisma = prisma;
426
+ }
427
+ prisma;
428
+ async findByHash(hash) {
429
+ const row = await this.prisma.file.findFirst({
430
+ where: { contentHash: hash, deletedAt: null }
431
+ });
432
+ return row ? toFileRecord(row) : null;
433
+ }
434
+ async findDedupCandidate(hash, isPublic, userId) {
435
+ const row = await this.prisma.file.findFirst({
436
+ where: {
437
+ contentHash: hash,
438
+ deletedAt: null,
439
+ isPublic,
440
+ ...isPublic ? {} : { userId }
441
+ },
442
+ orderBy: { createdAt: "asc" }
443
+ });
444
+ return row ? toFileRecord(row) : null;
445
+ }
446
+ async findById(id) {
447
+ const row = await this.prisma.file.findUnique({ where: { id } });
448
+ return row ? toFileRecord(row) : null;
449
+ }
450
+ async create(input) {
451
+ const row = await this.prisma.file.create({
452
+ data: {
453
+ id: input.id,
454
+ userId: input.userId,
455
+ filename: input.filename,
456
+ contentType: input.contentType,
457
+ size: BigInt(input.size),
458
+ storageKey: input.storageKey,
459
+ isPublic: input.isPublic,
460
+ status: FileStatus.COMPLETED,
461
+ uploadId: input.uploadId,
462
+ totalParts: input.totalParts,
463
+ contentHash: input.contentHash,
464
+ meta: input.meta ?? void 0
465
+ }
466
+ });
467
+ return toFileRecord(row);
468
+ }
469
+ async softDelete(id, metaPatch) {
470
+ if (metaPatch) {
471
+ const cur = await this.prisma.file.findUnique({ where: { id } });
472
+ const next = { ...cur?.meta ?? {}, ...metaPatch };
473
+ await this.prisma.file.update({
474
+ where: { id },
475
+ data: { deletedAt: /* @__PURE__ */ new Date(), meta: next }
476
+ });
477
+ } else {
478
+ await this.prisma.file.update({
479
+ where: { id },
480
+ data: { deletedAt: /* @__PURE__ */ new Date() }
481
+ });
482
+ }
483
+ }
484
+ async listOrphans(opts) {
485
+ const lookbackMs = opts.sinceDays * 24 * 3600 * 1e3;
486
+ const rows = await this.prisma.file.findMany({
487
+ where: {
488
+ deletedAt: null,
489
+ storageKey: { not: "" },
490
+ createdAt: { gt: new Date(Date.now() - lookbackMs) }
491
+ },
492
+ select: {
493
+ id: true,
494
+ userId: true,
495
+ filename: true,
496
+ contentType: true,
497
+ size: true,
498
+ storageKey: true,
499
+ isPublic: true,
500
+ status: true,
501
+ uploadId: true,
502
+ totalParts: true,
503
+ contentHash: true,
504
+ meta: true,
505
+ createdAt: true,
506
+ updatedAt: true,
507
+ deletedAt: true
508
+ },
509
+ take: opts.limit,
510
+ orderBy: { createdAt: "desc" }
511
+ });
512
+ return rows.map(toFileRecord);
513
+ }
514
+ };
515
+
516
+ // src/server/utils/repository/memory.ts
517
+ var InMemoryMultipartRepository = class {
518
+ store = /* @__PURE__ */ new Map();
519
+ idCounter = 0;
520
+ nextId() {
521
+ return `mp_${Date.now()}_${++this.idCounter}`;
522
+ }
523
+ async createInit(input) {
524
+ const id = this.nextId();
525
+ const now = /* @__PURE__ */ new Date();
526
+ const rec = {
527
+ id,
528
+ userId: input.userId,
529
+ filename: input.filename,
530
+ contentType: input.contentType,
531
+ totalSize: BigInt(input.totalSize),
532
+ partSize: input.partSize,
533
+ totalParts: input.totalParts,
534
+ isPublic: input.isPublic,
535
+ status: MultipartStatus.INIT,
536
+ receivedParts: [],
537
+ partHashes: {},
538
+ meta: input.meta ?? null,
539
+ expectedHash: input.expectedHash ?? null,
540
+ fileId: null,
541
+ createdAt: now,
542
+ updatedAt: now,
543
+ expiresAt: new Date(now.getTime() + input.ttlSeconds * 1e3),
544
+ completedAt: null,
545
+ abortedAt: null
546
+ };
547
+ this.store.set(id, rec);
548
+ return rec;
549
+ }
550
+ async findById(id) {
551
+ return this.store.get(id) ?? null;
552
+ }
553
+ async addPart(id, partNumber, partHash) {
554
+ const rec = this.store.get(id);
555
+ if (!rec) throw new Error("NOT_FOUND");
556
+ if (rec.status === MultipartStatus.COMPLETED || rec.status === MultipartStatus.ABORTED || rec.status === MultipartStatus.EXPIRED || rec.status === MultipartStatus.FAILED) {
557
+ throw new Error("STATUS_INVALID");
558
+ }
559
+ if (rec.receivedParts.includes(partNumber)) {
560
+ return {
561
+ updated: false,
562
+ duplicate: true,
563
+ existingHash: rec.partHashes[String(partNumber)] ?? null,
564
+ record: rec
565
+ };
566
+ }
567
+ const next = [...rec.receivedParts, partNumber].sort((a, b) => a - b);
568
+ const nextHashes = { ...rec.partHashes };
569
+ if (partHash) nextHashes[String(partNumber)] = partHash;
570
+ const updated = {
571
+ ...rec,
572
+ receivedParts: next,
573
+ partHashes: nextHashes,
574
+ status: rec.status === MultipartStatus.INIT ? MultipartStatus.UPLOADING : rec.status,
575
+ updatedAt: /* @__PURE__ */ new Date()
576
+ };
577
+ this.store.set(id, updated);
578
+ return { updated: true, record: updated };
579
+ }
580
+ async markCompleted(id, fileId) {
581
+ const rec = this.store.get(id);
582
+ if (!rec) throw new Error("NOT_FOUND");
583
+ if (rec.status !== MultipartStatus.INIT && rec.status !== MultipartStatus.UPLOADING) {
584
+ throw new Error("STATUS_INVALID");
585
+ }
586
+ const updated = {
587
+ ...rec,
588
+ status: MultipartStatus.COMPLETED,
589
+ fileId,
590
+ completedAt: /* @__PURE__ */ new Date(),
591
+ updatedAt: /* @__PURE__ */ new Date()
592
+ };
593
+ this.store.set(id, updated);
594
+ return updated;
595
+ }
596
+ async markAborted(id) {
597
+ const rec = this.store.get(id);
598
+ if (!rec) throw new Error("NOT_FOUND");
599
+ if (rec.status === MultipartStatus.COMPLETED) throw new Error("ALREADY_COMPLETED");
600
+ if (rec.status !== MultipartStatus.INIT && rec.status !== MultipartStatus.UPLOADING) {
601
+ throw new Error("STATUS_INVALID");
602
+ }
603
+ const updated = {
604
+ ...rec,
605
+ status: MultipartStatus.ABORTED,
606
+ abortedAt: /* @__PURE__ */ new Date(),
607
+ updatedAt: /* @__PURE__ */ new Date()
608
+ };
609
+ this.store.set(id, updated);
610
+ return updated;
611
+ }
612
+ async markExpired(id) {
613
+ const rec = this.store.get(id);
614
+ if (!rec) return;
615
+ if (rec.status !== MultipartStatus.INIT && rec.status !== MultipartStatus.UPLOADING) return;
616
+ this.store.set(id, {
617
+ ...rec,
618
+ status: MultipartStatus.EXPIRED,
619
+ abortedAt: /* @__PURE__ */ new Date(),
620
+ updatedAt: /* @__PURE__ */ new Date()
621
+ });
622
+ }
623
+ async listExpired(limit) {
624
+ const now = Date.now();
625
+ const list = [];
626
+ for (const rec of this.store.values()) {
627
+ if ((rec.status === MultipartStatus.INIT || rec.status === MultipartStatus.UPLOADING) && rec.expiresAt.getTime() < now) {
628
+ list.push(rec);
629
+ }
630
+ }
631
+ list.sort((a, b) => a.expiresAt.getTime() - b.expiresAt.getTime());
632
+ return list.slice(0, limit);
633
+ }
634
+ };
635
+ var InMemoryFileRepository = class {
636
+ store = /* @__PURE__ */ new Map();
637
+ idCounter = 0;
638
+ nextId() {
639
+ return `f_${Date.now()}_${++this.idCounter}`;
640
+ }
641
+ async findByHash(hash) {
642
+ for (const f of this.store.values()) {
643
+ if (f.contentHash === hash && !f.deletedAt) return f;
644
+ }
645
+ return null;
646
+ }
647
+ async findDedupCandidate(hash, isPublic, userId) {
648
+ for (const f of this.store.values()) {
649
+ if (f.deletedAt) continue;
650
+ if (f.contentHash !== hash) continue;
651
+ if (f.isPublic !== isPublic) continue;
652
+ if (!isPublic && f.userId !== userId) continue;
653
+ return f;
654
+ }
655
+ return null;
656
+ }
657
+ async findById(id) {
658
+ return this.store.get(id) ?? null;
659
+ }
660
+ async create(input) {
661
+ let id = input.id;
662
+ if (!id) id = this.nextId();
663
+ const now = /* @__PURE__ */ new Date();
664
+ const rec = {
665
+ id,
666
+ userId: input.userId,
667
+ filename: input.filename,
668
+ contentType: input.contentType,
669
+ size: BigInt(input.size),
670
+ storageKey: input.storageKey,
671
+ isPublic: input.isPublic,
672
+ status: FileStatus.COMPLETED,
673
+ uploadId: input.uploadId,
674
+ totalParts: input.totalParts,
675
+ contentHash: input.contentHash,
676
+ meta: input.meta ?? null,
677
+ createdAt: now,
678
+ updatedAt: now,
679
+ deletedAt: null
680
+ };
681
+ this.store.set(id, rec);
682
+ return rec;
683
+ }
684
+ async softDelete(id, metaPatch) {
685
+ const rec = this.store.get(id);
686
+ if (!rec) return;
687
+ const next = {
688
+ ...rec,
689
+ meta: metaPatch ? { ...rec.meta ?? {}, ...metaPatch } : rec.meta,
690
+ deletedAt: /* @__PURE__ */ new Date(),
691
+ updatedAt: /* @__PURE__ */ new Date()
692
+ };
693
+ this.store.set(id, next);
694
+ }
695
+ async listOrphans(opts) {
696
+ const sinceMs = opts.sinceDays * 24 * 3600 * 1e3;
697
+ const list = [];
698
+ for (const f of this.store.values()) {
699
+ if (f.deletedAt) continue;
700
+ if (f.createdAt.getTime() < Date.now() - sinceMs) continue;
701
+ list.push(f);
702
+ }
703
+ list.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
704
+ return list.slice(0, opts.limit);
705
+ }
706
+ };
707
+
708
+ // src/server/utils/strategy.ts
709
+ var DEFAULT_STRATEGY_CONFIG = {
710
+ oneShotMax: 1024 * 1024,
711
+ // 1MB
712
+ defaultPartSize: 1024 * 1024,
713
+ // 1MB
714
+ bigFileThreshold: 1024 ** 3,
715
+ // 1GB
716
+ bigFilePartSize: 5 * 1024 * 1024,
717
+ // 5MB
718
+ hugeFileThreshold: 10 * 1024 ** 3,
719
+ // 10GB
720
+ hugeFilePartSize: 10 * 1024 * 1024
721
+ // 10MB
722
+ };
723
+ function chooseStrategy(totalSize, cfg = DEFAULT_STRATEGY_CONFIG) {
724
+ if (!Number.isFinite(totalSize) || totalSize < 0) {
725
+ throw new Error(`[strategy] totalSize \u975E\u6CD5: ${totalSize}(\u5FC5\u987B \u2265 0 \u7684\u6709\u9650\u6570)`);
726
+ }
727
+ validateConfig(cfg);
728
+ if (totalSize === 0) {
729
+ return { kind: "ONE_SHOT", partSize: 0, totalParts: 1, reason: "0\u5B57\u8282" };
730
+ }
731
+ if (totalSize <= cfg.oneShotMax) {
732
+ return {
733
+ kind: "ONE_SHOT",
734
+ partSize: totalSize,
735
+ totalParts: 1,
736
+ reason: `\u2264 ${cfg.oneShotMax}B \u8D70 ONE_SHOT(1 part \u76F4\u4F20)`
737
+ };
738
+ }
739
+ if (totalSize > cfg.hugeFileThreshold) {
740
+ const partSize2 = cfg.hugeFilePartSize;
741
+ return {
742
+ kind: "MULTIPART",
743
+ partSize: partSize2,
744
+ totalParts: Math.ceil(totalSize / partSize2),
745
+ reason: `> ${cfg.hugeFileThreshold}B \u8D70 HUGE(${partSize2}B/part)`
746
+ };
747
+ }
748
+ if (totalSize > cfg.bigFileThreshold) {
749
+ const partSize2 = cfg.bigFilePartSize;
750
+ return {
751
+ kind: "MULTIPART",
752
+ partSize: partSize2,
753
+ totalParts: Math.ceil(totalSize / partSize2),
754
+ reason: `> ${cfg.bigFileThreshold}B \u8D70 BIG(${partSize2}B/part)`
755
+ };
756
+ }
757
+ const partSize = cfg.defaultPartSize;
758
+ return {
759
+ kind: "MULTIPART",
760
+ partSize,
761
+ totalParts: Math.ceil(totalSize / partSize),
762
+ reason: `\u2264 ${cfg.bigFileThreshold}B \u8D70 DEFAULT(${partSize}B/part)`
763
+ };
764
+ }
765
+ function suggestConcurrency(s) {
766
+ if (s.kind === "ONE_SHOT") return 1;
767
+ if (s.totalParts <= 4) return Math.max(1, s.totalParts);
768
+ return 4;
769
+ }
770
+ function validateConfig(cfg) {
771
+ const fields = [
772
+ ["oneShotMax", "oneShotMax"],
773
+ ["defaultPartSize", "defaultPartSize"],
774
+ ["bigFileThreshold", "bigFileThreshold"],
775
+ ["bigFilePartSize", "bigFilePartSize"],
776
+ ["hugeFileThreshold", "hugeFileThreshold"],
777
+ ["hugeFilePartSize", "hugeFilePartSize"]
778
+ ];
779
+ for (const [k, name] of fields) {
780
+ if (!Number.isFinite(cfg[k]) || cfg[k] <= 0) {
781
+ throw new Error(`[strategy] cfg.${name} \u975E\u6CD5: ${cfg[k]}(\u5FC5\u987B > 0)`);
782
+ }
783
+ }
784
+ if (cfg.oneShotMax > cfg.defaultPartSize) {
785
+ throw new Error(
786
+ `[strategy] oneShotMax(${cfg.oneShotMax}) \u4E0D\u80FD > defaultPartSize(${cfg.defaultPartSize})`
787
+ );
788
+ }
789
+ if (cfg.bigFileThreshold >= cfg.hugeFileThreshold) {
790
+ throw new Error(
791
+ `[strategy] bigFileThreshold(${cfg.bigFileThreshold}) \u5FC5\u987B < hugeFileThreshold(${cfg.hugeFileThreshold})`
792
+ );
793
+ }
794
+ if (cfg.defaultPartSize > cfg.bigFilePartSize) {
795
+ throw new Error(
796
+ `[strategy] defaultPartSize(${cfg.defaultPartSize}) \u4E0D\u80FD > bigFilePartSize(${cfg.bigFilePartSize})`
797
+ );
798
+ }
799
+ if (cfg.bigFilePartSize > cfg.hugeFilePartSize) {
800
+ throw new Error(
801
+ `[strategy] bigFilePartSize(${cfg.bigFilePartSize}) \u4E0D\u80FD > hugeFilePartSize(${cfg.hugeFilePartSize})`
802
+ );
803
+ }
804
+ }
805
+
806
+ // src/server/errors.ts
807
+ var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
808
+ ErrorCode2[ErrorCode2["INVALID_REQUEST"] = 1001] = "INVALID_REQUEST";
809
+ ErrorCode2[ErrorCode2["INTERNAL_ERROR"] = 1500] = "INTERNAL_ERROR";
810
+ ErrorCode2[ErrorCode2["NOT_FOUND"] = 1400] = "NOT_FOUND";
811
+ ErrorCode2[ErrorCode2["FORBIDDEN"] = 1403] = "FORBIDDEN";
812
+ ErrorCode2[ErrorCode2["RATE_LIMITED"] = 1402] = "RATE_LIMITED";
813
+ ErrorCode2[ErrorCode2["BINARY_PAYLOAD_EMPTY"] = 1501] = "BINARY_PAYLOAD_EMPTY";
814
+ ErrorCode2[ErrorCode2["BINARY_PAYLOAD_TOO_LARGE"] = 1502] = "BINARY_PAYLOAD_TOO_LARGE";
815
+ ErrorCode2[ErrorCode2["BINARY_AUTH_REQUIRED"] = 1503] = "BINARY_AUTH_REQUIRED";
816
+ ErrorCode2[ErrorCode2["BINARY_AUTH_FAILED"] = 1504] = "BINARY_AUTH_FAILED";
817
+ ErrorCode2[ErrorCode2["BATCH_EMPTY"] = 1510] = "BATCH_EMPTY";
818
+ ErrorCode2[ErrorCode2["BATCH_TOO_MANY_FILES"] = 1511] = "BATCH_TOO_MANY_FILES";
819
+ ErrorCode2[ErrorCode2["BATCH_TOTAL_TOO_LARGE"] = 1512] = "BATCH_TOTAL_TOO_LARGE";
820
+ ErrorCode2[ErrorCode2["BATCH_FILE_INVALID"] = 1513] = "BATCH_FILE_INVALID";
821
+ ErrorCode2[ErrorCode2["MULTIPART_NOT_FOUND"] = 1520] = "MULTIPART_NOT_FOUND";
822
+ ErrorCode2[ErrorCode2["MULTIPART_ALREADY_COMPLETED"] = 1521] = "MULTIPART_ALREADY_COMPLETED";
823
+ ErrorCode2[ErrorCode2["MULTIPART_ALREADY_ABORTED"] = 1522] = "MULTIPART_ALREADY_ABORTED";
824
+ ErrorCode2[ErrorCode2["MULTIPART_EXPIRED"] = 1523] = "MULTIPART_EXPIRED";
825
+ ErrorCode2[ErrorCode2["MULTIPART_STATUS_INVALID"] = 1524] = "MULTIPART_STATUS_INVALID";
826
+ ErrorCode2[ErrorCode2["MULTIPART_TOTAL_TOO_LARGE"] = 1530] = "MULTIPART_TOTAL_TOO_LARGE";
827
+ ErrorCode2[ErrorCode2["MULTIPART_PART_TOO_LARGE"] = 1531] = "MULTIPART_PART_TOO_LARGE";
828
+ ErrorCode2[ErrorCode2["MULTIPART_PART_TOO_SMALL"] = 1532] = "MULTIPART_PART_TOO_SMALL";
829
+ ErrorCode2[ErrorCode2["MULTIPART_INVALID_PART_NUMBER"] = 1533] = "MULTIPART_INVALID_PART_NUMBER";
830
+ ErrorCode2[ErrorCode2["MULTIPART_DUPLICATE_PART"] = 1534] = "MULTIPART_DUPLICATE_PART";
831
+ ErrorCode2[ErrorCode2["MULTIPART_PART_HASH_MISMATCH"] = 1535] = "MULTIPART_PART_HASH_MISMATCH";
832
+ ErrorCode2[ErrorCode2["MULTIPART_FINAL_HASH_MISMATCH"] = 1536] = "MULTIPART_FINAL_HASH_MISMATCH";
833
+ ErrorCode2[ErrorCode2["MULTIPART_INCOMPLETE_PARTS"] = 1537] = "MULTIPART_INCOMPLETE_PARTS";
834
+ ErrorCode2[ErrorCode2["MULTIPART_AUTH_REQUIRED"] = 1538] = "MULTIPART_AUTH_REQUIRED";
835
+ ErrorCode2[ErrorCode2["MULTIPART_INVALID_ARG"] = 1539] = "MULTIPART_INVALID_ARG";
836
+ return ErrorCode2;
837
+ })(ErrorCode || {});
838
+ var HTTP = {
839
+ [1001 /* INVALID_REQUEST */]: 400,
840
+ [1500 /* INTERNAL_ERROR */]: 500,
841
+ [1400 /* NOT_FOUND */]: 404,
842
+ [1403 /* FORBIDDEN */]: 403,
843
+ [1402 /* RATE_LIMITED */]: 429,
844
+ // binary
845
+ [1501 /* BINARY_PAYLOAD_EMPTY */]: 400,
846
+ [1502 /* BINARY_PAYLOAD_TOO_LARGE */]: 413,
847
+ [1503 /* BINARY_AUTH_REQUIRED */]: 401,
848
+ [1504 /* BINARY_AUTH_FAILED */]: 401,
849
+ // batch
850
+ [1510 /* BATCH_EMPTY */]: 400,
851
+ [1511 /* BATCH_TOO_MANY_FILES */]: 413,
852
+ [1512 /* BATCH_TOTAL_TOO_LARGE */]: 413,
853
+ [1513 /* BATCH_FILE_INVALID */]: 400,
854
+ // multipart
855
+ [1520 /* MULTIPART_NOT_FOUND */]: 404,
856
+ [1521 /* MULTIPART_ALREADY_COMPLETED */]: 409,
857
+ [1522 /* MULTIPART_ALREADY_ABORTED */]: 409,
858
+ [1523 /* MULTIPART_EXPIRED */]: 410,
859
+ [1524 /* MULTIPART_STATUS_INVALID */]: 409,
860
+ [1530 /* MULTIPART_TOTAL_TOO_LARGE */]: 413,
861
+ [1531 /* MULTIPART_PART_TOO_LARGE */]: 413,
862
+ [1532 /* MULTIPART_PART_TOO_SMALL */]: 400,
863
+ [1533 /* MULTIPART_INVALID_PART_NUMBER */]: 400,
864
+ [1534 /* MULTIPART_DUPLICATE_PART */]: 409,
865
+ [1535 /* MULTIPART_PART_HASH_MISMATCH */]: 400,
866
+ [1536 /* MULTIPART_FINAL_HASH_MISMATCH */]: 400,
867
+ [1537 /* MULTIPART_INCOMPLETE_PARTS */]: 409,
868
+ [1538 /* MULTIPART_AUTH_REQUIRED */]: 401,
869
+ [1539 /* MULTIPART_INVALID_ARG */]: 400
870
+ };
871
+ var SecureError = class extends Error {
872
+ constructor(code, message) {
873
+ super(message ?? ErrorCode[code]);
874
+ this.code = code;
875
+ this.name = "SecureError";
876
+ }
877
+ code;
878
+ get statusCode() {
879
+ return HTTP[this.code];
880
+ }
881
+ };
882
+ function getHttpStatus(code) {
883
+ return HTTP[code];
884
+ }
885
+
886
+ // src/server/session.ts
887
+ function parseJwtPayload(token) {
888
+ if (!token) return null;
889
+ try {
890
+ const parts = token.split(".");
891
+ if (parts.length !== 3) return null;
892
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
893
+ } catch {
894
+ return null;
895
+ }
896
+ }
897
+ var defaultSessionValidator = (token, isPublic) => {
898
+ if (isPublic) return null;
899
+ if (!token) {
900
+ throw new SecureError(1503 /* BINARY_AUTH_REQUIRED */, "private \u4E0A\u4F20\u5FC5\u987B\u63D0\u4F9B session (JWT)");
901
+ }
902
+ const p = parseJwtPayload(token);
903
+ if (!p?.sub) throw new SecureError(1504 /* BINARY_AUTH_FAILED */, "session \u683C\u5F0F\u65E0\u6548");
904
+ if (p.exp && p.exp * 1e3 < Date.now()) {
905
+ throw new SecureError(1504 /* BINARY_AUTH_FAILED */, "session \u5DF2\u8FC7\u671F");
906
+ }
907
+ return Number(p.sub);
908
+ };
909
+
910
+ // src/server/routes/multipart.ts
911
+ import crypto from "crypto";
912
+ import { z } from "zod";
913
+
914
+ // src/server/routes/_internal.ts
915
+ async function resolveSession(req, isPublic, validate) {
916
+ const sr = req.secureRequest;
917
+ const session = sr?.session ?? req.headers["x-secure-session"];
918
+ const userId = await validate(session, isPublic);
919
+ const sessionKey = session ?? "anonymous";
920
+ return { userId: userId ?? null, sessionKey };
921
+ }
922
+ function resolveSessionKey(req) {
923
+ const sr = req.secureRequest;
924
+ return sr?.session ?? req.headers["x-secure-session"] ?? "anonymous";
925
+ }
926
+ async function resolveUserId(req, isPublic, validate) {
927
+ const sr = req.secureRequest;
928
+ const session = sr?.session ?? req.headers["x-secure-session"];
929
+ return await validate(session, isPublic);
930
+ }
931
+ function resolvePartData(req) {
932
+ const sr = req.secureRequest;
933
+ if (sr?.raw?.data) {
934
+ return { data: sr.raw.data, declaredSize: Number(sr.raw.size ?? sr.raw.data.length) };
935
+ }
936
+ const rawBase64 = req.headers["x-secure-raw-base64"];
937
+ const declaredSize = Number(req.headers["x-secure-content-length"] || 0);
938
+ if (!rawBase64) {
939
+ throw new SecureError(
940
+ 1001 /* INVALID_REQUEST */,
941
+ "\u7F3A\u5C11 part \u6570\u636E(secure-crypto \u6A21\u5F0F\u8BFB secureRequest.raw,raw \u6A21\u5F0F\u8BFB x-secure-raw-base64 header)"
942
+ );
943
+ }
944
+ return { data: Buffer.from(rawBase64, "base64"), declaredSize };
945
+ }
946
+ function resolvePartBiz(req) {
947
+ const sr = req.secureRequest;
948
+ const biz = sr?.biz ?? req.body ?? {};
949
+ const uploadId = String(biz.uploadId ?? "").trim();
950
+ const partNumberRaw = biz.partNumber;
951
+ const partHash = biz.partHash ?? null;
952
+ if (!uploadId) {
953
+ throw new SecureError(1001 /* INVALID_REQUEST */, "\u7F3A\u5C11 uploadId");
954
+ }
955
+ const partNumber = Number(partNumberRaw);
956
+ if (!Number.isInteger(partNumber) || partNumber < 1) {
957
+ throw new SecureError(1533 /* MULTIPART_INVALID_PART_NUMBER */, `partNumber \u975E\u6CD5: ${partNumberRaw}`);
958
+ }
959
+ return { uploadId, partNumber, partHash };
960
+ }
961
+
962
+ // src/server/utils/redis-helpers.ts
963
+ var LUA_SLIDING_WINDOW_RATE_LIMIT = `
964
+ -- KEYS[1] = rate limit key
965
+ -- ARGV[1] = window (ms)
966
+ -- ARGV[2] = limit (max requests in window)
967
+ -- ARGV[3] = now (ms)
968
+ -- ARGV[4] = member (unique per request)
969
+ local key = KEYS[1]
970
+ local window = tonumber(ARGV[1])
971
+ local limit = tonumber(ARGV[2])
972
+ local now = tonumber(ARGV[3])
973
+ local member = ARGV[4]
974
+ redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
975
+ local count = redis.call('ZCARD', key)
976
+ if count >= limit then
977
+ local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
978
+ local retryAfter = window
979
+ if #oldest >= 2 then
980
+ retryAfter = (tonumber(oldest[2]) + window) - now
981
+ if retryAfter < 0 then retryAfter = 0 end
982
+ end
983
+ return { 0, retryAfter }
984
+ end
985
+ redis.call('ZADD', key, now, member)
986
+ redis.call('PEXPIRE', key, window)
987
+ return { 1, 0 }
988
+ `;
989
+ var LUA_RELEASE_LOCK = `
990
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
991
+ return redis.call('DEL', KEYS[1])
992
+ else
993
+ return 0
994
+ end
995
+ `;
996
+ var LUA_ACQUIRE_LOCK = `
997
+ if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', tonumber(ARGV[2])) then
998
+ return 1
999
+ else
1000
+ return 0
1001
+ end
1002
+ `;
1003
+ async function slidingWindowRateLimit(redis, key, windowMs, limit) {
1004
+ const now = Date.now();
1005
+ const member = `${now}-${Math.random().toString(36).slice(2, 10)}`;
1006
+ const result = await redis.eval(
1007
+ LUA_SLIDING_WINDOW_RATE_LIMIT,
1008
+ 1,
1009
+ key,
1010
+ String(windowMs),
1011
+ String(limit),
1012
+ String(now),
1013
+ member
1014
+ );
1015
+ const ok = result[0] === 1;
1016
+ return {
1017
+ ok,
1018
+ retryAfterMs: ok ? 0 : Number(result[1] ?? 0),
1019
+ remaining: ok ? limit - 1 : 0
1020
+ };
1021
+ }
1022
+ async function acquireLock(redis, key, ttlMs = 5e3) {
1023
+ const token = `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
1024
+ const ok = await redis.eval(LUA_ACQUIRE_LOCK, 1, key, token, String(ttlMs));
1025
+ if (ok === 1) return { ok: true, token };
1026
+ return { ok: false, token: null };
1027
+ }
1028
+ async function releaseLock(redis, key, token) {
1029
+ const r = await redis.eval(LUA_RELEASE_LOCK, 1, key, token);
1030
+ return r === 1;
1031
+ }
1032
+ async function withLock(redis, key, ttlMs, fn, retries = 0) {
1033
+ let lastErr;
1034
+ for (let i = 0; i <= retries; i++) {
1035
+ const lock2 = await acquireLock(redis, key, ttlMs);
1036
+ if (lock2.ok) {
1037
+ try {
1038
+ return await fn();
1039
+ } finally {
1040
+ await releaseLock(redis, key, lock2.token).catch(() => {
1041
+ });
1042
+ }
1043
+ }
1044
+ lastErr = new Error(`[redis-helpers] acquireLock failed: ${key}`);
1045
+ if (i < retries) {
1046
+ const wait = 50 * Math.pow(2, i) + Math.floor(Math.random() * 20);
1047
+ await new Promise((r) => setTimeout(r, wait));
1048
+ }
1049
+ }
1050
+ const e = lastErr instanceof Error ? lastErr : new Error("[redis-helpers] withLock failed");
1051
+ e.code = "LOCK_BUSY";
1052
+ throw e;
1053
+ }
1054
+
1055
+ // src/server/utils/memory-rate-limit.ts
1056
+ var _limitStore = /* @__PURE__ */ new Map();
1057
+ var _lockStore = /* @__PURE__ */ new Map();
1058
+ function gcLimit(now, windowMs) {
1059
+ if (_limitStore.size > 5e3) {
1060
+ for (const [k, v] of _limitStore.entries()) {
1061
+ const filtered = v.timestamps.filter((t) => t > now - windowMs);
1062
+ if (filtered.length === 0) _limitStore.delete(k);
1063
+ else _limitStore.set(k, { timestamps: filtered });
1064
+ }
1065
+ }
1066
+ }
1067
+ function memorySlidingWindowRateLimit(key, windowMs, limit) {
1068
+ const now = Date.now();
1069
+ gcLimit(now, windowMs);
1070
+ const entry = _limitStore.get(key) ?? { timestamps: [] };
1071
+ entry.timestamps = entry.timestamps.filter((t) => t > now - windowMs);
1072
+ if (entry.timestamps.length >= limit) {
1073
+ const oldest = entry.timestamps[0];
1074
+ const retryAfter = Math.max(0, oldest + windowMs - now);
1075
+ return { ok: false, retryAfterMs: retryAfter, remaining: 0 };
1076
+ }
1077
+ entry.timestamps.push(now);
1078
+ _limitStore.set(key, entry);
1079
+ return { ok: true, retryAfterMs: 0, remaining: limit - entry.timestamps.length };
1080
+ }
1081
+ function memoryAcquireLock(key) {
1082
+ if (_lockStore.get(key)) return false;
1083
+ _lockStore.set(key, true);
1084
+ return true;
1085
+ }
1086
+ function memoryReleaseLock(key) {
1087
+ _lockStore.delete(key);
1088
+ }
1089
+ async function memoryWithLock(key, fn, retries = 0) {
1090
+ for (let i = 0; i <= retries; i++) {
1091
+ if (memoryAcquireLock(key)) {
1092
+ try {
1093
+ return await fn();
1094
+ } finally {
1095
+ memoryReleaseLock(key);
1096
+ }
1097
+ }
1098
+ if (i < retries) {
1099
+ const wait = 50 * Math.pow(2, i) + Math.floor(Math.random() * 20);
1100
+ await new Promise((r) => setTimeout(r, wait));
1101
+ }
1102
+ }
1103
+ const e = new Error(`[memory-lock] LOCK_BUSY: ${key}`);
1104
+ e.code = "LOCK_BUSY";
1105
+ throw e;
1106
+ }
1107
+
1108
+ // src/server/routes/multipart.ts
1109
+ var InitBodySchema = z.object({
1110
+ filename: z.string().min(1).max(255),
1111
+ contentType: z.string().min(1).max(128).default("application/octet-stream"),
1112
+ totalSize: z.coerce.number().int().nonnegative(),
1113
+ partSize: z.coerce.number().int().positive().optional(),
1114
+ isPublic: z.coerce.boolean().default(false),
1115
+ expectedHash: z.string().regex(/^[a-f0-9]{64}$/i).optional(),
1116
+ meta: z.record(z.unknown()).optional()
1117
+ }).strict();
1118
+ var CompleteBodySchema = z.object({
1119
+ uploadId: z.string().min(1).max(128),
1120
+ parts: z.array(z.object({
1121
+ partNumber: z.coerce.number().int().positive(),
1122
+ hash: z.string().regex(/^[a-f0-9]{64}$/i).optional()
1123
+ })).optional(),
1124
+ finalHash: z.string().regex(/^[a-f0-9]{64}$/i).optional()
1125
+ }).strict();
1126
+ var AbortBodySchema = z.object({
1127
+ uploadId: z.string().min(1).max(128)
1128
+ }).strict();
1129
+ var StatusBodySchema = z.object({
1130
+ uploadId: z.string().min(1).max(128)
1131
+ });
1132
+ async function rateLimit(rt, key, windowMs, limit) {
1133
+ if (rt.redis) {
1134
+ const r2 = await slidingWindowRateLimit(rt.redis, key, windowMs, limit);
1135
+ return { ok: r2.ok, retryAfterMs: r2.retryAfterMs };
1136
+ }
1137
+ const r = memorySlidingWindowRateLimit(key, windowMs, limit);
1138
+ return { ok: r.ok, retryAfterMs: r.retryAfterMs };
1139
+ }
1140
+ async function lock(rt, key, ttlMs, fn, retries = 0) {
1141
+ if (rt.redis) {
1142
+ return await withLock(rt.redis, key, ttlMs, fn, retries);
1143
+ }
1144
+ return await memoryWithLock(key, fn, retries);
1145
+ }
1146
+ async function registerMultipartRoutes(app, rt) {
1147
+ if (!rt.config.enabled) {
1148
+ app.log.warn("\u26A0\uFE0F multipart \u4E0A\u4F20\u5DF2\u7981\u7528 (enabled=false)");
1149
+ return;
1150
+ }
1151
+ const base = rt.config.multipartPath;
1152
+ app.log.info(
1153
+ {
1154
+ routes: [`${base}/init`, `${base}/part`, `${base}/complete`, `${base}/abort`, `${base}/status`],
1155
+ partMin: rt.config.partMin,
1156
+ partMax: rt.config.partMax,
1157
+ partDefault: rt.config.partDefault,
1158
+ fileMax: rt.config.fileMaxSize,
1159
+ ttl: rt.config.ttlSeconds
1160
+ },
1161
+ "\u{1F527} multipart-routes: \u6CE8\u518C\u5206\u7247\u8DEF\u7531..."
1162
+ );
1163
+ app.post(`${base}/init`, async (req, reply) => {
1164
+ const body = InitBodySchema.parse(req.body ?? {});
1165
+ const { userId, sessionKey } = await resolveSession(req, body.isPublic, rt.validateSession);
1166
+ const rlKey = `rl:mp:init:${userId ?? sessionKey.slice(0, 32)}`;
1167
+ const rl = await rateLimit(rt, rlKey, 6e4, rt.config.initRateLimit);
1168
+ if (!rl.ok) {
1169
+ throw new SecureError(
1170
+ 1402 /* RATE_LIMITED */,
1171
+ `init \u8FC7\u4E8E\u9891\u7E41,\u8BF7 ${Math.ceil(rl.retryAfterMs / 1e3)}s \u540E\u91CD\u8BD5`
1172
+ );
1173
+ }
1174
+ if (body.totalSize > rt.config.fileMaxSize) {
1175
+ throw new SecureError(
1176
+ 1530 /* MULTIPART_TOTAL_TOO_LARGE */,
1177
+ `\u6587\u4EF6\u603B\u5927\u5C0F ${body.totalSize} \u8D85\u8FC7\u5355\u6587\u4EF6\u9650\u5236 ${rt.config.fileMaxSize}`
1178
+ );
1179
+ }
1180
+ let partSize = body.partSize ?? rt.config.partDefault;
1181
+ if (partSize < rt.config.partMin) partSize = rt.config.partMin;
1182
+ if (partSize > rt.config.partMax) partSize = rt.config.partMax;
1183
+ const totalParts = body.totalSize === 0 ? 1 : Math.ceil(body.totalSize / partSize);
1184
+ if (body.expectedHash) {
1185
+ const existing = await rt.fileRepo.findDedupCandidate(
1186
+ body.expectedHash,
1187
+ body.isPublic,
1188
+ userId
1189
+ );
1190
+ if (existing) {
1191
+ const diskOk = await rt.storage.exists(existing.storageKey).catch(() => false);
1192
+ if (diskOk) {
1193
+ req.log.info(
1194
+ { hash: body.expectedHash, fileId: existing.id, isPublic: body.isPublic, userId },
1195
+ "\u26A1 \u79D2\u4F20\u547D\u4E2D(\u4E25\u683C\u5339\u914D\u53EF\u89C1\u6027+\u8EAB\u4EFD)"
1196
+ );
1197
+ return {
1198
+ success: true,
1199
+ data: {
1200
+ uploadId: "",
1201
+ partSize: 0,
1202
+ totalParts: 0,
1203
+ totalSize: Number(existing.size),
1204
+ expiresAt: "",
1205
+ ttl: 0,
1206
+ suggestedConcurrency: 0,
1207
+ dedup: {
1208
+ fileId: existing.id,
1209
+ filename: existing.filename,
1210
+ size: Number(existing.size),
1211
+ contentType: existing.contentType,
1212
+ url: `${rt.config.fileUrlPrefix}/${existing.id}`
1213
+ }
1214
+ }
1215
+ };
1216
+ }
1217
+ req.log.warn(
1218
+ { hash: body.expectedHash, fileId: existing.id, storageKey: existing.storageKey, userId },
1219
+ "\u26A0\uFE0F DB \u547D\u4E2D\u4F46\u78C1\u76D8\u5DF2\u4E22\u5931,\u653E\u5F03\u79D2\u4F20\u8D70\u6B63\u5E38\u5206\u7247"
1220
+ );
1221
+ await rt.fileRepo.softDelete(existing.id, { orphanedAt: (/* @__PURE__ */ new Date()).toISOString() }).catch((e) => {
1222
+ req.log.warn({ err: e, fileId: existing.id }, "\u26A0\uFE0F \u8F6F\u5220\u9664\u5B64\u513F record \u5931\u8D25");
1223
+ });
1224
+ }
1225
+ }
1226
+ const rec = await rt.multipartRepo.createInit({
1227
+ userId,
1228
+ filename: body.filename,
1229
+ contentType: body.contentType,
1230
+ totalSize: body.totalSize,
1231
+ partSize,
1232
+ totalParts,
1233
+ isPublic: body.isPublic,
1234
+ expectedHash: body.expectedHash ?? null,
1235
+ meta: body.meta ?? null,
1236
+ ttlSeconds: rt.config.ttlSeconds
1237
+ });
1238
+ req.log.info(
1239
+ { uploadId: rec.id, userId, totalSize: body.totalSize, partSize, totalParts, isPublic: body.isPublic },
1240
+ "\u2705 multipart: init"
1241
+ );
1242
+ return {
1243
+ success: true,
1244
+ data: {
1245
+ uploadId: rec.id,
1246
+ partSize,
1247
+ totalParts,
1248
+ totalSize: body.totalSize,
1249
+ expiresAt: rec.expiresAt.toISOString(),
1250
+ ttl: rt.config.ttlSeconds,
1251
+ suggestedConcurrency: rt.config.suggestedConcurrency
1252
+ }
1253
+ };
1254
+ });
1255
+ app.post(`${base}/part`, async (req, reply) => {
1256
+ const { data } = resolvePartData(req);
1257
+ if (data.length === 0) {
1258
+ throw new SecureError(1501 /* BINARY_PAYLOAD_EMPTY */, "part \u6570\u636E\u4E3A\u7A7A");
1259
+ }
1260
+ if (data.length > rt.config.partMax) {
1261
+ throw new SecureError(
1262
+ 1531 /* MULTIPART_PART_TOO_LARGE */,
1263
+ `part \u5927\u5C0F ${data.length} \u8D85\u8FC7 ${rt.config.partMax}`
1264
+ );
1265
+ }
1266
+ const { uploadId, partNumber, partHash } = resolvePartBiz(req);
1267
+ const sessionKey = resolveSessionKey(req);
1268
+ const rlKey = `rl:mp:part:${sessionKey.slice(0, 64)}`;
1269
+ const rl = await rateLimit(rt, rlKey, 6e4, rt.config.partRateLimit);
1270
+ if (!rl.ok) {
1271
+ throw new SecureError(
1272
+ 1402 /* RATE_LIMITED */,
1273
+ `part \u4E0A\u4F20\u8FC7\u4E8E\u9891\u7E41,\u8BF7 ${Math.ceil(rl.retryAfterMs / 1e3)}s \u540E\u91CD\u8BD5`
1274
+ );
1275
+ }
1276
+ const rec = await rt.multipartRepo.findById(uploadId);
1277
+ if (!rec) {
1278
+ throw new SecureError(1520 /* MULTIPART_NOT_FOUND */, `uploadId \u4E0D\u5B58\u5728: ${uploadId}`);
1279
+ }
1280
+ if (!rec.isPublic) {
1281
+ const userId = await resolveUserIdForRecord(req, rec.userId, rec.isPublic, rt);
1282
+ if (userId === null) {
1283
+ throw new SecureError(1400 /* NOT_FOUND */, "uploadId \u4E0D\u5B58\u5728");
1284
+ }
1285
+ }
1286
+ if (rec.status === MultipartStatus.COMPLETED) {
1287
+ throw new SecureError(1521 /* MULTIPART_ALREADY_COMPLETED */, "uploadId \u5DF2\u5B8C\u6210,\u4E0D\u80FD\u7EE7\u7EED part");
1288
+ }
1289
+ if (rec.status === MultipartStatus.ABORTED) {
1290
+ throw new SecureError(1522 /* MULTIPART_ALREADY_ABORTED */, "uploadId \u5DF2\u7EC8\u6B62,\u4E0D\u80FD\u7EE7\u7EED part");
1291
+ }
1292
+ if (rec.status === MultipartStatus.EXPIRED || rec.expiresAt.getTime() < Date.now()) {
1293
+ throw new SecureError(1523 /* MULTIPART_EXPIRED */, "uploadId \u5DF2\u8FC7\u671F");
1294
+ }
1295
+ if (partNumber < 1 || partNumber > rec.totalParts) {
1296
+ throw new SecureError(
1297
+ 1533 /* MULTIPART_INVALID_PART_NUMBER */,
1298
+ `partNumber=${partNumber} \u8D8A\u754C [1, ${rec.totalParts}]`
1299
+ );
1300
+ }
1301
+ const actualHash = crypto.createHash("sha256").update(data).digest("hex");
1302
+ if (partHash && partHash.toLowerCase() !== actualHash) {
1303
+ throw new SecureError(
1304
+ 1535 /* MULTIPART_PART_HASH_MISMATCH */,
1305
+ `part ${partNumber} \u58F0\u660E hash ${partHash} \u4E0E\u5B9E\u9645 ${actualHash} \u4E0D\u4E00\u81F4`
1306
+ );
1307
+ }
1308
+ await rt.storage.putPart(uploadId, partNumber, data);
1309
+ const writeRes = await lock(
1310
+ rt,
1311
+ `lock:mp:${uploadId}:meta`,
1312
+ 1e4,
1313
+ async () => {
1314
+ const cur = await rt.multipartRepo.findById(uploadId);
1315
+ if (!cur) throw new SecureError(1520 /* MULTIPART_NOT_FOUND */, "uploadId \u4E0D\u5B58\u5728");
1316
+ if (cur.status === MultipartStatus.COMPLETED) {
1317
+ throw new SecureError(1521 /* MULTIPART_ALREADY_COMPLETED */, "uploadId \u5DF2\u5B8C\u6210");
1318
+ }
1319
+ if (cur.status === MultipartStatus.ABORTED) {
1320
+ throw new SecureError(1522 /* MULTIPART_ALREADY_ABORTED */, "uploadId \u5DF2\u7EC8\u6B62");
1321
+ }
1322
+ const r = await rt.multipartRepo.addPart(uploadId, partNumber, actualHash);
1323
+ if (r.updated) return { ok: true, record: r.record };
1324
+ if (r.existingHash && r.existingHash === actualHash) {
1325
+ return { ok: true, record: r.record, idempotent: true };
1326
+ }
1327
+ throw new SecureError(
1328
+ 1534 /* MULTIPART_DUPLICATE_PART */,
1329
+ `part ${partNumber} \u5DF2\u5B58\u5728(\u539F hash=${r.existingHash ?? "null"},\u65B0 hash=${actualHash})`
1330
+ );
1331
+ },
1332
+ 5
1333
+ );
1334
+ req.log.info(
1335
+ {
1336
+ uploadId,
1337
+ partNumber,
1338
+ size: data.length,
1339
+ receivedCount: writeRes.record.receivedParts.length,
1340
+ idempotent: writeRes.idempotent === true
1341
+ },
1342
+ "\u2705 multipart: part \u5199\u5165"
1343
+ );
1344
+ return {
1345
+ success: true,
1346
+ data: {
1347
+ uploadId,
1348
+ partNumber,
1349
+ size: data.length,
1350
+ receivedCount: writeRes.record.receivedParts.length,
1351
+ totalParts: rec.totalParts
1352
+ }
1353
+ };
1354
+ });
1355
+ app.post(`${base}/complete`, async (req, reply) => {
1356
+ const body = CompleteBodySchema.parse(req.body ?? {});
1357
+ const rec = await rt.multipartRepo.findById(body.uploadId);
1358
+ if (!rec) {
1359
+ throw new SecureError(1520 /* MULTIPART_NOT_FOUND */, `uploadId \u4E0D\u5B58\u5728`);
1360
+ }
1361
+ if (!rec.isPublic) {
1362
+ await resolveUserIdForRecord(req, rec.userId, rec.isPublic, rt);
1363
+ }
1364
+ if (rec.status === MultipartStatus.COMPLETED) {
1365
+ if (rec.fileId) {
1366
+ const file2 = await rt.fileRepo.findById(rec.fileId);
1367
+ if (file2) {
1368
+ return {
1369
+ success: true,
1370
+ data: {
1371
+ fileId: file2.id,
1372
+ filename: file2.filename,
1373
+ size: Number(file2.size),
1374
+ contentType: file2.contentType,
1375
+ url: `${rt.config.fileUrlPrefix}/${file2.id}`,
1376
+ finalHash: file2.contentHash ?? "",
1377
+ idempotent: true
1378
+ }
1379
+ };
1380
+ }
1381
+ }
1382
+ }
1383
+ if (rec.status === MultipartStatus.ABORTED) {
1384
+ throw new SecureError(1522 /* MULTIPART_ALREADY_ABORTED */, "uploadId \u5DF2\u7EC8\u6B62");
1385
+ }
1386
+ if (rec.status === MultipartStatus.EXPIRED || rec.expiresAt.getTime() < Date.now()) {
1387
+ throw new SecureError(1523 /* MULTIPART_EXPIRED */, "uploadId \u5DF2\u8FC7\u671F");
1388
+ }
1389
+ const expected = Array.from({ length: rec.totalParts }, (_, i) => i + 1);
1390
+ const missing = expected.filter((n) => !rec.receivedParts.includes(n));
1391
+ if (missing.length > 0) {
1392
+ throw new SecureError(
1393
+ 1537 /* MULTIPART_INCOMPLETE_PARTS */,
1394
+ `\u7F3A\u5206\u7247: [${missing.slice(0, 10).join(",")}${missing.length > 10 ? `...(${missing.length}\u7247)` : ""}]`
1395
+ );
1396
+ }
1397
+ if (body.parts && body.parts.length > 0) {
1398
+ const expectedHashes = {};
1399
+ for (const p of body.parts) {
1400
+ if (p.hash) expectedHashes[p.partNumber] = p.hash.toLowerCase();
1401
+ }
1402
+ for (const [pnStr, h] of Object.entries(rec.partHashes)) {
1403
+ const pn = Number(pnStr);
1404
+ if (expectedHashes[pn] && expectedHashes[pn] !== h.toLowerCase()) {
1405
+ throw new SecureError(
1406
+ 1535 /* MULTIPART_PART_HASH_MISMATCH */,
1407
+ `part ${pn} \u670D\u52A1\u7AEF hash ${h} \u4E0E\u5BA2\u6237\u7AEF\u58F0\u660E ${expectedHashes[pn]} \u4E0D\u4E00\u81F4`
1408
+ );
1409
+ }
1410
+ }
1411
+ }
1412
+ const fileId = `f_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
1413
+ const result = await lock(
1414
+ rt,
1415
+ `lock:mp:${body.uploadId}`,
1416
+ 6e4,
1417
+ async () => {
1418
+ const cur = await rt.multipartRepo.findById(body.uploadId);
1419
+ if (!cur) throw new SecureError(1520 /* MULTIPART_NOT_FOUND */, "uploadId \u4E0D\u5B58\u5728");
1420
+ if (cur.status === MultipartStatus.COMPLETED && cur.fileId) {
1421
+ return { idempotent: true, fileId: cur.fileId };
1422
+ }
1423
+ if (cur.status === MultipartStatus.ABORTED) {
1424
+ throw new SecureError(1522 /* MULTIPART_ALREADY_ABORTED */, "uploadId \u5DF2\u7EC8\u6B62");
1425
+ }
1426
+ const merged = await rt.storage.mergeParts(
1427
+ body.uploadId,
1428
+ rec.totalParts,
1429
+ { id: fileId, filename: rec.filename, contentType: rec.contentType }
1430
+ );
1431
+ const finalHash = await rt.storage.computeMergedHash(body.uploadId, rec.totalParts);
1432
+ if (body.finalHash && body.finalHash.toLowerCase() !== finalHash) {
1433
+ await rt.storage.delete(merged.key);
1434
+ throw new SecureError(
1435
+ 1536 /* MULTIPART_FINAL_HASH_MISMATCH */,
1436
+ `\u6700\u7EC8 hash \u4E0D\u4E00\u81F4(\u58F0\u660E=${body.finalHash},\u5B9E\u9645=${finalHash})`
1437
+ );
1438
+ }
1439
+ const file2 = await rt.fileRepo.create({
1440
+ id: fileId,
1441
+ userId: rec.userId,
1442
+ filename: rec.filename,
1443
+ contentType: rec.contentType,
1444
+ size: merged.size,
1445
+ storageKey: merged.key,
1446
+ isPublic: rec.isPublic,
1447
+ uploadId: rec.id,
1448
+ totalParts: rec.totalParts,
1449
+ contentHash: finalHash,
1450
+ meta: rec.meta ?? void 0
1451
+ });
1452
+ await rt.multipartRepo.markCompleted(rec.id, fileId);
1453
+ return { idempotent: false, fileId: file2.id, size: merged.size, finalHash };
1454
+ }
1455
+ );
1456
+ if (!result.idempotent) {
1457
+ rt.storage.cleanupMultipart(body.uploadId).catch((e) => {
1458
+ req.log.warn({ err: e, uploadId: body.uploadId }, "\u26A0\uFE0F \u6E05\u7406\u5206\u7247\u76EE\u5F55\u5931\u8D25");
1459
+ });
1460
+ }
1461
+ req.log.info(
1462
+ {
1463
+ uploadId: body.uploadId,
1464
+ fileId: result.fileId,
1465
+ size: result.size,
1466
+ userId: rec.userId
1467
+ },
1468
+ "\u2705 multipart: complete"
1469
+ );
1470
+ const file = await rt.fileRepo.findById(result.fileId);
1471
+ if (!file) {
1472
+ throw new SecureError(1500 /* INTERNAL_ERROR */, "file record lost");
1473
+ }
1474
+ return {
1475
+ success: true,
1476
+ data: {
1477
+ fileId: file.id,
1478
+ filename: file.filename,
1479
+ size: Number(file.size),
1480
+ contentType: file.contentType,
1481
+ url: `${rt.config.fileUrlPrefix}/${file.id}`,
1482
+ finalHash: result.finalHash,
1483
+ idempotent: result.idempotent === true
1484
+ }
1485
+ };
1486
+ });
1487
+ app.post(`${base}/abort`, async (req, reply) => {
1488
+ const body = AbortBodySchema.parse(req.body ?? {});
1489
+ const rec = await rt.multipartRepo.findById(body.uploadId);
1490
+ if (!rec) {
1491
+ throw new SecureError(1520 /* MULTIPART_NOT_FOUND */, `uploadId \u4E0D\u5B58\u5728`);
1492
+ }
1493
+ if (!rec.isPublic) {
1494
+ await resolveUserIdForRecord(req, rec.userId, rec.isPublic, rt);
1495
+ }
1496
+ if (rec.status === MultipartStatus.COMPLETED) {
1497
+ throw new SecureError(1521 /* MULTIPART_ALREADY_COMPLETED */, "\u5DF2\u5B8C\u6210\u7684 uploadId \u4E0D\u80FD abort");
1498
+ }
1499
+ if (rec.status === MultipartStatus.ABORTED) {
1500
+ return { success: true, data: { uploadId: rec.id, status: "ABORTED", idempotent: true } };
1501
+ }
1502
+ try {
1503
+ await rt.multipartRepo.markAborted(body.uploadId);
1504
+ } catch (e) {
1505
+ if (e.message === "ALREADY_COMPLETED") {
1506
+ throw new SecureError(1521 /* MULTIPART_ALREADY_COMPLETED */, "\u5DF2\u5B8C\u6210\u7684 uploadId \u4E0D\u80FD abort");
1507
+ }
1508
+ if (e.message === "STATUS_INVALID") {
1509
+ throw new SecureError(1524 /* MULTIPART_STATUS_INVALID */, "\u72B6\u6001\u673A\u975E\u6CD5\u8F6C\u79FB");
1510
+ }
1511
+ throw e;
1512
+ }
1513
+ rt.storage.cleanupMultipart(body.uploadId).catch((e) => {
1514
+ req.log.warn({ err: e, uploadId: body.uploadId }, "\u26A0\uFE0F abort \u540E\u6E05\u7406\u5931\u8D25");
1515
+ });
1516
+ req.log.info({ uploadId: body.uploadId, userId: rec.userId }, "\u{1F6D1} multipart: abort");
1517
+ return { success: true, data: { uploadId: rec.id, status: "ABORTED" } };
1518
+ });
1519
+ app.post(`${base}/status`, async (req, reply) => {
1520
+ const body = StatusBodySchema.parse(req.body ?? {});
1521
+ const rec = await rt.multipartRepo.findById(body.uploadId);
1522
+ if (!rec) {
1523
+ throw new SecureError(1520 /* MULTIPART_NOT_FOUND */, `uploadId \u4E0D\u5B58\u5728`);
1524
+ }
1525
+ if (!rec.isPublic) {
1526
+ await resolveUserIdForRecord(req, rec.userId, rec.isPublic, rt);
1527
+ }
1528
+ const diskParts = await rt.storage.listParts(rec.id);
1529
+ const diskSet = new Set(diskParts);
1530
+ const dbSet = new Set(rec.receivedParts);
1531
+ const mergedReceived = Array.from(/* @__PURE__ */ new Set([...diskSet, ...dbSet])).sort((a, b) => a - b);
1532
+ return {
1533
+ success: true,
1534
+ data: {
1535
+ uploadId: rec.id,
1536
+ status: rec.status,
1537
+ filename: rec.filename,
1538
+ contentType: rec.contentType,
1539
+ totalSize: Number(rec.totalSize),
1540
+ partSize: rec.partSize,
1541
+ totalParts: rec.totalParts,
1542
+ receivedParts: mergedReceived,
1543
+ receivedCount: mergedReceived.length,
1544
+ isPublic: rec.isPublic,
1545
+ expiresAt: rec.expiresAt.toISOString(),
1546
+ createdAt: rec.createdAt.toISOString(),
1547
+ completedAt: rec.completedAt?.toISOString() ?? null,
1548
+ fileId: rec.fileId
1549
+ }
1550
+ };
1551
+ });
1552
+ }
1553
+ async function resolveUserIdForRecord(req, expectedUserId, isPublic, rt) {
1554
+ const sr = req.secureRequest;
1555
+ const session = sr?.session ?? req.headers["x-secure-session"];
1556
+ const userId = await rt.validateSession(session, isPublic);
1557
+ if (!isPublic && userId !== expectedUserId) {
1558
+ throw new SecureError(1400 /* NOT_FOUND */, "uploadId \u4E0D\u5B58\u5728");
1559
+ }
1560
+ return userId;
1561
+ }
1562
+
1563
+ // src/server/routes/smart.ts
1564
+ import { z as z2 } from "zod";
1565
+ var FileInitItemSchema = z2.object({
1566
+ filename: z2.string().min(1).max(255),
1567
+ contentType: z2.string().min(1).max(128).default("application/octet-stream"),
1568
+ size: z2.coerce.number().int().nonnegative(),
1569
+ isPublic: z2.coerce.boolean().default(false),
1570
+ expectedHash: z2.string().regex(/^[a-f0-9]{64}$/i).optional(),
1571
+ meta: z2.record(z2.unknown()).optional()
1572
+ }).strict();
1573
+ var SmartInitBodySchema = z2.object({
1574
+ files: z2.array(FileInitItemSchema).min(1).max(50)
1575
+ }).strict();
1576
+ async function processOneFile(req, rt, file, index) {
1577
+ try {
1578
+ const { userId } = await resolveSession(req, file.isPublic, rt.validateSession);
1579
+ if (file.size > rt.config.fileMaxSize) {
1580
+ throw new SecureError(
1581
+ 1530 /* MULTIPART_TOTAL_TOO_LARGE */,
1582
+ `\u6587\u4EF6 ${file.filename} \u5927\u5C0F ${file.size} \u8D85\u8FC7\u5355\u6587\u4EF6\u9650\u5236 ${rt.config.fileMaxSize}`
1583
+ );
1584
+ }
1585
+ if (file.expectedHash) {
1586
+ const existing = await rt.fileRepo.findDedupCandidate(
1587
+ file.expectedHash,
1588
+ file.isPublic,
1589
+ userId
1590
+ );
1591
+ if (existing) {
1592
+ const diskOk = await rt.storage.exists(existing.storageKey).catch(() => false);
1593
+ if (diskOk) {
1594
+ req.log.info(
1595
+ { hash: file.expectedHash, fileId: existing.id, isPublic: file.isPublic, userId, index },
1596
+ "\u26A1 smart: \u79D2\u4F20\u547D\u4E2D(\u4E25\u683C\u5339\u914D\u53EF\u89C1\u6027+\u8EAB\u4EFD)"
1597
+ );
1598
+ return {
1599
+ ok: true,
1600
+ index,
1601
+ filename: file.filename,
1602
+ size: Number(existing.size),
1603
+ dedup: true,
1604
+ fileId: existing.id,
1605
+ contentType: existing.contentType,
1606
+ url: `${rt.config.fileUrlPrefix}/${existing.id}`
1607
+ };
1608
+ }
1609
+ req.log.warn(
1610
+ { hash: file.expectedHash, fileId: existing.id, userId, index },
1611
+ "\u26A0\uFE0F smart: DB \u547D\u4E2D\u4F46\u78C1\u76D8\u4E22\u5931,\u653E\u5F03\u79D2\u4F20"
1612
+ );
1613
+ await rt.fileRepo.softDelete(existing.id, { orphanedAt: (/* @__PURE__ */ new Date()).toISOString() }).catch((e) => {
1614
+ req.log.warn({ err: e, fileId: existing.id }, "\u26A0\uFE0F \u8F6F\u5220\u9664\u5B64\u513F record \u5931\u8D25");
1615
+ });
1616
+ }
1617
+ }
1618
+ const strategy = chooseStrategy(file.size, rt.config.strategy);
1619
+ req.log.info(
1620
+ {
1621
+ index,
1622
+ filename: file.filename,
1623
+ size: file.size,
1624
+ strategy: strategy.kind,
1625
+ partSize: strategy.partSize,
1626
+ totalParts: strategy.totalParts,
1627
+ reason: strategy.reason
1628
+ },
1629
+ `\u{1F9E0} smart: \u9009\u7B56\u7565 ${strategy.kind}(${strategy.reason})`
1630
+ );
1631
+ const rec = await rt.multipartRepo.createInit({
1632
+ userId,
1633
+ filename: file.filename,
1634
+ contentType: file.contentType,
1635
+ totalSize: file.size,
1636
+ partSize: strategy.partSize,
1637
+ totalParts: strategy.totalParts,
1638
+ isPublic: file.isPublic,
1639
+ expectedHash: file.expectedHash ?? null,
1640
+ meta: file.meta ?? null,
1641
+ ttlSeconds: rt.config.ttlSeconds
1642
+ });
1643
+ return {
1644
+ ok: true,
1645
+ index,
1646
+ filename: file.filename,
1647
+ size: file.size,
1648
+ dedup: false,
1649
+ uploadId: rec.id,
1650
+ contentType: file.contentType,
1651
+ isPublic: file.isPublic,
1652
+ partSize: strategy.partSize,
1653
+ totalParts: strategy.totalParts,
1654
+ totalSize: file.size,
1655
+ strategy: strategy.kind,
1656
+ suggestedConcurrency: suggestConcurrency(strategy),
1657
+ expiresAt: rec.expiresAt.toISOString(),
1658
+ ttl: rt.config.ttlSeconds
1659
+ };
1660
+ } catch (e) {
1661
+ const code = e instanceof SecureError ? e.code : e?.code ?? 1001 /* INVALID_REQUEST */;
1662
+ const message = e?.message ?? String(e);
1663
+ const errName = e instanceof SecureError ? e.name : "INTERNAL_ERROR";
1664
+ return {
1665
+ ok: false,
1666
+ index,
1667
+ filename: file.filename,
1668
+ code,
1669
+ error: errName,
1670
+ message
1671
+ };
1672
+ }
1673
+ }
1674
+ async function registerSmartRoutes(app, rt) {
1675
+ if (!rt.config.enabled) {
1676
+ app.log.warn("\u26A0\uFE0F smart \u4E0A\u4F20\u5DF2\u7981\u7528 (enabled=false)");
1677
+ return;
1678
+ }
1679
+ const base = rt.config.smartPath;
1680
+ app.log.info(
1681
+ {
1682
+ routes: [`${base}/init`],
1683
+ maxFiles: rt.config.smartMaxFiles,
1684
+ rateLimit: rt.config.smartInitRateLimit
1685
+ },
1686
+ "\u{1F527} smart-routes: \u6CE8\u518C smart \u4E0A\u4F20\u8DEF\u7531(part/complete/abort/status \u590D\u7528 multipart/*)..."
1687
+ );
1688
+ app.post(`${base}/init`, async (req) => {
1689
+ const body = SmartInitBodySchema.parse(req.body ?? {});
1690
+ if (body.files.length > rt.config.smartMaxFiles) {
1691
+ throw new SecureError(
1692
+ 1511 /* BATCH_TOO_MANY_FILES */,
1693
+ `\u5355\u6B21 /smart/init \u6700\u591A ${rt.config.smartMaxFiles} \u4E2A\u6587\u4EF6,\u5B9E\u9645 ${body.files.length}`
1694
+ );
1695
+ }
1696
+ const sessionKeyForRl = (() => {
1697
+ const sr = req.secureRequest;
1698
+ return sr?.session ?? req.headers["x-secure-session"] ?? "anonymous";
1699
+ })();
1700
+ const allPublic = body.files.every((f) => f.isPublic);
1701
+ const userIdForRl = await resolveUserId(req, allPublic, rt.validateSession);
1702
+ const rlKey = `rl:mp:init:${userIdForRl ?? sessionKeyForRl.slice(0, 32)}`;
1703
+ for (let i = 0; i < body.files.length; i++) {
1704
+ const rl = rt.redis ? await slidingWindowRateLimit(rt.redis, rlKey, 6e4, rt.config.smartInitRateLimit) : memorySlidingWindowRateLimit(rlKey, 6e4, rt.config.smartInitRateLimit);
1705
+ if (!rl.ok) {
1706
+ req.log.warn(
1707
+ { remaining: 0, consumed: i, total: body.files.length, retryAfterMs: rl.retryAfterMs },
1708
+ "\u26A0\uFE0F smart: \u6279\u91CF\u914D\u989D\u4E0D\u8DB3,\u540E\u7EED\u6587\u4EF6\u6807\u8BB0\u5931\u8D25"
1709
+ );
1710
+ const results2 = [];
1711
+ for (let j = 0; j < i; j++) {
1712
+ const r = await processOneFile(req, rt, body.files[j], j);
1713
+ results2.push(r);
1714
+ }
1715
+ for (let j = i; j < body.files.length; j++) {
1716
+ results2.push({
1717
+ ok: false,
1718
+ index: j,
1719
+ filename: body.files[j].filename,
1720
+ code: 1402 /* RATE_LIMITED */,
1721
+ error: "RATE_LIMITED",
1722
+ message: `\u6279\u91CF\u914D\u989D\u4E0D\u8DB3,\u9700 ${Math.ceil(rl.retryAfterMs / 1e3)}s \u540E\u91CD\u8BD5`
1723
+ });
1724
+ }
1725
+ return {
1726
+ success: true,
1727
+ data: {
1728
+ files: results2,
1729
+ summary: {
1730
+ total: body.files.length,
1731
+ ok: results2.filter((r) => r.ok).length,
1732
+ failed: results2.filter((r) => !r.ok).length,
1733
+ dedup: results2.filter((r) => r.ok && r.dedup === true).length,
1734
+ oneShot: results2.filter((r) => r.ok && r.dedup === false && r.strategy === "ONE_SHOT").length,
1735
+ multipart: results2.filter((r) => r.ok && r.dedup === false && r.strategy === "MULTIPART").length,
1736
+ rateLimited: body.files.length - i
1737
+ }
1738
+ }
1739
+ };
1740
+ }
1741
+ }
1742
+ const t0 = Date.now();
1743
+ const results = [];
1744
+ for (let i = 0; i < body.files.length; i++) {
1745
+ const f = body.files[i];
1746
+ const result = await processOneFile(req, rt, f, i);
1747
+ results.push(result);
1748
+ }
1749
+ const summary = {
1750
+ total: results.length,
1751
+ ok: results.filter((r) => r.ok).length,
1752
+ failed: results.filter((r) => !r.ok).length,
1753
+ dedup: results.filter((r) => r.ok && r.dedup === true).length,
1754
+ oneShot: results.filter((r) => r.ok && r.dedup === false && r.strategy === "ONE_SHOT").length,
1755
+ multipart: results.filter((r) => r.ok && r.dedup === false && r.strategy === "MULTIPART").length
1756
+ };
1757
+ req.log.info(
1758
+ { total: summary.total, ok: summary.ok, dedup: summary.dedup, oneShot: summary.oneShot, multipart: summary.multipart, ms: Date.now() - t0 },
1759
+ "\u2705 smart: init \u6C47\u603B"
1760
+ );
1761
+ return { success: true, data: { files: results, summary } };
1762
+ });
1763
+ }
1764
+
1765
+ // src/server/utils/cleaner.ts
1766
+ function startMultipartCleanupTask(opts) {
1767
+ if (!opts.enabled) {
1768
+ opts.log.info("\u23F8\uFE0F multipart-cleanup: \u5DF2\u7981\u7528");
1769
+ return;
1770
+ }
1771
+ const intervalMs = opts.intervalSeconds * 1e3;
1772
+ const tick = async () => {
1773
+ try {
1774
+ const expired = await opts.multipartRepo.listExpired(opts.batchLimit);
1775
+ if (expired.length === 0) return;
1776
+ opts.log.info({ count: expired.length }, "\u{1F9F9} multipart cleanup: \u53D1\u73B0\u8FC7\u671F uploadId");
1777
+ for (const rec of expired) {
1778
+ try {
1779
+ await opts.multipartRepo.markExpired(rec.id);
1780
+ await opts.storage.cleanupMultipart(rec.id);
1781
+ opts.log.info({ uploadId: rec.id }, "\u{1F9F9} multipart cleanup: \u5DF2\u6E05\u7406");
1782
+ } catch (e) {
1783
+ opts.log.warn({ err: e, uploadId: rec.id }, "\u26A0\uFE0F multipart cleanup \u5355\u6761\u5931\u8D25");
1784
+ }
1785
+ }
1786
+ } catch (e) {
1787
+ opts.log.error({ err: e }, "\u274C multipart cleanup tick \u5931\u8D25");
1788
+ }
1789
+ };
1790
+ setImmediate(tick);
1791
+ const timer = setInterval(tick, intervalMs);
1792
+ opts.onClose(() => clearInterval(timer));
1793
+ opts.log.info(
1794
+ { intervalSec: opts.intervalSeconds },
1795
+ "\u{1F9F9} multipart cleanup task \u5DF2\u542F\u52A8"
1796
+ );
1797
+ }
1798
+
1799
+ // src/server/utils/orphan-scanner.ts
1800
+ function startOrphanFileScanTask(opts) {
1801
+ if (!opts.enabled) {
1802
+ opts.log.info("\u23F8\uFE0F orphan-file-scan: \u5DF2\u7981\u7528");
1803
+ return;
1804
+ }
1805
+ const intervalMs = opts.intervalSeconds * 1e3;
1806
+ const batchSize = opts.batchSize;
1807
+ const tick = async () => {
1808
+ try {
1809
+ const recent = await opts.fileRepo.listOrphans({
1810
+ sinceDays: opts.lookbackDays,
1811
+ limit: batchSize
1812
+ });
1813
+ if (recent.length === 0) {
1814
+ opts.log.debug({ lookbackDays: opts.lookbackDays }, "\u{1F9F9} orphan-file-scan: \u65E0\u53EF\u626B\u8BB0\u5F55");
1815
+ return;
1816
+ }
1817
+ let orphanCount = 0;
1818
+ const samples = [];
1819
+ for (const f of recent) {
1820
+ try {
1821
+ const ok = await opts.storage.exists(f.storageKey);
1822
+ if (!ok) {
1823
+ await opts.fileRepo.softDelete(f.id, { orphanedAt: (/* @__PURE__ */ new Date()).toISOString() });
1824
+ orphanCount++;
1825
+ if (samples.length < 5) {
1826
+ samples.push({ fileId: f.id, storageKey: f.storageKey, size: Number(f.size) });
1827
+ }
1828
+ }
1829
+ } catch (e) {
1830
+ opts.log.warn(
1831
+ { err: e, fileId: f.id, storageKey: f.storageKey },
1832
+ "\u26A0\uFE0F orphan-file-scan: \u5355\u6761\u68C0\u6D4B\u5931\u8D25"
1833
+ );
1834
+ }
1835
+ }
1836
+ if (orphanCount > 0) {
1837
+ opts.log.warn(
1838
+ { scanned: recent.length, orphans: orphanCount, sample: samples },
1839
+ "\u{1F9F9} orphan-file-scan: \u5B8C\u6210\u672C\u8F6E"
1840
+ );
1841
+ } else {
1842
+ opts.log.info(
1843
+ { scanned: recent.length, orphans: 0 },
1844
+ "\u{1F9F9} orphan-file-scan: \u5B8C\u6210\u672C\u8F6E(\u5168\u90E8\u4E00\u81F4)"
1845
+ );
1846
+ }
1847
+ } catch (e) {
1848
+ opts.log.error({ err: e }, "\u274C orphan-file-scan tick \u5931\u8D25");
1849
+ }
1850
+ };
1851
+ setImmediate(tick);
1852
+ const timer = setInterval(tick, intervalMs);
1853
+ opts.onClose(() => clearInterval(timer));
1854
+ opts.log.info(
1855
+ {
1856
+ intervalSec: opts.intervalSeconds,
1857
+ lookbackDays: opts.lookbackDays,
1858
+ batchSize
1859
+ },
1860
+ "\u{1F9F9} orphan-file-scan task \u5DF2\u542F\u52A8"
1861
+ );
1862
+ }
1863
+
1864
+ // src/server/fastify-plugin.ts
1865
+ var KB = 1024;
1866
+ var MB = 1024 * KB;
1867
+ var GB = 1024 * MB;
1868
+ var DEFAULTS = {
1869
+ enabled: true,
1870
+ fileMaxSize: 5 * GB,
1871
+ partMin: 5 * MB,
1872
+ partMax: 50 * MB,
1873
+ partDefault: 20 * MB,
1874
+ ttlSeconds: 24 * 60 * 60,
1875
+ suggestedConcurrency: 3,
1876
+ initRateLimit: 30,
1877
+ partRateLimit: 600,
1878
+ smartMaxFiles: 50,
1879
+ smartInitRateLimit: 30,
1880
+ multipartPath: "/multipart",
1881
+ smartPath: "/smart",
1882
+ fileUrlPrefix: "/files",
1883
+ secure: "raw"
1884
+ };
1885
+ async function resolveStorage(cfg) {
1886
+ if (typeof cfg === "object" && "put" in cfg && typeof cfg.put === "function") {
1887
+ return cfg;
1888
+ }
1889
+ const preset = cfg;
1890
+ if (preset.adapter === "local") {
1891
+ return new LocalStorage(preset.options.baseDir);
1892
+ }
1893
+ throw new Error(`[secure-upload-sdk] \u672A\u77E5\u7684 storage.adapter: ${preset.adapter}`);
1894
+ }
1895
+ function resolveRepository(cfg) {
1896
+ if (cfg.kind === "prisma") {
1897
+ return {
1898
+ multipart: new PrismaMultipartRepository(cfg.prisma),
1899
+ file: new PrismaFileRepository(cfg.prisma)
1900
+ };
1901
+ }
1902
+ return { multipart: cfg.multipart, file: cfg.file };
1903
+ }
1904
+ async function secureUploadPluginImpl(app, options) {
1905
+ if (!options.storage) {
1906
+ throw new Error("[secure-upload-sdk] options.storage \u5FC5\u586B");
1907
+ }
1908
+ if (!options.repository) {
1909
+ throw new Error("[secure-upload-sdk] options.repository \u5FC5\u586B");
1910
+ }
1911
+ const storage = await resolveStorage(options.storage);
1912
+ const { multipart: multipartRepo, file: fileRepo } = resolveRepository(options.repository);
1913
+ const runtime = {
1914
+ storage,
1915
+ multipartRepo,
1916
+ fileRepo,
1917
+ redis: options.redis ?? null,
1918
+ config: {
1919
+ enabled: options.enabled ?? DEFAULTS.enabled,
1920
+ secure: options.secure?.mode ?? DEFAULTS.secure,
1921
+ fileMaxSize: options.fileMaxSize ?? DEFAULTS.fileMaxSize,
1922
+ partMin: options.partSize?.min ?? DEFAULTS.partMin,
1923
+ partMax: options.partSize?.max ?? DEFAULTS.partMax,
1924
+ partDefault: options.partSize?.default ?? DEFAULTS.partDefault,
1925
+ ttlSeconds: options.ttlSeconds ?? DEFAULTS.ttlSeconds,
1926
+ suggestedConcurrency: options.suggestedClientConcurrency ?? DEFAULTS.suggestedConcurrency,
1927
+ initRateLimit: options.rateLimit?.initPerMin ?? DEFAULTS.initRateLimit,
1928
+ partRateLimit: options.rateLimit?.partPerMin ?? DEFAULTS.partRateLimit,
1929
+ smartMaxFiles: options.rateLimit?.smartMaxFilesPerInit ?? DEFAULTS.smartMaxFiles,
1930
+ smartInitRateLimit: DEFAULTS.smartInitRateLimit,
1931
+ multipartPath: options.pathPrefix?.multipart ?? DEFAULTS.multipartPath,
1932
+ smartPath: options.pathPrefix?.smart ?? DEFAULTS.smartPath,
1933
+ fileUrlPrefix: options.fileUrlPrefix ?? DEFAULTS.fileUrlPrefix,
1934
+ strategy: {
1935
+ ...DEFAULT_STRATEGY_CONFIG,
1936
+ ...options.strategy ?? {}
1937
+ }
1938
+ },
1939
+ validateSession: options.sessionValidator ?? defaultSessionValidator
1940
+ };
1941
+ app.decorate("secureUpload", runtime);
1942
+ await registerMultipartRoutes(app, runtime);
1943
+ await registerSmartRoutes(app, runtime);
1944
+ app.setErrorHandler(function(err, req, reply) {
1945
+ if (err instanceof SecureError) {
1946
+ return reply.status(err.statusCode).send({
1947
+ success: false,
1948
+ code: err.code,
1949
+ error: err.name,
1950
+ message: err.message
1951
+ });
1952
+ }
1953
+ const anyErr = err;
1954
+ if (anyErr && typeof anyErr === "object" && "issues" in anyErr && Array.isArray(anyErr.issues)) {
1955
+ return reply.status(400).send({
1956
+ success: false,
1957
+ code: 1001,
1958
+ // INVALID_REQUEST
1959
+ error: "ZodValidationError",
1960
+ message: anyErr.issues?.[0]?.message ?? "invalid request"
1961
+ });
1962
+ }
1963
+ app.log.error({ err }, "\u274C secure-upload-sdk \u9519\u8BEF");
1964
+ return reply.status(anyErr?.statusCode ?? 500).send({
1965
+ success: false,
1966
+ code: 1500,
1967
+ // INTERNAL_ERROR
1968
+ error: anyErr?.name ?? "INTERNAL_ERROR",
1969
+ message: anyErr?.message ?? "internal error"
1970
+ });
1971
+ });
1972
+ if (options.cleanupTask?.enabled !== false) {
1973
+ startMultipartCleanupTask({
1974
+ enabled: true,
1975
+ intervalSeconds: options.cleanupTask?.intervalSeconds ?? 300,
1976
+ batchLimit: options.cleanupTask?.batchLimit ?? 100,
1977
+ storage,
1978
+ multipartRepo,
1979
+ log: app.log,
1980
+ onClose: (cb) => app.addHook("onClose", async () => cb())
1981
+ });
1982
+ }
1983
+ if (options.orphanScan?.enabled !== false) {
1984
+ startOrphanFileScanTask({
1985
+ enabled: true,
1986
+ intervalSeconds: options.orphanScan?.intervalSeconds ?? 300,
1987
+ lookbackDays: options.orphanScan?.lookbackDays ?? 7,
1988
+ batchSize: options.orphanScan?.batchSize ?? 2e3,
1989
+ storage,
1990
+ fileRepo,
1991
+ log: app.log,
1992
+ onClose: (cb) => app.addHook("onClose", async () => cb())
1993
+ });
1994
+ }
1995
+ app.log.info(
1996
+ {
1997
+ secure: runtime.config.secure,
1998
+ storage: storage.constructor.name,
1999
+ repository: options.repository.kind,
2000
+ redis: options.redis ? "yes" : "memory-fallback",
2001
+ multipartPath: runtime.config.multipartPath,
2002
+ smartPath: runtime.config.smartPath
2003
+ },
2004
+ "\u{1F680} secure-upload-fastify-sdk \u63D2\u4EF6\u5DF2\u6CE8\u518C"
2005
+ );
2006
+ }
2007
+ var fastify_plugin_default = fp(secureUploadPluginImpl, {
2008
+ name: "secure-upload-fastify-sdk",
2009
+ fastify: "4.x"
2010
+ });
2011
+ export {
2012
+ ErrorCode,
2013
+ InMemoryFileRepository,
2014
+ InMemoryMultipartRepository,
2015
+ InMemoryStorage,
2016
+ LocalStorage,
2017
+ PrismaFileRepository,
2018
+ PrismaMultipartRepository,
2019
+ SecureError,
2020
+ getHttpStatus,
2021
+ fastify_plugin_default as secureUploadPlugin
2022
+ };
2023
+ //# sourceMappingURL=index.mjs.map