koa-files 2.3.5 → 2.3.7

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