koa-files 2.0.0 → 2.1.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/typings/Send.d.ts CHANGED
@@ -5,78 +5,78 @@
5
5
  */
6
6
  import { Context } from 'koa';
7
7
  export interface Options {
8
- etag?: boolean;
9
- cacheControl?: string;
10
- acceptRanges?: boolean;
11
- lastModified?: boolean;
12
- ignore?: (path: string) => boolean;
8
+ etag?: boolean;
9
+ cacheControl?: string;
10
+ acceptRanges?: boolean;
11
+ lastModified?: boolean;
12
+ ignore?: (path: string) => boolean;
13
13
  }
14
14
  /**
15
15
  * @class Send
16
16
  */
17
17
  export default class Send {
18
- private ctx;
19
- private root;
20
- private options;
21
- private path;
22
- /**
23
- * @constructor
24
- * @param {Context} ctx
25
- * @param {string} root
26
- * @param {Options} options
27
- */
28
- constructor(ctx: Context, root: string | undefined, options: Options);
29
- /**
30
- * @method isConditionalGET
31
- * @returns {boolean}
32
- */
33
- private isConditionalGET;
34
- /**
35
- * @method isPreconditionFailure
36
- * @returns {boolean}
37
- */
38
- private isPreconditionFailure;
39
- /**
40
- * @method isRangeFresh
41
- * @returns {boolean}
42
- */
43
- private isRangeFresh;
44
- /**
45
- * @method isIgnore
46
- * @param {string} path
47
- * @returns {boolean}
48
- */
49
- private isIgnore;
50
- /**
51
- * @method parseRange
52
- * @param {Stats} stats
53
- * @returns {Ranges}
54
- */
55
- private parseRange;
56
- /**
57
- * @method setupHeaders
58
- * @param {string} path
59
- * @param {Stats} stats
60
- */
61
- private setupHeaders;
62
- /**
63
- * @method read
64
- * @param {string} path
65
- * @param {Range} range
66
- * @param {PassThrough} buffer
67
- * @param {boolean} end
68
- * @returns {Promise<true>}
69
- */
70
- private read;
71
- /**
72
- * @method send
73
- * @param {string} path
74
- * @param {Range[]} ranges
75
- */
76
- private send;
77
- /**
78
- * @method response
79
- * @returns {Promise<boolean>}
80
- */
81
- response(): Promise<boolean>;
18
+ private ctx;
19
+ private root;
20
+ private options;
21
+ private path;
22
+ /**
23
+ * @constructor
24
+ * @param {Context} ctx
25
+ * @param {string} root
26
+ * @param {Options} options
27
+ */
28
+ constructor(ctx: Context, root: string | undefined, options: Options);
29
+ /**
30
+ * @method isConditionalGET
31
+ * @returns {boolean}
32
+ */
33
+ private isConditionalGET;
34
+ /**
35
+ * @method isPreconditionFailure
36
+ * @returns {boolean}
37
+ */
38
+ private isPreconditionFailure;
39
+ /**
40
+ * @method isRangeFresh
41
+ * @returns {boolean}
42
+ */
43
+ private isRangeFresh;
44
+ /**
45
+ * @method isIgnore
46
+ * @param {string} path
47
+ * @returns {boolean}
48
+ */
49
+ private isIgnore;
50
+ /**
51
+ * @method parseRange
52
+ * @param {Stats} stats
53
+ * @returns {Ranges}
54
+ */
55
+ private parseRange;
56
+ /**
57
+ * @method setupHeaders
58
+ * @param {string} path
59
+ * @param {Stats} stats
60
+ */
61
+ private setupHeaders;
62
+ /**
63
+ * @method read
64
+ * @param {string} path
65
+ * @param {Range} range
66
+ * @param {PassThrough} buffer
67
+ * @param {boolean} end
68
+ * @returns {Promise<true>}
69
+ */
70
+ private read;
71
+ /**
72
+ * @method send
73
+ * @param {string} path
74
+ * @param {Range[]} ranges
75
+ */
76
+ private send;
77
+ /**
78
+ * @method response
79
+ * @returns {Promise<boolean>}
80
+ */
81
+ response(): Promise<boolean>;
82
82
  }
