@blamejs/core 0.7.0 → 0.7.4
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/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/index.js +2 -0
- package/lib/api-key.js +1 -10
- package/lib/cache-redis.js +1 -11
- package/lib/cache.js +11 -16
- package/lib/file-upload.js +933 -0
- package/lib/framework-error.js +13 -0
- package/lib/log-stream-syslog.js +1 -1
- package/lib/middleware/api-encrypt.js +415 -52
- package/lib/middleware/db-role-for.js +3 -8
- package/lib/notify.js +3 -5
- package/lib/object-store/azure-blob.js +42 -5
- package/lib/object-store/gcs.js +47 -7
- package/lib/object-store/sigv4.js +55 -7
- package/lib/pubsub-redis.js +1 -11
- package/lib/queue-redis.js +1 -8
- package/lib/redis-client.js +30 -0
- package/lib/request-helpers.js +21 -17
- package/lib/seeders.js +5 -17
- package/lib/static.js +699 -114
- package/lib/validate-opts.js +49 -0
- package/lib/webhook.js +3 -6
- package/lib/websocket.js +38 -6
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.fileUpload — chunked file upload primitive.
|
|
4
|
+
*
|
|
5
|
+
* var uploads = b.fileUpload.create({
|
|
6
|
+
* stagingDir: "/var/lib/myapp/uploads",
|
|
7
|
+
* maxFileBytes: C.BYTES.gib(2),
|
|
8
|
+
* maxChunkBytes: C.BYTES.mib(8),
|
|
9
|
+
* maxStreamReassemblyBytes: C.BYTES.mib(64), // > this → stream onFinalize
|
|
10
|
+
* maxStagingBytes: C.BYTES.gib(50),
|
|
11
|
+
* maxActiveUploadsPerActor: 5,
|
|
12
|
+
* maxChunks: 16384,
|
|
13
|
+
* incompleteTtlMs: C.TIME.hours(24),
|
|
14
|
+
* maxIdleMs: C.TIME.minutes(30),
|
|
15
|
+
* allowedFileTypes: ["image/jpeg", "image/png", "application/pdf"],
|
|
16
|
+
* audit: b.audit,
|
|
17
|
+
* observability: b.observability,
|
|
18
|
+
* permissions: b.permissions, // optional
|
|
19
|
+
* fileType: b.fileType, // optional — needed for allowedFileTypes
|
|
20
|
+
* onChunk: async function (info) { ... }, // optional per-chunk hook
|
|
21
|
+
* onFinalize: async function (info) { ... }, // operator decides final storage
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // Lifecycle:
|
|
25
|
+
* var initRv = await uploads.init({ uploadId, metadata, actor });
|
|
26
|
+
* await uploads.acceptChunk({ uploadId, index, body, sha3, actor });
|
|
27
|
+
* var rv = await uploads.finalize({ uploadId, manifest, actor });
|
|
28
|
+
*
|
|
29
|
+
* // Operator dashboards:
|
|
30
|
+
* var st = uploads.status(uploadId, { actor }); // { received, totalBytesAccepted, createdAt, ... }
|
|
31
|
+
* var active = uploads.list({ actor }); // active uploads for this actor
|
|
32
|
+
* await uploads.cancelUpload(uploadId, { actor }); // operator-cancel
|
|
33
|
+
*
|
|
34
|
+
* // Periodic cleanup (wire to b.scheduler):
|
|
35
|
+
* await uploads.purgeIncomplete(); // → { purged, ids }
|
|
36
|
+
*
|
|
37
|
+
* Surface (returned by create):
|
|
38
|
+
*
|
|
39
|
+
* init(opts) → { uploadId, expiresAt, ... }
|
|
40
|
+
* Allocates staging dir, stores metadata + actor +
|
|
41
|
+
* createdAt. Required before acceptChunk. Permission-
|
|
42
|
+
* checked: action "fileUpload.init".
|
|
43
|
+
*
|
|
44
|
+
* acceptChunk(opts) → { received, totalBytesAccepted, status }
|
|
45
|
+
* Validates body length + per-chunk SHA3-512.
|
|
46
|
+
* Per-chunk hook (onChunk) runs before write.
|
|
47
|
+
* Permission-checked: action "fileUpload.accept".
|
|
48
|
+
* Idempotent on re-PUT of same (uploadId, index)
|
|
49
|
+
* with matching body.
|
|
50
|
+
*
|
|
51
|
+
* finalize(opts) → result of onFinalize (or framework default)
|
|
52
|
+
* Walks chunks in manifest order, verifies per-chunk
|
|
53
|
+
* + total SHA3-512, sniffs MIME (when fileType
|
|
54
|
+
* wired) and gates against allowedFileTypes, hands
|
|
55
|
+
* assembled buffer (or readable stream when size >
|
|
56
|
+
* maxStreamReassemblyBytes) to onFinalize. Removes
|
|
57
|
+
* staging dir on success. Permission-checked:
|
|
58
|
+
* action "fileUpload.finalize".
|
|
59
|
+
*
|
|
60
|
+
* status(uploadId, opts) → { received, totalBytesAccepted, createdAt,
|
|
61
|
+
* lastChunkAt, metadata, expiresAt } | null
|
|
62
|
+
* Permission-checked: action "fileUpload.status".
|
|
63
|
+
* Returns null if upload not found.
|
|
64
|
+
*
|
|
65
|
+
* list(opts) → [{ uploadId, metadata, createdAt, lastChunkAt,
|
|
66
|
+
* totalBytesAccepted, actor }]
|
|
67
|
+
* Operator dashboards. Permission-checked: action
|
|
68
|
+
* "fileUpload.list". Filter by actor + since.
|
|
69
|
+
*
|
|
70
|
+
* cancelUpload(id,opts) → { ok, uploadId }
|
|
71
|
+
* Force-removes staging. Permission-checked:
|
|
72
|
+
* action "fileUpload.cancel".
|
|
73
|
+
*
|
|
74
|
+
* purgeIncomplete() → { purged: N, ids: [string] }
|
|
75
|
+
* Reclaims staging dirs that exceeded
|
|
76
|
+
* incompleteTtlMs (since createdAt) OR maxIdleMs
|
|
77
|
+
* (since lastChunkAt). Operator wires to
|
|
78
|
+
* b.scheduler or triggers on-demand.
|
|
79
|
+
*
|
|
80
|
+
* close() → void
|
|
81
|
+
* Lifecycle parity with other framework primitives.
|
|
82
|
+
*
|
|
83
|
+
* Design posture:
|
|
84
|
+
*
|
|
85
|
+
* - **init() before any chunk**: explicit lifecycle. Init records
|
|
86
|
+
* createdAt + actor + metadata + signing key in a per-upload sidecar
|
|
87
|
+
* so subsequent acceptChunk / finalize / status calls can authenticate
|
|
88
|
+
* and audit consistently.
|
|
89
|
+
*
|
|
90
|
+
* - **Framework owns chunk lifecycle**, not final storage. Operator
|
|
91
|
+
* decides via `onFinalize` what to do with the assembled buffer
|
|
92
|
+
* OR streamed chunks. Framework doesn't dictate the storage layer.
|
|
93
|
+
*
|
|
94
|
+
* - **SHA3-512** is the hash. PQC-first; SHA-256 is not offered.
|
|
95
|
+
* Per-chunk hash + total hash both verified.
|
|
96
|
+
*
|
|
97
|
+
* - **Stream reassembly above maxStreamReassemblyBytes**: in-memory
|
|
98
|
+
* Buffer.concat of a 2 GiB upload would OOM the process. When the
|
|
99
|
+
* upload exceeds the threshold, finalize calls onFinalize with a
|
|
100
|
+
* readable stream reading the chunk files in order; the body
|
|
101
|
+
* parameter is null. Operator pipes to disk / S3 / etc.
|
|
102
|
+
*
|
|
103
|
+
* - **MIME / file-type gate**: when allowedFileTypes is set and the
|
|
104
|
+
* fileType primitive is wired, finalize sniffs the assembled bytes
|
|
105
|
+
* (or first chunk for streamed uploads) and rejects if the magic
|
|
106
|
+
* bytes don't classify into one of the allowed types. Defense
|
|
107
|
+
* against `.exe disguised as .jpg` and similar mismatches.
|
|
108
|
+
*
|
|
109
|
+
* - **Per-actor + total staging quotas**: maxActiveUploadsPerActor
|
|
110
|
+
* prevents one actor from holding open dozens of uploads;
|
|
111
|
+
* maxStagingBytes prevents the staging dir from filling the disk
|
|
112
|
+
* across all actors. Both checked at init() time before any
|
|
113
|
+
* filesystem allocation.
|
|
114
|
+
*
|
|
115
|
+
* - **Permissions integration**: when `permissions` opt is wired,
|
|
116
|
+
* every operator-facing call checks the action via
|
|
117
|
+
* `permissions.check(actor, "fileUpload.<op>")` before acting.
|
|
118
|
+
* Action names: init / accept / finalize / status / list / cancel.
|
|
119
|
+
*
|
|
120
|
+
* - **Tombstone cleanup**: purgeIncomplete() walks staging entries
|
|
121
|
+
* and reclaims those exceeding incompleteTtlMs (since createdAt)
|
|
122
|
+
* OR maxIdleMs (since lastChunkAt — for in-flight uploads
|
|
123
|
+
* abandoned mid-stream). Audit emission on every purge.
|
|
124
|
+
*
|
|
125
|
+
* - **Validation errors are permanent**: chunk-hash mismatch,
|
|
126
|
+
* oversized chunk, oversized total file, manifest verification
|
|
127
|
+
* failure, MIME-type rejection, quota exhaustion all throw
|
|
128
|
+
* `FileUploadError` with `permanent: true` — no retry will
|
|
129
|
+
* succeed.
|
|
130
|
+
*
|
|
131
|
+
* Security defaults:
|
|
132
|
+
*
|
|
133
|
+
* - Per-chunk SHA3-512 mandatory.
|
|
134
|
+
* - Upload ID format: 1-128 chars from [A-Za-z0-9._-]; hostile
|
|
135
|
+
* values (`..`, `/`, `\`, `\0`, glob chars) refused.
|
|
136
|
+
* - Staging dir mode 0o700.
|
|
137
|
+
* - allowedFileTypes default empty (no whitelist; operator opts in).
|
|
138
|
+
* When set without fileType primitive wired, finalize throws at
|
|
139
|
+
* create() — fail-fast on misconfig.
|
|
140
|
+
*
|
|
141
|
+
* What this primitive intentionally does NOT do:
|
|
142
|
+
*
|
|
143
|
+
* - Resumable uploads via Range header — operator builds on top by
|
|
144
|
+
* reading status()'s `received` indices and resuming at index N+1
|
|
145
|
+
* client-side.
|
|
146
|
+
* - Direct browser → S3 presigned-PUT bypass — operators with that
|
|
147
|
+
* requirement use b.objectStore.presignedUploadUrl directly.
|
|
148
|
+
* - Background virus scanning — onChunk hook is the integration
|
|
149
|
+
* point. Operator wires their scanner of choice.
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
var fs = require("node:fs");
|
|
153
|
+
var path = require("node:path");
|
|
154
|
+
var stream = require("node:stream");
|
|
155
|
+
var atomicFile = require("./atomic-file");
|
|
156
|
+
var C = require("./constants");
|
|
157
|
+
var crypto = require("./crypto");
|
|
158
|
+
var numericBounds = require("./numeric-bounds");
|
|
159
|
+
var requestHelpers = require("./request-helpers");
|
|
160
|
+
var safeBuffer = require("./safe-buffer");
|
|
161
|
+
var safeJson = require("./safe-json");
|
|
162
|
+
var validateOpts = require("./validate-opts");
|
|
163
|
+
var { FileUploadError } = require("./framework-error");
|
|
164
|
+
|
|
165
|
+
var _err = FileUploadError.factory;
|
|
166
|
+
|
|
167
|
+
var DEFAULTS = Object.freeze({
|
|
168
|
+
maxFileBytes: C.BYTES.gib(2),
|
|
169
|
+
maxChunkBytes: C.BYTES.mib(8),
|
|
170
|
+
maxStreamReassemblyBytes: C.BYTES.mib(64),
|
|
171
|
+
maxStagingBytes: C.BYTES.gib(50),
|
|
172
|
+
maxActiveUploadsPerActor: 0x10,
|
|
173
|
+
maxChunks: 0x4000,
|
|
174
|
+
incompleteTtlMs: C.TIME.hours(24),
|
|
175
|
+
maxIdleMs: C.TIME.minutes(30),
|
|
176
|
+
// Empty array = no MIME allowlist gate (any type accepted).
|
|
177
|
+
allowedFileTypes: Object.freeze([]),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// SHA3-512 produces 64 bytes; named here so the chunk + manifest
|
|
181
|
+
// hash-shape checks read intentionally instead of as a raw 128.
|
|
182
|
+
var SHA3_512_HEX_LENGTH = C.BYTES.bytes(128);
|
|
183
|
+
// Cap on the bytes the per-upload sidecar files can grow to.
|
|
184
|
+
var SIDECAR_MAX_BYTES = C.BYTES.kib(256);
|
|
185
|
+
// Metadata cap — operators stash filename / mimeType / app-bag here.
|
|
186
|
+
// 64 KiB is generous for normal use and refuses payloads that look
|
|
187
|
+
// like the operator is trying to use the upload sidecar as a row store.
|
|
188
|
+
var METADATA_MAX_BYTES = C.BYTES.kib(64);
|
|
189
|
+
|
|
190
|
+
var UPLOAD_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
191
|
+
var UPLOAD_ID_MAX_LENGTH = C.BYTES.bytes(128);
|
|
192
|
+
|
|
193
|
+
function _validateUploadId(id) {
|
|
194
|
+
if (typeof id !== "string" ||
|
|
195
|
+
id.length === 0 ||
|
|
196
|
+
id.length > UPLOAD_ID_MAX_LENGTH ||
|
|
197
|
+
!UPLOAD_ID_RE.test(id)) {
|
|
198
|
+
var ID_PREVIEW_CHARS = C.BYTES.bytes(64);
|
|
199
|
+
throw _err("BAD_UPLOAD_ID",
|
|
200
|
+
"fileUpload: uploadId must be 1-128 chars matching " + UPLOAD_ID_RE +
|
|
201
|
+
" (path-traversal-hostile inputs refused before any filesystem op), got " +
|
|
202
|
+
JSON.stringify(typeof id === "string" ? id.slice(0, ID_PREVIEW_CHARS) : id));
|
|
203
|
+
}
|
|
204
|
+
return id;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function _validateCreateOpts(opts) {
|
|
208
|
+
validateOpts.requireObject(opts, "fileUpload.create", FileUploadError);
|
|
209
|
+
validateOpts.requireNonEmptyString(opts.stagingDir, "fileUpload.create: stagingDir", FileUploadError);
|
|
210
|
+
if (!path.isAbsolute(opts.stagingDir)) {
|
|
211
|
+
throw _err("BAD_OPT", "fileUpload.create: stagingDir must be an absolute path, got " +
|
|
212
|
+
JSON.stringify(opts.stagingDir));
|
|
213
|
+
}
|
|
214
|
+
validateOpts.optionalFunction(opts.onFinalize, "fileUpload.create: onFinalize", FileUploadError);
|
|
215
|
+
validateOpts.optionalFunction(opts.onChunk, "fileUpload.create: onChunk", FileUploadError);
|
|
216
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxFileBytes,
|
|
217
|
+
"fileUpload.create: maxFileBytes", FileUploadError, "BAD_OPT");
|
|
218
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxChunkBytes,
|
|
219
|
+
"fileUpload.create: maxChunkBytes", FileUploadError, "BAD_OPT");
|
|
220
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxStreamReassemblyBytes,
|
|
221
|
+
"fileUpload.create: maxStreamReassemblyBytes", FileUploadError, "BAD_OPT");
|
|
222
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxStagingBytes,
|
|
223
|
+
"fileUpload.create: maxStagingBytes", FileUploadError, "BAD_OPT");
|
|
224
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxActiveUploadsPerActor,
|
|
225
|
+
"fileUpload.create: maxActiveUploadsPerActor", FileUploadError, "BAD_OPT");
|
|
226
|
+
numericBounds.requireNonNegativeFiniteIntIfPresent(opts.incompleteTtlMs,
|
|
227
|
+
"fileUpload.create: incompleteTtlMs", FileUploadError, "BAD_OPT");
|
|
228
|
+
numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxIdleMs,
|
|
229
|
+
"fileUpload.create: maxIdleMs", FileUploadError, "BAD_OPT");
|
|
230
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxChunks,
|
|
231
|
+
"fileUpload.create: maxChunks", FileUploadError, "BAD_OPT");
|
|
232
|
+
validateOpts.auditShape(opts.audit, "fileUpload.create", FileUploadError);
|
|
233
|
+
validateOpts.observabilityShape(opts.observability, "fileUpload.create", FileUploadError);
|
|
234
|
+
validateOpts.optionalFunction(opts.clock, "fileUpload.create: clock", FileUploadError);
|
|
235
|
+
// allowedFileTypes — operator's MIME allowlist. Empty / undefined
|
|
236
|
+
// disables the gate. Setting it without wiring a fileType primitive
|
|
237
|
+
// is a misconfig — the gate would have nothing to enforce against.
|
|
238
|
+
validateOpts.optionalNonEmptyStringArray(opts.allowedFileTypes,
|
|
239
|
+
"fileUpload.create: allowedFileTypes", FileUploadError, "BAD_OPT");
|
|
240
|
+
if (Array.isArray(opts.allowedFileTypes) && opts.allowedFileTypes.length > 0 &&
|
|
241
|
+
(!opts.fileType || typeof opts.fileType.detect !== "function")) {
|
|
242
|
+
throw _err("BAD_OPT",
|
|
243
|
+
"fileUpload.create: allowedFileTypes is set but fileType primitive is not wired " +
|
|
244
|
+
"(pass fileType: b.fileType so the framework can sniff magic bytes at finalize)");
|
|
245
|
+
}
|
|
246
|
+
// permissions — when set, must expose check(actor, scope) → boolean.
|
|
247
|
+
validateOpts.optionalObjectWithMethod(opts.permissions, "check",
|
|
248
|
+
"fileUpload.create: permissions", FileUploadError, "BAD_OPT",
|
|
249
|
+
"must be a b.permissions instance (check fn)");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function create(opts) {
|
|
253
|
+
_validateCreateOpts(opts);
|
|
254
|
+
var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
|
|
255
|
+
var stagingDir = opts.stagingDir;
|
|
256
|
+
var onFinalize = opts.onFinalize || null;
|
|
257
|
+
var onChunk = opts.onChunk || null;
|
|
258
|
+
var fileType = opts.fileType || null;
|
|
259
|
+
var permissions = opts.permissions || null;
|
|
260
|
+
var maxFileBytes = cfg.maxFileBytes;
|
|
261
|
+
var maxChunkBytes = cfg.maxChunkBytes;
|
|
262
|
+
var maxStreamReassemblyBytes = cfg.maxStreamReassemblyBytes;
|
|
263
|
+
var maxStagingBytes = cfg.maxStagingBytes;
|
|
264
|
+
var maxActiveUploadsPerActor = cfg.maxActiveUploadsPerActor;
|
|
265
|
+
var maxChunks = cfg.maxChunks;
|
|
266
|
+
var incompleteTtlMs = cfg.incompleteTtlMs;
|
|
267
|
+
var maxIdleMs = cfg.maxIdleMs;
|
|
268
|
+
var allowedFileTypes = cfg.allowedFileTypes;
|
|
269
|
+
var audit = opts.audit || null;
|
|
270
|
+
var clock = opts.clock || function () { return Date.now(); };
|
|
271
|
+
|
|
272
|
+
var _emitAudit = validateOpts.makeAuditEmitter(audit);
|
|
273
|
+
function _emitObs(name, value, labels) {
|
|
274
|
+
if (opts.observability) opts.observability.safeEvent(name, value, labels || {});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Staging dir mode 0o700 — only the framework process reads its own
|
|
278
|
+
// staging files.
|
|
279
|
+
atomicFile.ensureDir(stagingDir, 0o700);
|
|
280
|
+
|
|
281
|
+
function _uploadDir(uploadId) { return path.join(stagingDir, uploadId); }
|
|
282
|
+
function _chunkPath(uploadId, index) { return path.join(_uploadDir(uploadId), String(index)); }
|
|
283
|
+
function _receivedPath(uploadId) { return path.join(_uploadDir(uploadId), "_received.json"); }
|
|
284
|
+
function _metaPath(uploadId) { return path.join(_uploadDir(uploadId), "_meta.json"); }
|
|
285
|
+
|
|
286
|
+
function _checkPermission(action, actor) {
|
|
287
|
+
if (!permissions) return;
|
|
288
|
+
var allowed;
|
|
289
|
+
try { allowed = permissions.check(actor, "fileUpload." + action); }
|
|
290
|
+
catch (_e) { allowed = false; }
|
|
291
|
+
if (!allowed) {
|
|
292
|
+
_emitObs("fileUpload.permission_denied", 1, { action: action });
|
|
293
|
+
throw _err("PERMISSION_DENIED",
|
|
294
|
+
"fileUpload." + action + ": actor lacks permission scope 'fileUpload." + action + "'");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function _readReceivedIndices(uploadId) {
|
|
299
|
+
var p = _receivedPath(uploadId);
|
|
300
|
+
if (!fs.existsSync(p)) return [];
|
|
301
|
+
try {
|
|
302
|
+
var raw = atomicFile.readSync(p, { maxBytes: SIDECAR_MAX_BYTES });
|
|
303
|
+
var parsed = safeJson.parse(raw.toString("utf8"));
|
|
304
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
305
|
+
} catch (_e) { return []; }
|
|
306
|
+
}
|
|
307
|
+
function _writeReceivedIndices(uploadId, indices) {
|
|
308
|
+
atomicFile.writeSync(_receivedPath(uploadId), JSON.stringify(indices), { mode: 0o600 });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function _readMeta(uploadId) {
|
|
312
|
+
var p = _metaPath(uploadId);
|
|
313
|
+
if (!fs.existsSync(p)) return null;
|
|
314
|
+
try {
|
|
315
|
+
var raw = atomicFile.readSync(p, { maxBytes: SIDECAR_MAX_BYTES });
|
|
316
|
+
return safeJson.parse(raw.toString("utf8"));
|
|
317
|
+
} catch (_e) { return null; }
|
|
318
|
+
}
|
|
319
|
+
function _writeMeta(uploadId, meta) {
|
|
320
|
+
atomicFile.writeSync(_metaPath(uploadId), JSON.stringify(meta), { mode: 0o600 });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function _actorKey(actor) {
|
|
324
|
+
// Actor identity for quota grouping. id field if present; otherwise
|
|
325
|
+
// anonymous bucket. Operators with un-id'd actors share quota.
|
|
326
|
+
return (actor && (actor.id || actor.userId)) || "_anonymous";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function _enumerateUploads() {
|
|
330
|
+
if (!fs.existsSync(stagingDir)) return [];
|
|
331
|
+
var entries;
|
|
332
|
+
try { entries = atomicFile.listDir(stagingDir, { includeStat: true }); }
|
|
333
|
+
catch (_e) { return []; }
|
|
334
|
+
var uploads = [];
|
|
335
|
+
for (var i = 0; i < entries.length; i++) {
|
|
336
|
+
var e = entries[i];
|
|
337
|
+
if (!e.isDirectory) continue;
|
|
338
|
+
var meta = _readMeta(e.name);
|
|
339
|
+
uploads.push({
|
|
340
|
+
uploadId: e.name,
|
|
341
|
+
meta: meta,
|
|
342
|
+
mtimeMs: e.mtimeMs,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return uploads;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function _stagingTotalBytes() {
|
|
349
|
+
var uploads = _enumerateUploads();
|
|
350
|
+
var total = 0;
|
|
351
|
+
for (var i = 0; i < uploads.length; i++) {
|
|
352
|
+
total += (uploads[i].meta && uploads[i].meta.totalBytesAccepted) || 0;
|
|
353
|
+
}
|
|
354
|
+
return total;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function _activeUploadsForActor(actorId) {
|
|
358
|
+
var uploads = _enumerateUploads();
|
|
359
|
+
var count = 0;
|
|
360
|
+
for (var i = 0; i < uploads.length; i++) {
|
|
361
|
+
if (uploads[i].meta && uploads[i].meta.actorId === actorId) count += 1;
|
|
362
|
+
}
|
|
363
|
+
return count;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- init ----
|
|
367
|
+
|
|
368
|
+
async function init(callerOpts) {
|
|
369
|
+
validateOpts.requireObject(callerOpts, "fileUpload.init", FileUploadError);
|
|
370
|
+
var uploadId = _validateUploadId(callerOpts.uploadId);
|
|
371
|
+
var actor = callerOpts.actor || null;
|
|
372
|
+
var metadata = callerOpts.metadata !== undefined ? callerOpts.metadata : {};
|
|
373
|
+
|
|
374
|
+
_checkPermission("init", actor);
|
|
375
|
+
|
|
376
|
+
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
|
|
377
|
+
throw _err("BAD_METADATA",
|
|
378
|
+
"fileUpload.init: metadata must be a plain object (operator app-bag)");
|
|
379
|
+
}
|
|
380
|
+
var metadataJson = JSON.stringify(metadata);
|
|
381
|
+
if (Buffer.byteLength(metadataJson, "utf8") > METADATA_MAX_BYTES) {
|
|
382
|
+
throw _err("METADATA_TOO_LARGE",
|
|
383
|
+
"fileUpload.init: metadata exceeds " + METADATA_MAX_BYTES + " bytes");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Refuse re-init of an existing upload (caller-side bug).
|
|
387
|
+
if (fs.existsSync(_uploadDir(uploadId))) {
|
|
388
|
+
throw _err("UPLOAD_EXISTS",
|
|
389
|
+
"fileUpload.init: upload '" + uploadId + "' already exists; cancel or finalize first");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
var actorId = _actorKey(actor);
|
|
393
|
+
if (_activeUploadsForActor(actorId) >= maxActiveUploadsPerActor) {
|
|
394
|
+
_emitObs("fileUpload.actor_quota_exceeded", 1);
|
|
395
|
+
throw _err("ACTOR_QUOTA_EXCEEDED",
|
|
396
|
+
"fileUpload.init: actor '" + actorId + "' has " + maxActiveUploadsPerActor +
|
|
397
|
+
" active uploads (cap maxActiveUploadsPerActor)");
|
|
398
|
+
}
|
|
399
|
+
if (_stagingTotalBytes() >= maxStagingBytes) {
|
|
400
|
+
_emitObs("fileUpload.staging_quota_exceeded", 1);
|
|
401
|
+
throw _err("STAGING_QUOTA_EXCEEDED",
|
|
402
|
+
"fileUpload.init: total staging exceeds " + maxStagingBytes + " bytes (maxStagingBytes)");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
atomicFile.ensureDir(_uploadDir(uploadId), 0o700);
|
|
406
|
+
var now = clock();
|
|
407
|
+
var meta = {
|
|
408
|
+
uploadId: uploadId,
|
|
409
|
+
actorId: actorId,
|
|
410
|
+
metadata: metadata,
|
|
411
|
+
createdAt: now,
|
|
412
|
+
lastChunkAt: now,
|
|
413
|
+
totalBytesAccepted: 0,
|
|
414
|
+
};
|
|
415
|
+
_writeMeta(uploadId, meta);
|
|
416
|
+
_writeReceivedIndices(uploadId, []);
|
|
417
|
+
|
|
418
|
+
_emitObs("fileUpload.init", 1);
|
|
419
|
+
_emitAudit("fileUpload.init", {
|
|
420
|
+
actor: requestHelpers.extractActorContext(actor),
|
|
421
|
+
resource: { kind: "fileUpload", id: uploadId },
|
|
422
|
+
outcome: "success",
|
|
423
|
+
metadata: { metadata: metadata },
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
uploadId: uploadId,
|
|
428
|
+
createdAt: now,
|
|
429
|
+
expiresAt: now + incompleteTtlMs,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---- acceptChunk ----
|
|
434
|
+
|
|
435
|
+
async function acceptChunk(callerOpts) {
|
|
436
|
+
validateOpts.requireObject(callerOpts, "fileUpload.acceptChunk", FileUploadError);
|
|
437
|
+
var uploadId = _validateUploadId(callerOpts.uploadId);
|
|
438
|
+
var index = callerOpts.index;
|
|
439
|
+
var body = callerOpts.body;
|
|
440
|
+
var sha3Hex = callerOpts.sha3;
|
|
441
|
+
var actor = callerOpts.actor;
|
|
442
|
+
|
|
443
|
+
_checkPermission("accept", actor);
|
|
444
|
+
|
|
445
|
+
var meta = _readMeta(uploadId);
|
|
446
|
+
if (!meta) {
|
|
447
|
+
throw _err("UNKNOWN_UPLOAD",
|
|
448
|
+
"fileUpload.acceptChunk: no init() seen for '" + uploadId + "'; call init() first");
|
|
449
|
+
}
|
|
450
|
+
if (clock() - meta.lastChunkAt > maxIdleMs) {
|
|
451
|
+
// Idle-timed-out — too much time since init or last chunk.
|
|
452
|
+
throw _err("UPLOAD_IDLE_EXPIRED",
|
|
453
|
+
"fileUpload.acceptChunk: upload '" + uploadId + "' exceeded maxIdleMs (" + maxIdleMs +
|
|
454
|
+
" ms since last chunk or init)");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!Number.isInteger(index) || index < 0 || index >= maxChunks) {
|
|
458
|
+
throw _err("BAD_INDEX",
|
|
459
|
+
"fileUpload.acceptChunk: index must be a non-negative integer < " + maxChunks +
|
|
460
|
+
", got " + numericBounds.shape(index));
|
|
461
|
+
}
|
|
462
|
+
if (!Buffer.isBuffer(body)) {
|
|
463
|
+
throw _err("BAD_BODY",
|
|
464
|
+
"fileUpload.acceptChunk: body must be a Buffer, got " + typeof body);
|
|
465
|
+
}
|
|
466
|
+
if (body.length === 0) {
|
|
467
|
+
throw _err("EMPTY_CHUNK",
|
|
468
|
+
"fileUpload.acceptChunk: body is empty (0 bytes)");
|
|
469
|
+
}
|
|
470
|
+
if (body.length > maxChunkBytes) {
|
|
471
|
+
_emitObs("fileUpload.chunk_too_large", 1);
|
|
472
|
+
throw _err("CHUNK_TOO_LARGE",
|
|
473
|
+
"fileUpload.acceptChunk: chunk body is " + body.length +
|
|
474
|
+
" bytes, exceeds maxChunkBytes (" + maxChunkBytes + ")");
|
|
475
|
+
}
|
|
476
|
+
if (!safeBuffer.isHex(sha3Hex) || sha3Hex.length !== SHA3_512_HEX_LENGTH) {
|
|
477
|
+
throw _err("BAD_CHUNK_HASH",
|
|
478
|
+
"fileUpload.acceptChunk: sha3 must be a SHA3-512 hex string (" +
|
|
479
|
+
SHA3_512_HEX_LENGTH + " chars); got " +
|
|
480
|
+
(typeof sha3Hex === "string" ? sha3Hex.length + " chars" : typeof sha3Hex));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Verify chunk hash matches the supplied header.
|
|
484
|
+
var actualHex = crypto.sha3Hash(body);
|
|
485
|
+
if (!crypto.timingSafeEqual(actualHex, sha3Hex)) {
|
|
486
|
+
_emitObs("fileUpload.chunk_hash_mismatch", 1);
|
|
487
|
+
_emitAudit("fileUpload.chunk_received", {
|
|
488
|
+
actor: requestHelpers.extractActorContext(actor),
|
|
489
|
+
resource: { kind: "fileUpload", id: uploadId },
|
|
490
|
+
outcome: "failure",
|
|
491
|
+
reason: "chunk-hash-mismatch",
|
|
492
|
+
metadata: { index: index, size: body.length },
|
|
493
|
+
});
|
|
494
|
+
throw _err("CHUNK_HASH_MISMATCH",
|
|
495
|
+
"fileUpload.acceptChunk: chunk SHA3-512 mismatch — supplied does not equal computed");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Per-chunk operator hook (e.g. virus scan, schema check). May
|
|
499
|
+
// throw to refuse the chunk.
|
|
500
|
+
if (onChunk) {
|
|
501
|
+
try {
|
|
502
|
+
await onChunk({
|
|
503
|
+
uploadId: uploadId,
|
|
504
|
+
index: index,
|
|
505
|
+
body: body,
|
|
506
|
+
sha3: actualHex,
|
|
507
|
+
actor: actor,
|
|
508
|
+
metadata: meta.metadata,
|
|
509
|
+
});
|
|
510
|
+
} catch (e) {
|
|
511
|
+
_emitObs("fileUpload.onchunk_rejected", 1);
|
|
512
|
+
_emitAudit("fileUpload.chunk_received", {
|
|
513
|
+
actor: requestHelpers.extractActorContext(actor),
|
|
514
|
+
resource: { kind: "fileUpload", id: uploadId },
|
|
515
|
+
outcome: "failure",
|
|
516
|
+
reason: "onchunk-rejected",
|
|
517
|
+
metadata: { index: index, size: body.length,
|
|
518
|
+
error: (e && e.message) || String(e) },
|
|
519
|
+
});
|
|
520
|
+
throw e;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Idempotent re-PUT: if this index is already received with a
|
|
525
|
+
// matching body, no-op. Different body = caller bug.
|
|
526
|
+
var p = _chunkPath(uploadId, index);
|
|
527
|
+
if (fs.existsSync(p)) {
|
|
528
|
+
var existing = atomicFile.readSync(p, { maxBytes: maxChunkBytes });
|
|
529
|
+
if (crypto.timingSafeEqual(crypto.sha3Hash(existing), sha3Hex)) {
|
|
530
|
+
return {
|
|
531
|
+
received: _readReceivedIndices(uploadId).length,
|
|
532
|
+
totalBytesAccepted: meta.totalBytesAccepted,
|
|
533
|
+
status: "in-progress",
|
|
534
|
+
duplicate: true,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
throw _err("CHUNK_REUSE_MISMATCH",
|
|
538
|
+
"fileUpload.acceptChunk: chunk " + index +
|
|
539
|
+
" already received with a different body (caller-side bug; refusing overwrite)");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
atomicFile.writeSync(p, body, { mode: 0o600 });
|
|
543
|
+
var receivedIndices = _readReceivedIndices(uploadId);
|
|
544
|
+
if (receivedIndices.indexOf(index) === -1) {
|
|
545
|
+
receivedIndices.push(index);
|
|
546
|
+
_writeReceivedIndices(uploadId, receivedIndices);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Update meta.
|
|
550
|
+
meta.lastChunkAt = clock();
|
|
551
|
+
meta.totalBytesAccepted = (meta.totalBytesAccepted || 0) + body.length;
|
|
552
|
+
if (meta.totalBytesAccepted > maxFileBytes) {
|
|
553
|
+
// Reclaim staging — the upload exceeded the cap mid-stream.
|
|
554
|
+
try { fs.rmSync(_uploadDir(uploadId), { recursive: true, force: true }); }
|
|
555
|
+
catch (_e) { /* purgeIncomplete will reclaim */ }
|
|
556
|
+
_emitObs("fileUpload.file_too_large", 1);
|
|
557
|
+
throw _err("FILE_TOO_LARGE",
|
|
558
|
+
"fileUpload.acceptChunk: cumulative upload exceeded maxFileBytes (" + maxFileBytes +
|
|
559
|
+
"); upload reclaimed");
|
|
560
|
+
}
|
|
561
|
+
_writeMeta(uploadId, meta);
|
|
562
|
+
|
|
563
|
+
_emitObs("fileUpload.chunks_received", 1);
|
|
564
|
+
_emitObs("fileUpload.bytes_received", body.length);
|
|
565
|
+
_emitAudit("fileUpload.chunk_received", {
|
|
566
|
+
actor: requestHelpers.extractActorContext(actor),
|
|
567
|
+
resource: { kind: "fileUpload", id: uploadId },
|
|
568
|
+
outcome: "success",
|
|
569
|
+
metadata: { index: index, size: body.length },
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
received: receivedIndices.length,
|
|
574
|
+
totalBytesAccepted: meta.totalBytesAccepted,
|
|
575
|
+
status: "in-progress",
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ---- finalize ----
|
|
580
|
+
|
|
581
|
+
function _validateManifest(manifest) {
|
|
582
|
+
validateOpts.requireObject(manifest, "fileUpload.finalize: manifest", FileUploadError);
|
|
583
|
+
if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0) {
|
|
584
|
+
throw _err("BAD_MANIFEST",
|
|
585
|
+
"fileUpload.finalize: manifest.chunks must be a non-empty array");
|
|
586
|
+
}
|
|
587
|
+
if (manifest.chunks.length > maxChunks) {
|
|
588
|
+
throw _err("TOO_MANY_CHUNKS",
|
|
589
|
+
"fileUpload.finalize: manifest declares " + manifest.chunks.length +
|
|
590
|
+
" chunks, exceeds maxChunks (" + maxChunks + ")");
|
|
591
|
+
}
|
|
592
|
+
if (!Number.isInteger(manifest.totalBytes) || manifest.totalBytes <= 0) {
|
|
593
|
+
throw _err("BAD_MANIFEST",
|
|
594
|
+
"fileUpload.finalize: manifest.totalBytes must be a positive integer");
|
|
595
|
+
}
|
|
596
|
+
if (manifest.totalBytes > maxFileBytes) {
|
|
597
|
+
throw _err("FILE_TOO_LARGE",
|
|
598
|
+
"fileUpload.finalize: manifest.totalBytes (" + manifest.totalBytes +
|
|
599
|
+
") exceeds maxFileBytes (" + maxFileBytes + ")");
|
|
600
|
+
}
|
|
601
|
+
if (!safeBuffer.isHex(manifest.sha3) || manifest.sha3.length !== SHA3_512_HEX_LENGTH) {
|
|
602
|
+
throw _err("BAD_MANIFEST",
|
|
603
|
+
"fileUpload.finalize: manifest.sha3 must be a SHA3-512 hex string (" +
|
|
604
|
+
SHA3_512_HEX_LENGTH + " chars)");
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function _verifyChunksOnDisk(uploadId, manifest) {
|
|
609
|
+
// Returns sorted chunk paths + verifies per-chunk + total hash.
|
|
610
|
+
// For small uploads we walk + concat; for large we just walk and
|
|
611
|
+
// verify, returning paths so the streaming path can read on
|
|
612
|
+
// demand.
|
|
613
|
+
var sortedChunks = manifest.chunks.slice().sort(function (a, b) {
|
|
614
|
+
return a.index - b.index;
|
|
615
|
+
});
|
|
616
|
+
var paths = [];
|
|
617
|
+
var hasher = require("node:crypto").createHash("sha3-512");
|
|
618
|
+
var totalBytes = 0;
|
|
619
|
+
|
|
620
|
+
for (var i = 0; i < sortedChunks.length; i++) {
|
|
621
|
+
var ck = sortedChunks[i];
|
|
622
|
+
if (!Number.isInteger(ck.index) || ck.index !== i) {
|
|
623
|
+
throw _err("MANIFEST_INDEX_GAP",
|
|
624
|
+
"fileUpload.finalize: chunk " + i + " in manifest has index " + ck.index +
|
|
625
|
+
" (expected " + i + " — chunk indices must be 0..N-1 contiguous)");
|
|
626
|
+
}
|
|
627
|
+
if (!safeBuffer.isHex(ck.sha3) || ck.sha3.length !== SHA3_512_HEX_LENGTH) {
|
|
628
|
+
throw _err("BAD_MANIFEST",
|
|
629
|
+
"fileUpload.finalize: chunk " + i + ".sha3 must be a SHA3-512 hex string (" +
|
|
630
|
+
SHA3_512_HEX_LENGTH + " chars)");
|
|
631
|
+
}
|
|
632
|
+
var chunkPath = _chunkPath(uploadId, ck.index);
|
|
633
|
+
if (!fs.existsSync(chunkPath)) {
|
|
634
|
+
throw _err("MISSING_CHUNK",
|
|
635
|
+
"fileUpload.finalize: chunk " + ck.index + " missing from staging");
|
|
636
|
+
}
|
|
637
|
+
var chunkBody = atomicFile.readSync(chunkPath, { maxBytes: maxChunkBytes });
|
|
638
|
+
var actualChunkHex = crypto.sha3Hash(chunkBody);
|
|
639
|
+
if (!crypto.timingSafeEqual(actualChunkHex, ck.sha3)) {
|
|
640
|
+
throw _err("CHUNK_HASH_MISMATCH",
|
|
641
|
+
"fileUpload.finalize: chunk " + ck.index +
|
|
642
|
+
" on-disk SHA3-512 doesn't match manifest");
|
|
643
|
+
}
|
|
644
|
+
paths.push(chunkPath);
|
|
645
|
+
totalBytes += chunkBody.length;
|
|
646
|
+
if (totalBytes > maxFileBytes) {
|
|
647
|
+
throw _err("FILE_TOO_LARGE",
|
|
648
|
+
"fileUpload.finalize: reassembly exceeds maxFileBytes mid-walk");
|
|
649
|
+
}
|
|
650
|
+
hasher.update(chunkBody);
|
|
651
|
+
}
|
|
652
|
+
if (totalBytes !== manifest.totalBytes) {
|
|
653
|
+
throw _err("MANIFEST_SIZE_MISMATCH",
|
|
654
|
+
"fileUpload.finalize: reassembled " + totalBytes +
|
|
655
|
+
" bytes, manifest declares " + manifest.totalBytes);
|
|
656
|
+
}
|
|
657
|
+
var totalHashHex = hasher.digest("hex");
|
|
658
|
+
if (!crypto.timingSafeEqual(totalHashHex, manifest.sha3)) {
|
|
659
|
+
throw _err("MANIFEST_HASH_MISMATCH",
|
|
660
|
+
"fileUpload.finalize: reassembled SHA3-512 doesn't match manifest.sha3");
|
|
661
|
+
}
|
|
662
|
+
return { paths: paths, totalBytes: totalBytes, totalHashHex: totalHashHex };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function _checkAllowedFileType(firstChunkBody) {
|
|
666
|
+
if (!allowedFileTypes || allowedFileTypes.length === 0) return;
|
|
667
|
+
if (!fileType) return; // create() guards this; defensive
|
|
668
|
+
var detected = fileType.detect(firstChunkBody);
|
|
669
|
+
var detectedMime = detected && detected.mime;
|
|
670
|
+
if (!detectedMime) {
|
|
671
|
+
throw _err("MIME_NOT_DETECTED",
|
|
672
|
+
"fileUpload.finalize: could not classify magic bytes against allowedFileTypes");
|
|
673
|
+
}
|
|
674
|
+
var ok = false;
|
|
675
|
+
for (var i = 0; i < allowedFileTypes.length; i++) {
|
|
676
|
+
var allowed = allowedFileTypes[i];
|
|
677
|
+
if (allowed === detectedMime) { ok = true; break; }
|
|
678
|
+
// Wildcard support: "image/*" matches "image/png".
|
|
679
|
+
if (allowed.endsWith("/*")) {
|
|
680
|
+
var prefix = allowed.slice(0, -1); // "image/"
|
|
681
|
+
if (detectedMime.indexOf(prefix) === 0) { ok = true; break; }
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (!ok) {
|
|
685
|
+
throw _err("MIME_NOT_ALLOWED",
|
|
686
|
+
"fileUpload.finalize: detected MIME '" + detectedMime +
|
|
687
|
+
"' not in allowedFileTypes (" + allowedFileTypes.join(", ") + ")");
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function _streamFromChunkPaths(paths /* totalBytes */) {
|
|
692
|
+
// Sequential ReadableStream over chunk files. Operator's
|
|
693
|
+
// onFinalize reads through to wherever they're piping.
|
|
694
|
+
async function* generate() {
|
|
695
|
+
for (var i = 0; i < paths.length; i += 1) {
|
|
696
|
+
var fh = fs.createReadStream(paths[i]);
|
|
697
|
+
for await (var chunk of fh) {
|
|
698
|
+
yield chunk;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return stream.Readable.from(generate(), { objectMode: false });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function finalize(callerOpts) {
|
|
706
|
+
validateOpts.requireObject(callerOpts, "fileUpload.finalize", FileUploadError);
|
|
707
|
+
var uploadId = _validateUploadId(callerOpts.uploadId);
|
|
708
|
+
var manifest = callerOpts.manifest;
|
|
709
|
+
var actor = callerOpts.actor;
|
|
710
|
+
|
|
711
|
+
_checkPermission("finalize", actor);
|
|
712
|
+
|
|
713
|
+
var meta = _readMeta(uploadId);
|
|
714
|
+
if (!meta) {
|
|
715
|
+
throw _err("UNKNOWN_UPLOAD",
|
|
716
|
+
"fileUpload.finalize: no init() seen for '" + uploadId + "'");
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
_validateManifest(manifest);
|
|
720
|
+
|
|
721
|
+
var verified = _verifyChunksOnDisk(uploadId, manifest);
|
|
722
|
+
|
|
723
|
+
// Decide buffer-vs-stream based on size.
|
|
724
|
+
var useStream = verified.totalBytes > maxStreamReassemblyBytes;
|
|
725
|
+
var bodyBuffer = null;
|
|
726
|
+
var bodyStream = null;
|
|
727
|
+
var firstChunk = null;
|
|
728
|
+
|
|
729
|
+
if (useStream) {
|
|
730
|
+
// Read just the first chunk for the MIME sniff; the operator
|
|
731
|
+
// gets the stream for the actual data.
|
|
732
|
+
firstChunk = atomicFile.readSync(verified.paths[0], { maxBytes: maxChunkBytes });
|
|
733
|
+
bodyStream = _streamFromChunkPaths(verified.paths, verified.totalBytes);
|
|
734
|
+
} else {
|
|
735
|
+
// Small enough to assemble in memory. Buffer.concat.
|
|
736
|
+
var pieces = [];
|
|
737
|
+
for (var i = 0; i < verified.paths.length; i++) {
|
|
738
|
+
pieces.push(atomicFile.readSync(verified.paths[i], { maxBytes: maxChunkBytes }));
|
|
739
|
+
}
|
|
740
|
+
bodyBuffer = Buffer.concat(pieces, verified.totalBytes);
|
|
741
|
+
firstChunk = pieces[0];
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// MIME allowlist gate (if configured).
|
|
745
|
+
try { _checkAllowedFileType(firstChunk); }
|
|
746
|
+
catch (e) {
|
|
747
|
+
_emitObs("fileUpload.mime_rejected", 1);
|
|
748
|
+
_emitAudit("fileUpload.finalize", {
|
|
749
|
+
actor: requestHelpers.extractActorContext(actor),
|
|
750
|
+
resource: { kind: "fileUpload", id: uploadId },
|
|
751
|
+
outcome: "failure",
|
|
752
|
+
reason: "mime-not-allowed",
|
|
753
|
+
metadata: { size: verified.totalBytes,
|
|
754
|
+
error: (e && e.message) || String(e) },
|
|
755
|
+
});
|
|
756
|
+
throw e;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Hand to operator's onFinalize.
|
|
760
|
+
var rv;
|
|
761
|
+
try {
|
|
762
|
+
if (onFinalize) {
|
|
763
|
+
rv = await onFinalize({
|
|
764
|
+
uploadId: uploadId,
|
|
765
|
+
body: bodyBuffer,
|
|
766
|
+
stream: bodyStream,
|
|
767
|
+
sha3: verified.totalHashHex,
|
|
768
|
+
size: verified.totalBytes,
|
|
769
|
+
actor: actor,
|
|
770
|
+
metadata: meta.metadata,
|
|
771
|
+
});
|
|
772
|
+
} else {
|
|
773
|
+
rv = { ok: true, sha3: verified.totalHashHex, size: verified.totalBytes };
|
|
774
|
+
}
|
|
775
|
+
} catch (e) {
|
|
776
|
+
_emitObs("fileUpload.finalize_failure", 1);
|
|
777
|
+
_emitAudit("fileUpload.finalize", {
|
|
778
|
+
actor: requestHelpers.extractActorContext(actor),
|
|
779
|
+
resource: { kind: "fileUpload", id: uploadId },
|
|
780
|
+
outcome: "failure",
|
|
781
|
+
reason: "onfinalize-threw",
|
|
782
|
+
metadata: { size: verified.totalBytes, sha3: verified.totalHashHex,
|
|
783
|
+
error: (e && e.message) || String(e) },
|
|
784
|
+
});
|
|
785
|
+
throw e;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Cleanup staging on success.
|
|
789
|
+
try { fs.rmSync(_uploadDir(uploadId), { recursive: true, force: true }); }
|
|
790
|
+
catch (_e) { /* best-effort */ }
|
|
791
|
+
|
|
792
|
+
_emitObs("fileUpload.finalize_success", 1);
|
|
793
|
+
_emitObs("fileUpload.finalize_bytes", verified.totalBytes);
|
|
794
|
+
_emitAudit("fileUpload.finalize", {
|
|
795
|
+
actor: requestHelpers.extractActorContext(actor),
|
|
796
|
+
resource: { kind: "fileUpload", id: uploadId },
|
|
797
|
+
outcome: "success",
|
|
798
|
+
metadata: { size: verified.totalBytes, sha3: verified.totalHashHex,
|
|
799
|
+
mode: useStream ? "stream" : "buffer" },
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
return rv;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ---- status / list / cancel ----
|
|
806
|
+
|
|
807
|
+
function status(uploadId, callerOpts) {
|
|
808
|
+
callerOpts = callerOpts || {};
|
|
809
|
+
_validateUploadId(uploadId);
|
|
810
|
+
_checkPermission("status", callerOpts.actor);
|
|
811
|
+
var meta = _readMeta(uploadId);
|
|
812
|
+
if (!meta) return null;
|
|
813
|
+
var indices = _readReceivedIndices(uploadId).slice().sort(function (a, b) { return a - b; });
|
|
814
|
+
return {
|
|
815
|
+
uploadId: uploadId,
|
|
816
|
+
received: indices,
|
|
817
|
+
totalBytesAccepted: meta.totalBytesAccepted || 0,
|
|
818
|
+
createdAt: meta.createdAt,
|
|
819
|
+
lastChunkAt: meta.lastChunkAt,
|
|
820
|
+
metadata: meta.metadata || {},
|
|
821
|
+
expiresAt: meta.createdAt + incompleteTtlMs,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function list(callerOpts) {
|
|
826
|
+
callerOpts = callerOpts || {};
|
|
827
|
+
_checkPermission("list", callerOpts.actor);
|
|
828
|
+
var actorFilter = callerOpts.actor && (callerOpts.actor.id || callerOpts.actor.userId);
|
|
829
|
+
var sinceMs = (typeof callerOpts.since === "number") ? callerOpts.since : 0;
|
|
830
|
+
var uploads = _enumerateUploads();
|
|
831
|
+
var out = [];
|
|
832
|
+
for (var i = 0; i < uploads.length; i++) {
|
|
833
|
+
var u = uploads[i];
|
|
834
|
+
if (!u.meta) continue;
|
|
835
|
+
if (sinceMs && u.meta.createdAt < sinceMs) continue;
|
|
836
|
+
if (actorFilter && callerOpts.scopeToActor !== false && u.meta.actorId !== actorFilter) continue;
|
|
837
|
+
out.push({
|
|
838
|
+
uploadId: u.meta.uploadId,
|
|
839
|
+
actorId: u.meta.actorId,
|
|
840
|
+
metadata: u.meta.metadata || {},
|
|
841
|
+
createdAt: u.meta.createdAt,
|
|
842
|
+
lastChunkAt: u.meta.lastChunkAt,
|
|
843
|
+
totalBytesAccepted: u.meta.totalBytesAccepted || 0,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
return out;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function cancelUpload(uploadId, callerOpts) {
|
|
850
|
+
callerOpts = callerOpts || {};
|
|
851
|
+
_validateUploadId(uploadId);
|
|
852
|
+
_checkPermission("cancel", callerOpts.actor);
|
|
853
|
+
var meta = _readMeta(uploadId);
|
|
854
|
+
if (!meta) return { ok: false, uploadId: uploadId, reason: "not-found" };
|
|
855
|
+
try { fs.rmSync(_uploadDir(uploadId), { recursive: true, force: true }); }
|
|
856
|
+
catch (_e) { /* best-effort */ }
|
|
857
|
+
_emitObs("fileUpload.cancelled", 1);
|
|
858
|
+
_emitAudit("fileUpload.cancelled", {
|
|
859
|
+
actor: requestHelpers.extractActorContext(callerOpts.actor),
|
|
860
|
+
resource: { kind: "fileUpload", id: uploadId },
|
|
861
|
+
outcome: "success",
|
|
862
|
+
metadata: { totalBytesAccepted: meta.totalBytesAccepted || 0 },
|
|
863
|
+
});
|
|
864
|
+
return { ok: true, uploadId: uploadId };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ---- purgeIncomplete ----
|
|
868
|
+
|
|
869
|
+
function purgeIncomplete() {
|
|
870
|
+
if (!fs.existsSync(stagingDir)) return { purged: 0, ids: [] };
|
|
871
|
+
var now = clock();
|
|
872
|
+
var entries;
|
|
873
|
+
try { entries = atomicFile.listDir(stagingDir, { includeStat: true }); }
|
|
874
|
+
catch (_e) { return { purged: 0, ids: [] }; }
|
|
875
|
+
var purged = [];
|
|
876
|
+
for (var i = 0; i < entries.length; i++) {
|
|
877
|
+
var e = entries[i];
|
|
878
|
+
if (!e.isDirectory) continue;
|
|
879
|
+
var meta = _readMeta(e.name);
|
|
880
|
+
var purgeReason = null;
|
|
881
|
+
if (meta) {
|
|
882
|
+
if (now - meta.createdAt > incompleteTtlMs) purgeReason = "ttl-exceeded";
|
|
883
|
+
else if (now - meta.lastChunkAt > maxIdleMs) purgeReason = "idle-exceeded";
|
|
884
|
+
} else {
|
|
885
|
+
// No meta sidecar → orphaned dir from a prior version or
|
|
886
|
+
// failed init. Reclaim by mtime.
|
|
887
|
+
if (now - e.mtimeMs > incompleteTtlMs) purgeReason = "orphan";
|
|
888
|
+
}
|
|
889
|
+
if (!purgeReason) continue;
|
|
890
|
+
try {
|
|
891
|
+
fs.rmSync(e.fullPath, { recursive: true, force: true });
|
|
892
|
+
purged.push({ id: e.name, reason: purgeReason });
|
|
893
|
+
} catch (_e2) { /* best-effort; will retry */ }
|
|
894
|
+
}
|
|
895
|
+
if (purged.length > 0) {
|
|
896
|
+
_emitObs("fileUpload.purged_incomplete", purged.length);
|
|
897
|
+
_emitAudit("fileUpload.purged", {
|
|
898
|
+
actor: { kind: "framework" },
|
|
899
|
+
resource: { kind: "fileUpload", id: stagingDir },
|
|
900
|
+
outcome: "success",
|
|
901
|
+
metadata: { purgedIds: purged.map(function (p) { return p.id; }),
|
|
902
|
+
count: purged.length },
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
return {
|
|
906
|
+
purged: purged.length,
|
|
907
|
+
ids: purged.map(function (p) { return p.id; }),
|
|
908
|
+
reasons: purged,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function close() {
|
|
913
|
+
// Lifecycle parity. No timers / connections to release.
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return {
|
|
917
|
+
init: init,
|
|
918
|
+
acceptChunk: acceptChunk,
|
|
919
|
+
finalize: finalize,
|
|
920
|
+
status: status,
|
|
921
|
+
list: list,
|
|
922
|
+
cancelUpload: cancelUpload,
|
|
923
|
+
purgeIncomplete: purgeIncomplete,
|
|
924
|
+
close: close,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
module.exports = {
|
|
929
|
+
create: create,
|
|
930
|
+
FileUploadError: FileUploadError,
|
|
931
|
+
DEFAULTS: DEFAULTS,
|
|
932
|
+
UPLOAD_ID_RE: UPLOAD_ID_RE,
|
|
933
|
+
};
|