koa-files 2.3.8 → 3.0.1

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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * @package koa-files
3
+ * @license MIT
4
+ * @version 3.0.1
5
+ * @author nuintun <nuintun@qq.com>
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 hash = require('./hash.cjs');
13
+ const parseRange = require('range-parser');
14
+
15
+ function _interopDefault(e) {
16
+ return e && e.__esModule ? e : { default: e };
17
+ }
18
+
19
+ const parseRange__default = /*#__PURE__*/ _interopDefault(parseRange);
20
+
21
+ /**
22
+ * @module http
23
+ */
24
+ /**
25
+ * @function isETag
26
+ * @description Check if etag is valid.
27
+ * @param value The value to check.
28
+ */
29
+ function isETag(value) {
30
+ return /^(?:W\/)?"[\s\S]+"$/.test(value);
31
+ }
32
+ /**
33
+ * @function parseTokens
34
+ * @description Parse HTTP tokens.
35
+ * @param value The tokens value string.
36
+ */
37
+ function parseTokens(value) {
38
+ let end = 0;
39
+ let start = 0;
40
+ let tokens = [];
41
+ // Gather tokens.
42
+ for (let i = 0, length = value.length; i < length; i++) {
43
+ switch (value.charCodeAt(i)) {
44
+ case 0x20:
45
+ // ' '.
46
+ if (start === end) {
47
+ start = end = i + 1;
48
+ }
49
+ break;
50
+ case 0x2c:
51
+ // ','.
52
+ tokens.push(value.substring(start, end));
53
+ start = end = i + 1;
54
+ break;
55
+ default:
56
+ end = i + 1;
57
+ break;
58
+ }
59
+ }
60
+ // Final token.
61
+ tokens.push(value.substring(start, end));
62
+ return tokens;
63
+ }
64
+ /**
65
+ * @function isRangeFresh
66
+ * @description Check if request range fresh.
67
+ * @param context Koa context.
68
+ */
69
+ function isRangeFresh(context) {
70
+ const { request, response } = context;
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
+ * @function isETagFresh
87
+ * @description Check if etag is fresh.
88
+ * @param match The match value.
89
+ * @param etag The etag value.
90
+ */
91
+ function isETagFresh(match, etag) {
92
+ return parseTokens(match).some(match => {
93
+ return match === etag || match === 'W/' + etag || 'W/' + match === etag;
94
+ });
95
+ }
96
+ /**
97
+ * @function decodeURI
98
+ * @description Decode URI component.
99
+ * @param URI The URI to decode.
100
+ */
101
+ function decodeURI(URI) {
102
+ try {
103
+ return decodeURIComponent(URI);
104
+ } catch (_a) {
105
+ return -1;
106
+ }
107
+ }
108
+ /**
109
+ * @function isConditionalGET
110
+ * @description Check if request is conditional GET.
111
+ * @param context Koa context.
112
+ */
113
+ function isConditionalGET(context) {
114
+ const { request } = context;
115
+ return !!(
116
+ request.get('If-Match') ||
117
+ request.get('If-None-Match') ||
118
+ request.get('If-Modified-Since') ||
119
+ request.get('If-Unmodified-Since')
120
+ );
121
+ }
122
+ /**
123
+ * @function isPreconditionFailure
124
+ * @description Check if request precondition failure.
125
+ * @param context Koa context.
126
+ */
127
+ function isPreconditionFailure({ request, response }) {
128
+ // If-Match.
129
+ const match = request.get('If-Match');
130
+ // Check if request match.
131
+ if (match) {
132
+ // Etag.
133
+ const etag = response.get('ETag');
134
+ return !etag || match === '*' || !isETagFresh(match, etag);
135
+ }
136
+ // If-Unmodified-Since.
137
+ const unmodifiedSince = Date.parse(request.get('If-Unmodified-Since'));
138
+ // Check if request unmodified.
139
+ if (!Number.isNaN(unmodifiedSince)) {
140
+ // Last-Modified.
141
+ const lastModified = Date.parse(response.get('Last-Modified'));
142
+ return Number.isNaN(lastModified) || lastModified > unmodifiedSince;
143
+ }
144
+ // Check precondition passed.
145
+ return false;
146
+ }
147
+ /**
148
+ * @function parseRanges
149
+ * @description Parse ranges.
150
+ * @param context Koa context.
151
+ * @param stats File stats.
152
+ */
153
+ function parseRanges(context, stats) {
154
+ const { size } = stats;
155
+ // Range support.
156
+ if (/^bytes$/i.test(context.response.get('Accept-Ranges'))) {
157
+ const range = context.request.get('Range');
158
+ // Range fresh.
159
+ if (range && isRangeFresh(context)) {
160
+ // Parse range -1 -2 or [].
161
+ const parsed = parseRange__default.default(size, range, { combine: true });
162
+ // -1 signals an unsatisfiable range.
163
+ // -2 signals a malformed header string.
164
+ if (parsed === -1 || parsed === -2) {
165
+ return parsed;
166
+ }
167
+ // Ranges ok, support multiple ranges.
168
+ if (parsed.type === 'bytes') {
169
+ // Set 206 status.
170
+ context.status = 206;
171
+ const { length } = parsed;
172
+ // Multiple ranges.
173
+ if (length > 1) {
174
+ // Content-Length.
175
+ let contentLength = 0;
176
+ // Ranges.
177
+ const ranges = [];
178
+ // Range boundary.
179
+ const boundary = `<${hash.generate()}>`;
180
+ // Range suffix.
181
+ const suffix = `\r\n--${boundary}--\r\n`;
182
+ // Multipart Content-Type.
183
+ const contentType = `Content-Type: ${context.type}`;
184
+ // Override Content-Type.
185
+ context.type = `multipart/byteranges; boundary=${boundary}`;
186
+ // Map ranges.
187
+ for (let index = 0; index < length; index++) {
188
+ const { start, end } = parsed[index];
189
+ // The first prefix boundary no \r\n.
190
+ const head = index > 0 ? '\r\n' : '';
191
+ const contentRange = `Content-Range: bytes ${start}-${end}/${size}`;
192
+ const prefix = `${head}--${boundary}\r\n${contentType}\r\n${contentRange}\r\n\r\n`;
193
+ // Compute Content-Length
194
+ contentLength += end - start + 1 + Buffer.byteLength(prefix);
195
+ // Cache range.
196
+ ranges.push({ start, end, prefix });
197
+ }
198
+ // The last add suffix boundary.
199
+ ranges[length - 1].suffix = suffix;
200
+ // Compute Content-Length.
201
+ contentLength += Buffer.byteLength(suffix);
202
+ // Set Content-Length.
203
+ context.length = contentLength;
204
+ // Return ranges.
205
+ return ranges;
206
+ } else {
207
+ const [{ start, end }] = parsed;
208
+ // Set Content-Length.
209
+ context.length = end - start + 1;
210
+ // Set Content-Range.
211
+ context.set('Content-Range', `bytes ${start}-${end}/${size}`);
212
+ // Return ranges.
213
+ return parsed;
214
+ }
215
+ }
216
+ }
217
+ }
218
+ // Set Content-Length.
219
+ context.length = size;
220
+ // Return ranges.
221
+ return [{ start: 0, end: Math.max(size - 1) }];
222
+ }
223
+
224
+ exports.decodeURI = decodeURI;
225
+ exports.isConditionalGET = isConditionalGET;
226
+ exports.isPreconditionFailure = isPreconditionFailure;
227
+ exports.parseRanges = parseRanges;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @package koa-files
3
+ * @license MIT
4
+ * @version 3.0.1
5
+ * @author nuintun <nuintun@qq.com>
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 path = require('path');
13
+
14
+ /**
15
+ * @module path
16
+ */
17
+ /**
18
+ * @function unixify
19
+ * @description Convert path to unix style.
20
+ * @param path The path to convert.
21
+ */
22
+ function unixify(path) {
23
+ return path.replace(/\\/g, '/');
24
+ }
25
+ /**
26
+ * @function hasTrailingSlash
27
+ * @description Check if path has trailing slash.
28
+ * @param path The path to check.
29
+ */
30
+ function hasTrailingSlash(path) {
31
+ return /\/$/.test(path);
32
+ }
33
+ /**
34
+ * @function isOutRoot
35
+ * @description Check if path is out of root.
36
+ * @param path The path to check.
37
+ * @param root The root path.
38
+ */
39
+ function isOutRoot(path$1, root) {
40
+ path$1 = path.relative(root, path$1);
41
+ return /\.\.(?:[\\/]|$)/.test(path$1) || path.isAbsolute(path$1);
42
+ }
43
+
44
+ exports.hasTrailingSlash = hasTrailingSlash;
45
+ exports.isOutRoot = isOutRoot;
46
+ exports.unixify = unixify;
package/esm/Service.js ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * @package koa-files
3
+ * @license MIT
4
+ * @version 3.0.1
5
+ * @author nuintun <nuintun@qq.com>
6
+ * @description A static files serving middleware for koa.
7
+ * @see https://github.com/nuintun/koa-files#readme
8
+ */
9
+
10
+ import createETag from 'etag';
11
+ import destroy from 'destroy';
12
+ import { PassThrough } from 'stream';
13
+ import { stat } from './utils/fs.js';
14
+ import { resolve, extname, join } from 'path';
15
+ import { unixify, isOutRoot, hasTrailingSlash } from './utils/path.js';
16
+ import { decodeURI, isConditionalGET, isPreconditionFailure, parseRanges } from './utils/http.js';
17
+
18
+ /**
19
+ * @module Service
20
+ */
21
+ /**
22
+ * @function isFunction
23
+ * @description Check if value is function.
24
+ * @param value The value to check.
25
+ */
26
+ function isFunction(value) {
27
+ return typeof value === 'function';
28
+ }
29
+ /**
30
+ * @class Service
31
+ */
32
+ class Service {
33
+ /**
34
+ * @constructor
35
+ * @description Create file service.
36
+ * @param root File service root.
37
+ * @param options File service options.
38
+ */
39
+ constructor(root, options) {
40
+ this.options = options;
41
+ this.root = unixify(resolve(root));
42
+ }
43
+ /**
44
+ * @private
45
+ * @method isIgnore
46
+ * @description Check if path is ignore.
47
+ * @param path File path.
48
+ */
49
+ isIgnore(path) {
50
+ const { ignore } = this.options;
51
+ return (isFunction(ignore) ? ignore(path) : false) === true;
52
+ }
53
+ /**
54
+ * @private
55
+ * @method setupHeaders
56
+ * @description Setup headers
57
+ * @param context Koa context
58
+ * @param path File path
59
+ * @param stats File stats
60
+ */
61
+ setupHeaders(context, path, stats) {
62
+ const { options } = this;
63
+ const { headers, etag } = options;
64
+ // Set status.
65
+ context.status = 200;
66
+ // Set Content-Type.
67
+ context.type = extname(path);
68
+ // Accept-Ranges.
69
+ if (options.acceptRanges === false) {
70
+ // Set Accept-Ranges to none tell client not support.
71
+ context.set('Accept-Ranges', 'none');
72
+ } else {
73
+ // Set Accept-Ranges.
74
+ context.set('Accept-Ranges', 'bytes');
75
+ }
76
+ // ETag.
77
+ if (etag === false) {
78
+ // Remove ETag.
79
+ context.remove('ETag');
80
+ } else {
81
+ context.set('ETag', createETag(stats));
82
+ }
83
+ // Last-Modified.
84
+ if (options.lastModified === false) {
85
+ // Remove Last-Modified.
86
+ context.remove('Last-Modified');
87
+ } else {
88
+ // Set mtime utc string.
89
+ context.set('Last-Modified', stats.mtime.toUTCString());
90
+ }
91
+ // Set headers.
92
+ if (headers) {
93
+ if (isFunction(headers)) {
94
+ const fields = headers(path, stats);
95
+ if (fields) {
96
+ context.set(fields);
97
+ }
98
+ } else {
99
+ context.set(headers);
100
+ }
101
+ }
102
+ }
103
+ /**
104
+ * @private
105
+ * @method write
106
+ * @description Write file to stream.
107
+ * @param stream Destination stream.
108
+ * @param path The file path to read.
109
+ * @param range The range to read.
110
+ * @param end Is destory destination stream after read complete.
111
+ */
112
+ write(stream, path, range, end) {
113
+ const { fs } = this.options;
114
+ return new Promise(resolve => {
115
+ // Range prefix and suffix.
116
+ const { prefix, suffix } = range;
117
+ // Create file stream reader.
118
+ const reader = fs.createReadStream(path, range);
119
+ // File read stream open.
120
+ if (prefix) {
121
+ reader.once('open', () => {
122
+ // Write prefix boundary.
123
+ stream.write(prefix);
124
+ });
125
+ }
126
+ // File read stream end.
127
+ if (suffix) {
128
+ reader.once('end', () => {
129
+ // Push suffix boundary.
130
+ stream.write(suffix);
131
+ });
132
+ }
133
+ // File read stream error.
134
+ reader.once('error', () => {
135
+ // End stream.
136
+ stream.end();
137
+ // Unpipe.
138
+ reader.unpipe();
139
+ // Destroy.
140
+ destroy(reader);
141
+ // Resolve.
142
+ resolve(false);
143
+ });
144
+ // File read stream close.
145
+ reader.once('close', () => {
146
+ // Unpipe.
147
+ reader.unpipe();
148
+ // Destroy.
149
+ destroy(reader);
150
+ // Resolve.
151
+ resolve(true);
152
+ });
153
+ // Write data to buffer.
154
+ reader.pipe(stream, { end });
155
+ });
156
+ }
157
+ /**
158
+ * @private
159
+ * @method send
160
+ * @description Send file.
161
+ * @param context Koa context.
162
+ * @param path File path.
163
+ * @param ranges Read ranges.
164
+ */
165
+ async send(context, path, ranges) {
166
+ // Set stream body, highWaterMark 64kb.
167
+ const stream = new PassThrough({
168
+ highWaterMark: 65536
169
+ });
170
+ // Set response body.
171
+ context.body = stream;
172
+ // Ranges length.
173
+ let { length } = ranges;
174
+ // Write ranges to stream.
175
+ for (const range of ranges) {
176
+ // Write range.
177
+ const passed = await this.write(stream, path, range, --length === 0);
178
+ // If not passed, break.
179
+ if (!passed) {
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ /**
185
+ * @public
186
+ * @method response
187
+ * @description Response to koa context.
188
+ * @param context Koa context.
189
+ */
190
+ async response(context) {
191
+ const { root } = this;
192
+ // Only support GET and HEAD (405).
193
+ if (context.method !== 'GET' && context.method !== 'HEAD') {
194
+ return false;
195
+ }
196
+ // Get path of file
197
+ const pathname = decodeURI(context.path);
198
+ const path = pathname === -1 ? pathname : unixify(join(root, pathname));
199
+ // Path -1 or null byte(s)
200
+ if (path === -1 || path.includes('\0')) {
201
+ return context.throw(400);
202
+ }
203
+ // Malicious path (403).
204
+ if (isOutRoot(path, root)) {
205
+ return false;
206
+ }
207
+ // Is ignore path or file (403).
208
+ if (this.isIgnore(path)) {
209
+ return false;
210
+ }
211
+ // File stats.
212
+ const stats = await stat(this.options.fs, path);
213
+ // Check file stats.
214
+ if (
215
+ // File not exist (404 | 500).
216
+ stats == null ||
217
+ // Is directory (403).
218
+ stats.isDirectory() ||
219
+ // Not a directory but has trailing slash (404).
220
+ hasTrailingSlash(path)
221
+ ) {
222
+ return false;
223
+ }
224
+ // Setup headers.
225
+ this.setupHeaders(context, path, stats);
226
+ // Conditional get support.
227
+ if (isConditionalGET(context)) {
228
+ // Request precondition failure.
229
+ if (isPreconditionFailure(context)) {
230
+ return context.throw(412);
231
+ }
232
+ // Request fresh (304).
233
+ if (context.fresh) {
234
+ // Set status.
235
+ context.status = 304;
236
+ // Set body null.
237
+ context.body = null;
238
+ // File found.
239
+ return true;
240
+ }
241
+ }
242
+ // Head request.
243
+ if (context.method === 'HEAD') {
244
+ // Set Content-Length.
245
+ context.length = stats.size;
246
+ // Set body null
247
+ context.body = null;
248
+ // File found.
249
+ return true;
250
+ }
251
+ // Parsed ranges.
252
+ const ranges = parseRanges(context, stats);
253
+ // 416
254
+ if (ranges === -1) {
255
+ // Set Content-Range.
256
+ context.set('Content-Range', `bytes */${stats.size}`);
257
+ // Unsatisfiable 416.
258
+ return context.throw(416);
259
+ }
260
+ // 400.
261
+ if (ranges === -2) {
262
+ return context.throw(400);
263
+ }
264
+ // Send file.
265
+ this.send(context, path, ranges);
266
+ // File found.
267
+ return true;
268
+ }
269
+ }
270
+
271
+ export { Service as default };
package/esm/index.js CHANGED
@@ -1,18 +1,17 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 2.3.8
4
+ * @version 3.0.1
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
8
8
  */
9
9
 
10
- import Files from './Files.js';
10
+ import fs from 'fs';
11
+ import Service from './Service.js';
11
12
 
12
13
  /**
13
14
  * @module index
14
- * @license MIT
15
- * @author nuintun
16
15
  */
17
16
  /**
18
17
  * @function server
@@ -20,15 +19,16 @@ import Files from './Files.js';
20
19
  * @param {Options} options
21
20
  */
22
21
  function server(root, options) {
23
- const files = new Files(root, options);
24
- if (options && options.defer) {
22
+ const config = Object.assign({ fs }, options);
23
+ const service = new Service(root, config);
24
+ if (config.defer) {
25
25
  return async (context, next) => {
26
26
  await next();
27
- await files.response(context);
27
+ await service.response(context);
28
28
  };
29
29
  }
30
30
  return async (context, next) => {
31
- if (!(await files.response(context))) {
31
+ if (!(await service.response(context))) {
32
32
  await next();
33
33
  }
34
34
  };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @package koa-files
3
+ * @license MIT
4
+ * @version 3.0.1
5
+ * @author nuintun <nuintun@qq.com>
6
+ * @description A static files serving middleware for koa.
7
+ * @see https://github.com/nuintun/koa-files#readme
8
+ */
9
+
10
+ /**
11
+ * @module fs
12
+ */
13
+ /**
14
+ * @function stat
15
+ * @description Get file stats.
16
+ * @param fs The file system to used.
17
+ * @param path The file path.
18
+ */
19
+ function stat(fs, path) {
20
+ return new Promise(resolve => {
21
+ fs.stat(path, (error, stats) => {
22
+ resolve(error ? null : stats);
23
+ });
24
+ });
25
+ }
26
+
27
+ export { stat };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @package koa-files
3
+ * @license MIT
4
+ * @version 3.0.1
5
+ * @author nuintun <nuintun@qq.com>
6
+ * @description A static files serving middleware for koa.
7
+ * @see https://github.com/nuintun/koa-files#readme
8
+ */
9
+
10
+ import { getRandomValues } from 'crypto';
11
+
12
+ /**
13
+ * @module hash
14
+ */
15
+ // prettier-ignore
16
+ const CHARS = [
17
+ // 0-9
18
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
19
+ // A-M
20
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
21
+ // N-Z
22
+ 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
23
+ // a-m
24
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
25
+ // n-z
26
+ 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'
27
+ ];
28
+ /**
29
+ * @function generate
30
+ * @description Generate a hash.
31
+ * @param length The length of hash.
32
+ */
33
+ function generate(length = 32) {
34
+ let hash = '';
35
+ const randomValues = getRandomValues(new Uint8Array(length));
36
+ // Create hash.
37
+ for (const value of randomValues) {
38
+ hash += CHARS[value % 62];
39
+ }
40
+ // Return hash.
41
+ return hash;
42
+ }
43
+
44
+ export { generate };