koa-files 2.3.7 → 2.3.8

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/cjs/utils.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 2.3.7
4
+ * @version 2.3.8
5
5
  * @author nuintun <nuintun@qq.com>
6
6
  * @description A static files serving middleware for koa.
7
7
  * @see https://github.com/nuintun/koa-files#readme
@@ -12,9 +12,11 @@
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
 
15
- function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
15
+ function _interopDefault(e) {
16
+ return e && e.__esModule ? e : { default: e };
17
+ }
16
18
 
17
- const fs__default = /*#__PURE__*/_interopDefault(fs);
19
+ const fs__default = /*#__PURE__*/ _interopDefault(fs);
18
20
 
19
21
  /**
20
22
  * @module utils
@@ -24,122 +26,117 @@ const fs__default = /*#__PURE__*/_interopDefault(fs);
24
26
  const CHARS = Array.from('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz');
25
27
  /**
26
28
  * @function isOutRoot
27
- * @description Test path is out of bound of root
28
- * @param {string} path
29
- * @param {string} root
30
- * @returns {boolean}
29
+ * @description Check if path is out of root
30
+ * @param path The path to check
31
+ * @param root The root path
31
32
  */
32
33
  function isOutRoot(path$1, root) {
33
- path$1 = path.relative(root, path$1);
34
- return /\.\.(?:[\\/]|$)/.test(path$1) || path.isAbsolute(path$1);
34
+ path$1 = path.relative(root, path$1);
35
+ return /\.\.(?:[\\/]|$)/.test(path$1) || path.isAbsolute(path$1);
35
36
  }
36
37
  /**
37
38
  * @function unixify
38
- * @description Convert path separators to posix/unix-style forward slashes
39
- * @param {string} path
40
- * @returns {string}
39
+ * @description Convert path to unix style
40
+ * @param path The path to convert
41
41
  */
42
42
  function unixify(path) {
43
- return path.replace(/\\/g, '/');
43
+ return path.replace(/\\/g, '/');
44
44
  }
45
45
  /**
46
46
  * @function decodeURI
47
- * @description Decode URI component.
48
- * @param {string} URI
49
- * @returns {string|-1}
47
+ * @description Decode URI component
48
+ * @param URI The URI to decode
50
49
  */
51
50
  function decodeURI(URI) {
52
- try {
53
- return decodeURIComponent(URI);
54
- }
55
- catch (error) {
56
- return -1;
57
- }
51
+ try {
52
+ return decodeURIComponent(URI);
53
+ } catch (error) {
54
+ return -1;
55
+ }
58
56
  }
59
57
  /**
60
58
  * @function boundaryGenerator
61
- * @description Create boundary
62
- * @returns {string}
59
+ * @description Generate a boundary
63
60
  */
64
61
  function boundaryGenerator() {
65
- let boundary = '';
66
- // Create boundary
67
- for (let i = 0; i < 38; i++) {
68
- boundary += CHARS[Math.floor(Math.random() * 62)];
69
- }
70
- // Return boundary
71
- return boundary;
62
+ let boundary = '';
63
+ // Create boundary
64
+ for (let i = 0; i < 38; i++) {
65
+ boundary += CHARS[Math.floor(Math.random() * 62)];
66
+ }
67
+ // Return boundary
68
+ return boundary;
72
69
  }
73
70
  /**
74
71
  * @function fstat
75
- * @param {string} path
76
- * @returns {Promise<Stats>}
72
+ * @description Get file stats
73
+ * @param path The file path
77
74
  */
