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