@pkg-nec/http-server 14.1.1-pkgnec.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ #! /usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const url = require('url');
8
+ const { Readable } = require('stream');
9
+ const buffer = require('buffer');
10
+ const mime = require('mime');
11
+ const urlJoin = require('url-join');
12
+ const showDir = require('./show-dir');
13
+ const version = require('../../package.json').version;
14
+ const status = require('./status-handlers');
15
+ const generateEtag = require('./etag');
16
+ const optsParser = require('./opts');
17
+ const htmlEncodingSniffer = require('html-encoding-sniffer');
18
+
19
+ let httpServerCore = null;
20
+
21
+ function decodePathname(pathname) {
22
+ const pieces = pathname.replace(/\\/g, '/').split('/');
23
+
24
+ const normalized = path.normalize(pieces.map((rawPiece) => {
25
+ const piece = decodeURIComponent(rawPiece);
26
+
27
+ if (process.platform === 'win32' && /\\/.test(piece)) {
28
+ throw new Error('Invalid forward slash character');
29
+ }
30
+
31
+ return piece;
32
+ }).join('/'));
33
+ return process.platform === 'win32'
34
+ ? normalized.replace(/\\/g, '/') : normalized;
35
+ }
36
+
37
+ const nonUrlSafeCharsRgx = /[\x00-\x1F\x20\x7F-\uFFFF]+/g;
38
+ function ensureUriEncoded(text) {
39
+ return text
40
+ return String(text).replace(nonUrlSafeCharsRgx, encodeURIComponent);
41
+ }
42
+
43
+ // Check to see if we should try to compress a file with gzip.
44
+ function shouldCompressGzip(req) {
45
+ const headers = req.headers;
46
+
47
+ return headers && headers['accept-encoding'] &&
48
+ headers['accept-encoding']
49
+ .split(',')
50
+ .some(el => ['*', 'compress', 'gzip', 'deflate'].indexOf(el.trim()) !== -1)
51
+ ;
52
+ }
53
+
54
+ function shouldCompressBrotli(req) {
55
+ const headers = req.headers;
56
+
57
+ return headers && headers['accept-encoding'] &&
58
+ headers['accept-encoding']
59
+ .split(',')
60
+ .some(el => ['*', 'br'].indexOf(el.trim()) !== -1)
61
+ ;
62
+ }
63
+
64
+ function hasGzipId12(gzipped, cb) {
65
+ const stream = fs.createReadStream(gzipped, { start: 0, end: 1 });
66
+ let buffer = Buffer.from('');
67
+ let hasBeenCalled = false;
68
+
69
+ stream.on('data', (chunk) => {
70
+ buffer = Buffer.concat([buffer, chunk], 2);
71
+ });
72
+
73
+ stream.on('error', (err) => {
74
+ if (hasBeenCalled) {
75
+ throw err;
76
+ }
77
+
78
+ hasBeenCalled = true;
79
+ cb(err);
80
+ });
81
+
82
+ stream.on('close', () => {
83
+ if (hasBeenCalled) {
84
+ return;
85
+ }
86
+
87
+ hasBeenCalled = true;
88
+ cb(null, buffer[0] === 31 && buffer[1] === 139);
89
+ });
90
+ }
91
+
92
+
93
+ module.exports = function createMiddleware(_dir, _options) {
94
+ let dir;
95
+ let options;
96
+ if (typeof _dir === 'string') {
97
+ dir = _dir;
98
+ options = _options;
99
+ } else {
100
+ options = _dir;
101
+ dir = options.root;
102
+ }
103
+
104
+ const root = path.join(path.resolve(dir), '/');
105
+ const opts = optsParser(options);
106
+ const cache = opts.cache;
107
+ const autoIndex = opts.autoIndex;
108
+ const baseDir = opts.baseDir;
109
+ let defaultExt = opts.defaultExt;
110
+ const handleError = opts.handleError;
111
+ const headers = opts.headers;
112
+ const weakEtags = opts.weakEtags;
113
+ const handleOptionsMethod = opts.handleOptionsMethod;
114
+
115
+ opts.root = dir;
116
+ if (defaultExt && /^\./.test(defaultExt)) {
117
+ defaultExt = defaultExt.replace(/^\./, '');
118
+ }
119
+
120
+ // Support hashes and .types files in mimeTypes @since 0.8
121
+ if (opts.mimeTypes) {
122
+ try {
123
+ // You can pass a JSON blob here---useful for CLI use
124
+ opts.mimeTypes = JSON.parse(opts.mimeTypes);
125
+ } catch (e) {
126
+ // swallow parse errors, treat this as a string mimetype input
127
+ }
128
+ if (typeof opts.mimeTypes === 'string') {
129
+ mime.load(opts.mimeTypes);
130
+ } else if (typeof opts.mimeTypes === 'object') {
131
+ mime.define(opts.mimeTypes);
132
+ }
133
+ }
134
+
135
+ function shouldReturn304(req, serverLastModified, serverEtag) {
136
+ if (!req || !req.headers) {
137
+ return false;
138
+ }
139
+
140
+ const clientModifiedSince = req.headers['if-modified-since'];
141
+ const clientEtag = req.headers['if-none-match'];
142
+ let clientModifiedDate;
143
+
144
+ if (!clientModifiedSince && !clientEtag) {
145
+ // Client did not provide any conditional caching headers
146
+ return false;
147
+ }
148
+
149
+ if (clientModifiedSince) {
150
+ // Catch "illegal access" dates that will crash v8
151
+ try {
152
+ clientModifiedDate = new Date(Date.parse(clientModifiedSince));
153
+ } catch (err) {
154
+ return false;
155
+ }
156
+
157
+ if (clientModifiedDate.toString() === 'Invalid Date') {
158
+ return false;
159
+ }
160
+ // If the client's copy is older than the server's, don't return 304
161
+ if (clientModifiedDate < new Date(serverLastModified)) {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ if (clientEtag) {
167
+ // Do a strong or weak etag comparison based on setting
168
+ // https://www.ietf.org/rfc/rfc2616.txt Section 13.3.3
169
+ if (opts.weakCompare && clientEtag !== serverEtag
170
+ && clientEtag !== `W/${serverEtag}` && `W/${clientEtag}` !== serverEtag) {
171
+ return false;
172
+ }
173
+ if (!opts.weakCompare && (clientEtag !== serverEtag || clientEtag.indexOf('W/') === 0)) {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ return true;
179
+ }
180
+
181
+ return function middleware(req, res, next) {
182
+ // Figure out the path for the file from the given url
183
+ const parsed = url.parse(req.url);
184
+ let pathname = null;
185
+ let file = null;
186
+ let gzippedFile = null;
187
+ let brotliFile = null;
188
+
189
+ try {
190
+ decodeURIComponent(req.url); // check validity of url
191
+ pathname = decodePathname(parsed.pathname);
192
+ } catch (err) {
193
+ status[400](res, next, { error: err });
194
+ return;
195
+ }
196
+
197
+ file = path.normalize(
198
+ path.join(
199
+ root,
200
+ path.relative(path.join('/', baseDir), pathname)
201
+ )
202
+ );
203
+ // determine compressed forms if they were to exist
204
+ gzippedFile = `${file}.gz`;
205
+ brotliFile = `${file}.br`;
206
+
207
+ Object.keys(headers).forEach((key) => {
208
+ res.setHeader(key, headers[key]);
209
+ });
210
+
211
+ if (req.method === 'OPTIONS' && handleOptionsMethod) {
212
+ res.end();
213
+ return;
214
+ }
215
+
216
+ // TODO: This check is broken, which causes the 403 on the
217
+ // expected 404.
218
+ if (file.slice(0, root.length) !== root) {
219
+ status[403](res, next);
220
+ return;
221
+ }
222
+
223
+ if (req.method && (req.method !== 'GET' && req.method !== 'HEAD')) {
224
+ status[405](res, next);
225
+ return;
226
+ }
227
+
228
+
229
+ function serve(stat) {
230
+ // Do a MIME lookup, fall back to octet-stream and handle gzip
231
+ // and brotli special case.
232
+ const defaultType = opts.contentType || 'application/octet-stream';
233
+ let contentType = mime.lookup(file, defaultType);
234
+ const range = (req.headers && req.headers.range);
235
+ const lastModified = (new Date(stat.mtime)).toUTCString();
236
+ const etag = generateEtag(stat, weakEtags);
237
+ let cacheControl = cache;
238
+ let stream = null;
239
+ if (contentType && isTextFile(contentType)) {
240
+ if (stat.size < buffer.constants.MAX_LENGTH) {
241
+ const bytes = fs.readFileSync(file);
242
+ const sniffedEncoding = htmlEncodingSniffer(bytes, {
243
+ defaultEncoding: 'UTF-8'
244
+ });
245
+ contentType += `; charset=${sniffedEncoding}`;
246
+ stream = Readable.from(bytes)
247
+ } else {
248
+ // Assume text types are utf8
249
+ contentType += '; charset=UTF-8';
250
+ }
251
+ }
252
+
253
+ if (file === gzippedFile) { // is .gz picked up
254
+ res.setHeader('Content-Encoding', 'gzip');
255
+ // strip gz ending and lookup mime type
256
+ contentType = mime.lookup(path.basename(file, '.gz'), defaultType);
257
+ } else if (file === brotliFile) { // is .br picked up
258
+ res.setHeader('Content-Encoding', 'br');
259
+ // strip br ending and lookup mime type
260
+ contentType = mime.lookup(path.basename(file, '.br'), defaultType);
261
+ }
262
+
263
+ if (typeof cacheControl === 'function') {
264
+ cacheControl = cache(pathname);
265
+ }
266
+ if (typeof cacheControl === 'number') {
267
+ cacheControl = `max-age=${cacheControl}`;
268
+ }
269
+
270
+ if (range) {
271
+ const total = stat.size;
272
+ const parts = range.trim().replace(/bytes=/, '').split('-');
273
+ const partialstart = parts[0];
274
+ const partialend = parts[1];
275
+ const start = parseInt(partialstart, 10);
276
+ const end = Math.min(
277
+ total - 1,
278
+ partialend ? parseInt(partialend, 10) : total - 1
279
+ );
280
+ const chunksize = (end - start) + 1;
281
+ let fstream = null;
282
+
283
+ if (start > end || isNaN(start) || isNaN(end)) {
284
+ status['416'](res, next);
285
+ return;
286
+ }
287
+
288
+ fstream = fs.createReadStream(file, { start, end });
289
+ fstream.on('error', (err) => {
290
+ status['500'](res, next, { error: err });
291
+ });
292
+ res.on('close', () => {
293
+ fstream.destroy();
294
+ });
295
+ res.writeHead(206, {
296
+ 'Content-Range': `bytes ${start}-${end}/${total}`,
297
+ 'Accept-Ranges': 'bytes',
298
+ 'Content-Length': chunksize,
299
+ 'Content-Type': contentType,
300
+ 'cache-control': cacheControl,
301
+ 'last-modified': lastModified,
302
+ etag,
303
+ });
304
+ fstream.pipe(res);
305
+ return;
306
+ }
307
+
308
+ // TODO: Helper for this, with default headers.
309
+ res.setHeader('cache-control', cacheControl);
310
+ res.setHeader('last-modified', lastModified);
311
+ res.setHeader('etag', etag);
312
+
313
+ // Return a 304 if necessary
314
+ if (shouldReturn304(req, lastModified, etag)) {
315
+ status[304](res, next);
316
+ return;
317
+ }
318
+
319
+ res.setHeader('content-length', stat.size);
320
+ res.setHeader('content-type', contentType);
321
+
322
+ // set the response statusCode if we have a request statusCode.
323
+ // This only can happen if we have a 404 with some kind of 404.html
324
+ // In all other cases where we have a file we serve the 200
325
+ res.statusCode = req.statusCode || 200;
326
+
327
+ if (req.method === 'HEAD') {
328
+ res.end();
329
+ return;
330
+ }
331
+
332
+ // stream may already have been assigned during encoding sniffing.
333
+ if (stream === null) {
334
+ stream = fs.createReadStream(file);
335
+ }
336
+
337
+ stream.pipe(res);
338
+ stream.on('error', (err) => {
339
+ status['500'](res, next, { error: err });
340
+ });
341
+ stream.on('close', () => {
342
+ stream.destroy();
343
+ })
344
+ }
345
+
346
+
347
+ function statFile() {
348
+ try {
349
+ fs.stat(file, (err, stat) => {
350
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
351
+ if (req.statusCode === 404) {
352
+ // This means we're already trying ./404.html and can not find it.
353
+ // So send plain text response with 404 status code
354
+ status[404](res, next);
355
+ } else if (!path.extname(parsed.pathname).length && defaultExt) {
356
+ // If there is no file extension in the path and we have a default
357
+ // extension try filename and default extension combination before rendering 404.html.
358
+ middleware({
359
+ url: `${parsed.pathname}.${defaultExt}${(parsed.search) ? parsed.search : ''}`,
360
+ headers: req.headers,
361
+ }, res, next);
362
+ } else {
363
+ // Try to serve default ./404.html
364
+ const rawUrl = (handleError ? `/${path.join(baseDir, `404.${defaultExt}`)}` : req.url);
365
+ const encodedUrl = ensureUriEncoded(rawUrl);
366
+ middleware({
367
+ url: encodedUrl,
368
+ headers: req.headers,
369
+ statusCode: 404,
370
+ }, res, next);
371
+ }
372
+ } else if (err) {
373
+ status[500](res, next, { error: err });
374
+ } else if (stat.isDirectory()) {
375
+ if (!autoIndex && !opts.showDir) {
376
+ status[404](res, next);
377
+ return;
378
+ }
379
+
380
+
381
+ // 302 to / if necessary
382
+ if (!pathname.match(/\/$/)) {
383
+ res.statusCode = 302;
384
+ const q = parsed.query ? `?${parsed.query}` : '';
385
+ res.setHeader(
386
+ 'location',
387
+ ensureUriEncoded(`${parsed.pathname}/${q}`)
388
+ );
389
+ res.end();
390
+ return;
391
+ }
392
+
393
+ if (autoIndex) {
394
+ middleware({
395
+ url: urlJoin(
396
+ encodeURIComponent(pathname),
397
+ `/index.${defaultExt}`
398
+ ),
399
+ headers: req.headers,
400
+ }, res, (autoIndexError) => {
401
+ if (autoIndexError) {
402
+ status[500](res, next, { error: autoIndexError });
403
+ return;
404
+ }
405
+ if (opts.showDir) {
406
+ showDir(opts, stat)(req, res);
407
+ return;
408
+ }
409
+
410
+ status[403](res, next);
411
+ });
412
+ return;
413
+ }
414
+
415
+ if (opts.showDir) {
416
+ showDir(opts, stat)(req, res);
417
+ }
418
+ } else {
419
+ serve(stat);
420
+ }
421
+ });
422
+ } catch (err) {
423
+ status[500](res, next, { error: err.message });
424
+ }
425
+ }
426
+
427
+ function isTextFile(mimeType) {
428
+ return (/^text\/|^application\/(javascript|json)/).test(mimeType);
429
+ }
430
+
431
+ // serve gzip file if exists and is valid
432
+ function tryServeWithGzip() {
433
+ try {
434
+ fs.stat(gzippedFile, (err, stat) => {
435
+ if (!err && stat.isFile()) {
436
+ hasGzipId12(gzippedFile, (gzipErr, isGzip) => {
437
+ if (!gzipErr && isGzip) {
438
+ file = gzippedFile;
439
+ serve(stat);
440
+ } else {
441
+ statFile();
442
+ }
443
+ });
444
+ } else {
445
+ statFile();
446
+ }
447
+ });
448
+ } catch (err) {
449
+ status[500](res, next, { error: err.message });
450
+ }
451
+ }
452
+
453
+ // serve brotli file if exists, otherwise try gzip
454
+ function tryServeWithBrotli(shouldTryGzip) {
455
+ try {
456
+ fs.stat(brotliFile, (err, stat) => {
457
+ if (!err && stat.isFile()) {
458
+ file = brotliFile;
459
+ serve(stat);
460
+ } else if (shouldTryGzip) {
461
+ tryServeWithGzip();
462
+ } else {
463
+ statFile();
464
+ }
465
+ });
466
+ } catch (err) {
467
+ status[500](res, next, { error: err.message });
468
+ }
469
+ }
470
+
471
+ const shouldTryBrotli = opts.brotli && shouldCompressBrotli(req);
472
+ const shouldTryGzip = opts.gzip && shouldCompressGzip(req);
473
+ // always try brotli first, next try gzip, finally serve without compression
474
+ if (shouldTryBrotli) {
475
+ tryServeWithBrotli(shouldTryGzip);
476
+ } else if (shouldTryGzip) {
477
+ tryServeWithGzip();
478
+ } else {
479
+ statFile();
480
+ }
481
+ };
482
+ };
483
+
484
+
485
+ httpServerCore = module.exports;
486
+ httpServerCore.version = version;
487
+ httpServerCore.showDir = showDir;
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ // This is so you can have options aliasing and defaults in one place.
4
+
5
+ const defaults = require('./defaults.json');
6
+ const aliases = require('./aliases.json');
7
+
8
+ module.exports = (opts) => {
9
+ let autoIndex = defaults.autoIndex;
10
+ let showDir = defaults.showDir;
11
+ let showDotfiles = defaults.showDotfiles;
12
+ let humanReadable = defaults.humanReadable;
13
+ let hidePermissions = defaults.hidePermissions;
14
+ let si = defaults.si;
15
+ let cache = defaults.cache;
16
+ let gzip = defaults.gzip;
17
+ let brotli = defaults.brotli;
18
+ let defaultExt = defaults.defaultExt;
19
+ let handleError = defaults.handleError;
20
+ const headers = {};
21
+ let contentType = defaults.contentType;
22
+ let mimeTypes;
23
+ let weakEtags = defaults.weakEtags;
24
+ let weakCompare = defaults.weakCompare;
25
+ let handleOptionsMethod = defaults.handleOptionsMethod;
26
+
27
+ function isDeclared(k) {
28
+ return typeof opts[k] !== 'undefined' && opts[k] !== null;
29
+ }
30
+
31
+ function setHeader(str) {
32
+ const m = /^(.+?)\s*:\s*(.*)$/.exec(str);
33
+ if (!m) {
34
+ headers[str] = true;
35
+ } else {
36
+ headers[m[1]] = m[2];
37
+ }
38
+ }
39
+
40
+
41
+ if (opts) {
42
+ aliases.autoIndex.some((k) => {
43
+ if (isDeclared(k)) {
44
+ autoIndex = opts[k];
45
+ return true;
46
+ }
47
+ return false;
48
+ });
49
+
50
+ aliases.showDir.some((k) => {
51
+ if (isDeclared(k)) {
52
+ showDir = opts[k];
53
+ return true;
54
+ }
55
+ return false;
56
+ });
57
+
58
+ aliases.showDotfiles.some((k) => {
59
+ if (isDeclared(k)) {
60
+ showDotfiles = opts[k];
61
+ return true;
62
+ }
63
+ return false;
64
+ });
65
+
66
+ aliases.humanReadable.some((k) => {
67
+ if (isDeclared(k)) {
68
+ humanReadable = opts[k];
69
+ return true;
70
+ }
71
+ return false;
72
+ });
73
+
74
+ aliases.hidePermissions.some((k) => {
75
+ if (isDeclared(k)) {
76
+ hidePermissions = opts[k];
77
+ return true;
78
+ }
79
+ return false;
80
+ });
81
+
82
+ aliases.si.some((k) => {
83
+ if (isDeclared(k)) {
84
+ si = opts[k];
85
+ return true;
86
+ }
87
+ return false;
88
+ });
89
+
90
+ if (opts.defaultExt && typeof opts.defaultExt === 'string') {
91
+ defaultExt = opts.defaultExt;
92
+ }
93
+
94
+ if (typeof opts.cache !== 'undefined' && opts.cache !== null) {
95
+ if (typeof opts.cache === 'string') {
96
+ cache = opts.cache;
97
+ } else if (typeof opts.cache === 'number') {
98
+ cache = `max-age=${opts.cache}`;
99
+ } else if (typeof opts.cache === 'function') {
100
+ cache = opts.cache;
101
+ }
102
+ }
103
+
104
+ if (typeof opts.gzip !== 'undefined' && opts.gzip !== null) {
105
+ gzip = opts.gzip;
106
+ }
107
+
108
+ if (typeof opts.brotli !== 'undefined' && opts.brotli !== null) {
109
+ brotli = opts.brotli;
110
+ }
111
+
112
+ aliases.handleError.some((k) => {
113
+ if (isDeclared(k)) {
114
+ handleError = opts[k];
115
+ return true;
116
+ }
117
+ return false;
118
+ });
119
+
120
+ aliases.cors.forEach((k) => {
121
+ if (isDeclared(k) && opts[k]) {
122
+ handleOptionsMethod = true;
123
+ headers['Access-Control-Allow-Origin'] = '*';
124
+ headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since';
125
+ }
126
+ });
127
+
128
+ aliases.headers.forEach((k) => {
129
+ if (isDeclared(k)) {
130
+ if (Array.isArray(opts[k])) {
131
+ opts[k].forEach(setHeader);
132
+ } else if (opts[k] && typeof opts[k] === 'object') {
133
+ Object.keys(opts[k]).forEach((key) => {
134
+ headers[key] = opts[k][key];
135
+ });
136
+ } else {
137
+ setHeader(opts[k]);
138
+ }
139
+ }
140
+ });
141
+
142
+ aliases.contentType.some((k) => {
143
+ if (isDeclared(k)) {
144
+ contentType = opts[k];
145
+ return true;
146
+ }
147
+ return false;
148
+ });
149
+
150
+ aliases.mimeType.some((k) => {
151
+ if (isDeclared(k)) {
152
+ mimeTypes = opts[k];
153
+ return true;
154
+ }
155
+ return false;
156
+ });
157
+
158
+ aliases.weakEtags.some((k) => {
159
+ if (isDeclared(k)) {
160
+ weakEtags = opts[k];
161
+ return true;
162
+ }
163
+ return false;
164
+ });
165
+
166
+ aliases.weakCompare.some((k) => {
167
+ if (isDeclared(k)) {
168
+ weakCompare = opts[k];
169
+ return true;
170
+ }
171
+ return false;
172
+ });
173
+
174
+ aliases.handleOptionsMethod.some((k) => {
175
+ if (isDeclared(k)) {
176
+ handleOptionsMethod = handleOptionsMethod || opts[k];
177
+ return true;
178
+ }
179
+ return false;
180
+ });
181
+ }
182
+
183
+ return {
184
+ cache,
185
+ autoIndex,
186
+ showDir,
187
+ showDotfiles,
188
+ humanReadable,
189
+ hidePermissions,
190
+ si,
191
+ defaultExt,
192
+ baseDir: (opts && opts.baseDir) || '/',
193
+ gzip,
194
+ brotli,
195
+ handleError,
196
+ headers,
197
+ contentType,
198
+ mimeTypes,
199
+ weakEtags,
200
+ weakCompare,
201
+ handleOptionsMethod,
202
+ };
203
+ };