@zero-server/sdk 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +460 -443
  3. package/index.js +414 -412
  4. package/lib/app.js +1172 -1172
  5. package/lib/auth/authorize.js +399 -399
  6. package/lib/auth/enrollment.js +367 -367
  7. package/lib/auth/index.js +57 -57
  8. package/lib/auth/jwt.js +731 -731
  9. package/lib/auth/oauth.js +362 -362
  10. package/lib/auth/session.js +588 -588
  11. package/lib/auth/trustedDevice.js +409 -409
  12. package/lib/auth/twoFactor.js +1150 -1150
  13. package/lib/auth/webauthn.js +946 -946
  14. package/lib/body/index.js +14 -14
  15. package/lib/body/json.js +109 -109
  16. package/lib/body/multipart.js +440 -440
  17. package/lib/body/raw.js +71 -71
  18. package/lib/body/rawBuffer.js +160 -160
  19. package/lib/body/sendError.js +25 -25
  20. package/lib/body/text.js +75 -75
  21. package/lib/body/typeMatch.js +41 -41
  22. package/lib/body/urlencoded.js +235 -235
  23. package/lib/cli.js +845 -845
  24. package/lib/cluster.js +666 -666
  25. package/lib/debug.js +372 -372
  26. package/lib/env/index.js +465 -465
  27. package/lib/errors.js +683 -683
  28. package/lib/fetch/index.js +256 -256
  29. package/lib/grpc/balancer.js +378 -378
  30. package/lib/grpc/call.js +708 -708
  31. package/lib/grpc/client.js +764 -764
  32. package/lib/grpc/codec.js +1221 -1221
  33. package/lib/grpc/credentials.js +398 -398
  34. package/lib/grpc/frame.js +262 -262
  35. package/lib/grpc/health.js +287 -287
  36. package/lib/grpc/index.js +121 -121
  37. package/lib/grpc/metadata.js +461 -461
  38. package/lib/grpc/proto.js +821 -821
  39. package/lib/grpc/reflection.js +590 -590
  40. package/lib/grpc/server.js +445 -445
  41. package/lib/grpc/status.js +118 -118
  42. package/lib/grpc/watch.js +173 -173
  43. package/lib/http/index.js +10 -10
  44. package/lib/http/request.js +727 -727
  45. package/lib/http/response.js +799 -799
  46. package/lib/lifecycle.js +557 -557
  47. package/lib/middleware/compress.js +230 -230
  48. package/lib/middleware/cookieParser.js +237 -237
  49. package/lib/middleware/cors.js +93 -93
  50. package/lib/middleware/csrf.js +137 -137
  51. package/lib/middleware/errorHandler.js +101 -101
  52. package/lib/middleware/helmet.js +175 -175
  53. package/lib/middleware/index.js +19 -17
  54. package/lib/middleware/logger.js +74 -74
  55. package/lib/middleware/rateLimit.js +88 -88
  56. package/lib/middleware/requestId.js +53 -53
  57. package/lib/middleware/static.js +326 -326
  58. package/lib/middleware/timeout.js +71 -71
  59. package/lib/middleware/validator.js +255 -255
  60. package/lib/observe/health.js +326 -326
  61. package/lib/observe/index.js +50 -50
  62. package/lib/observe/logger.js +359 -359
  63. package/lib/observe/metrics.js +805 -805
  64. package/lib/observe/tracing.js +592 -592
  65. package/lib/orm/adapters/json.js +290 -290
  66. package/lib/orm/adapters/memory.js +764 -764
  67. package/lib/orm/adapters/mongo.js +764 -764
  68. package/lib/orm/adapters/mysql.js +933 -933
  69. package/lib/orm/adapters/postgres.js +1144 -1144
  70. package/lib/orm/adapters/redis.js +1534 -1534
  71. package/lib/orm/adapters/sql-base.js +212 -212
  72. package/lib/orm/adapters/sqlite.js +858 -858
  73. package/lib/orm/audit.js +649 -649
  74. package/lib/orm/cache.js +394 -394
  75. package/lib/orm/geo.js +387 -387
  76. package/lib/orm/index.js +784 -784
  77. package/lib/orm/migrate.js +432 -432
  78. package/lib/orm/model.js +1706 -1706
  79. package/lib/orm/plugin.js +375 -375
  80. package/lib/orm/procedures.js +836 -836
  81. package/lib/orm/profiler.js +233 -233
  82. package/lib/orm/query.js +1772 -1772
  83. package/lib/orm/replicas.js +241 -241
  84. package/lib/orm/schema.js +307 -307
  85. package/lib/orm/search.js +380 -380
  86. package/lib/orm/seed/data/commerce.js +136 -136
  87. package/lib/orm/seed/data/internet.js +111 -111
  88. package/lib/orm/seed/data/locations.js +204 -204
  89. package/lib/orm/seed/data/names.js +338 -338
  90. package/lib/orm/seed/data/person.js +128 -128
  91. package/lib/orm/seed/data/phone.js +211 -211
  92. package/lib/orm/seed/data/words.js +134 -134
  93. package/lib/orm/seed/factory.js +178 -178
  94. package/lib/orm/seed/fake.js +1186 -1186
  95. package/lib/orm/seed/index.js +18 -18
  96. package/lib/orm/seed/rng.js +70 -70
  97. package/lib/orm/seed/seeder.js +124 -124
  98. package/lib/orm/seed/unique.js +68 -68
  99. package/lib/orm/snapshot.js +366 -366
  100. package/lib/orm/tenancy.js +605 -605
  101. package/lib/orm/views.js +350 -350
  102. package/lib/router/index.js +436 -436
  103. package/lib/sse/index.js +8 -8
  104. package/lib/sse/stream.js +349 -349
  105. package/lib/ws/connection.js +451 -451
  106. package/lib/ws/handshake.js +125 -125
  107. package/lib/ws/index.js +14 -14
  108. package/lib/ws/room.js +223 -223
  109. package/package.json +73 -73
  110. package/types/app.d.ts +223 -223
  111. package/types/auth.d.ts +520 -520
  112. package/types/body.d.ts +14 -0
  113. package/types/cli.d.ts +2 -0
  114. package/types/cluster.d.ts +75 -75
  115. package/types/env.d.ts +80 -80
  116. package/types/errors.d.ts +316 -316
  117. package/types/fetch.d.ts +43 -43
  118. package/types/grpc.d.ts +432 -432
  119. package/types/index.d.ts +384 -384
  120. package/types/lifecycle.d.ts +60 -60
  121. package/types/middleware.d.ts +320 -320
  122. package/types/observe.d.ts +304 -304
  123. package/types/orm.d.ts +1887 -1887
  124. package/types/request.d.ts +109 -109
  125. package/types/response.d.ts +157 -157
  126. package/types/router.d.ts +78 -78
  127. package/types/sse.d.ts +78 -78
  128. package/types/websocket.d.ts +126 -126
