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