koa-files 3.1.10 → 3.2.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/README.md CHANGED
@@ -28,8 +28,8 @@ interface Headers {
28
28
  [key: string]: string | string[];
29
29
  }
30
30
 
31
- interface HeaderFunction {
32
- (path: string, stats: Stats): Headers | void;
31
+ interface HeadersFunction {
32
+ (path: string, stats: Stats): Promise<Headers | void> | Headers | void;
33
33
  }
34
34
 
35
35
  export interface FileSystem {
@@ -47,7 +47,7 @@ export interface Options {
47
47
  lastModified?: boolean;
48
48
  highWaterMark?: number;
49
49
  ignore?: IgnoreFunction;
50
- headers?: Headers | HeaderFunction;
50
+ headers?: Headers | HeadersFunction;
51
51
  }
52
52
 
53
53
  export default function server(root: string, options?: Options): Middleware;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
@@ -13,10 +13,10 @@ const buffer = require('buffer');
13
13
  const stream = require('stream');
14
14
 
15
15
  /**
16
- * @module stream
16
+ * @module ReadStream
17
17
  */
18
18
  const DISPOSE_EVENT = Symbol('dispose');
19
- class FileReadStream extends stream.Readable {
19
+ class ReadStream extends stream.Readable {
20
20
  /**
21
21
  * @constructor
22
22
  * @param path The file path.
@@ -24,8 +24,8 @@ class FileReadStream extends stream.Readable {
24
24
  * @param options The stream options.
25
25
  */
26
26
  constructor(path, ranges, options) {
27
- const { fs, encoding, highWaterMark } = options;
28
- super({ encoding, highWaterMark });
27
+ const { fs, highWaterMark } = options;
28
+ super({ highWaterMark });
29
29
  this.bytesRead = 0;
30
30
  this.fd = null;
31
31
  this.reading = false;
@@ -218,4 +218,4 @@ class FileReadStream extends stream.Readable {
218
218
  }
219
219
  }
220
220
 
221
- exports.FileReadStream = FileReadStream;
221
+ exports.ReadStream = ReadStream;
package/cjs/Service.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
@@ -11,9 +11,9 @@
11
11
 
12
12
  const createETag = require('etag');
13
13
  const fs = require('fs');
14
+ const ReadStream = require('./ReadStream.cjs');
14
15
  const fs$1 = require('./utils/fs.cjs');
15
16
  const path$1 = require('path');
16
- const stream = require('./utils/stream.cjs');
17
17
  const path = require('./utils/path.cjs');
18
18
  const http = require('./utils/http.cjs');
19
19
 
@@ -67,46 +67,47 @@ class Service {
67
67
  * @param path The file path.
68
68
  * @param stats The file stats.
69
69
  */
70
- setupHeaders(context, path, stats) {
70
+ async setupHeaders({ response }, path, stats) {
71
71
  const { options } = this;
72
- const { headers, etag } = options;
72
+ const { headers } = options;
73
73
  // Set status.
74
- context.status = 200;
74
+ response.status = 200;
75
75
  // Set Content-Type.
76
- context.type = path$1.extname(path);
76
+ response.type = path$1.extname(path);
77
+ // Set headers.
78
+ if (headers) {
79
+ if (isFunction(headers)) {
80
+ const fields = await headers(path, stats);
81
+ if (fields) {
82
+ response.set(fields);
83
+ }
84
+ } else {
85
+ response.set(headers);
86
+ }
87
+ }
77
88
  // Accept-Ranges.
78
89
  if (options.acceptRanges === false) {
79
90
  // Set Accept-Ranges to none tell client not support.
80
- context.set('Accept-Ranges', 'none');
91
+ response.set('Accept-Ranges', 'none');
81
92
  } else {
82
93
  // Set Accept-Ranges.
83
- context.set('Accept-Ranges', 'bytes');
94
+ response.set('Accept-Ranges', 'bytes');
84
95
  }
85
96
  // ETag.
86
- if (etag === false) {
97
+ if (options.etag === false) {
87
98
  // Remove ETag.
88
- context.remove('ETag');
89
- } else {
90
- context.set('ETag', createETag__default.default(stats));
99
+ response.remove('ETag');
100
+ } else if (!response.get('ETag')) {
101
+ // Set weak ETag.
102
+ response.set('ETag', createETag__default.default(stats));
91
103
  }
92
104
  // Last-Modified.
93
105
  if (options.lastModified === false) {
94
106
  // Remove Last-Modified.
95
- context.remove('Last-Modified');
96
- } else {
97
- // Set mtime utc string.
98
- context.set('Last-Modified', stats.mtime.toUTCString());
99
- }
100
- // Set headers.
101
- if (headers) {
102
- if (isFunction(headers)) {
103
- const fields = headers(path, stats);
104
- if (fields) {
105
- context.set(fields);
106
- }
107
- } else {
108
- context.set(headers);
109
- }
107
+ response.remove('Last-Modified');
108
+ } else if (!response.get('Last-Modified')) {
109
+ // Set last modified from mtime.
110
+ response.set('Last-Modified', stats.mtime.toUTCString());
110
111
  }
111
112
  }
112
113
  /**
@@ -116,17 +117,21 @@ class Service {
116
117
  * @param context The koa context.
117
118
  */
118
119
  async respond(context) {
119
- const { root } = this;
120
+ const { request } = context;
121
+ const { method } = request;
120
122
  // Only support GET and HEAD (405).
121
- if (context.method !== 'GET' && context.method !== 'HEAD') {
123
+ if (method !== 'GET' && method !== 'HEAD') {
122
124
  return false;
123
125
  }
124
126
  // Get pathname of file.
125
- const pathname = http.decodeURI(context.path);
127
+ const pathname = http.decodeURI(request.path);
126
128
  // Pathname decode failed or includes null byte(s).
127
129
  if (pathname === -1 || pathname.includes('\0')) {
128
130
  return context.throw(400);
129
131
  }
132
+ // Get service root.
133
+ const { root } = this;
134
+ // Get file path.
130
135
  const path$2 = path.unixify(path$1.join(root, pathname));
131
136
  // Malicious path (403).
132
137
  if (path.isOutRoot(path$2, root)) {
@@ -138,10 +143,8 @@ class Service {
138
143
  }
139
144
  // Get options.
140
145
  const { options } = this;
141
- // Get file system.
142
- const { fs } = options;
143
146
  // File stats.
144
- const stats = await fs$1.stat(fs, path$2);
147
+ const stats = await fs$1.stat(options.fs, path$2);
145
148
  // Check file stats.
146
149
  if (
147
150
  // File not exist (404 | 500).
@@ -153,30 +156,32 @@ class Service {
153
156
  ) {
154
157
  return false;
155
158
  }
159
+ // Koa response.
160
+ const { response } = context;
156
161
  // Setup headers.
157
- this.setupHeaders(context, path$2, stats);
162
+ await this.setupHeaders(context, path$2, stats);
158
163
  // Conditional get support.
159
164
  if (http.isConditionalGET(context)) {
160
- // Request precondition failure.
161
- if (http.isPreconditionFailure(context)) {
165
+ // Request precondition failed.
166
+ if (http.isPreconditionFailed(context)) {
162
167
  return context.throw(412);
163
168
  }
164
169
  // Request fresh (304).
165
- if (context.fresh) {
170
+ if (request.fresh) {
166
171
  // Set status.
167
- context.status = 304;
172
+ response.status = 304;
168
173
  // Set body null.
169
- context.body = null;
174
+ response.body = null;
170
175
  // File found.
171
176
  return true;
172
177
  }
173
178
  }
174
179
  // Head request.
175
- if (context.method === 'HEAD') {
180
+ if (method === 'HEAD') {
176
181
  // Set Content-Length.
177
- context.length = stats.size;
182
+ response.length = stats.size;
178
183
  // Set body null
179
- context.body = null;
184
+ response.body = null;
180
185
  // File found.
181
186
  return true;
182
187
  }
@@ -185,7 +190,7 @@ class Service {
185
190
  // 416
186
191
  if (ranges === -1) {
187
192
  // Set Content-Range.
188
- context.set('Content-Range', `bytes */${stats.size}`);
193
+ response.set('Content-Range', `bytes */${stats.size}`);
189
194
  // Unsatisfiable 416.
190
195
  return context.throw(416);
191
196
  }
@@ -193,10 +198,8 @@ class Service {
193
198
  if (ranges === -2) {
194
199
  return context.throw(400);
195
200
  }
196
- // Get stream options.
197
- const { highWaterMark } = options;
198
201
  // Set response body.
199
- context.body = new stream.FileReadStream(path$2, ranges, { fs, highWaterMark });
202
+ response.body = new ReadStream.ReadStream(path$2, ranges, options);
200
203
  // File found.
201
204
  return true;
202
205
  }
package/cjs/index.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
package/cjs/utils/fs.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
@@ -22,23 +22,8 @@ const parseRange__default = /*#__PURE__*/ _interopDefault(parseRange);
22
22
  /**
23
23
  * @module http
24
24
  */
25
- const TOKEN_SPLIT_REGEX = /\s*,\s*/;
26
- /**
27
- * @function isETag
28
- * @description Check if etag is valid.
29
- * @param value The value to check.
30
- */
31
- function isETag(value) {
32
- return /^(?:W\/)?"[\s\S]+"$/.test(value);
33
- }
34
- /**
35
- * @function parseTokens
36
- * @description Parse HTTP tokens.
37
- * @param value The tokens value string.
38
- */
39
- function parseTokens(value) {
40
- return value.trim().split(TOKEN_SPLIT_REGEX);
41
- }
25
+ const SPLIT_ETAG_RE = /\s*,\s*/;
26
+ const SINGLE_ETAG_RE = /^(?:W\/)?"[ !#-\x7E\x80-\xFF]+"$/;
42
27
  /**
43
28
  * @function decodeURI
44
29
  * @description Decode URI component.
@@ -51,45 +36,12 @@ function decodeURI(URI) {
51
36
  return -1;
52
37
  }
53
38
  }
54
- /**
55
- * @function isRangeFresh
56
- * @description Check if request range fresh.
57
- * @param context Koa context.
58
- */
59
- function isRangeFresh(context) {
60
- const { request, response } = context;
61
- const ifRange = request.get('If-Range');
62
- // No If-Range.
63
- if (!ifRange) {
64
- return true;
65
- }
66
- // If-Range as etag.
67
- if (isETag(ifRange)) {
68
- const etag = response.get('ETag');
69
- return !!(etag && isETagFresh(ifRange, etag));
70
- }
71
- // If-Range as modified date.
72
- const lastModified = response.get('Last-Modified');
73
- return Date.parse(lastModified) <= Date.parse(ifRange);
74
- }
75
- /**
76
- * @function isETagFresh
77
- * @description Check if etag is fresh.
78
- * @param match The match value.
79
- * @param etag The etag value.
80
- */
81
- function isETagFresh(match, etag) {
82
- return parseTokens(match).some(match => {
83
- return match === etag || match === 'W/' + etag || 'W/' + match === etag;
84
- });
85
- }
86
39
  /**
87
40
  * @function isConditionalGET
88
41
  * @description Check if request is conditional GET.
89
42
  * @param context The koa context.
90
43
  */
91
- function isConditionalGET(context) {
92
- const { request } = context;
44
+ function isConditionalGET({ request }) {
93
45
  return !!(
94
46
  request.get('If-Match') ||
95
47
  request.get('If-None-Match') ||
@@ -98,30 +50,72 @@ function isConditionalGET(context) {
98
50
  );
99
51
  }
100
52
  /**
101
- * @function isPreconditionFailure
102
- * @description Check if request precondition failure.
53
+ * @function isETagMatch
54
+ * @see https://httpwg.org/specs/rfc9110.html
55
+ * @param match The match value.
56
+ * @param etag The etag value.
57
+ * @param isIfMatch The flag of if-match.
58
+ */
59
+ function isETagMatch(match, etag, isIfMatch) {
60
+ // Trim etag.
61
+ etag = etag.trim();
62
+ // Weak tags cannot be matched and return false.
63
+ if (!etag || etag.startsWith('W/')) {
64
+ return false;
65
+ }
66
+ // Trim match.
67
+ match = match.trim();
68
+ // Check If-Match.
69
+ if (isIfMatch) {
70
+ if (match === '*') {
71
+ return true;
72
+ }
73
+ // If-Match maybe a list of etag.
74
+ return match.split(SPLIT_ETAG_RE).includes(etag);
75
+ }
76
+ // Check If-Range.
77
+ return match === etag;
78
+ }
79
+ /**
80
+ * @function isPreconditionFailed
81
+ * @description Check if request precondition failed.
103
82
  * @param context The koa context.
104
83
  */
105
- function isPreconditionFailure({ request, response }) {
84
+ function isPreconditionFailed({ request, response }) {
106
85
  // If-Match.
107
- const match = request.get('If-Match');
108
- // Check if request match.
109
- if (match) {
110
- // Etag.
111
- const etag = response.get('ETag');
112
- return !etag || match === '*' || !isETagFresh(match, etag);
86
+ const ifMatch = request.get('If-Match');
87
+ // Check If-Match.
88
+ if (ifMatch) {
89
+ return !isETagMatch(ifMatch, response.get('ETag'), true);
113
90
  }
114
91
  // If-Unmodified-Since.
115
92
  const unmodifiedSince = Date.parse(request.get('If-Unmodified-Since'));
116
- // Check if request unmodified.
93
+ // Check If-Unmodified-Since.
117
94
  if (!Number.isNaN(unmodifiedSince)) {
118
- // Last-Modified.
119
95
  const lastModified = Date.parse(response.get('Last-Modified'));
120
96
  return Number.isNaN(lastModified) || lastModified > unmodifiedSince;
121
97
  }
122
98
  // Check precondition passed.
123
99
  return false;
124
100
  }
101
+ /**
102
+ * @function isRangeFresh
103
+ * @description Check if request range fresh.
104
+ * @param context Koa context.
105
+ */
106
+ function isRangeFresh({ request, response }) {
107
+ const ifRange = request.get('If-Range');
108
+ // No If-Range.
109
+ if (!ifRange) {
110
+ return true;
111
+ }
112
+ // If-Range as modified date failed.
113
+ if (SINGLE_ETAG_RE.test(ifRange)) {
114
+ return isETagMatch(ifRange, response.get('ETag'));
115
+ }
116
+ // Check if Last-Modified is valid and equal to If-Range date
117
+ return Date.parse(response.get('Last-Modified')) <= Date.parse(ifRange);
118
+ }
125
119
  /**
126
120
  * @function parseRanges
127
121
  * @description Parse ranges.
@@ -130,9 +124,10 @@ function isPreconditionFailure({ request, response }) {
130
124
  */
131
125
  function parseRanges(context, stats) {
132
126
  const { size } = stats;
127
+ const { request, response } = context;
133
128
  // Range support.
134
- if (/^bytes$/i.test(context.response.get('Accept-Ranges'))) {
135
- const range = context.request.get('Range');
129
+ if (/^bytes$/i.test(response.get('Accept-Ranges'))) {
130
+ const range = request.get('Range');
136
131
  // Range fresh.
137
132
  if (range && isRangeFresh(context)) {
138
133
  // Parse range -1 -2 or [].
@@ -145,7 +140,7 @@ function parseRanges(context, stats) {
145
140
  // Ranges ok, support multiple ranges.
146
141
  if (parsed.type === 'bytes') {
147
142
  // Set 206 status.
148
- context.status = 206;
143
+ response.status = 206;
149
144
  const { length } = parsed;
150
145
  // Multiple ranges.
151
146
  if (length > 1) {
@@ -158,11 +153,11 @@ function parseRanges(context, stats) {
158
153
  // Range boundary.
159
154
  const boundary = `${hash.generate(32)}`;
160
155
  // Multipart Content-Type.
161
- const contentType = `Content-Type: ${context.type}`;
156
+ const contentType = `Content-Type: ${response.type}`;
162
157
  // Range suffix.
163
158
  const suffix = buffer.Buffer.from(`\r\n--${boundary}--\r\n`);
164
159
  // Override Content-Type.
165
- context.type = `multipart/byteranges; boundary="${boundary}"`;
160
+ response.type = `multipart/byteranges; boundary="${boundary}"`;
166
161
  // Map ranges.
167
162
  for (const [index, { start, end }] of entries) {
168
163
  const length = end - start + 1;
@@ -177,7 +172,7 @@ function parseRanges(context, stats) {
177
172
  // Compute Content-Length.
178
173
  contentLength += suffix.length;
179
174
  // Set Content-Length.
180
- context.length = contentLength;
175
+ response.length = contentLength;
181
176
  // The last add suffix boundary.
182
177
  ranges[length - 1].suffix = suffix;
183
178
  // Return ranges.
@@ -186,9 +181,9 @@ function parseRanges(context, stats) {
186
181
  const [{ start, end }] = parsed;
187
182
  const length = end - start + 1;
188
183
  // Set Content-Length.
189
- context.length = length;
184
+ response.length = length;
190
185
  // Set Content-Range.
191
- context.set('Content-Range', `bytes ${start}-${end}/${size}`);
186
+ response.set('Content-Range', `bytes ${start}-${end}/${size}`);
192
187
  // Return ranges.
193
188
  return [{ offset: start, length }];
194
189
  }
@@ -196,12 +191,12 @@ function parseRanges(context, stats) {
196
191
  }
197
192
  }
198
193
  // Set Content-Length.
199
- context.length = size;
194
+ response.length = size;
200
195
  // Return ranges.
201
196
  return [{ offset: 0, length: size }];
202
197
  }
203
198
 
204
199
  exports.decodeURI = decodeURI;
205
200
  exports.isConditionalGET = isConditionalGET;
206
- exports.isPreconditionFailure = isPreconditionFailure;
201
+ exports.isPreconditionFailed = isPreconditionFailed;
207
202
  exports.parseRanges = parseRanges;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
@@ -11,10 +11,10 @@ import { Buffer } from 'buffer';
11
11
  import { Readable } from 'stream';
12
12
 
13
13
  /**
14
- * @module stream
14
+ * @module ReadStream
15
15
  */
16
16
  const DISPOSE_EVENT = Symbol('dispose');
17
- class FileReadStream extends Readable {
17
+ class ReadStream extends Readable {
18
18
  /**
19
19
  * @constructor
20
20
  * @param path The file path.
@@ -22,8 +22,8 @@ class FileReadStream extends Readable {
22
22
  * @param options The stream options.
23
23
  */
24
24
  constructor(path, ranges, options) {
25
- const { fs, encoding, highWaterMark } = options;
26
- super({ encoding, highWaterMark });
25
+ const { fs, highWaterMark } = options;
26
+ super({ highWaterMark });
27
27
  this.bytesRead = 0;
28
28
  this.fd = null;
29
29
  this.reading = false;
@@ -216,4 +216,4 @@ class FileReadStream extends Readable {
216
216
  }
217
217
  }
218
218
 
219
- export { FileReadStream };
219
+ export { ReadStream };
package/esm/Service.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
@@ -9,11 +9,11 @@
9
9
 
10
10
  import createETag from 'etag';
11
11
  import fs from 'fs';
12
+ import { ReadStream } from './ReadStream.js';
12
13
  import { stat } from './utils/fs.js';
13
14
  import { resolve, extname, join } from 'path';
14
- import { FileReadStream } from './utils/stream.js';
15
15
  import { unixify, isOutRoot, hasTrailingSlash } from './utils/path.js';
16
- import { decodeURI, isConditionalGET, isPreconditionFailure, parseRanges } from './utils/http.js';
16
+ import { decodeURI, isConditionalGET, isPreconditionFailed, parseRanges } from './utils/http.js';
17
17
 
18
18
  /**
19
19
  * @module Service
@@ -58,46 +58,47 @@ class Service {
58
58
  * @param path The file path.
59
59
  * @param stats The file stats.
60
60
  */
61
- setupHeaders(context, path, stats) {
61
+ async setupHeaders({ response }, path, stats) {
62
62
  const { options } = this;
63
- const { headers, etag } = options;
63
+ const { headers } = options;
64
64
  // Set status.
65
- context.status = 200;
65
+ response.status = 200;
66
66
  // Set Content-Type.
67
- context.type = extname(path);
67
+ response.type = extname(path);
68
+ // Set headers.
69
+ if (headers) {
70
+ if (isFunction(headers)) {
71
+ const fields = await headers(path, stats);
72
+ if (fields) {
73
+ response.set(fields);
74
+ }
75
+ } else {
76
+ response.set(headers);
77
+ }
78
+ }
68
79
  // Accept-Ranges.
69
80
  if (options.acceptRanges === false) {
70
81
  // Set Accept-Ranges to none tell client not support.
71
- context.set('Accept-Ranges', 'none');
82
+ response.set('Accept-Ranges', 'none');
72
83
  } else {
73
84
  // Set Accept-Ranges.
74
- context.set('Accept-Ranges', 'bytes');
85
+ response.set('Accept-Ranges', 'bytes');
75
86
  }
76
87
  // ETag.
77
- if (etag === false) {
88
+ if (options.etag === false) {
78
89
  // Remove ETag.
79
- context.remove('ETag');
80
- } else {
81
- context.set('ETag', createETag(stats));
90
+ response.remove('ETag');
91
+ } else if (!response.get('ETag')) {
92
+ // Set weak ETag.
93
+ response.set('ETag', createETag(stats));
82
94
  }
83
95
  // Last-Modified.
84
96
  if (options.lastModified === false) {
85
97
  // 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
- }
98
+ response.remove('Last-Modified');
99
+ } else if (!response.get('Last-Modified')) {
100
+ // Set last modified from mtime.
101
+ response.set('Last-Modified', stats.mtime.toUTCString());
101
102
  }
102
103
  }
103
104
  /**
@@ -107,17 +108,21 @@ class Service {
107
108
  * @param context The koa context.
108
109
  */
109
110
  async respond(context) {
110
- const { root } = this;
111
+ const { request } = context;
112
+ const { method } = request;
111
113
  // Only support GET and HEAD (405).
112
- if (context.method !== 'GET' && context.method !== 'HEAD') {
114
+ if (method !== 'GET' && method !== 'HEAD') {
113
115
  return false;
114
116
  }
115
117
  // Get pathname of file.
116
- const pathname = decodeURI(context.path);
118
+ const pathname = decodeURI(request.path);
117
119
  // Pathname decode failed or includes null byte(s).
118
120
  if (pathname === -1 || pathname.includes('\0')) {
119
121
  return context.throw(400);
120
122
  }
123
+ // Get service root.
124
+ const { root } = this;
125
+ // Get file path.
121
126
  const path = unixify(join(root, pathname));
122
127
  // Malicious path (403).
123
128
  if (isOutRoot(path, root)) {
@@ -129,10 +134,8 @@ class Service {
129
134
  }
130
135
  // Get options.
131
136
  const { options } = this;
132
- // Get file system.
133
- const { fs } = options;
134
137
  // File stats.
135
- const stats = await stat(fs, path);
138
+ const stats = await stat(options.fs, path);
136
139
  // Check file stats.
137
140
  if (
138
141
  // File not exist (404 | 500).
@@ -144,30 +147,32 @@ class Service {
144
147
  ) {
145
148
  return false;
146
149
  }
150
+ // Koa response.
151
+ const { response } = context;
147
152
  // Setup headers.
148
- this.setupHeaders(context, path, stats);
153
+ await this.setupHeaders(context, path, stats);
149
154
  // Conditional get support.
150
155
  if (isConditionalGET(context)) {
151
- // Request precondition failure.
152
- if (isPreconditionFailure(context)) {
156
+ // Request precondition failed.
157
+ if (isPreconditionFailed(context)) {
153
158
  return context.throw(412);
154
159
  }
155
160
  // Request fresh (304).
156
- if (context.fresh) {
161
+ if (request.fresh) {
157
162
  // Set status.
158
- context.status = 304;
163
+ response.status = 304;
159
164
  // Set body null.
160
- context.body = null;
165
+ response.body = null;
161
166
  // File found.
162
167
  return true;
163
168
  }
164
169
  }
165
170
  // Head request.
166
- if (context.method === 'HEAD') {
171
+ if (method === 'HEAD') {
167
172
  // Set Content-Length.
168
- context.length = stats.size;
173
+ response.length = stats.size;
169
174
  // Set body null
170
- context.body = null;
175
+ response.body = null;
171
176
  // File found.
172
177
  return true;
173
178
  }
@@ -176,7 +181,7 @@ class Service {
176
181
  // 416
177
182
  if (ranges === -1) {
178
183
  // Set Content-Range.
179
- context.set('Content-Range', `bytes */${stats.size}`);
184
+ response.set('Content-Range', `bytes */${stats.size}`);
180
185
  // Unsatisfiable 416.
181
186
  return context.throw(416);
182
187
  }
@@ -184,10 +189,8 @@ class Service {
184
189
  if (ranges === -2) {
185
190
  return context.throw(400);
186
191
  }
187
- // Get stream options.
188
- const { highWaterMark } = options;
189
192
  // Set response body.
190
- context.body = new FileReadStream(path, ranges, { fs, highWaterMark });
193
+ response.body = new ReadStream(path, ranges, options);
191
194
  // File found.
192
195
  return true;
193
196
  }
package/esm/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
package/esm/utils/fs.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
package/esm/utils/hash.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
package/esm/utils/http.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
@@ -14,23 +14,8 @@ import parseRange from 'range-parser';
14
14
  /**
15
15
  * @module http
16
16
  */
17
- const TOKEN_SPLIT_REGEX = /\s*,\s*/;
18
- /**
19
- * @function isETag
20
- * @description Check if etag is valid.
21
- * @param value The value to check.
22
- */
23
- function isETag(value) {
24
- return /^(?:W\/)?"[\s\S]+"$/.test(value);
25
- }
26
- /**
27
- * @function parseTokens
28
- * @description Parse HTTP tokens.
29
- * @param value The tokens value string.
30
- */
31
- function parseTokens(value) {
32
- return value.trim().split(TOKEN_SPLIT_REGEX);
33
- }
17
+ const SPLIT_ETAG_RE = /\s*,\s*/;
18
+ const SINGLE_ETAG_RE = /^(?:W\/)?"[ !#-\x7E\x80-\xFF]+"$/;
34
19
  /**
35
20
  * @function decodeURI
36
21
  * @description Decode URI component.
@@ -43,45 +28,12 @@ function decodeURI(URI) {
43
28
  return -1;
44
29
  }
45
30
  }
46
- /**
47
- * @function isRangeFresh
48
- * @description Check if request range fresh.
49
- * @param context Koa context.
50
- */
51
- function isRangeFresh(context) {
52
- const { request, response } = context;
53
- const ifRange = request.get('If-Range');
54
- // No If-Range.
55
- if (!ifRange) {
56
- return true;
57
- }
58
- // If-Range as etag.
59
- if (isETag(ifRange)) {
60
- const etag = response.get('ETag');
61
- return !!(etag && isETagFresh(ifRange, etag));
62
- }
63
- // If-Range as modified date.
64
- const lastModified = response.get('Last-Modified');
65
- return Date.parse(lastModified) <= Date.parse(ifRange);
66
- }
67
- /**
68
- * @function isETagFresh
69
- * @description Check if etag is fresh.
70
- * @param match The match value.
71
- * @param etag The etag value.
72
- */
73
- function isETagFresh(match, etag) {
74
- return parseTokens(match).some(match => {
75
- return match === etag || match === 'W/' + etag || 'W/' + match === etag;
76
- });
77
- }
78
31
  /**
79
32
  * @function isConditionalGET
80
33
  * @description Check if request is conditional GET.
81
34
  * @param context The koa context.
82
35
  */
83
- function isConditionalGET(context) {
84
- const { request } = context;
36
+ function isConditionalGET({ request }) {
85
37
  return !!(
86
38
  request.get('If-Match') ||
87
39
  request.get('If-None-Match') ||
@@ -90,30 +42,72 @@ function isConditionalGET(context) {
90
42
  );
91
43
  }
92
44
  /**
93
- * @function isPreconditionFailure
94
- * @description Check if request precondition failure.
45
+ * @function isETagMatch
46
+ * @see https://httpwg.org/specs/rfc9110.html
47
+ * @param match The match value.
48
+ * @param etag The etag value.
49
+ * @param isIfMatch The flag of if-match.
50
+ */
51
+ function isETagMatch(match, etag, isIfMatch) {
52
+ // Trim etag.
53
+ etag = etag.trim();
54
+ // Weak tags cannot be matched and return false.
55
+ if (!etag || etag.startsWith('W/')) {
56
+ return false;
57
+ }
58
+ // Trim match.
59
+ match = match.trim();
60
+ // Check If-Match.
61
+ if (isIfMatch) {
62
+ if (match === '*') {
63
+ return true;
64
+ }
65
+ // If-Match maybe a list of etag.
66
+ return match.split(SPLIT_ETAG_RE).includes(etag);
67
+ }
68
+ // Check If-Range.
69
+ return match === etag;
70
+ }
71
+ /**
72
+ * @function isPreconditionFailed
73
+ * @description Check if request precondition failed.
95
74
  * @param context The koa context.
96
75
  */
97
- function isPreconditionFailure({ request, response }) {
76
+ function isPreconditionFailed({ request, response }) {
98
77
  // If-Match.
99
- const match = request.get('If-Match');
100
- // Check if request match.
101
- if (match) {
102
- // Etag.
103
- const etag = response.get('ETag');
104
- return !etag || match === '*' || !isETagFresh(match, etag);
78
+ const ifMatch = request.get('If-Match');
79
+ // Check If-Match.
80
+ if (ifMatch) {
81
+ return !isETagMatch(ifMatch, response.get('ETag'), true);
105
82
  }
106
83
  // If-Unmodified-Since.
107
84
  const unmodifiedSince = Date.parse(request.get('If-Unmodified-Since'));
108
- // Check if request unmodified.
85
+ // Check If-Unmodified-Since.
109
86
  if (!Number.isNaN(unmodifiedSince)) {
110
- // Last-Modified.
111
87
  const lastModified = Date.parse(response.get('Last-Modified'));
112
88
  return Number.isNaN(lastModified) || lastModified > unmodifiedSince;
113
89
  }
114
90
  // Check precondition passed.
115
91
  return false;
116
92
  }
93
+ /**
94
+ * @function isRangeFresh
95
+ * @description Check if request range fresh.
96
+ * @param context Koa context.
97
+ */
98
+ function isRangeFresh({ request, response }) {
99
+ const ifRange = request.get('If-Range');
100
+ // No If-Range.
101
+ if (!ifRange) {
102
+ return true;
103
+ }
104
+ // If-Range as modified date failed.
105
+ if (SINGLE_ETAG_RE.test(ifRange)) {
106
+ return isETagMatch(ifRange, response.get('ETag'));
107
+ }
108
+ // Check if Last-Modified is valid and equal to If-Range date
109
+ return Date.parse(response.get('Last-Modified')) <= Date.parse(ifRange);
110
+ }
117
111
  /**
118
112
  * @function parseRanges
119
113
  * @description Parse ranges.
@@ -122,9 +116,10 @@ function isPreconditionFailure({ request, response }) {
122
116
  */
123
117
  function parseRanges(context, stats) {
124
118
  const { size } = stats;
119
+ const { request, response } = context;
125
120
  // Range support.
126
- if (/^bytes$/i.test(context.response.get('Accept-Ranges'))) {
127
- const range = context.request.get('Range');
121
+ if (/^bytes$/i.test(response.get('Accept-Ranges'))) {
122
+ const range = request.get('Range');
128
123
  // Range fresh.
129
124
  if (range && isRangeFresh(context)) {
130
125
  // Parse range -1 -2 or [].
@@ -137,7 +132,7 @@ function parseRanges(context, stats) {
137
132
  // Ranges ok, support multiple ranges.
138
133
  if (parsed.type === 'bytes') {
139
134
  // Set 206 status.
140
- context.status = 206;
135
+ response.status = 206;
141
136
  const { length } = parsed;
142
137
  // Multiple ranges.
143
138
  if (length > 1) {
@@ -150,11 +145,11 @@ function parseRanges(context, stats) {
150
145
  // Range boundary.
151
146
  const boundary = `${generate(32)}`;
152
147
  // Multipart Content-Type.
153
- const contentType = `Content-Type: ${context.type}`;
148
+ const contentType = `Content-Type: ${response.type}`;
154
149
  // Range suffix.
155
150
  const suffix = Buffer.from(`\r\n--${boundary}--\r\n`);
156
151
  // Override Content-Type.
157
- context.type = `multipart/byteranges; boundary="${boundary}"`;
152
+ response.type = `multipart/byteranges; boundary="${boundary}"`;
158
153
  // Map ranges.
159
154
  for (const [index, { start, end }] of entries) {
160
155
  const length = end - start + 1;
@@ -169,7 +164,7 @@ function parseRanges(context, stats) {
169
164
  // Compute Content-Length.
170
165
  contentLength += suffix.length;
171
166
  // Set Content-Length.
172
- context.length = contentLength;
167
+ response.length = contentLength;
173
168
  // The last add suffix boundary.
174
169
  ranges[length - 1].suffix = suffix;
175
170
  // Return ranges.
@@ -178,9 +173,9 @@ function parseRanges(context, stats) {
178
173
  const [{ start, end }] = parsed;
179
174
  const length = end - start + 1;
180
175
  // Set Content-Length.
181
- context.length = length;
176
+ response.length = length;
182
177
  // Set Content-Range.
183
- context.set('Content-Range', `bytes ${start}-${end}/${size}`);
178
+ response.set('Content-Range', `bytes ${start}-${end}/${size}`);
184
179
  // Return ranges.
185
180
  return [{ offset: start, length }];
186
181
  }
@@ -188,9 +183,9 @@ function parseRanges(context, stats) {
188
183
  }
189
184
  }
190
185
  // Set Content-Length.
191
- context.length = size;
186
+ response.length = size;
192
187
  // Return ranges.
193
188
  return [{ offset: 0, length: size }];
194
189
  }
195
190
 
196
- export { decodeURI, isConditionalGET, isPreconditionFailure, parseRanges };
191
+ export { decodeURI, isConditionalGET, isPreconditionFailed, parseRanges };
package/esm/utils/path.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @package koa-files
3
3
  * @license MIT
4
- * @version 3.1.10
4
+ * @version 3.2.0
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koa-files",
3
- "version": "3.1.10",
3
+ "version": "3.2.0",
4
4
  "description": "A static files serving middleware for koa.",
5
5
  "type": "module",
6
6
  "main": "cjs/index.cjs",
@@ -1,23 +1,17 @@
1
1
  /**
2
- * @module stream
2
+ * @module ReadStream
3
3
  */
4
4
  import { PathLike } from 'fs';
5
- import { Buffer } from 'buffer';
6
- import { FileSystem } from './fs';
5
+ import { Range } from './utils/http';
6
+ import { FileSystem } from './utils/fs';
7
7
  import { Readable, ReadableOptions } from 'stream';
8
- export interface Options extends Pick<ReadableOptions, 'encoding' | 'highWaterMark'> {
8
+ export interface Options extends Pick<ReadableOptions, 'highWaterMark'> {
9
9
  fs: FileSystem;
10
10
  }
11
- export interface Range {
12
- offset: number;
13
- length: number;
14
- prefix?: Buffer;
15
- suffix?: Buffer;
16
- }
17
11
  interface Callback {
18
12
  (error?: Error | null): void;
19
13
  }
20
- export declare class FileReadStream extends Readable {
14
+ export declare class ReadStream extends Readable {
21
15
  private readonly fs;
22
16
  private readonly path;
23
17
  private readonly ranges;
@@ -10,8 +10,8 @@ interface IgnoreFunction {
10
10
  interface Headers {
11
11
  [key: string]: string | string[];
12
12
  }
13
- interface HeaderFunction {
14
- (path: string, stats: Stats): Headers | void;
13
+ interface HeadersFunction {
14
+ (path: string, stats: Stats): Promise<Headers | void> | Headers | void;
15
15
  }
16
16
  export interface Options {
17
17
  fs?: FileSystem;
@@ -20,7 +20,7 @@ export interface Options {
20
20
  lastModified?: boolean;
21
21
  highWaterMark?: number;
22
22
  ignore?: IgnoreFunction;
23
- headers?: Headers | HeaderFunction;
23
+ headers?: Headers | HeadersFunction;
24
24
  }
25
25
  /**
26
26
  * @class Service
@@ -3,8 +3,13 @@
3
3
  */
4
4
  import { Stats } from 'fs';
5
5
  import { Context } from 'koa';
6
- import { Range } from './stream';
7
- type Ranges = Range[] | -1 | -2;
6
+ import { Buffer } from 'buffer';
7
+ export interface Range {
8
+ offset: number;
9
+ length: number;
10
+ prefix?: Buffer;
11
+ suffix?: Buffer;
12
+ }
8
13
  /**
9
14
  * @function decodeURI
10
15
  * @description Decode URI component.
@@ -16,18 +21,17 @@ export declare function decodeURI(URI: string): string | -1;
16
21
  * @description Check if request is conditional GET.
17
22
  * @param context The koa context.
18
23
  */
19
- export declare function isConditionalGET(context: Context): boolean;
24
+ export declare function isConditionalGET({ request }: Context): boolean;
20
25
  /**
21
- * @function isPreconditionFailure
22
- * @description Check if request precondition failure.
26
+ * @function isPreconditionFailed
27
+ * @description Check if request precondition failed.
23
28
  * @param context The koa context.
24
29
  */
25
- export declare function isPreconditionFailure({ request, response }: Context): boolean;
30
+ export declare function isPreconditionFailed({ request, response }: Context): boolean;
26
31
  /**
27
32
  * @function parseRanges
28
33
  * @description Parse ranges.
29
34
  * @param context The koa context.
30
35
  * @param stats The file stats.
31
36
  */
32
- export declare function parseRanges(context: Context, stats: Stats): Ranges;
33
- export {};
37
+ export declare function parseRanges(context: Context, stats: Stats): Range[] | -1 | -2;