@@ -1,440 +1,440 @@
1
- /**
2
- * @module body/multipart
3
- * @description Streaming multipart/form-data parser.
4
- * Writes uploaded files to a temp directory and collects
5
- * form fields. Sets `req.body = { fields, files }`.
6
- */
7
- const fs = require('fs');
8
- const os = require('os');
9
- const path = require('path');
10
- const sendError = require('./sendError');
11
-
12
- /**
13
- * Generate a unique filename with an optional prefix.
14
- *
15
- * @private
16
- * @param {string} [prefix='miniex'] - Filename prefix.
17
- * @returns {string} Formatted string.
18
- */
19
- function uniqueName(prefix = 'miniex')
20
- {
21
- return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
22
- }
23
-
24
- /**
25
- * Recursively create a directory if it doesn't exist.
26
- *
27
- * @private
28
- * @param {string} dir - Directory path.
29
- */
30
- function ensureDir(dir)
31
- {
32
- try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { }
33
- }
34
-
35
- /**
36
- * Parse raw MIME header text (CRLF-separated) into a plain object.
37
- *
38
- * @private
39
- * @param {string} headerText - Raw header block.
40
- * @returns {Object<string, string>} Lower-cased header key/value map.
41
- */
42
- function parseHeaders(headerText)
43
- {
44
- const lines = headerText.split('\r\n');
45
- const obj = {};
46
- for (const l of lines)
47
- {
48
- const idx = l.indexOf(':');
49
- if (idx === -1) continue;
50
- const k = l.slice(0, idx).trim().toLowerCase();
51
- const v = l.slice(idx + 1).trim();
52
- obj[k] = v;
53
- }
54
- return obj;
55
- }
56
-
57
- /**
58
- * Sanitize a filename by stripping path traversal characters and
59
- * null bytes. Keeps only the basename.
60
- *
61
- * @private
62
- * @param {string} filename - Raw filename from the upload.
63
- * @returns {string} Sanitized filename.
64
- */
65
- function sanitizeFilename(filename)
66
- {
67
- if (!filename) return '';
68
- // Strip null bytes
69
- let safe = filename.replace(/\0/g, '');
70
- // Take only the basename (strip directory traversal)
71
- safe = safe.replace(/^.*[/\\]/, '');
72
- // Remove leading dots (prevent dotfile creation)
73
- safe = safe.replace(/^\.+/, '');
74
- // Replace potentially dangerous characters
75
- safe = safe.replace(/[<>:"|?*]/g, '_');
76
- return safe || 'unnamed';
77
- }
78
-
79
- /**
80
- * Extract `name` and `filename` fields from a `Content-Disposition` header.
81
- *
82
- * @private
83
- * @param {string} cd - Content-Disposition value.
84
- * @returns {Object<string, string>} Parsed disposition parameters.
85
- */
86
- function parseContentDisposition(cd)
87
- {
88
- const m = /form-data;(.*)/i.exec(cd);
89
- if (!m) return {};
90
- const parts = m[1].split(';').map(s => s.trim());
91
- const out = {};
92
- for (const p of parts)
93
- {
94
- const mm = /([^=]+)="?([^"]+)"?/.exec(p);
95
- if (mm)
96
- {
97
- const key = mm[1].trim();
98
- let val = mm[2];
99
- // Sanitize filename values
100
- if (key === 'filename') val = sanitizeFilename(val);
101
- out[key] = val;
102
- }
103
- }
104
- return out;
105
- }
106
-
107
- /**
108
- * Create a streaming multipart/form-data parsing middleware.
109
- *
110
- * @param {object} [opts] - Configuration options.
111
- * @param {string} [opts.dir] - Upload directory (default: OS temp dir).
112
- * @param {number} [opts.maxFileSize] - Maximum size per file in bytes.
113
- * @param {boolean} [opts.requireSecure=false] - When true, reject non-HTTPS requests with 403.
114
- * @param {number} [opts.maxFields=1000] - Maximum number of non-file fields. Prevents DoS via field flooding.
115
- * @param {number} [opts.maxFiles=10] - Maximum number of uploaded files.
116
- * @param {number} [opts.maxFieldSize] - Maximum size of a single field value in bytes. Default 1 MB.
117
- * @param {string[]} [opts.allowedMimeTypes] - Whitelist of MIME types for uploaded files (e.g. `['image/png', 'image/jpeg']`).
118
- * @param {number} [opts.maxTotalSize] - Maximum combined size of all uploaded files in bytes.
119
- * @returns {Function} Async middleware `(req, res, next) => void`.
120
- *
121
- * @example
122
- * const { multipart } = require('@zero-server/sdk');
123
- *
124
- * app.use(multipart({
125
- * dir: './uploads',
126
- * maxFileSize: 10 * 1024 * 1024, // 10 MB
127
- * maxFiles: 5,
128
- * allowedMimeTypes: ['image/png', 'image/jpeg'],
129
- * }));
130
- *
131
- * app.post('/upload', (req, res) => {
132
- * const { fields, files } = req.body;
133
- * res.json({ fields, uploaded: Object.keys(files) });
134
- * });
135
- */
136
- function multipart(opts = {})
137
- {
138
- return async (req, res, next) =>
139
- {
140
- if (opts.requireSecure && !req.secure) return sendError(res, 403, 'HTTPS required');
141
- const ct = req.headers['content-type'] || '';
142
- const m = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(ct);
143
- if (!m) return next();
144
- const boundary = (m[1] || m[2] || '').replace(/^"|"$/g, '');
145
- const dashBoundary = `--${boundary}`;
146
- const dashBoundaryBuf = Buffer.from('\r\n' + dashBoundary);
147
- const startBoundaryBuf = Buffer.from(dashBoundary);
148
-
149
- let tmpDir;
150
- if (opts.dir)
151
- {
152
- tmpDir = path.isAbsolute(opts.dir) ? opts.dir : path.join(process.cwd(), opts.dir);
153
- } else
154
- {
155
- tmpDir = path.join(os.tmpdir(), 'zero-server-uploads');
156
- }
157
- const maxFileSize = opts.maxFileSize || null; // bytes
158
- const maxFields = opts.maxFields !== undefined ? opts.maxFields : 1000;
159
- const maxFiles = opts.maxFiles !== undefined ? opts.maxFiles : 10;
160
- const maxFieldSize = opts.maxFieldSize !== undefined ? opts.maxFieldSize : (1024 * 1024); // 1 MB
161
- const allowedMimeTypes = opts.allowedMimeTypes || null;
162
- const maxTotalSize = opts.maxTotalSize || null;
163
- ensureDir(tmpDir);
164
-
165
- const fields = {};
166
- const files = {};
167
- let fieldCount = 0;
168
- let fileCount = 0;
169
- let totalFileSize = 0;
170
-
171
- let buffer = Buffer.alloc(0);
172
- let state = 'start'; // start, headers, body
173
- let current = null; // { headers, name, filename, contentType, writeStream, collectedSize }
174
-
175
- const pendingWrites = [];
176
-
177
- function abortFileTooLarge()
178
- {
179
- if (current.writeStream) { current.writeStream.on('error', () => {}); try { current.writeStream.destroy(); } catch (e) { } }
180
- try { fs.unlinkSync(current.filePath); } catch (e) { }
181
- if (!req._multipartErrorHandled)
182
- {
183
- req._multipartErrorHandled = true;
184
- sendError(res, 413, 'file too large');
185
- req.raw.pause && req.raw.pause();
186
- }
187
- }
188
-
189
- function closeCurrent()
190
- {
191
- if (!current) return;
192
- if (current.writeStream)
193
- {
194
- // end the stream and record file after it's flushed to disk
195
- // capture values so we don't rely on `current` later
196
- const info = { name: current.name, filename: current.filename, filePath: current.filePath, contentType: current.contentType, size: current.collectedSize };
197
- const p = new Promise((resolve) =>
198
- {
199
- current.writeStream.on('finish', () =>
200
- {
201
- files[info.name] = { originalFilename: info.filename, storedName: path.basename(info.filePath), path: info.filePath, contentType: info.contentType, size: info.size };
202
- resolve();
203
- });
204
- current.writeStream.on('error', () =>
205
- {
206
- resolve();
207
- });
208
- });
209
- pendingWrites.push(p);
210
- current.writeStream.end();
211
- } else
212
- {
213
- fields[current.name] = current.value || '';
214
- }
215
- current = null;
216
- }
217
-
218
- req.raw.on('data', (chunk) =>
219
- {
220
- buffer = Buffer.concat([buffer, chunk]);
221
-
222
- while (true)
223
- {
224
- if (state === 'start')
225
- {
226
- // look for starting boundary
227
- const idx = buffer.indexOf(startBoundaryBuf);
228
- if (idx === -1)
229
- {
230
- // boundary not yet found
231
- if (buffer.length > startBoundaryBuf.length) buffer = buffer.slice(buffer.length - startBoundaryBuf.length);
232
- break;
233
- }
234
- // consume up to after boundary and CRLF
235
- const after = idx + startBoundaryBuf.length;
236
- if (buffer.length < after + 2) break; // wait for CRLF
237
- buffer = buffer.slice(after);
238
- if (buffer.slice(0, 2).toString() === '\r\n') buffer = buffer.slice(2);
239
- state = 'headers';
240
- } else if (state === 'headers')
241
- {
242
- const idx = buffer.indexOf('\r\n\r\n');
243
- if (idx === -1)
244
- {
245
- // wait for more
246
- if (buffer.length > 1024 * 1024)
247
- {
248
- // keep buffer bounded
249
- buffer = buffer.slice(buffer.length - 1024 * 16);
250
- }
251
- break;
252
- }
253
- const headerText = buffer.slice(0, idx).toString('utf8');
254
- buffer = buffer.slice(idx + 4);
255
- const hdrs = parseHeaders(headerText);
256
- const disp = hdrs['content-disposition'] || '';
257
- const cd = parseContentDisposition(disp);
258
- const name = cd.name;
259
- const filename = cd.filename;
260
- const contentType = hdrs['content-type'] || null;
261
- current = { headers: hdrs, name, filename, contentType, collectedSize: 0 };
262
- if (filename)
263
- {
264
- // Enforce file count limit
265
- fileCount++;
266
- if (maxFiles && fileCount > maxFiles)
267
- {
268
- if (!req._multipartErrorHandled)
269
- {
270
- req._multipartErrorHandled = true;
271
- sendError(res, 413, 'too many files');
272
- req.raw.pause && req.raw.pause();
273
- }
274
- return;
275
- }
276
- // Enforce MIME type whitelist
277
- if (allowedMimeTypes && contentType && !allowedMimeTypes.includes(contentType))
278
- {
279
- if (!req._multipartErrorHandled)
280
- {
281
- req._multipartErrorHandled = true;
282
- sendError(res, 415, 'file type not allowed: ' + contentType);
283
- req.raw.pause && req.raw.pause();
284
- }
285
- return;
286
- }
287
- // create temp file; preserve the original extension when possible
288
- const ext = path.extname(filename) || '';
289
- const safeExt = ext.replace(/[^a-z0-9.]/gi, '');
290
- let fname = uniqueName('upload');
291
- if (safeExt) fname = fname + (safeExt.startsWith('.') ? safeExt : ('.' + safeExt));
292
- const filePath = path.join(tmpDir, fname);
293
- current.filePath = filePath;
294
- current.writeStream = fs.createWriteStream(filePath);
295
- } else
296
- {
297
- // Enforce field count limit
298
- fieldCount++;
299
- if (maxFields && fieldCount > maxFields)
300
- {
301
- if (!req._multipartErrorHandled)
302
- {
303
- req._multipartErrorHandled = true;
304
- sendError(res, 413, 'too many fields');
305
- req.raw.pause && req.raw.pause();
306
- }
307
- return;
308
- }
309
- current.value = '';
310
- }
311
- state = 'body';
312
- } else if (state === 'body')
313
- {
314
- // look for boundary preceded by CRLF
315
- const idx = buffer.indexOf(dashBoundaryBuf);
316
- if (idx === -1)
317
- {
318
- // keep tail in buffer to match partial boundary
319
- const keep = Math.max(dashBoundaryBuf.length, 1024);
320
- const writeLen = buffer.length - keep;
321
- if (writeLen > 0)
322
- {
323
- const toWrite = buffer.slice(0, writeLen);
324
- if (current.writeStream)
325
- {
326
- current.collectedSize += toWrite.length;
327
- totalFileSize += toWrite.length;
328
- if (maxFileSize && current.collectedSize > maxFileSize)
329
- {
330
- abortFileTooLarge();
331
- return;
332
- }
333
- if (maxTotalSize && totalFileSize > maxTotalSize)
334
- {
335
- abortFileTooLarge();
336
- return;
337
- }
338
- current.writeStream.write(toWrite);
339
- } else
340
- {
341
- if (maxFieldSize && current.value.length + toWrite.length > maxFieldSize)
342
- {
343
- if (!req._multipartErrorHandled)
344
- {
345
- req._multipartErrorHandled = true;
346
- sendError(res, 413, 'field value too large');
347
- req.raw.pause && req.raw.pause();
348
- }
349
- return;
350
- }
351
- current.value += toWrite.toString('utf8');
352
- }
353
- buffer = buffer.slice(writeLen);
354
- }
355
- break;
356
- }
357
- // boundary found at idx; data before idx is body chunk (without the leading CRLF)
358
- const bodyChunk = buffer.slice(0, idx);
359
- // if bodyChunk starts with CRLF, strip it
360
- const toWrite = (bodyChunk.slice(0, 2).toString() === '\r\n') ? bodyChunk.slice(2) : bodyChunk;
361
- if (toWrite.length)
362
- {
363
- if (current.writeStream)
364
- {
365
- current.collectedSize += toWrite.length;
366
- totalFileSize += toWrite.length;
367
- if (maxFileSize && current.collectedSize > maxFileSize)
368
- {
369
- abortFileTooLarge();
370
- return;
371
- }
372
- if (maxTotalSize && totalFileSize > maxTotalSize)
373
- {
374
- abortFileTooLarge();
375
- return;
376
- }
377
- current.writeStream.write(toWrite);
378
- } else
379
- {
380
- if (maxFieldSize && current.value.length + toWrite.length > maxFieldSize)
381
- {
382
- if (!req._multipartErrorHandled)
383
- {
384
- req._multipartErrorHandled = true;
385
- sendError(res, 413, 'field value too large');
386
- req.raw.pause && req.raw.pause();
387
- }
388
- return;
389
- }
390
- current.value += toWrite.toString('utf8');
391
- }
392
- }
393
- // consume boundary marker
394
- buffer = buffer.slice(idx + dashBoundaryBuf.length);
395
- // check for final boundary '--'
396
- if (buffer.slice(0, 2).toString() === '--')
397
- {
398
- // final
399
- closeCurrent();
400
- // wait for any pending file flushes then continue
401
- req.raw.pause && req.raw.pause();
402
- Promise.all(pendingWrites).then(() =>
403
- {
404
- req.body = { fields, files };
405
- req._multipart = true;
406
- return next();
407
- }).catch(() =>
408
- {
409
- req.body = { fields, files };
410
- req._multipart = true;
411
- return next();
412
- });
413
- return;
414
- }
415
- // trim leading CRLF if present
416
- if (buffer.slice(0, 2).toString() === '\r\n') buffer = buffer.slice(2);
417
- // close current and continue to next headers
418
- closeCurrent();
419
- state = 'headers';
420
- }
421
- }
422
- });
423
-
424
- req.raw.on('end', () =>
425
- {
426
- // finish any current
427
- if (current) closeCurrent();
428
- req.body = { fields, files };
429
- req._multipart = true;
430
- next();
431
- });
432
-
433
- req.raw.on('error', (err) =>
434
- {
435
- next();
436
- });
437
- };
438
- }
439
-
440
- module.exports = multipart;
1
+ /**
2
+ * @module body/multipart
3
+ * @description Streaming multipart/form-data parser.
4
+ * Writes uploaded files to a temp directory and collects
5
+ * form fields. Sets `req.body = { fields, files }`.
6
+ */
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const sendError = require('./sendError');
11
+
12
+ /**
13
+ * Generate a unique filename with an optional prefix.
14
+ *
15
+ * @private
16
+ * @param {string} [prefix='miniex'] - Filename prefix.
17
+ * @returns {string} Formatted string.
18
+ */
19
+ function uniqueName(prefix = 'miniex')
20
+ {
21
+ return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
22
+ }
23
+
24
+ /**
25
+ * Recursively create a directory if it doesn't exist.
26
+ *
27
+ * @private
28
+ * @param {string} dir - Directory path.
29
+ */
30
+ function ensureDir(dir)
31
+ {
32
+ try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { }
33
+ }
34
+
35
+ /**
36
+ * Parse raw MIME header text (CRLF-separated) into a plain object.
37
+ *
38
+ * @private
39
+ * @param {string} headerText - Raw header block.
40
+ * @returns {Object<string, string>} Lower-cased header key/value map.
41
+ */
42
+ function parseHeaders(headerText)
43
+ {
44
+ const lines = headerText.split('\r\n');
45
+ const obj = {};
46
+ for (const l of lines)
47
+ {
48
+ const idx = l.indexOf(':');
49
+ if (idx === -1) continue;
50
+ const k = l.slice(0, idx).trim().toLowerCase();
51
+ const v = l.slice(idx + 1).trim();
52
+ obj[k] = v;
53
+ }
54
+ return obj;
55
+ }
56
+
57
+ /**
58
+ * Sanitize a filename by stripping path traversal characters and
59
+ * null bytes. Keeps only the basename.
60
+ *
61
+ * @private
62
+ * @param {string} filename - Raw filename from the upload.
63
+ * @returns {string} Sanitized filename.
64
+ */
65
+ function sanitizeFilename(filename)
66
+ {
67
+ if (!filename) return '';
68
+ // Strip null bytes
69
+ let safe = filename.replace(/\0/g, '');
70
+ // Take only the basename (strip directory traversal)
71
+ safe = safe.replace(/^.*[/\\]/, '');
72
+ // Remove leading dots (prevent dotfile creation)
73
+ safe = safe.replace(/^\.+/, '');
74
+ // Replace potentially dangerous characters
75
+ safe = safe.replace(/[<>:"|?*]/g, '_');
76
+ return safe || 'unnamed';
77
+ }
78
+
79
+ /**
80
+ * Extract `name` and `filename` fields from a `Content-Disposition` header.
81
+ *
82
+ * @private
83
+ * @param {string} cd - Content-Disposition value.
84
+ * @returns {Object<string, string>} Parsed disposition parameters.
85
+ */
86
+ function parseContentDisposition(cd)
87
+ {
88
+ const m = /form-data;(.*)/i.exec(cd);
89
+ if (!m) return {};
90
+ const parts = m[1].split(';').map(s => s.trim());
91
+ const out = {};
92
+ for (const p of parts)
93
+ {
94
+ const mm = /([^=]+)="?([^"]+)"?/.exec(p);
95
+ if (mm)
96
+ {
97
+ const key = mm[1].trim();
98
+ let val = mm[2];
99
+ // Sanitize filename values
100
+ if (key === 'filename') val = sanitizeFilename(val);
101
+ out[key] = val;
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Create a streaming multipart/form-data parsing middleware.
109
+ *
110
+ * @param {object} [opts] - Configuration options.
111
+ * @param {string} [opts.dir] - Upload directory (default: OS temp dir).
112
+ * @param {number} [opts.maxFileSize] - Maximum size per file in bytes.
113
+ * @param {boolean} [opts.requireSecure=false] - When true, reject non-HTTPS requests with 403.
114
+ * @param {number} [opts.maxFields=1000] - Maximum number of non-file fields. Prevents DoS via field flooding.
115
+ * @param {number} [opts.maxFiles=10] - Maximum number of uploaded files.
116
+ * @param {number} [opts.maxFieldSize] - Maximum size of a single field value in bytes. Default 1 MB.
117
+ * @param {string[]} [opts.allowedMimeTypes] - Whitelist of MIME types for uploaded files (e.g. `['image/png', 'image/jpeg']`).
118
+ * @param {number} [opts.maxTotalSize] - Maximum combined size of all uploaded files in bytes.
119
+ * @returns {Function} Async middleware `(req, res, next) => void`.
120
+ *
121
+ * @example
122
+ * const { multipart } = require('@zero-server/sdk');
123
+ *
124
+ * app.use(multipart({
125
+ * dir: './uploads',
126
+ * maxFileSize: 10 * 1024 * 1024, // 10 MB
127
+ * maxFiles: 5,
128
+ * allowedMimeTypes: ['image/png', 'image/jpeg'],
129
+ * }));
130
+ *
131
+ * app.post('/upload', (req, res) => {
132
+ * const { fields, files } = req.body;
133
+ * res.json({ fields, uploaded: Object.keys(files) });
134
+ * });
135
+ */
136
+ function multipart(opts = {})
137
+ {
138
+ return async (req, res, next) =>
139
+ {
140
+ if (opts.requireSecure && !req.secure) return sendError(res, 403, 'HTTPS required');
141
+ const ct = req.headers['content-type'] || '';
142
+ const m = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(ct);
143
+ if (!m) return next();
144
+ const boundary = (m[1] || m[2] || '').replace(/^"|"$/g, '');
145
+ const dashBoundary = `--${boundary}`;
146
+ const dashBoundaryBuf = Buffer.from('\r\n' + dashBoundary);
147
+ const startBoundaryBuf = Buffer.from(dashBoundary);
148
+
149
+ let tmpDir;
150
+ if (opts.dir)
151
+ {
152
+ tmpDir = path.isAbsolute(opts.dir) ? opts.dir : path.join(process.cwd(), opts.dir);
153
+ } else
154
+ {
155
+ tmpDir = path.join(os.tmpdir(), 'zero-server-uploads');
156
+ }
157
+ const maxFileSize = opts.maxFileSize || null; // bytes
158
+ const maxFields = opts.maxFields !== undefined ? opts.maxFields : 1000;
159
+ const maxFiles = opts.maxFiles !== undefined ? opts.maxFiles : 10;
160
+ const maxFieldSize = opts.maxFieldSize !== undefined ? opts.maxFieldSize : (1024 * 1024); // 1 MB
161
+ const allowedMimeTypes = opts.allowedMimeTypes || null;
162
+ const maxTotalSize = opts.maxTotalSize || null;
163
+ ensureDir(tmpDir);
164
+
165
+ const fields = {};
166
+ const files = {};
167
+ let fieldCount = 0;
168
+ let fileCount = 0;
169
+ let totalFileSize = 0;
170
+
171
+ let buffer = Buffer.alloc(0);
172
+ let state = 'start'; // start, headers, body
173
+ let current = null; // { headers, name, filename, contentType, writeStream, collectedSize }
174
+
175
+ const pendingWrites = [];
176
+
177
+ function abortFileTooLarge()
178
+ {
179
+ if (current.writeStream) { current.writeStream.on('error', () => {}); try { current.writeStream.destroy(); } catch (e) { } }
180
+ try { fs.unlinkSync(current.filePath); } catch (e) { }
181
+ if (!req._multipartErrorHandled)
182
+ {
183
+ req._multipartErrorHandled = true;
184
+ sendError(res, 413, 'file too large');
185
+ req.raw.pause && req.raw.pause();
186
+ }
187
+ }
188
+
189
+ function closeCurrent()
190
+ {
191
+ if (!current) return;
192
+ if (current.writeStream)
193
+ {
194
+ // end the stream and record file after it's flushed to disk
195
+ // capture values so we don't rely on `current` later
196
+ const info = { name: current.name, filename: current.filename, filePath: current.filePath, contentType: current.contentType, size: current.collectedSize };
197
+ const p = new Promise((resolve) =>
198
+ {
199
+ current.writeStream.on('finish', () =>
200
+ {
201
+ files[info.name] = { originalFilename: info.filename, storedName: path.basename(info.filePath), path: info.filePath, contentType: info.contentType, size: info.size };
202
+ resolve();
203
+ });
204
+ current.writeStream.on('error', () =>
205
+ {
206
+ resolve();
207
+ });
208
+ });
209
+ pendingWrites.push(p);
210
+ current.writeStream.end();
211
+ } else
212
+ {
213
+ fields[current.name] = current.value || '';
214
+ }
215
+ current = null;
216
+ }
217
+
218
+ req.raw.on('data', (chunk) =>
219
+ {
220
+ buffer = Buffer.concat([buffer, chunk]);
221
+
222
+ while (true)
223
+ {
224
+ if (state === 'start')
225
+ {
226
+ // look for starting boundary
227
+ const idx = buffer.indexOf(startBoundaryBuf);
228
+ if (idx === -1)
229
+ {
230
+ // boundary not yet found
231
+ if (buffer.length > startBoundaryBuf.length) buffer = buffer.slice(buffer.length - startBoundaryBuf.length);
232
+ break;
233
+ }
234
+ // consume up to after boundary and CRLF
235
+ const after = idx + startBoundaryBuf.length;
236
+ if (buffer.length < after + 2) break; // wait for CRLF
237
+ buffer = buffer.slice(after);
238
+ if (buffer.slice(0, 2).toString() === '\r\n') buffer = buffer.slice(2);
239
+ state = 'headers';
240
+ } else if (state === 'headers')
241
+ {
242
+ const idx = buffer.indexOf('\r\n\r\n');
243
+ if (idx === -1)
244
+ {
245
+ // wait for more
246
+ if (buffer.length > 1024 * 1024)
247
+ {
248
+ // keep buffer bounded
249
+ buffer = buffer.slice(buffer.length - 1024 * 16);
250
+ }
251
+ break;
252
+ }
253
+ const headerText = buffer.slice(0, idx).toString('utf8');
254
+ buffer = buffer.slice(idx + 4);
255
+ const hdrs = parseHeaders(headerText);
256
+ const disp = hdrs['content-disposition'] || '';
257
+ const cd = parseContentDisposition(disp);
258
+ const name = cd.name;
259
+ const filename = cd.filename;
260
+ const contentType = hdrs['content-type'] || null;
261
+ current = { headers: hdrs, name, filename, contentType, collectedSize: 0 };
262
+ if (filename)
263
+ {
264
+ // Enforce file count limit
265
+ fileCount++;
266
+ if (maxFiles && fileCount > maxFiles)
267
+ {
268
+ if (!req._multipartErrorHandled)
269
+ {
270
+ req._multipartErrorHandled = true;
271
+ sendError(res, 413, 'too many files');
272
+ req.raw.pause && req.raw.pause();
273
+ }
274
+ return;
275
+ }
276
+ // Enforce MIME type whitelist
277
+ if (allowedMimeTypes && contentType && !allowedMimeTypes.includes(contentType))
278
+ {
279
+ if (!req._multipartErrorHandled)
280
+ {
281
+ req._multipartErrorHandled = true;
282
+ sendError(res, 415, 'file type not allowed: ' + contentType);
283
+ req.raw.pause && req.raw.pause();
284
+ }
285
+ return;
286
+ }
287
+ // create temp file; preserve the original extension when possible
288
+ const ext = path.extname(filename) || '';
289
+ const safeExt = ext.replace(/[^a-z0-9.]/gi, '');
290
+ let fname = uniqueName('upload');
291
+ if (safeExt) fname = fname + (safeExt.startsWith('.') ? safeExt : ('.' + safeExt));
292
+ const filePath = path.join(tmpDir, fname);
293
+ current.filePath = filePath;
294
+ current.writeStream = fs.createWriteStream(filePath);
295
+ } else
296
+ {
297
+ // Enforce field count limit
298
+ fieldCount++;
299
+ if (maxFields && fieldCount > maxFields)
300
+ {
301
+ if (!req._multipartErrorHandled)
302
+ {
303
+ req._multipartErrorHandled = true;
304
+ sendError(res, 413, 'too many fields');
305
+ req.raw.pause && req.raw.pause();
306
+ }
307
+ return;
308
+ }
309
+ current.value = '';
310
+ }
311
+ state = 'body';
312
+ } else if (state === 'body')
313
+ {
314
+ // look for boundary preceded by CRLF
315
+ const idx = buffer.indexOf(dashBoundaryBuf);
316
+ if (idx === -1)
317
+ {
318
+ // keep tail in buffer to match partial boundary
319
+ const keep = Math.max(dashBoundaryBuf.length, 1024);
320
+ const writeLen = buffer.length - keep;
321
+ if (writeLen > 0)
322
+ {
323
+ const toWrite = buffer.slice(0, writeLen);
324
+ if (current.writeStream)
325
+ {
326
+ current.collectedSize += toWrite.length;
327
+ totalFileSize += toWrite.length;
328
+ if (maxFileSize && current.collectedSize > maxFileSize)
329
+ {
330
+ abortFileTooLarge();
331
+ return;
332
+ }
333
+ if (maxTotalSize && totalFileSize > maxTotalSize)
334
+ {
335
+ abortFileTooLarge();
336
+ return;
337
+ }
338
+ current.writeStream.write(toWrite);
339
+ } else
340
+ {
341
+ if (maxFieldSize && current.value.length + toWrite.length > maxFieldSize)
342
+ {
343
+ if (!req._multipartErrorHandled)
344
+ {
345
+ req._multipartErrorHandled = true;
346
+ sendError(res, 413, 'field value too large');
347
+ req.raw.pause && req.raw.pause();
348
+ }
349
+ return;
350
+ }
351
+ current.value += toWrite.toString('utf8');
352
+ }
353
+ buffer = buffer.slice(writeLen);
354
+ }
355
+ break;
356
+ }
357
+ // boundary found at idx; data before idx is body chunk (without the leading CRLF)
358
+ const bodyChunk = buffer.slice(0, idx);
359
+ // if bodyChunk starts with CRLF, strip it
360
+ const toWrite = (bodyChunk.slice(0, 2).toString() === '\r\n') ? bodyChunk.slice(2) : bodyChunk;
361
+ if (toWrite.length)
362
+ {
363
+ if (current.writeStream)
364
+ {
365
+ current.collectedSize += toWrite.length;
366
+ totalFileSize += toWrite.length;
367
+ if (maxFileSize && current.collectedSize > maxFileSize)
368
+ {
369
+ abortFileTooLarge();
370
+ return;
371
+ }
372
+ if (maxTotalSize && totalFileSize > maxTotalSize)
373
+ {
374
+ abortFileTooLarge();
375
+ return;
376
+ }
377
+ current.writeStream.write(toWrite);
378
+ } else
379
+ {
380
+ if (maxFieldSize && current.value.length + toWrite.length > maxFieldSize)
381
+ {
382
+ if (!req._multipartErrorHandled)
383
+ {
384
+ req._multipartErrorHandled = true;
385
+ sendError(res, 413, 'field value too large');
386
+ req.raw.pause && req.raw.pause();
387
+ }
388
+ return;
389
+ }
390
+ current.value += toWrite.toString('utf8');
391
+ }
392
+ }
393
+ // consume boundary marker
394
+ buffer = buffer.slice(idx + dashBoundaryBuf.length);
395
+ // check for final boundary '--'
396
+ if (buffer.slice(0, 2).toString() === '--')
397
+ {
398
+ // final
399
+ closeCurrent();
400
+ // wait for any pending file flushes then continue
401
+ req.raw.pause && req.raw.pause();
402
+ Promise.all(pendingWrites).then(() =>
403
+ {
404
+ req.body = { fields, files };
405
+ req._multipart = true;
406
+ return next();
407
+ }).catch(() =>
408
+ {
409
+ req.body = { fields, files };
410
+ req._multipart = true;
411
+ return next();
412
+ });
413
+ return;
414
+ }
415
+ // trim leading CRLF if present
416
+ if (buffer.slice(0, 2).toString() === '\r\n') buffer = buffer.slice(2);
417
+ // close current and continue to next headers
418
+ closeCurrent();
419
+ state = 'headers';
420
+ }
421
+ }
422
+ });
423
+
424
+ req.raw.on('end', () =>
425
+ {
426
+ // finish any current
427
+ if (current) closeCurrent();
428
+ req.body = { fields, files };
429
+ req._multipart = true;
430
+ next();
431
+ });
432
+
433
+ req.raw.on('error', (err) =>
434
+ {
435
+ next();
436
+ });
437
+ };
438
+ }
439
+
440
+ module.exports = multipart;