@zero-server/middleware 0.9.0 → 0.9.2
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/LICENSE +21 -21
- package/README.md +1 -1
- package/index.js +13 -13
- package/lib/debug.js +372 -0
- package/lib/middleware/compress.js +230 -0
- package/lib/middleware/cookieParser.js +237 -0
- package/lib/middleware/cors.js +93 -0
- package/lib/middleware/csrf.js +137 -0
- package/lib/middleware/errorHandler.js +101 -0
- package/lib/middleware/helmet.js +176 -0
- package/lib/middleware/index.js +19 -0
- package/lib/middleware/logger.js +74 -0
- package/lib/middleware/rateLimit.js +88 -0
- package/lib/middleware/requestId.js +54 -0
- package/lib/middleware/static.js +326 -0
- package/lib/middleware/timeout.js +72 -0
- package/lib/middleware/validator.js +255 -0
- package/package.json +12 -3
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module static
|
|
3
|
+
* @description Static file-serving middleware with MIME detection, directory
|
|
4
|
+
* index files, extension fallbacks, dotfile policies, caching,
|
|
5
|
+
* custom header hooks, and HTTP/2 server push for linked assets.
|
|
6
|
+
*/
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const log = require('../debug')('zero:static');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extension → MIME-type lookup table.
|
|
13
|
+
* @type {Object<string, string>}
|
|
14
|
+
*/
|
|
15
|
+
const MIME = {
|
|
16
|
+
// Text
|
|
17
|
+
'.html': 'text/html',
|
|
18
|
+
'.htm': 'text/html',
|
|
19
|
+
'.css': 'text/css',
|
|
20
|
+
'.txt': 'text/plain',
|
|
21
|
+
'.csv': 'text/csv',
|
|
22
|
+
'.xml': 'application/xml',
|
|
23
|
+
'.json': 'application/json',
|
|
24
|
+
'.jsonld': 'application/ld+json',
|
|
25
|
+
|
|
26
|
+
// JavaScript / WASM
|
|
27
|
+
'.js': 'application/javascript',
|
|
28
|
+
'.mjs': 'application/javascript',
|
|
29
|
+
'.wasm': 'application/wasm',
|
|
30
|
+
|
|
31
|
+
// Images
|
|
32
|
+
'.png': 'image/png',
|
|
33
|
+
'.jpg': 'image/jpeg',
|
|
34
|
+
'.jpeg': 'image/jpeg',
|
|
35
|
+
'.gif': 'image/gif',
|
|
36
|
+
'.webp': 'image/webp',
|
|
37
|
+
'.avif': 'image/avif',
|
|
38
|
+
'.svg': 'image/svg+xml',
|
|
39
|
+
'.ico': 'image/x-icon',
|
|
40
|
+
'.bmp': 'image/bmp',
|
|
41
|
+
'.tiff': 'image/tiff',
|
|
42
|
+
'.tif': 'image/tiff',
|
|
43
|
+
|
|
44
|
+
// Fonts
|
|
45
|
+
'.woff': 'font/woff',
|
|
46
|
+
'.woff2': 'font/woff2',
|
|
47
|
+
'.ttf': 'font/ttf',
|
|
48
|
+
'.otf': 'font/otf',
|
|
49
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
50
|
+
|
|
51
|
+
// Audio
|
|
52
|
+
'.mp3': 'audio/mpeg',
|
|
53
|
+
'.ogg': 'audio/ogg',
|
|
54
|
+
'.wav': 'audio/wav',
|
|
55
|
+
'.flac': 'audio/flac',
|
|
56
|
+
'.aac': 'audio/aac',
|
|
57
|
+
'.m4a': 'audio/mp4',
|
|
58
|
+
|
|
59
|
+
// Video
|
|
60
|
+
'.mp4': 'video/mp4',
|
|
61
|
+
'.webm': 'video/webm',
|
|
62
|
+
'.ogv': 'video/ogg',
|
|
63
|
+
'.avi': 'video/x-msvideo',
|
|
64
|
+
'.mov': 'video/quicktime',
|
|
65
|
+
|
|
66
|
+
// Documents / Archives
|
|
67
|
+
'.pdf': 'application/pdf',
|
|
68
|
+
'.zip': 'application/zip',
|
|
69
|
+
'.gz': 'application/gzip',
|
|
70
|
+
'.tar': 'application/x-tar',
|
|
71
|
+
'.7z': 'application/x-7z-compressed',
|
|
72
|
+
|
|
73
|
+
// Other
|
|
74
|
+
'.map': 'application/json',
|
|
75
|
+
'.yaml': 'text/yaml',
|
|
76
|
+
'.yml': 'text/yaml',
|
|
77
|
+
'.md': 'text/markdown',
|
|
78
|
+
'.sh': 'application/x-sh',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generate a weak ETag from file stats (mtime + size).
|
|
83
|
+
* @private
|
|
84
|
+
* @param {import('fs').Stats} stat - File system stat object.
|
|
85
|
+
* @returns {string} Weak ETag string (e.g. `W/"1a2b-3c4d"`).
|
|
86
|
+
*/
|
|
87
|
+
function generateETag(stat)
|
|
88
|
+
{
|
|
89
|
+
return 'W/"' + stat.size.toString(16) + '-' + stat.mtimeMs.toString(16) + '"';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Stream a file to the raw Node response, setting Content-Type,
|
|
94
|
+
* Content-Length, ETag, and Last-Modified headers.
|
|
95
|
+
*
|
|
96
|
+
* @private
|
|
97
|
+
* @param {import('./response')} res - Wrapped response object.
|
|
98
|
+
* @param {string} filePath - Absolute path to the file.
|
|
99
|
+
* @param {import('fs').Stats} [stat] - Pre-fetched `fs.Stats` (for Content-Length).
|
|
100
|
+
* @param {import('./request')} [req] - Wrapped request (for conditional checks).
|
|
101
|
+
*/
|
|
102
|
+
function sendFile(res, filePath, stat, req)
|
|
103
|
+
{
|
|
104
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
105
|
+
const ct = MIME[ext] || 'application/octet-stream';
|
|
106
|
+
const raw = res.raw;
|
|
107
|
+
try
|
|
108
|
+
{
|
|
109
|
+
raw.setHeader('Content-Type', ct);
|
|
110
|
+
if (stat)
|
|
111
|
+
{
|
|
112
|
+
if (stat.size) raw.setHeader('Content-Length', stat.size);
|
|
113
|
+
// ETag and Last-Modified for caching
|
|
114
|
+
const etag = generateETag(stat);
|
|
115
|
+
raw.setHeader('ETag', etag);
|
|
116
|
+
raw.setHeader('Last-Modified', stat.mtime.toUTCString());
|
|
117
|
+
raw.setHeader('Accept-Ranges', 'bytes');
|
|
118
|
+
|
|
119
|
+
// Conditional request handling (304 Not Modified)
|
|
120
|
+
if (req)
|
|
121
|
+
{
|
|
122
|
+
const ifNoneMatch = req.headers['if-none-match'];
|
|
123
|
+
const ifModifiedSince = req.headers['if-modified-since'];
|
|
124
|
+
if (ifNoneMatch && ifNoneMatch === etag)
|
|
125
|
+
{
|
|
126
|
+
raw.statusCode = 304;
|
|
127
|
+
raw.end();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (ifModifiedSince && !ifNoneMatch)
|
|
131
|
+
{
|
|
132
|
+
const since = Date.parse(ifModifiedSince);
|
|
133
|
+
if (!isNaN(since) && stat.mtimeMs <= since)
|
|
134
|
+
{
|
|
135
|
+
raw.statusCode = 304;
|
|
136
|
+
raw.end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Range request support (HTTP 206)
|
|
142
|
+
const rangeHeader = req.headers['range'];
|
|
143
|
+
if (rangeHeader && stat.size > 0)
|
|
144
|
+
{
|
|
145
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
|
|
146
|
+
if (match)
|
|
147
|
+
{
|
|
148
|
+
let start = match[1] ? parseInt(match[1], 10) : 0;
|
|
149
|
+
let end = match[2] ? parseInt(match[2], 10) : stat.size - 1;
|
|
150
|
+
if (!match[1] && match[2])
|
|
151
|
+
{
|
|
152
|
+
// suffix range: bytes=-500 means last 500 bytes
|
|
153
|
+
start = Math.max(0, stat.size - parseInt(match[2], 10));
|
|
154
|
+
end = stat.size - 1;
|
|
155
|
+
}
|
|
156
|
+
if (start > end || start >= stat.size || end >= stat.size)
|
|
157
|
+
{
|
|
158
|
+
raw.statusCode = 416;
|
|
159
|
+
raw.setHeader('Content-Range', 'bytes */' + stat.size);
|
|
160
|
+
raw.setHeader('Content-Length', 0);
|
|
161
|
+
raw.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
raw.statusCode = 206;
|
|
165
|
+
raw.setHeader('Content-Range', 'bytes ' + start + '-' + end + '/' + stat.size);
|
|
166
|
+
raw.setHeader('Content-Length', end - start + 1);
|
|
167
|
+
const stream = fs.createReadStream(filePath, { start, end });
|
|
168
|
+
stream.on('error', (err) => { log.warn('file read error %s: %s', filePath, err.message); try { raw.statusCode = 404; raw.end(); } catch (e) { } });
|
|
169
|
+
log.debug('serving %s (range %d-%d)', filePath, start, end);
|
|
170
|
+
stream.pipe(raw);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (e) { /* best-effort */ }
|
|
178
|
+
const stream = fs.createReadStream(filePath);
|
|
179
|
+
stream.on('error', (err) => { log.warn('file read error %s: %s', filePath, err.message); try { raw.statusCode = 404; raw.end(); } catch (e) { } });
|
|
180
|
+
log.debug('serving %s', filePath);
|
|
181
|
+
stream.pipe(raw);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Create a static-file-serving middleware.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} root - Root directory to serve files from.
|
|
188
|
+
* @param {object} [options] - Configuration options.
|
|
189
|
+
* @param {string|false} [options.index='index.html'] - Default file for directory requests, or `false` to disable.
|
|
190
|
+
* @param {number} [options.maxAge=0] - `Cache-Control` max-age in **milliseconds**.
|
|
191
|
+
* @param {string} [options.dotfiles='ignore'] - Dotfile policy: `'allow'` | `'deny'` | `'ignore'`.
|
|
192
|
+
* @param {string[]} [options.extensions] - Array of fallback extensions (e.g. `['html', 'htm']`).
|
|
193
|
+
* @param {Function} [options.setHeaders] - `(res, filePath) => void` hook to set custom headers.
|
|
194
|
+
* @param {string[]|Function} [options.pushAssets] - HTTP/2 server push. Array of paths
|
|
195
|
+
* (relative to root) to push when serving HTML files, or a function `(filePath) => string[]`.
|
|
196
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* app.use(serveStatic('public')); // serve ./public
|
|
200
|
+
* app.use(serveStatic('dist', { maxAge: 86400000 })); // 1-day cache
|
|
201
|
+
* app.use(serveStatic('assets', { extensions: ['html'] })); // .html fallback
|
|
202
|
+
*
|
|
203
|
+
* @example | HTTP/2 Server Push
|
|
204
|
+
* app.use(serveStatic('public', {
|
|
205
|
+
* pushAssets: ['/styles/main.css', '/modules/app.js'],
|
|
206
|
+
* }));
|
|
207
|
+
*/
|
|
208
|
+
function serveStatic(root, options = {})
|
|
209
|
+
{
|
|
210
|
+
root = path.resolve(root);
|
|
211
|
+
const index = options.hasOwnProperty('index') ? options.index : 'index.html';
|
|
212
|
+
const maxAge = options.hasOwnProperty('maxAge') ? options.maxAge : 0;
|
|
213
|
+
const dotfiles = options.hasOwnProperty('dotfiles') ? options.dotfiles : 'ignore'; // allow|deny|ignore
|
|
214
|
+
const extensions = Array.isArray(options.extensions) ? options.extensions : null;
|
|
215
|
+
const setHeaders = typeof options.setHeaders === 'function' ? options.setHeaders : null;
|
|
216
|
+
const pushAssets = options.pushAssets || null;
|
|
217
|
+
|
|
218
|
+
function isDotfile(p)
|
|
219
|
+
{
|
|
220
|
+
return path.basename(p).startsWith('.');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function applyHeaders(res, filePath)
|
|
224
|
+
{
|
|
225
|
+
if (maxAge) try { res.raw.setHeader('Cache-Control', 'max-age=' + Math.floor(Number(maxAge) / 1000)); } catch (e) { }
|
|
226
|
+
if (setHeaders) try { setHeaders(res, filePath); } catch (e) { }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Push linked assets via HTTP/2 server push when serving HTML files.
|
|
231
|
+
* @private
|
|
232
|
+
*/
|
|
233
|
+
function pushLinkedAssets(res, filePath)
|
|
234
|
+
{
|
|
235
|
+
if (!pushAssets) return;
|
|
236
|
+
// Only push for HTML files
|
|
237
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
238
|
+
if (ext !== '.html' && ext !== '.htm') return;
|
|
239
|
+
// Only push on HTTP/2 connections
|
|
240
|
+
if (!res.supportsPush) return;
|
|
241
|
+
|
|
242
|
+
const assets = typeof pushAssets === 'function'
|
|
243
|
+
? pushAssets(filePath)
|
|
244
|
+
: pushAssets;
|
|
245
|
+
|
|
246
|
+
if (!Array.isArray(assets)) return;
|
|
247
|
+
|
|
248
|
+
for (const assetPath of assets)
|
|
249
|
+
{
|
|
250
|
+
const absPath = path.resolve(root, '.' + path.sep + assetPath);
|
|
251
|
+
// Security: verify asset is within root
|
|
252
|
+
if (!absPath.startsWith(root + path.sep) && absPath !== root) continue;
|
|
253
|
+
res.push(assetPath, { filePath: absPath });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return (req, res, next) =>
|
|
258
|
+
{
|
|
259
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
260
|
+
let urlPath;
|
|
261
|
+
try { urlPath = decodeURIComponent(req.url.split('?')[0]); } catch (e) { return res.status(400).json({ error: 'Bad Request' }); }
|
|
262
|
+
|
|
263
|
+
// Block null bytes (poison byte attack)
|
|
264
|
+
if (urlPath.indexOf('\0') !== -1) return res.status(400).json({ error: 'Bad Request' });
|
|
265
|
+
|
|
266
|
+
let file = path.resolve(root, '.' + path.sep + urlPath);
|
|
267
|
+
// Normalize and verify the resolved path is within root (prevents path traversal)
|
|
268
|
+
if (!file.startsWith(root + path.sep) && file !== root) return res.status(403).json({ error: 'Forbidden' });
|
|
269
|
+
|
|
270
|
+
if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
271
|
+
|
|
272
|
+
fs.stat(file, (err, st) =>
|
|
273
|
+
{
|
|
274
|
+
if (err)
|
|
275
|
+
{
|
|
276
|
+
// try extensions fallback
|
|
277
|
+
if (extensions && !urlPath.endsWith('/'))
|
|
278
|
+
{
|
|
279
|
+
(function tryExt(i)
|
|
280
|
+
{
|
|
281
|
+
if (i >= extensions.length) return next();
|
|
282
|
+
const ext = extensions[i].startsWith('.') ? extensions[i] : '.' + extensions[i];
|
|
283
|
+
const f = file + ext;
|
|
284
|
+
fs.stat(f, (e2, st2) =>
|
|
285
|
+
{
|
|
286
|
+
if (!e2 && st2 && st2.isFile())
|
|
287
|
+
{
|
|
288
|
+
if (isDotfile(f) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
289
|
+
applyHeaders(res, f);
|
|
290
|
+
pushLinkedAssets(res, f);
|
|
291
|
+
return sendFile(res, f, st2, req);
|
|
292
|
+
}
|
|
293
|
+
tryExt(i + 1);
|
|
294
|
+
});
|
|
295
|
+
})(0);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
return next();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (st.isDirectory())
|
|
302
|
+
{
|
|
303
|
+
if (!index) return next();
|
|
304
|
+
const idxFile = path.join(file, index);
|
|
305
|
+
fs.stat(idxFile, (err2, st2) =>
|
|
306
|
+
{
|
|
307
|
+
if (err2) return next();
|
|
308
|
+
if (isDotfile(idxFile) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
309
|
+
applyHeaders(res, idxFile);
|
|
310
|
+
pushLinkedAssets(res, idxFile);
|
|
311
|
+
sendFile(res, idxFile, st2, req);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
else
|
|
315
|
+
{
|
|
316
|
+
if (isDotfile(file) && dotfiles === 'ignore') return next();
|
|
317
|
+
if (isDotfile(file) && dotfiles === 'deny') return res.status(403).json({ error: 'Forbidden' });
|
|
318
|
+
applyHeaders(res, file);
|
|
319
|
+
pushLinkedAssets(res, file);
|
|
320
|
+
sendFile(res, file, st, req);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports = serveStatic;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module timeout
|
|
3
|
+
* @description Request timeout middleware.
|
|
4
|
+
* Automatically sends a 408 response if the handler doesn't
|
|
5
|
+
* respond within the configured time limit.
|
|
6
|
+
* Helps prevent Slowloris-style attacks and hung requests.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a request timeout middleware.
|
|
11
|
+
*
|
|
12
|
+
* @param {number} [ms=30000] - Timeout in milliseconds (default 30s).
|
|
13
|
+
* @param {object} [opts] - Configuration options.
|
|
14
|
+
* @param {number} [opts.status=408] - HTTP status code for timeout responses.
|
|
15
|
+
* @param {string} [opts.message='Request Timeout'] - Error message body.
|
|
16
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* app.use(timeout(5000)); // 5 second timeout
|
|
20
|
+
* app.use(timeout(10000, { message: 'Too slow' }));
|
|
21
|
+
*/
|
|
22
|
+
const log = require('../debug')('zero:timeout');
|
|
23
|
+
|
|
24
|
+
function timeout(ms = 30000, opts = {})
|
|
25
|
+
{
|
|
26
|
+
if (typeof ms === 'object') { opts = ms; ms = 30000; }
|
|
27
|
+
|
|
28
|
+
const statusCode = opts.status || 408;
|
|
29
|
+
const message = opts.message || 'Request Timeout';
|
|
30
|
+
|
|
31
|
+
return (req, res, next) =>
|
|
32
|
+
{
|
|
33
|
+
let timedOut = false;
|
|
34
|
+
|
|
35
|
+
const timer = setTimeout(() =>
|
|
36
|
+
{
|
|
37
|
+
timedOut = true;
|
|
38
|
+
req._timedOut = true;
|
|
39
|
+
log.warn('request timed out after %dms: %s %s', ms, req.method, req.url);
|
|
40
|
+
|
|
41
|
+
// Only send response if headers haven't been sent yet
|
|
42
|
+
if (!res.headersSent && !res._sent)
|
|
43
|
+
{
|
|
44
|
+
res.status(statusCode).json({ error: message });
|
|
45
|
+
}
|
|
46
|
+
}, ms);
|
|
47
|
+
|
|
48
|
+
// Unref so the timer doesn't keep the process alive
|
|
49
|
+
if (timer.unref) timer.unref();
|
|
50
|
+
|
|
51
|
+
// Clear timeout when response finishes
|
|
52
|
+
const raw = res.raw;
|
|
53
|
+
const onFinish = () =>
|
|
54
|
+
{
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
raw.removeListener('finish', onFinish);
|
|
57
|
+
raw.removeListener('close', onFinish);
|
|
58
|
+
};
|
|
59
|
+
raw.on('finish', onFinish);
|
|
60
|
+
raw.on('close', onFinish);
|
|
61
|
+
|
|
62
|
+
// Expose timedOut check on request
|
|
63
|
+
Object.defineProperty(req, 'timedOut', {
|
|
64
|
+
get() { return timedOut; },
|
|
65
|
+
configurable: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
next();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = timeout;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module middleware/validator
|
|
3
|
+
* @description Request validation middleware.
|
|
4
|
+
* Validates `req.body`, `req.query`, and `req.params` against a
|
|
5
|
+
* schema object. Returns 422 with detailed errors on failure.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { createApp, validate } = require('@zero-server/sdk');
|
|
9
|
+
* const app = createApp();
|
|
10
|
+
*
|
|
11
|
+
* app.post('/users', validate({
|
|
12
|
+
* body: {
|
|
13
|
+
* name: { type: 'string', required: true, minLength: 1, maxLength: 100 },
|
|
14
|
+
* email: { type: 'string', required: true, match: /^[^@]+@[^@]+\.[^@]+$/ },
|
|
15
|
+
* age: { type: 'integer', min: 0, max: 150 },
|
|
16
|
+
* },
|
|
17
|
+
* query: {
|
|
18
|
+
* format: { type: 'string', enum: ['json', 'xml'], default: 'json' },
|
|
19
|
+
* },
|
|
20
|
+
* }), (req, res) => {
|
|
21
|
+
* // req.body / req.query are now validated and sanitised
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Supported shorthand types for validation rules.
|
|
27
|
+
* @private
|
|
28
|
+
*/
|
|
29
|
+
const COERCE = {
|
|
30
|
+
string(v) { return v == null ? v : String(v); },
|
|
31
|
+
integer(v) { const n = parseInt(v, 10); return Number.isNaN(n) ? v : n; },
|
|
32
|
+
number(v) { const n = Number(v); return Number.isNaN(n) ? v : n; },
|
|
33
|
+
float(v) { const n = parseFloat(v); return Number.isNaN(n) ? v : n; },
|
|
34
|
+
boolean(v)
|
|
35
|
+
{
|
|
36
|
+
if (typeof v === 'boolean') return v;
|
|
37
|
+
if (typeof v === 'string')
|
|
38
|
+
{
|
|
39
|
+
const l = v.toLowerCase();
|
|
40
|
+
if (l === 'true' || l === '1' || l === 'yes' || l === 'on') return true;
|
|
41
|
+
if (l === 'false' || l === '0' || l === 'no' || l === 'off') return false;
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
},
|
|
45
|
+
array(v)
|
|
46
|
+
{
|
|
47
|
+
if (Array.isArray(v)) return v;
|
|
48
|
+
if (typeof v === 'string')
|
|
49
|
+
{
|
|
50
|
+
try { const p = JSON.parse(v); if (Array.isArray(p)) return p; } catch {}
|
|
51
|
+
return v.split(',').map(s => s.trim());
|
|
52
|
+
}
|
|
53
|
+
return v;
|
|
54
|
+
},
|
|
55
|
+
json(v)
|
|
56
|
+
{
|
|
57
|
+
if (typeof v === 'string') { try { return JSON.parse(v); } catch {} }
|
|
58
|
+
return v;
|
|
59
|
+
},
|
|
60
|
+
date(v)
|
|
61
|
+
{
|
|
62
|
+
if (v instanceof Date) return v;
|
|
63
|
+
const d = new Date(v);
|
|
64
|
+
return Number.isNaN(d.getTime()) ? v : d;
|
|
65
|
+
},
|
|
66
|
+
uuid(v) { return v == null ? v : String(v); },
|
|
67
|
+
email(v) { return v == null ? v : String(v).trim().toLowerCase(); },
|
|
68
|
+
url(v) { return v == null ? v : String(v).trim(); },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate a single value against a rule definition.
|
|
73
|
+
*
|
|
74
|
+
* @param {*} value - Raw input value.
|
|
75
|
+
* @param {object} rule - Rule definition.
|
|
76
|
+
* @param {string} field - Field name (for error messages).
|
|
77
|
+
* @returns {{ value: *, error: string|null }}
|
|
78
|
+
*/
|
|
79
|
+
function validateField(value, rule, field)
|
|
80
|
+
{
|
|
81
|
+
// Apply default
|
|
82
|
+
if ((value === undefined || value === null || value === '') && rule.default !== undefined)
|
|
83
|
+
{
|
|
84
|
+
value = typeof rule.default === 'function' ? rule.default() : rule.default;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Required check
|
|
88
|
+
if (rule.required && (value === undefined || value === null || value === ''))
|
|
89
|
+
{
|
|
90
|
+
return { value, error: `${field} is required` };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// If not required and absent, skip further checks
|
|
94
|
+
if (value === undefined || value === null) return { value, error: null };
|
|
95
|
+
|
|
96
|
+
// Type coercion
|
|
97
|
+
if (rule.type && COERCE[rule.type]) value = COERCE[rule.type](value);
|
|
98
|
+
|
|
99
|
+
// Type validation
|
|
100
|
+
if (rule.type)
|
|
101
|
+
{
|
|
102
|
+
switch (rule.type)
|
|
103
|
+
{
|
|
104
|
+
case 'string':
|
|
105
|
+
if (typeof value !== 'string') return { value, error: `${field} must be a string` };
|
|
106
|
+
break;
|
|
107
|
+
case 'integer':
|
|
108
|
+
if (!Number.isInteger(value)) return { value, error: `${field} must be an integer` };
|
|
109
|
+
break;
|
|
110
|
+
case 'number':
|
|
111
|
+
case 'float':
|
|
112
|
+
if (typeof value !== 'number' || Number.isNaN(value)) return { value, error: `${field} must be a number` };
|
|
113
|
+
break;
|
|
114
|
+
case 'boolean':
|
|
115
|
+
if (typeof value !== 'boolean') return { value, error: `${field} must be a boolean` };
|
|
116
|
+
break;
|
|
117
|
+
case 'array':
|
|
118
|
+
if (!Array.isArray(value)) return { value, error: `${field} must be an array` };
|
|
119
|
+
break;
|
|
120
|
+
case 'date':
|
|
121
|
+
if (!(value instanceof Date)) return { value, error: `${field} must be a valid date` };
|
|
122
|
+
break;
|
|
123
|
+
case 'email':
|
|
124
|
+
if (typeof value !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
125
|
+
return { value, error: `${field} must be a valid email` };
|
|
126
|
+
break;
|
|
127
|
+
case 'url':
|
|
128
|
+
try { new URL(value); }
|
|
129
|
+
catch { return { value, error: `${field} must be a valid URL` }; }
|
|
130
|
+
break;
|
|
131
|
+
case 'uuid':
|
|
132
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
|
|
133
|
+
return { value, error: `${field} must be a valid UUID` };
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Constraints
|
|
139
|
+
if (rule.minLength !== undefined && typeof value === 'string' && value.length < rule.minLength)
|
|
140
|
+
return { value, error: `${field} must be at least ${rule.minLength} characters` };
|
|
141
|
+
if (rule.maxLength !== undefined && typeof value === 'string' && value.length > rule.maxLength)
|
|
142
|
+
return { value, error: `${field} must be at most ${rule.maxLength} characters` };
|
|
143
|
+
if (rule.min !== undefined && typeof value === 'number' && value < rule.min)
|
|
144
|
+
return { value, error: `${field} must be >= ${rule.min}` };
|
|
145
|
+
if (rule.max !== undefined && typeof value === 'number' && value > rule.max)
|
|
146
|
+
return { value, error: `${field} must be <= ${rule.max}` };
|
|
147
|
+
if (rule.match && typeof value === 'string' && !rule.match.test(value))
|
|
148
|
+
return { value, error: `${field} format is invalid` };
|
|
149
|
+
if (rule.enum && !rule.enum.includes(value))
|
|
150
|
+
return { value, error: `${field} must be one of: ${rule.enum.join(', ')}` };
|
|
151
|
+
if (rule.minItems !== undefined && Array.isArray(value) && value.length < rule.minItems)
|
|
152
|
+
return { value, error: `${field} must have at least ${rule.minItems} items` };
|
|
153
|
+
if (rule.maxItems !== undefined && Array.isArray(value) && value.length > rule.maxItems)
|
|
154
|
+
return { value, error: `${field} must have at most ${rule.maxItems} items` };
|
|
155
|
+
|
|
156
|
+
// Custom validator function
|
|
157
|
+
if (typeof rule.validate === 'function')
|
|
158
|
+
{
|
|
159
|
+
const msg = rule.validate(value);
|
|
160
|
+
if (typeof msg === 'string') return { value, error: msg };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { value, error: null };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Validate an object against a schema.
|
|
168
|
+
*
|
|
169
|
+
* @param {object} data - Input data.
|
|
170
|
+
* @param {object} schema - { fieldName: ruleObject }
|
|
171
|
+
* @param {object} [opts] - Configuration options.
|
|
172
|
+
* @param {boolean} [opts.stripUnknown=true] - Remove fields not in schema.
|
|
173
|
+
* @returns {{ sanitized: object, errors: string[] }}
|
|
174
|
+
*/
|
|
175
|
+
function validateObject(data, schema, opts = {})
|
|
176
|
+
{
|
|
177
|
+
const errors = [];
|
|
178
|
+
const sanitized = {};
|
|
179
|
+
const stripUnknown = opts.stripUnknown !== false;
|
|
180
|
+
const source = data || {};
|
|
181
|
+
|
|
182
|
+
for (const [field, rule] of Object.entries(schema))
|
|
183
|
+
{
|
|
184
|
+
const { value, error } = validateField(source[field], rule, field);
|
|
185
|
+
if (error) errors.push(error);
|
|
186
|
+
else if (value !== undefined) sanitized[field] = value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Preserve unknown fields if not stripping
|
|
190
|
+
if (!stripUnknown)
|
|
191
|
+
{
|
|
192
|
+
for (const key of Object.keys(source))
|
|
193
|
+
{
|
|
194
|
+
if (!(key in schema)) sanitized[key] = source[key];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { sanitized, errors };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Create a validation middleware.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} schema - Validation rules object.
|
|
205
|
+
* @param {object} [schema.body] - Rules for req.body fields.
|
|
206
|
+
* @param {object} [schema.query] - Rules for req.query fields.
|
|
207
|
+
* @param {object} [schema.params] - Rules for req.params fields.
|
|
208
|
+
* @param {object} [options] - Validation options.
|
|
209
|
+
* @param {boolean} [options.stripUnknown=true] - Remove fields not in schema.
|
|
210
|
+
* @param {Function} [options.onError] - Custom error handler `(errors, req, res) => {}`.
|
|
211
|
+
* @returns {Function} Middleware function.
|
|
212
|
+
*/
|
|
213
|
+
function validate(schema, options = {})
|
|
214
|
+
{
|
|
215
|
+
return function validatorMiddleware(req, res, next)
|
|
216
|
+
{
|
|
217
|
+
const allErrors = [];
|
|
218
|
+
|
|
219
|
+
if (schema.body)
|
|
220
|
+
{
|
|
221
|
+
const { sanitized, errors } = validateObject(req.body, schema.body, options);
|
|
222
|
+
if (errors.length) allErrors.push(...errors.map(e => `body.${e}`));
|
|
223
|
+
else req.body = sanitized;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (schema.query)
|
|
227
|
+
{
|
|
228
|
+
const { sanitized, errors } = validateObject(req.query, schema.query, options);
|
|
229
|
+
if (errors.length) allErrors.push(...errors.map(e => `query.${e}`));
|
|
230
|
+
else req.query = sanitized;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (schema.params)
|
|
234
|
+
{
|
|
235
|
+
const { sanitized, errors } = validateObject(req.params, schema.params, options);
|
|
236
|
+
if (errors.length) allErrors.push(...errors.map(e => `params.${e}`));
|
|
237
|
+
else req.params = sanitized;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (allErrors.length > 0)
|
|
241
|
+
{
|
|
242
|
+
if (options.onError) return options.onError(allErrors, req, res);
|
|
243
|
+
res.status(422).json({ errors: allErrors });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
next();
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Also export helpers for standalone use
|
|
252
|
+
validate.field = validateField;
|
|
253
|
+
validate.object = validateObject;
|
|
254
|
+
|
|
255
|
+
module.exports = validate;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zero-server/middleware",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "20+ zero-dependency middleware.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"zero-server",
|
|
7
|
-
"zero-
|
|
7
|
+
"zero-server",
|
|
8
8
|
"middleware"
|
|
9
9
|
],
|
|
10
10
|
"author": "Anthony Wiedman",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./package.json": "./package.json"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
|
+
"lib",
|
|
23
24
|
"index.js",
|
|
24
25
|
"index.d.ts",
|
|
25
26
|
"README.md",
|
|
@@ -43,6 +44,14 @@
|
|
|
43
44
|
},
|
|
44
45
|
"sideEffects": false,
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"@zero-server/
|
|
47
|
+
"@zero-server/errors": "0.9.2"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@zero-server/sdk": ">=0.9.2"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"@zero-server/sdk": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
47
56
|
}
|
|
48
57
|
}
|