@@ -6,7 +6,7 @@
6
6
  import { Middleware } from 'koa';
7
7
  import { Options as SendOptions } from './Send';
8
8
  export interface Options extends SendOptions {
9
- defer?: boolean;
9
+ defer?: boolean;
10
10
  }
11
11
  /**
12
12
  * @function server
package/.prettierignore DELETED
@@ -1,5 +0,0 @@
1
- # Ignore files
2
- *.min.*
3
- package.json
4
- tsconfig.json
5
- .vscode/**/*.json
package/index.js DELETED
@@ -1,512 +0,0 @@
1
- /**
2
- * @module koa-files
3
- * @license MIT
4
- * @version 2.0.0
5
- * @author nuintun
6
- * @description A static files serving middleware for koa.
7
- * @see https://github.com/nuintun/koa-files#readme
8
- */
9
-
10
- 'use strict';
11
-
12
- const etag = require('etag');
13
- const destroy = require('destroy');
14
- const stream = require('stream');
15
- const fs = require('fs');
16
- const path = require('path');
17
- const parseRange = require('range-parser');
18
-
19
- /**
20
- * @module utils
21
- * @license MIT
22
- * @author nuintun
23
- */
24
- const CHARS = Array.from('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz');
25
- /**
26
- * @function isOutRoot
27
- * @description Test path is out of bound of root
28
- * @param {string} path
29
- * @param {string} root
30
- * @returns {boolean}
31
- */
32
- function isOutRoot(path$1, root) {
33
- path$1 = path.relative(root, path$1);
34
- return /\.\.(?:[\\/]|$)/.test(path$1) || path.isAbsolute(path$1);
35
- }
36
- /**
37
- * @function unixify
38
- * @description Convert path separators to posix/unix-style forward slashes
39
- * @param {string} path
40
- * @returns {string}
41
- */
42
- function unixify(path) {
43
- return path.replace(/\\/g, '/');
44
- }
45
- /**
46
- * @function decodeURI
47
- * @description Decode URI component.
48
- * @param {string} URI
49
- * @returns {string|-1}
50
- */
51
- function decodeURI(URI) {
52
- try {
53
- return decodeURIComponent(URI);
54
- }
55
- catch (error) {
56
- return -1;
57
- }
58
- }
59
- /**
60
- * @function boundaryGenerator
61
- * @description Create boundary
62
- * @returns {string}
63
- */
64
- 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;
72
- }
73
- /**
74
- * @function fstat
75
- * @param {string} path
76
- * @returns {Promise<Stats>}
77
- */
78
- function fstat(path) {
79
- return new Promise((resolve, reject) => {
80
- fs.stat(path, (error, stats) => {
81
- error ? reject(error) : resolve(stats);
82
- });
83
- });
84
- }
85
- /**
86
- * @function hasTrailingSlash
87
- * @param {string} path
88
- * @returns {boolean}
89
- */
90
- function hasTrailingSlash(path) {
91
- return /\/$/.test(path);
92
- }
93
- /**
94
- * @function parseTokens
95
- * @description Parse a HTTP tokens.
96
- * @param {string[]} value
97
- */
98
- function parseTokens(value) {
99
- let start = 0;
100
- let end = 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;
119
- }
120
- }
121
- // final token
122
- tokens.push(value.substring(start, end));
123
- return tokens;
124
- }
125
- /**
126
- * @function isETagFresh
127
- * @param {string} match
128
- * @param {string} etag
129
- * @returns {boolean}
130
- */
131
- function isETagFresh(match, etag) {
132
- return parseTokens(match).some((match) => {
133
- return match === etag || match === 'W/' + etag || 'W/' + match === etag;
134
- });
135
- }
136
- /**
137
- * @function isETag
138
- * @param {string} etag
139
- * @returns {boolean}
140
- */
141
- function isETag(etag) {
142
- return /(?:W\/)?"(?:[ !#-\x7E\x80-\xFF]*|\r\n[\t ]|\\.)*"/.test(etag);
143
- }
144
-
145
- /**
146
- * @module Send
147
- * @license MIT
148
- * @author nuintun
149
- */
150
- /**
151
- * @class Send
152
- */
153
- class Send {
154
- /**
155
- * @constructor
156
- * @param {Context} ctx
157
- * @param {string} root
158
- * @param {Options} options
159
- */
160
- constructor(ctx, root = '.', options) {
161
- let { cacheControl } = options;
162
- const path$1 = decodeURI(ctx.path);
163
- this.ctx = ctx;
164
- this.root = unixify(path.resolve(root));
165
- this.options = Object.assign(Object.assign({}, options), { cacheControl });
166
- this.path = path$1 === -1 ? -1 : unixify(path.join(this.root, path$1));
167
- }
168
- /**
169
- * @method isConditionalGET
170
- * @returns {boolean}
171
- */
172
- isConditionalGET() {
173
- const { request } = this.ctx;
174
- return !!(request.get('If-Match') ||
175
- request.get('If-None-Match') ||
176
- request.get('If-Modified-Since') ||
177
- request.get('if-Unmodified-Since'));
178
- }
179
- /**
180
- * @method isPreconditionFailure
181
- * @returns {boolean}
182
- */
183
- isPreconditionFailure() {
184
- const { request, response } = this.ctx;
185
- // If-Match
186
- const match = request.get('If-Match');
187
- if (match) {
188
- const etag = response.get('ETag');
189
- return !etag || (match !== '*' && !isETagFresh(match, etag));
190
- }
191
- // If-Unmodified-Since
192
- const unmodifiedSince = Date.parse(request.get('If-Unmodified-Since'));
193
- if (!isNaN(unmodifiedSince)) {
194
- const lastModified = Date.parse(response.get('Last-Modified'));
195
- return isNaN(lastModified) || lastModified > unmodifiedSince;
196
- }
197
- return false;
198
- }
199
- /**
200
- * @method isRangeFresh
201
- * @returns {boolean}
202
- */
203
- isRangeFresh() {
204
- const { request, response } = this.ctx;
205
- const ifRange = request.get('If-Range');
206
- // No If-Range
207
- if (!ifRange) {
208
- return true;
209
- }
210
- // If-Range as etag
211
- if (isETag(ifRange)) {
212
- const etag = response.get('ETag');
213
- return !!(etag && isETagFresh(ifRange, etag));
214
- }
215
- // If-Range as modified date
216
- const lastModified = response.get('Last-Modified');
217
- return Date.parse(lastModified) <= Date.parse(ifRange);
218
- }
219
- /**
220
- * @method isIgnore
221
- * @param {string} path
222
- * @returns {boolean}
223
- */
224
- isIgnore(path) {
225
- const { ignore } = this.options;
226
- return (typeof ignore === 'function' ? ignore(path) : false) === true;
227
- }
228
- /**
229
- * @method parseRange
230
- * @param {Stats} stats
231
- * @returns {Ranges}
232
- */
233
- parseRange(stats) {
234
- const { ctx } = this;
235
- const result = [];
236
- const { size } = stats;
237
- const { request } = ctx;
238
- // Content-Length
239
- let contentLength = size;
240
- // Range support
241
- if (this.options.acceptRanges !== false) {
242
- const range = request.get('Range');
243
- // Range fresh
244
- if (range && this.isRangeFresh()) {
245
- // Parse range -1 -2 or []
246
- const ranges = parseRange(size, range, { combine: true });
247
- // -1 signals an unsatisfiable range
248
- // -2 signals a malformed header string
249
- if (ranges === -1 || ranges === -2) {
250
- return ranges;
251
- }
252
- // Ranges ok, support multiple ranges
253
- if (ranges.type === 'bytes') {
254
- // Set 206 status
255
- ctx.status = 206;
256
- // Multiple ranges
257
- if (ranges.length > 1) {
258
- // Reset content-length
259
- contentLength = 0;
260
- // Range boundary
261
- const boundary = `<${boundaryGenerator()}>`;
262
- const suffix = `\r\n--${boundary}--\r\n`;
263
- const contentType = `Content-Type: ${ctx.type}`;
264
- ctx.type = `multipart/byteranges; boundary=${boundary}`;
265
- // Map ranges
266
- ranges.forEach(({ start, end }, index) => {
267
- // The first prefix boundary no \r\n
268
- const prefixHead = index > 0 ? '\r\n' : '';
269
- const contentRange = `Content-Range: bytes ${start}-${end}/${size}`;
270
- const prefix = `${prefixHead}--${boundary}\r\n${contentType}\r\n${contentRange}\r\n\r\n`;
271
- // Compute content-length
272
- contentLength += end - start + 1 + Buffer.byteLength(prefix);
273
- // Cache range
274
- result.push({ start, end, prefix });
275
- });
276
- // The last add suffix boundary
277
- result[result.length - 1].suffix = suffix;
278
- // Compute content-length
279
- contentLength += Buffer.byteLength(suffix);
280
- }
281
- else {
282
- const { start, end } = ranges[0];
283
- ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
284
- // Compute content-length
285
- contentLength = end - start + 1;
286
- // Cache range
287
- result.push({ start, end });
288
- }
289
- }
290
- }
291
- }
292
- // Set Content-Length
293
- ctx.length = contentLength;
294
- return result.length ? result : [{ start: 0 }];
295
- }
296
- /**
297
- * @method setupHeaders
298
- * @param {string} path
299
- * @param {Stats} stats
300
- */
301
- setupHeaders(path$1, stats) {
302
- const { ctx, options } = this;
303
- const { toString } = Object.prototype;
304
- const { acceptRanges, cacheControl, lastModified } = options;
305
- // Set status
306
- ctx.status = 200;
307
- // Set Content-Type
308
- ctx.type = path.extname(path$1);
309
- // ETag
310
- if (options.etag !== false) {
311
- // Set ETag
312
- ctx.set('ETag', etag(stats));
313
- }
314
- // Accept-Ranges
315
- if (acceptRanges !== false) {
316
- // Set Accept-Ranges
317
- ctx.set('Accept-Ranges', 'bytes');
318
- }
319
- // Last-Modified
320
- if (lastModified !== false) {
321
- // Set mtime utc string
322
- ctx.set('Last-Modified', stats.mtime.toUTCString());
323
- }
324
- // Cache-Control
325
- if (cacheControl && toString.call(cacheControl) === '[object String]') {
326
- // Set Cache-Control
327
- ctx.set('Cache-Control', cacheControl);
328
- }
329
- }
330
- /**
331
- * @method read
332
- * @param {string} path
333
- * @param {Range} range
334
- * @param {PassThrough} buffer
335
- * @param {boolean} end
336
- * @returns {Promise<true>}
337
- */
338
- read(path, range, buffer, end) {
339
- return new Promise((resolve, reject) => {
340
- // Create file stream
341
- const file = fs.createReadStream(path, range);
342
- // File read stream open
343
- if (range.prefix) {
344
- file.once('open', () => {
345
- // Write prefix boundary
346
- buffer.write(range.prefix);
347
- });
348
- }
349
- // File read stream error
350
- file.once('error', (error) => {
351
- // Unpipe
352
- file.unpipe(buffer);
353
- // Destroy file stream
354
- destroy(file);
355
- // Reject
356
- reject(error);
357
- });
358
- // File read stream end
359
- if (range.suffix) {
360
- file.once('end', () => {
361
- // Push suffix boundary
362
- buffer.write(range.suffix);
363
- });
364
- }
365
- // File read stream close
366
- file.once('close', () => {
367
- // Unpipe
368
- file.unpipe(buffer);
369
- // Destroy file stream
370
- destroy(file);
371
- // Resolve
372
- resolve(true);
373
- });
374
- // Write data to buffer
375
- file.pipe(buffer, { end });
376
- });
377
- }
378
- /**
379
- * @method send
380
- * @param {string} path
381
- * @param {Range[]} ranges
382
- */
383
- async send(path, ranges) {
384
- // Ranges count
385
- let count = ranges.length;
386
- // Set stream body, highWaterMark 64kb
387
- const stream$1 = new stream.PassThrough({ highWaterMark: 65536 });
388
- // Set response body
389
- this.ctx.body = stream$1;
390
- // Read file ranges
391
- try {
392
- for (const range of ranges) {
393
- await this.read(path, range, stream$1, --count === 0);
394
- }
395
- }
396
- catch (error) {
397
- // End stream when read exception
398
- stream$1.end();
399
- }
400
- }
401
- /**
402
- * @method response
403
- * @returns {Promise<boolean>}
404
- */
405
- async response() {
406
- const { ctx, root, path } = this;
407
- // Only support GET and HEAD (405)
408
- if (ctx.method !== 'GET' && ctx.method !== 'HEAD') {
409
- return false;
410
- }
411
- // Path -1 or null byte(s)
412
- if (path === -1 || path.includes('\0')) {
413
- return ctx.throw(400);
414
- }
415
- // Malicious path (403)
416
- if (isOutRoot(path, root)) {
417
- return false;
418
- }
419
- // Is ignore path or file (403)
420
- if (this.isIgnore(path)) {
421
- return false;
422
- }
423
- // File stats
424
- let stats;
425
- // Get file stats
426
- try {
427
- stats = await fstat(path);
428
- }
429
- catch (error) {
430
- // 404 | 500
431
- return false;
432
- }
433
- // File not exist (404 | 500)
434
- if (!stats) {
435
- return false;
436
- }
437
- // Is directory (403)
438
- if (stats.isDirectory()) {
439
- return false;
440
- }
441
- // Not a directory but has trailing slash (404)
442
- if (hasTrailingSlash(path)) {
443
- return false;
444
- }
445
- // Setup headers
446
- this.setupHeaders(path, stats);
447
- // Conditional get support
448
- if (this.isConditionalGET()) {
449
- // Request precondition failure
450
- if (this.isPreconditionFailure()) {
451
- return ctx.throw(412);
452
- }
453
- // Request fresh (304)
454
- if (ctx.fresh) {
455
- // Set status
456
- ctx.status = 304;
457
- // Set body null
458
- ctx.body = null;
459
- return true;
460
- }
461
- }
462
- // Head request
463
- if (ctx.method === 'HEAD') {
464
- // Set content-length
465
- ctx.length = stats.size;
466
- // Set body null
467
- ctx.body = null;
468
- return true;
469
- }
470
- // Parse ranges
471
- const ranges = this.parseRange(stats);
472
- // 416
473
- if (ranges === -1) {
474
- // Set content-range
475
- ctx.set('Content-Range', `bytes */${stats.size}`);
476
- // Unsatisfiable 416
477
- return ctx.throw(416);
478
- }
479
- // 400
480
- if (ranges === -2) {
481
- return ctx.throw(400);
482
- }
483
- // Send file
484
- this.send(path, ranges);
485
- return true;
486
- }
487
- }
488
-
489
- /**
490
- * @module index
491
- * @license MIT
492
- * @author nuintun
493
- */
494
- /**
495
- * @function server
496
- * @param {string} root
497
- * @param {Options} options
498
- */
499
- function server(root, options = {}) {
500
- if (options.defer) {
501
- return async (ctx, next) => {
502
- await next();
503
- await new Send(ctx, root, options).response();
504
- };
505
- }
506
- return async (ctx, next) => {
507
- const respond = await new Send(ctx, root, options).response();
508
- !respond && (await next());
509
- };
510
- }
511
-
512
- module.exports = server;