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