78
75
  function fstat(path) {
79
- return new Promise((resolve, reject) => {
80
- fs__default.default.stat(path, (error, stats) => {
81
- error ? reject(error) : resolve(stats);
82
- });
76
+ return new Promise((resolve, reject) => {
77
+ fs__default.default.stat(path, (error, stats) => {
78
+ error ? reject(error) : resolve(stats);
83
79
  });
80
+ });
84
81
  }
85
82
  /**
86
83
  * @function hasTrailingSlash
87
- * @param {string} path
88
- * @returns {boolean}
84
+ * @description Check if path has trailing slash
85
+ * @param path The path to check
89
86
  */
90
87
  function hasTrailingSlash(path) {
91
- return /\/$/.test(path);
88
+ return /\/$/.test(path);
92
89
  }
93
90
  /**
94
91
  * @function parseTokens
95
- * @description Parse a HTTP tokens.
96
- * @param {string[]} value
92
+ * @description Parse HTTP tokens
93
+ * @param value The tokens value string
97
94
  */
98
95
  function parseTokens(value) {
99
- let end = 0;
100
- let start = 0;
101
- let tokens = [];
102
- // gather tokens
103
- for (let i = 0, length = value.length; i < length; i++) {
104
- switch (value.charCodeAt(i)) {
105
- case 0x20:
106
- // ' '
107
- if (start === end) {
108
- start = end = i + 1;
109
- }
110
- break;
111
- case 0x2c:
112
- // ','
113
- tokens.push(value.substring(start, end));
114
- start = end = i + 1;
115
- break;
116
- default:
117
- end = i + 1;
118
- break;
96
+ let end = 0;
97
+ let start = 0;
98
+ let tokens = [];
99
+ // gather tokens
100
+ for (let i = 0, length = value.length; i < length; i++) {
101
+ switch (value.charCodeAt(i)) {
102
+ case 0x20:
103
+ // ' '
104
+ if (start === end) {
105
+ start = end = i + 1;
119
106
  }
107
+ break;
108
+ case 0x2c:
109
+ // ','
110
+ tokens.push(value.substring(start, end));
111
+ start = end = i + 1;
112
+ break;
113
+ default:
114
+ end = i + 1;
115
+ break;
120
116
  }
121
- // final token
122
- tokens.push(value.substring(start, end));
123
- return tokens;
117
+ }
118
+ // final token
119
+ tokens.push(value.substring(start, end));
120
+ return tokens;
124
121
  }
125
122
  /**
126
- * @function isETagFresh
127
- * @param {string} match
128
- * @param {string} etag
129
- * @returns {boolean}
123
+ * @function isETag
124
+ * @description Check if etag is valid
125
+ * @param value The value to check
130
126
  */
131
- function isETagFresh(match, etag) {
132
- return parseTokens(match).some(match => {
133
- return match === etag || match === 'W/' + etag || 'W/' + match === etag;
134
- });
127
+ function isETag(value) {
128
+ return /^(?:W\/)?"[\s\S]+"$/.test(value);
135
129
  }
136
130
  /**
137
- * @function isETag
138
- * @param {string} etag
139
- * @returns {boolean}
131
+ * @function isETagFresh
132
+ * @description Check if etag is fresh
133
+ * @param match The match value
134
+ * @param etag The etag value
140
135
  */
141
- function isETag(etag) {
142
- return /(?:W\/)?"(?:[ !#-\x7E\x80-\xFF]*|\r\n[\t ]|\\.)*"/.test(etag);
136
+ function isETagFresh(match, etag) {
137
+ return parseTokens(match).some(match => {
138
+ return match === etag || match === 'W/' + etag || 'W/' + match === etag;
139
+ });
143
140
  }
144
141
 
145
142
  exports.boundaryGenerator = boundaryGenerator;
package/esm/Files.js ADDED
@@ -0,0 +1,378 @@
1
+ /**
2
+ * @package koa-files
3
+ * @license MIT
4
+ * @version 2.3.8
5
+ * @author nuintun <nuintun@qq.com>
6
+ * @description A static files serving middleware for koa.
7
+ * @see https://github.com/nuintun/koa-files#readme
8
+ */
9
+
10
+ import etag from 'etag';
11
+ import destroy from 'destroy';
12
+ import fs from 'fs';
13
+ import { PassThrough } from 'stream';
14
+ import parseRange from 'range-parser';
15
+ import { resolve, extname, join } from 'path';
16
+ import { unixify, isETagFresh, isETag, boundaryGenerator, decodeURI, isOutRoot, fstat, hasTrailingSlash } from './utils.js';
17
+
18
+ /**
19
+ * @module Files
20
+ * @license MIT
21
+ * @author nuintun
22
+ */
23
+ /**
24
+ * @class Files
25
+ */
26
+ class Files {
27
+ /**
28
+ * @constructor
29
+ * @description Create files service
30
+ * @param root Files service root
31
+ * @param options Files service options
32
+ */
33
+ constructor(root, options = {}) {
34
+ this.options = options;
35
+ this.root = unixify(resolve(root));
36
+ }
37
+ /**
38
+ * @private
39
+ * @method isConditionalGET
40
+ * @description Check if request is conditional GET
41
+ * @param context Koa context
42
+ */
43
+ isConditionalGET(context) {
44
+ const { request } = context;
45
+ return !!(
46
+ request.get('If-Match') ||
47
+ request.get('If-None-Match') ||
48
+ request.get('If-Modified-Since') ||
49
+ request.get('if-Unmodified-Since')
50
+ );
51
+ }
52
+ /**
53
+ * @private
54
+ * @method isPreconditionFailure
55
+ * @description Check if request precondition failure
56
+ * @param context Koa context
57
+ */
58
+ isPreconditionFailure(context) {
59
+ const { request, response } = context;
60
+ // If-Match
61
+ const match = request.get('If-Match');
62
+ if (match) {
63
+ const etag = response.get('ETag');
64
+ return !etag || (match !== '*' && !isETagFresh(match, etag));
65
+ }
66
+ // If-Unmodified-Since
67
+ const unmodifiedSince = Date.parse(request.get('If-Unmodified-Since'));
68
+ if (!isNaN(unmodifiedSince)) {
69
+ const lastModified = Date.parse(response.get('Last-Modified'));
70
+ return isNaN(lastModified) || lastModified > unmodifiedSince;
71
+ }
72
+ return false;
73
+ }
74
+ /**
75
+ * @private
76
+ * @method isRangeFresh
77
+ * @description Check if request range fresh
78
+ * @param context Koa context
79
+ */
80
+ isRangeFresh(context) {
81
+ const { request, response } = context;
82
+ const ifRange = request.get('If-Range');
83
+ // No If-Range
84
+ if (!ifRange) {
85
+ return true;
86
+ }
87
+ // If-Range as etag
88
+ if (isETag(ifRange)) {
89
+ const etag = response.get('ETag');
90
+ return !!(etag && isETagFresh(ifRange, etag));
91
+ }
92
+ // If-Range as modified date
93
+ const lastModified = response.get('Last-Modified');
94
+ return Date.parse(lastModified) <= Date.parse(ifRange);
95
+ }
96
+ /**
97
+ * @private
98
+ * @method isIgnore
99
+ * @description Check if path is ignore
100
+ * @param path File path
101
+ */
102
+ isIgnore(path) {
103
+ const { ignore } = this.options;
104
+ return (typeof ignore === 'function' ? ignore(path) : false) === true;
105
+ }
106
+ /**
107
+ * @private
108
+ * @method parseRange
109
+ * @description Parse range
110
+ * @param context Koa context
111
+ * @param stats File stats
112
+ */
113
+ parseRange(context, stats) {
114
+ const { size } = stats;
115
+ const { request } = context;
116
+ // Content-Length
117
+ let contentLength = size;
118
+ // Ranges
119
+ const ranges = [];
120
+ // Range support
121
+ if (this.options.acceptRanges !== false) {
122
+ const range = request.get('Range');
123
+ // Range fresh
124
+ if (range && this.isRangeFresh(context)) {
125
+ // Parse range -1 -2 or []
126
+ const parsed = parseRange(size, range, { combine: true });
127
+ // -1 signals an unsatisfiable range
128
+ // -2 signals a malformed header string
129
+ if (parsed === -1 || parsed === -2) {
130
+ return parsed;
131
+ }
132
+ // Ranges ok, support multiple ranges
133
+ if (parsed.type === 'bytes') {
134
+ // Set 206 status
135
+ context.status = 206;
136
+ // Multiple ranges
137
+ if (parsed.length > 1) {
138
+ // Reset content-length
139
+ contentLength = 0;
140
+ // Range boundary
141
+ const boundary = `<${boundaryGenerator()}>`;
142
+ const suffix = `\r\n--${boundary}--\r\n`;
143
+ const contentType = `Content-Type: ${context.type}`;
144
+ context.type = `multipart/byteranges; boundary=${boundary}`;
145
+ // Map ranges
146
+ parsed.forEach(({ start, end }, index) => {
147
+ // The first prefix boundary no \r\n
148
+ const prefixHead = index > 0 ? '\r\n' : '';
149
+ const contentRange = `Content-Range: bytes ${start}-${end}/${size}`;
150
+ const prefix = `${prefixHead}--${boundary}\r\n${contentType}\r\n${contentRange}\r\n\r\n`;
151
+ // Compute content-length
152
+ contentLength += end - start + 1 + Buffer.byteLength(prefix);
153
+ // Cache range
154
+ ranges.push({ start, end, prefix });
155
+ });
156
+ // The last add suffix boundary
157
+ ranges[ranges.length - 1].suffix = suffix;
158
+ // Compute content-length
159
+ contentLength += Buffer.byteLength(suffix);
160
+ } else {
161
+ const { start, end } = parsed[0];
162
+ context.set('Content-Range', `bytes ${start}-${end}/${size}`);
163
+ // Compute content-length
164
+ contentLength = end - start + 1;
165
+ // Cache range
166
+ ranges.push({ start, end });
167
+ }
168
+ }
169
+ }
170
+ }
171
+ // Set Content-Length
172
+ context.length = contentLength;
173
+ return ranges.length ? ranges : [{ start: 0 }];
174
+ }
175
+ /**
176
+ * @private
177
+ * @method setupHeaders
178
+ * @description Setup headers
179
+ * @param context Koa context
180
+ * @param path File path
181
+ * @param stats File stats
182
+ */
183
+ setupHeaders(context, path, stats) {
184
+ const { options } = this;
185
+ const { toString } = Object.prototype;
186
+ const { acceptRanges, cacheControl, lastModified } = options;
187
+ // Set status
188
+ context.status = 200;
189
+ // Set Content-Type
190
+ context.type = extname(path);
191
+ // ETag
192
+ if (options.etag !== false) {
193
+ // Set ETag
194
+ context.set('ETag', etag(stats));
195
+ }
196
+ // Accept-Ranges
197
+ if (acceptRanges !== false) {
198
+ // Set Accept-Ranges
199
+ context.set('Accept-Ranges', 'bytes');
200
+ }
201
+ // Last-Modified
202
+ if (lastModified !== false) {
203
+ // Set mtime utc string
204
+ context.set('Last-Modified', stats.mtime.toUTCString());
205
+ }
206
+ // Cache-Control
207
+ if (cacheControl && toString.call(cacheControl) === '[object String]') {
208
+ // Set Cache-Control
209
+ context.set('Cache-Control', cacheControl);
210
+ }
211
+ }
212
+ /**
213
+ * @private
214
+ * @method read
215
+ * @description Read file
216
+ * @param path File path
217
+ * @param range Read range
218
+ * @param buffer Destination stream
219
+ * @param end Is destory destination stream after read
220
+ */
221
+ read(path, range, buffer, end) {
222
+ return new Promise((resolve, reject) => {
223
+ // Create file stream
224
+ const file = fs.createReadStream(path, range);
225
+ // File read stream open
226
+ if (range.prefix) {
227
+ file.once('open', () => {
228
+ // Write prefix boundary
229
+ buffer.write(range.prefix);
230
+ });
231
+ }
232
+ // File read stream error
233
+ file.once('error', error => {
234
+ // Unpipe
235
+ file.unpipe(buffer);
236
+ // Destroy file stream
237
+ destroy(file);
238
+ // Reject
239
+ reject(error);
240
+ });
241
+ // File read stream end
242
+ if (range.suffix) {
243
+ file.once('end', () => {
244
+ // Push suffix boundary
245
+ buffer.write(range.suffix);
246
+ });
247
+ }
248
+ // File read stream close
249
+ file.once('close', () => {
250
+ // Unpipe
251
+ file.unpipe(buffer);
252
+ // Destroy file stream
253
+ destroy(file);
254
+ // Resolve
255
+ resolve(true);
256
+ });
257
+ // Write data to buffer
258
+ file.pipe(buffer, { end });
259
+ });
260
+ }
261
+ /**
262
+ * @private
263
+ * @method send
264
+ * @description Send file
265
+ * @param context Koa context
266
+ * @param path File path
267
+ * @param ranges Read ranges
268
+ */
269
+ async send(context, path, ranges) {
270
+ // Ranges length
271
+ let { length } = ranges;
272
+ // Set stream body, highWaterMark 64kb
273
+ const stream = new PassThrough({ highWaterMark: 65536 });
274
+ // Set response body
275
+ context.body = stream;
276
+ // Read file ranges
277
+ try {
278
+ for (const range of ranges) {
279
+ await this.read(path, range, stream, --length === 0);
280
+ }
281
+ } catch (error) {
282
+ // End stream when read exception
283
+ stream.end();
284
+ }
285
+ }
286
+ /**
287
+ * @public
288
+ * @method response
289
+ * @description Response to koa context
290
+ * @param context Koa context
291
+ */
292
+ async response(context) {
293
+ const { root } = this;
294
+ // Only support GET and HEAD (405)
295
+ if (context.method !== 'GET' && context.method !== 'HEAD') {
296
+ return false;
297
+ }
298
+ // Get path of file
299
+ const pathname = decodeURI(context.path);
300
+ const path = pathname === -1 ? pathname : unixify(join(root, pathname));
301
+ // Path -1 or null byte(s)
302
+ if (path === -1 || path.includes('\0')) {
303
+ return context.throw(400);
304
+ }
305
+ // Malicious path (403)
306
+ if (isOutRoot(path, root)) {
307
+ return false;
308
+ }
309
+ // Is ignore path or file (403)
310
+ if (this.isIgnore(path)) {
311
+ return false;
312
+ }
313
+ // File stats
314
+ let stats;
315
+ // Get file stats
316
+ try {
317
+ stats = await fstat(path);
318
+ } catch (error) {
319
+ // 404 | 500
320
+ return false;
321
+ }
322
+ // File not exist (404 | 500)
323
+ if (!stats) {
324
+ return false;
325
+ }
326
+ // Is directory (403)
327
+ if (stats.isDirectory()) {
328
+ return false;
329
+ }
330
+ // Not a directory but has trailing slash (404)
331
+ if (hasTrailingSlash(path)) {
332
+ return false;
333
+ }
334
+ // Setup headers
335
+ this.setupHeaders(context, path, stats);
336
+ // Conditional get support
337
+ if (this.isConditionalGET(context)) {
338
+ // Request precondition failure
339
+ if (this.isPreconditionFailure(context)) {
340
+ return context.throw(412);
341
+ }
342
+ // Request fresh (304)
343
+ if (context.fresh) {
344
+ // Set status
345
+ context.status = 304;
346
+ // Set body null
347
+ context.body = null;
348
+ return true;
349
+ }
350
+ }
351
+ // Head request
352
+ if (context.method === 'HEAD') {
353
+ // Set content-length
354
+ context.length = stats.size;
355
+ // Set body null
356
+ context.body = null;
357
+ return true;
358
+ }
359
+ // Parsed ranges
360
+ const ranges = this.parseRange(context, stats);
361
+ // 416
362
+ if (ranges === -1) {
363
+ // Set content-range
364
+ context.set('Content-Range', `bytes */${stats.size}`);
365
+ // Unsatisfiable 416
366
+ return context.throw(416);
367
+ }
368
+ // 400
369
+ if (ranges === -2) {
370
+ return context.throw(400);
371
+ }
372
+ // Send file
373
+ this.send(context, path, ranges);
374
+ return true;
375
+ }
376
+ }
377
+
378
+ export { Files as default };
package/esm/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 2.3.7
4
+ * @version 2.3.8
5
5
  * @author nuintun <nuintun@qq.com>
6
6
  * @description A static files serving middleware for koa.
7
7
  * @see https://github.com/nuintun/koa-files#readme
8
8
  */
9
9
 
10
- import Send from './Send.js';
10
+ import Files from './Files.js';
11
11
 
12
12
  /**
13
13
  * @module index
@@ -20,18 +20,18 @@ import Send from './Send.js';
20
20
  * @param {Options} options
21
21
  */
22
22
  function server(root, options) {
23
- const send = new Send(root, options);
24
- if (options && options.defer) {
25
- return async (context, next) => {
26
- await next();
27
- await send.response(context);
28
- };
29
- }
23
+ const files = new Files(root, options);
24
+ if (options && options.defer) {
30
25
  return async (context, next) => {
31
- if (!(await send.response(context))) {
32
- await next();
33
- }
26
+ await next();
27
+ await files.response(context);
34
28
  };
29
+ }
30
+ return async (context, next) => {
31
+ if (!(await files.response(context))) {
32
+ await next();
33
+ }
34
+ };
35
35
  }
36
36
 
37
37
  export { server as default };