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/index.js DELETED
@@ -1,520 +0,0 @@
1
- /**
2
- * @module koa-files
3
- * @license MIT
4
- * @version 1.0.2
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
- // Default options
151
- const defaultOptions = {
152
- maxAge: 31557600
153
- };
154
- /**
155
- * @class Send
156
- */
157
- class Send {
158
- /**
159
- * @constructor
160
- * @param {Context} ctx
161
- * @param {string} root
162
- * @param {Options} options
163
- */
164
- constructor(ctx, root = '.', options) {
165
- this.ctx = ctx;
166
- this.root = unixify(path.resolve(root));
167
- this.options = Object.assign(Object.assign({}, defaultOptions), options);
168
- // Decode path
169
- const path$1 = decodeURI(ctx.path);
170
- // Get real path
171
- this.path = path$1 === -1 ? -1 : unixify(path.join(this.root, path$1));
172
- }
173
- /**
174
- * @method isConditionalGET
175
- * @returns {boolean}
176
- */
177
- isConditionalGET() {
178
- const { request } = this.ctx;
179
- return !!(request.get('If-Match') ||
180
- request.get('If-None-Match') ||
181
- request.get('If-Modified-Since') ||
182
- request.get('if-Unmodified-Since'));
183
- }
184
- /**
185
- * @method isPreconditionFailure
186
- * @returns {boolean}
187
- */
188
- isPreconditionFailure() {
189
- const { request, response } = this.ctx;
190
- // If-Match
191
- const match = request.get('If-Match');
192
- if (match) {
193
- const etag = response.get('ETag');
194
- return !etag || (match !== '*' && !isETagFresh(match, etag));
195
- }
196
- // If-Unmodified-Since
197
- const unmodifiedSince = Date.parse(request.get('If-Unmodified-Since'));
198
- if (!isNaN(unmodifiedSince)) {
199
- const lastModified = Date.parse(response.get('Last-Modified'));
200
- return isNaN(lastModified) || lastModified > unmodifiedSince;
201
- }
202
- return false;
203
- }
204
- /**
205
- * @method isRangeFresh
206
- * @returns {boolean}
207
- */
208
- isRangeFresh() {
209
- const { request, response } = this.ctx;
210
- const ifRange = request.get('If-Range');
211
- // No If-Range
212
- if (!ifRange) {
213
- return true;
214
- }
215
- // If-Range as etag
216
- if (isETag(ifRange)) {
217
- const etag = response.get('ETag');
218
- return !!(etag && isETagFresh(ifRange, etag));
219
- }
220
- // If-Range as modified date
221
- const lastModified = response.get('Last-Modified');
222
- return Date.parse(lastModified) <= Date.parse(ifRange);
223
- }
224
- /**
225
- * @method isIgnore
226
- * @param {string} path
227
- * @returns {boolean}
228
- */
229
- isIgnore(path) {
230
- const { ignore } = this.options;
231
- return (typeof ignore === 'function' ? ignore(path) : false) === true;
232
- }
233
- /**
234
- * @method parseRange
235
- * @param {Stats} stats
236
- * @returns {Ranges}
237
- */
238
- parseRange(stats) {
239
- const { ctx } = this;
240
- const result = [];
241
- const { size } = stats;
242
- const { request } = ctx;
243
- // Content-Length
244
- let contentLength = size;
245
- // Range support
246
- if (this.options.acceptRanges !== false) {
247
- const range = request.get('Range');
248
- // Range fresh
249
- if (range && this.isRangeFresh()) {
250
- // Parse range -1 -2 or []
251
- const ranges = parseRange(size, range, { combine: true });
252
- // -1 signals an unsatisfiable range
253
- // -2 signals a malformed header string
254
- if (ranges === -1 || ranges === -2) {
255
- return ranges;
256
- }
257
- // Ranges ok, support multiple ranges
258
- if (ranges.type === 'bytes') {
259
- // Set 206 status
260
- ctx.status = 206;
261
- // Multiple ranges
262
- if (ranges.length > 1) {
263
- // Reset content-length
264
- contentLength = 0;
265
- // Range boundary
266
- const boundary = `<${boundaryGenerator()}>`;
267
- const suffix = `\r\n--${boundary}--\r\n`;
268
- const contentType = `Content-Type: ${ctx.type}`;
269
- ctx.type = `multipart/byteranges; boundary=${boundary}`;
270
- // Map ranges
271
- ranges.forEach(({ start, end }, index) => {
272
- // The first prefix boundary no \r\n
273
- const prefixHead = index > 0 ? '\r\n' : '';
274
- const contentRange = `Content-Range: bytes ${start}-${end}/${size}`;
275
- const prefix = `${prefixHead}--${boundary}\r\n${contentType}\r\n${contentRange}\r\n\r\n`;
276
- // Compute content-length
277
- contentLength += end - start + 1 + Buffer.byteLength(prefix);
278
- // Cache range
279
- result.push({ start, end, prefix });
280
- });
281
- // The last add suffix boundary
282
- result[result.length - 1].suffix = suffix;
283
- // Compute content-length
284
- contentLength += Buffer.byteLength(suffix);
285
- }
286
- else {
287
- const { start, end } = ranges[0];
288
- ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
289
- // Compute content-length
290
- contentLength = end - start + 1;
291
- // Cache range
292
- result.push({ start, end });
293
- }
294
- }
295
- }
296
- }
297
- // Set Content-Length
298
- ctx.length = contentLength;
299
- return result.length ? result : [{ start: 0 }];
300
- }
301
- /**
302
- * @method setupHeaders
303
- * @param {string} path
304
- * @param {Stats} stats
305
- */
306
- setupHeaders(path$1, stats) {
307
- const { ctx, options } = this;
308
- // Set status
309
- ctx.status = 200;
310
- // Accept-Ranges
311
- if (options.acceptRanges !== false) {
312
- // Set Accept-Ranges
313
- ctx.set('Accept-Ranges', 'bytes');
314
- }
315
- // Set Content-Type
316
- ctx.type = path.extname(path$1);
317
- // Cache-Control
318
- if (options.cacheControl !== false) {
319
- // Get maxAge
320
- const maxAge = Math.floor(Math.abs(options.maxAge)) || defaultOptions.maxAge;
321
- // Get Cache-Control
322
- let cacheControl = `public, max-age=${maxAge}`;
323
- // Immutable
324
- if (options.immutable) {
325
- cacheControl += ', immutable';
326
- }
327
- // Set Cache-Control
328
- ctx.set('Cache-Control', cacheControl);
329
- }
330
- // Last-Modified
331
- if (options.lastModified !== false) {
332
- // Get mtime utc string
333
- ctx.set('Last-Modified', stats.mtime.toUTCString());
334
- }
335
- // ETag
336
- if (options.etag !== false) {
337
- // Set ETag
338
- ctx.set('ETag', etag(stats));
339
- }
340
- }
341
- /**
342
- * @method read
343
- * @param {string} path
344
- * @param {Range} range
345
- * @param {PassThrough} buffer
346
- * @param {boolean} end
347
- * @returns {Promise<true>}
348
- */
349
- read(path, range, buffer, end) {
350
- return new Promise((resolve, reject) => {
351
- // Create file stream
352
- const file = fs.createReadStream(path, range);
353
- // File read stream open
354
- if (range.prefix) {
355
- file.once('open', () => {
356
- // Write prefix boundary
357
- buffer.write(range.prefix);
358
- });
359
- }
360
- // File read stream error
361
- file.once('error', (error) => {
362
- // Unpipe
363
- file.unpipe(buffer);
364
- // Destroy file stream
365
- destroy(file);
366
- // Reject
367
- reject(error);
368
- });
369
- // File read stream end
370
- if (range.suffix) {
371
- file.once('end', () => {
372
- // Push suffix boundary
373
- buffer.write(range.suffix);
374
- });
375
- }
376
- // File read stream close
377
- file.once('close', () => {
378
- // Unpipe
379
- file.unpipe(buffer);
380
- // Destroy file stream
381
- destroy(file);
382
- // Resolve
383
- resolve(true);
384
- });
385
- // Write data to buffer
386
- file.pipe(buffer, { end });
387
- });
388
- }
389
- /**
390
- * @method send
391
- * @param {string} path
392
- * @param {Range[]} ranges
393
- */
394
- async send(path, ranges) {
395
- const { ctx } = this;
396
- // Set stream body, highWaterMark 64kb
397
- ctx.body = new stream.PassThrough({ highWaterMark: 65536 });
398
- // Ranges count
399
- let count = ranges.length;
400
- // Read file ranges
401
- try {
402
- for (const range of ranges) {
403
- await this.read(path, range, ctx.body, --count === 0);
404
- }
405
- }
406
- catch (error) {
407
- // End stream when read exception
408
- ctx.body.end();
409
- }
410
- }
411
- /**
412
- * @method response
413
- * @returns {Promise<boolean>}
414
- */
415
- async response() {
416
- const { ctx, root, path } = this;
417
- // Only support GET and HEAD (405)
418
- if (ctx.method !== 'GET' && ctx.method !== 'HEAD') {
419
- return false;
420
- }
421
- // Path -1 or null byte(s)
422
- if (path === -1 || path.includes('\0')) {
423
- return ctx.throw(400);
424
- }
425
- // Malicious path (403)
426
- if (isOutRoot(path, root)) {
427
- return false;
428
- }
429
- // Is ignore path or file (403 | 404)
430
- if (this.isIgnore(path)) {
431
- return false;
432
- }
433
- // File stats
434
- let stats;
435
- // Get file stats
436
- try {
437
- stats = await fstat(path);
438
- }
439
- catch (error) {
440
- // 404 | 500
441
- return false;
442
- }
443
- // File not exist (404 | 500)
444
- if (!stats) {
445
- return false;
446
- }
447
- // Is directory (403)
448
- if (stats.isDirectory()) {
449
- return false;
450
- }
451
- // Not a directory but has trailing slash (404)
452
- if (hasTrailingSlash(path)) {
453
- return false;
454
- }
455
- // Setup headers
456
- this.setupHeaders(path, stats);
457
- // Conditional get support
458
- if (this.isConditionalGET()) {
459
- // Request precondition failure
460
- if (this.isPreconditionFailure()) {
461
- return ctx.throw(412);
462
- }
463
- // Request fresh (304)
464
- if (ctx.fresh) {
465
- // Set status
466
- ctx.status = 304;
467
- // Set body null
468
- ctx.body = null;
469
- return true;
470
- }
471
- }
472
- // Head request
473
- if (ctx.method === 'HEAD') {
474
- // Set content-length
475
- ctx.length = stats.size;
476
- return true;
477
- }
478
- // Parse ranges
479
- const ranges = this.parseRange(stats);
480
- // 416
481
- if (ranges === -1) {
482
- // Set content-range
483
- ctx.set('Content-Range', `bytes */${stats.size}`);
484
- // Unsatisfiable 416
485
- return ctx.throw(416);
486
- }
487
- // 400
488
- if (ranges === -2) {
489
- return ctx.throw(400);
490
- }
491
- // Send file
492
- this.send(path, ranges);
493
- return true;
494
- }
495
- }
496
-
497
- /**
498
- * @module index
499
- * @license MIT
500
- * @author nuintun
501
- */
502
- /**
503
- * @function server
504
- * @param {string} root
505
- * @param {Options} options
506
- */
507
- function server(root, options = {}) {
508
- if (options.defer) {
509
- return async (ctx, next) => {
510
- await next();
511
- await new Send(ctx, root, options).response();
512
- };
513
- }
514
- return async (ctx, next) => {
515
- const respond = await new Send(ctx, root, options).response();
516
- !respond && (await next());
517
- };
518
- }
519
-
520
- module.exports = server;
package/rollup.config.js DELETED
@@ -1,38 +0,0 @@
1
- /**
2
- * @module rollup.config
3
- */
4
-
5
- import rimraf from 'rimraf';
6
- import pkg from './package.json';
7
- import typescript from 'rollup-plugin-typescript2';
8
-
9
- rimraf.sync('typings');
10
- rimraf.sync('index.js');
11
-
12
- const banner = `/**
13
- * @module ${pkg.name}
14
- * @license ${pkg.license}
15
- * @version ${pkg.version}
16
- * @author ${pkg.author.name}
17
- * @description ${pkg.description}
18
- * @see ${pkg.homepage}
19
- */
20
- `;
21
-
22
- const tsconfigOverride = { compilerOptions: { declaration: true, declarationDir: 'typings' } };
23
-
24
- export default {
25
- input: 'src/index.ts',
26
- output: {
27
- banner,
28
- indent: true,
29
- strict: true,
30
- format: 'cjs',
31
- interop: false,
32
- esModule: false,
33
- file: 'index.js',
34
- preferConst: true
35
- },
36
- external: ['tslib', 'ms', 'etag', 'destroy', 'range-parser', 'fs', 'path', 'stream'],
37
- plugins: [typescript({ tsconfigOverride, clean: true, useTsconfigDeclarationDir: true })]
38
- };