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