expediate 0.0.3 → 1.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.
- package/.npmignore +7 -5
- package/LICENSE +21 -0
- package/README.md +662 -51
- package/dist/apis.d.ts +166 -0
- package/dist/apis.d.ts.map +1 -0
- package/dist/apis.js.map +1 -0
- package/dist/git.d.ts +74 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt-auth.d.ts +280 -0
- package/dist/jwt-auth.d.ts.map +1 -0
- package/dist/jwt-auth.js.map +1 -0
- package/dist/misc.d.ts +203 -0
- package/dist/misc.d.ts.map +1 -0
- package/dist/misc.js.map +1 -0
- package/dist/router.d.ts +224 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js.map +1 -0
- package/dist/static.d.ts +164 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js.map +1 -0
- package/package.json +31 -7
- package/src/apis.ts +428 -0
- package/src/git.ts +326 -0
- package/src/index.ts +85 -0
- package/src/jwt-auth.ts +861 -0
- package/src/mimetypes.json +1 -0
- package/src/misc.ts +734 -0
- package/src/router.ts +736 -0
- package/src/static.ts +904 -0
- package/.gitignore +0 -14
- package/index.js +0 -305
- package/sample.js +0 -9
- package/static.js +0 -416
package/src/static.ts
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
/* Copyright 2021 Fabien Bavent
|
|
2
|
+
*
|
|
3
|
+
* Permission is hereby granted, free of charge, to any person obtaining a
|
|
4
|
+
* copy of this software and associated documentation files (the "Software"),
|
|
5
|
+
* to deal in the Software without restriction, including without limitation
|
|
6
|
+
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
7
|
+
* and/or sell copies of the Software, and to permit persons to whom the
|
|
8
|
+
* Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
*
|
|
10
|
+
* The above copyright notice and this permission notice shall be included
|
|
11
|
+
* in all copies or substantial portions of the Software.
|
|
12
|
+
*
|
|
13
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
14
|
+
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
16
|
+
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
18
|
+
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
19
|
+
* DEALINGS IN THE SOFTWARE.
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import nodePath from 'path';
|
|
25
|
+
|
|
26
|
+
import type { RouterRequest, RouterResponse, Middleware } from './router';
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Mimetypes
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
export type Mime = {
|
|
32
|
+
lookup: (path: string, fallback: string | null) => string,
|
|
33
|
+
charsets: (mimeType: string) => string | null,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mime_types = new Map<string, string>();
|
|
37
|
+
const mime_extensions = new Map<string, string>();
|
|
38
|
+
function mime_define(map:string[][]): void {
|
|
39
|
+
for (var type in map) {
|
|
40
|
+
var exts = map[type]!;
|
|
41
|
+
for (var i = 0; i < exts.length; i++)
|
|
42
|
+
mime_types.set(exts[i], type);
|
|
43
|
+
if (!mime_extensions.has(type))
|
|
44
|
+
mime_extensions.set(type, exts[0]);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const mime: Mime = {
|
|
49
|
+
lookup: (path: string, fallback: string | null = null): string => mime_types.get(path.replace(/^.*[\.\/\\]/, '').toLowerCase()) ?? fallback ?? 'application/octet-stream',
|
|
50
|
+
charsets: (mimeType: string): string | null => (/^text\/|^application\/(javascript|json)/).test(mimeType) ? 'UTF-8' : null,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
mime_define(require('./mimetypes.json'));
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Types
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/** HTTP header map used both in options and as argument to res.status(). */
|
|
60
|
+
type HeaderMap = Record<string, string>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Options accepted by {@link serveStatic}, {@link serveFile}, and
|
|
64
|
+
* {@link sendFile}.
|
|
65
|
+
*/
|
|
66
|
+
export interface StaticOptions {
|
|
67
|
+
/**
|
|
68
|
+
* Extra HTTP headers added to every response (merged with the built-in
|
|
69
|
+
* security headers `Content-Security-Policy` and `X-Content-Type-Options`).
|
|
70
|
+
*/
|
|
71
|
+
headers?: HeaderMap;
|
|
72
|
+
/**
|
|
73
|
+
* When `true`, unhandled requests (wrong method, missing file) are passed
|
|
74
|
+
* to the next middleware via `next()` instead of sending an error response.
|
|
75
|
+
* Defaults to `false`.
|
|
76
|
+
*/
|
|
77
|
+
fallthrough?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Browser cache lifetime for served files, in **milliseconds**.
|
|
80
|
+
* Translated to a `Cache-Control: public, max-age=<seconds>` header.
|
|
81
|
+
* Defaults to `0` (no caching).
|
|
82
|
+
*/
|
|
83
|
+
maxage?: number;
|
|
84
|
+
/** Alias for {@link maxage}. */
|
|
85
|
+
maxAge?: number;
|
|
86
|
+
/**
|
|
87
|
+
* When `true`, appends `, immutable` to the `Cache-Control` header,
|
|
88
|
+
* signalling that the response will never change during its `max-age`
|
|
89
|
+
* window. Only meaningful when {@link maxage} is non-zero.
|
|
90
|
+
*/
|
|
91
|
+
immutable?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* When `true` (default), the server generates and sends a weak `ETag`
|
|
94
|
+
* header based on the file's size and last-modification time.
|
|
95
|
+
*/
|
|
96
|
+
etag?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* When `true` (default), the server sends a `Last-Modified` header
|
|
99
|
+
* derived from the file's `mtime`.
|
|
100
|
+
*/
|
|
101
|
+
lastModified?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Override the auto-detected `Content-Type`. When set, MIME detection via
|
|
104
|
+
* the `mime` package is skipped entirely.
|
|
105
|
+
*/
|
|
106
|
+
contentType?: string | null;
|
|
107
|
+
/**
|
|
108
|
+
* Controls how dot-files (files or directories whose name starts with `.`)
|
|
109
|
+
* are handled:
|
|
110
|
+
* - `'allow'` — serve them like any other file.
|
|
111
|
+
* - `'deny'` — respond with 403 Forbidden.
|
|
112
|
+
* - `'hide'` — respond with 404 Not Found (default).
|
|
113
|
+
*/
|
|
114
|
+
dotfiles?: 'allow' | 'deny' | 'hide';
|
|
115
|
+
/**
|
|
116
|
+
* When `true` (default), a request for a directory path is transparently
|
|
117
|
+
* redirected to `index.html` inside that directory.
|
|
118
|
+
* When `false`, directory requests receive a 404 response.
|
|
119
|
+
*/
|
|
120
|
+
redirect?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* When `true`, a request for a directory path that has no `index.html`
|
|
123
|
+
* renders an Apache-style directory listing.
|
|
124
|
+
* Defaults to `false`.
|
|
125
|
+
*/
|
|
126
|
+
indexOf?: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Fully resolved options used internally after merging caller-supplied values
|
|
131
|
+
* with {@link DEFAULT_OPTIONS}. All optional fields from {@link StaticOptions}
|
|
132
|
+
* are guaranteed to be present.
|
|
133
|
+
*/
|
|
134
|
+
interface ResolvedOptions extends Required<Omit<StaticOptions, 'maxAge'>> {
|
|
135
|
+
/** Absolute, normalised path to the root directory or file. */
|
|
136
|
+
root: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Constants
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Regular expression that detects a directory-traversal component (`..`) in
|
|
145
|
+
* any position of a URL path, including encoded forms using back-slashes.
|
|
146
|
+
*/
|
|
147
|
+
const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
|
|
148
|
+
|
|
149
|
+
/** Default security and caching options applied to every response. */
|
|
150
|
+
const DEFAULT_OPTIONS: Omit<ResolvedOptions, 'root'> = {
|
|
151
|
+
headers: {
|
|
152
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
153
|
+
'X-Content-Type-Options': 'nosniff',
|
|
154
|
+
},
|
|
155
|
+
fallthrough: false,
|
|
156
|
+
maxage: 0,
|
|
157
|
+
immutable: false,
|
|
158
|
+
etag: true,
|
|
159
|
+
lastModified: true,
|
|
160
|
+
contentType: null,
|
|
161
|
+
dotfiles: 'hide',
|
|
162
|
+
redirect: true,
|
|
163
|
+
indexOf: false,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// HTTP response helpers
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Collection of pre-built HTTP error/success response helpers.
|
|
172
|
+
* Each helper sets the appropriate status code, merges the caller's custom
|
|
173
|
+
* headers, and terminates the response.
|
|
174
|
+
*/
|
|
175
|
+
const HTTP = {
|
|
176
|
+
/** 304 Not Modified — sent for conditional GET cache hits. */
|
|
177
|
+
NOT_MODIFIED: (res: RouterResponse, opts: ResolvedOptions) =>
|
|
178
|
+
{ res.status(304, opts.headers).end() },
|
|
179
|
+
/** 403 Forbidden — sent for denied dot-files or path traversal attempts. */
|
|
180
|
+
FORBIDDEN: (res: RouterResponse, opts: ResolvedOptions) =>
|
|
181
|
+
res.status(403, opts.headers).send('Forbidden'),
|
|
182
|
+
/** 404 Not Found — sent when the requested file does not exist. */
|
|
183
|
+
NOT_FOUND: (res: RouterResponse, opts: ResolvedOptions) =>
|
|
184
|
+
res.status(404, opts.headers).send('Not Found'),
|
|
185
|
+
/**
|
|
186
|
+
* 405 Method Not Allowed — sent when the HTTP method is neither GET nor
|
|
187
|
+
* HEAD and {@link ResolvedOptions.fallthrough} is `false`.
|
|
188
|
+
* Always includes an `Allow: GET, HEAD` header per RFC 7231 §6.5.5.
|
|
189
|
+
*/
|
|
190
|
+
NOT_ALLOWED: (res: RouterResponse, opts: ResolvedOptions) =>
|
|
191
|
+
{ res.status(405, { ...opts.headers, Allow: 'GET, HEAD' }).end() },
|
|
192
|
+
/** 412 Precondition Failed — sent when `If-Match` / `If-Unmodified-Since` fails. */
|
|
193
|
+
PRECONDITION_FAILS: (res: RouterResponse, opts: ResolvedOptions) =>
|
|
194
|
+
res.status(412, opts.headers).send('Precondition Failed'),
|
|
195
|
+
/** 500 Internal Server Error — sent on unexpected filesystem or stream errors. */
|
|
196
|
+
INTERNAL_ERROR: (res: RouterResponse, opts: ResolvedOptions, err: string) =>
|
|
197
|
+
res.status(500, opts.headers).send(`Internal error: ${err}`),
|
|
198
|
+
} as const;
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Internal utilities
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Safely destroy a `fs.ReadStream`, working around a Node.js core bug where
|
|
206
|
+
* streams that have not yet emitted `'open'` do not close their file
|
|
207
|
+
* descriptor when `destroy()` is called.
|
|
208
|
+
*
|
|
209
|
+
* @param stream - The readable stream to destroy.
|
|
210
|
+
*/
|
|
211
|
+
function destroyReadStream(stream: fs.ReadStream): void {
|
|
212
|
+
stream.destroy();
|
|
213
|
+
if (typeof (stream as any).close === 'function') {
|
|
214
|
+
// Node.js core bug work-around: if the stream has not yet opened the file,
|
|
215
|
+
// `destroy()` will not close the fd. Listening for 'open' ensures we close
|
|
216
|
+
// it as soon as the fd becomes available.
|
|
217
|
+
stream.on('open', () => {
|
|
218
|
+
if (typeof (stream as any).fd === 'number')
|
|
219
|
+
(stream as any).close();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Remove all `Content-*` headers from the response, **except**
|
|
226
|
+
* `Content-Location`.
|
|
227
|
+
*
|
|
228
|
+
* Called before sending a 304 Not Modified response, as RFC 7232 §4.1
|
|
229
|
+
* requires that the response body and most content metadata headers be
|
|
230
|
+
* omitted in that case.
|
|
231
|
+
*
|
|
232
|
+
* @param res - The server response whose headers will be mutated.
|
|
233
|
+
*/
|
|
234
|
+
function removeContentHeaders(res: RouterResponse): void {
|
|
235
|
+
const keys = Object.keys(res.getHeaders() ?? {});
|
|
236
|
+
for (const key of keys) {
|
|
237
|
+
if (key.startsWith('content-') && key !== 'content-location')
|
|
238
|
+
res.removeHeader(key);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Build a weak ETag string from a file's `stat` metadata.
|
|
244
|
+
*
|
|
245
|
+
* The format is `W/"<size_hex>-<mtime_hex>"`, matching the convention used by
|
|
246
|
+
* Node's `serve-static` and compatible with all major HTTP clients.
|
|
247
|
+
*
|
|
248
|
+
* @param stat - The `fs.Stats` object for the file.
|
|
249
|
+
* @returns A weak ETag string.
|
|
250
|
+
*/
|
|
251
|
+
function createETag(stat: fs.Stats): string {
|
|
252
|
+
const mtime = stat.mtime.getTime().toString(16);
|
|
253
|
+
const size = stat.size.toString(16);
|
|
254
|
+
return `W/"${size}-${mtime}"`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Parse a comma-separated HTTP token list (e.g. the value of an `ETag` or
|
|
259
|
+
* `Accept` header) into an array of trimmed token strings.
|
|
260
|
+
*
|
|
261
|
+
* Follows the token-list grammar from RFC 7230 §3.2.6: tokens are separated
|
|
262
|
+
* by commas, leading/trailing spaces around each token are ignored.
|
|
263
|
+
*
|
|
264
|
+
* @param str - The raw header value string.
|
|
265
|
+
* @returns An array of individual token strings (may be empty).
|
|
266
|
+
*/
|
|
267
|
+
function parseTokenList(str: string): string[] {
|
|
268
|
+
const list: string[] = [];
|
|
269
|
+
let start = 0;
|
|
270
|
+
let end = 0;
|
|
271
|
+
|
|
272
|
+
for (let i = 0, len = str.length; i < len; i++) {
|
|
273
|
+
const ch = str.charCodeAt(i);
|
|
274
|
+
if (ch === 0x20 /* space */) {
|
|
275
|
+
if (start === end) start = end = i + 1;
|
|
276
|
+
} else if (ch === 0x2c /* comma */) {
|
|
277
|
+
list.push(str.substring(start, end));
|
|
278
|
+
start = end = i + 1;
|
|
279
|
+
} else {
|
|
280
|
+
end = i + 1;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Push the final token (there is always at least one).
|
|
285
|
+
list.push(str.substring(start, end));
|
|
286
|
+
return list;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Parse an HTTP-date string (e.g. `Thu, 01 Jan 1970 00:00:00 GMT`) into a
|
|
291
|
+
* Unix timestamp in milliseconds.
|
|
292
|
+
*
|
|
293
|
+
* @param date - The raw date string from an HTTP header.
|
|
294
|
+
* @returns A numeric timestamp, or `NaN` if parsing fails.
|
|
295
|
+
*/
|
|
296
|
+
function parseHttpDate(date: string | undefined): number {
|
|
297
|
+
const timestamp = date ? Date.parse(date) : NaN;
|
|
298
|
+
return typeof timestamp === 'number' ? timestamp : NaN;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Return `true` when the request carries at least one conditional header
|
|
303
|
+
* (`If-Match`, `If-Unmodified-Since`, `If-None-Match`, or
|
|
304
|
+
* `If-Modified-Since`).
|
|
305
|
+
*
|
|
306
|
+
* @param headers - The incoming request's header map.
|
|
307
|
+
*/
|
|
308
|
+
function hasCondition(headers: Record<string, string | string[] | undefined>): boolean {
|
|
309
|
+
return !!(
|
|
310
|
+
headers['if-match'] ||
|
|
311
|
+
headers['if-unmodified-since'] ||
|
|
312
|
+
headers['if-none-match'] ||
|
|
313
|
+
headers['if-modified-since']
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Evaluate precondition headers (`If-Match` / `If-Unmodified-Since`) against
|
|
319
|
+
* the current response headers and return whether the precondition is
|
|
320
|
+
* satisfied.
|
|
321
|
+
*
|
|
322
|
+
* Used to decide whether to proceed with the response (`true`) or send
|
|
323
|
+
* 412 Precondition Failed (`false`).
|
|
324
|
+
*
|
|
325
|
+
* Implements the subset defined in RFC 7232 §6 (step 3 and step 5):
|
|
326
|
+
* - **`If-Match`** — matches when the response ETag equals the request ETag,
|
|
327
|
+
* or when the request value is `*`.
|
|
328
|
+
* - **`If-Unmodified-Since`** — matches when the file has not been modified
|
|
329
|
+
* since the given date.
|
|
330
|
+
*
|
|
331
|
+
* @param reqHeaders - Normalised (lowercase) request headers.
|
|
332
|
+
* @param resHeaders - Current response headers (as returned by
|
|
333
|
+
* `res.getHeaders()`).
|
|
334
|
+
* @returns `true` if the precondition is satisfied.
|
|
335
|
+
*/
|
|
336
|
+
function conditionMatch(
|
|
337
|
+
reqHeaders: Record<string, string | string[] | undefined>,
|
|
338
|
+
resHeaders: Record<string, number | string | string[] | undefined>,
|
|
339
|
+
): boolean {
|
|
340
|
+
// --- If-Match ---
|
|
341
|
+
const match = reqHeaders['if-match'] as string | undefined;
|
|
342
|
+
if (match) {
|
|
343
|
+
const etag = resHeaders['etag'] as string | undefined;
|
|
344
|
+
if (match === '*' || match === etag) return true;
|
|
345
|
+
for (const tag of parseTokenList(match)) {
|
|
346
|
+
if (tag === etag || `W/${tag}` === etag || tag === `W/${etag}`)
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
// If-Match was present but none of the tags matched → precondition failed.
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// --- If-Unmodified-Since ---
|
|
354
|
+
// BUG FIX: the original code read req['if-modified-since'] here, which is
|
|
355
|
+
// the wrong header. The correct header for this precondition is
|
|
356
|
+
// 'if-unmodified-since'.
|
|
357
|
+
const lastModified = parseHttpDate(resHeaders['last-modified'] as string | undefined);
|
|
358
|
+
const unmodifiedSince = parseHttpDate(reqHeaders['if-unmodified-since'] as string | undefined);
|
|
359
|
+
if (!isNaN(unmodifiedSince) && !isNaN(lastModified))
|
|
360
|
+
return lastModified <= unmodifiedSince;
|
|
361
|
+
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Determine whether a cached response is still fresh by evaluating the
|
|
367
|
+
* `If-None-Match` and `If-Modified-Since` conditional headers.
|
|
368
|
+
*
|
|
369
|
+
* Returns `true` only when the response can safely be replaced by a
|
|
370
|
+
* 304 Not Modified. Returns `false` (stale) when:
|
|
371
|
+
* - No conditional header is present (unconditional request).
|
|
372
|
+
* - `Cache-Control: no-cache` is set.
|
|
373
|
+
* - The ETag or modification time no longer matches.
|
|
374
|
+
*
|
|
375
|
+
* Implements RFC 7232 §6 (steps 4 and 5) and RFC 7234 §5.2.
|
|
376
|
+
*
|
|
377
|
+
* @param reqHeaders - Normalised (lowercase) request headers.
|
|
378
|
+
* @param resHeaders - Current response headers.
|
|
379
|
+
* @returns `true` if the cached response is fresh and a 304 should be sent.
|
|
380
|
+
*/
|
|
381
|
+
function isCacheFresh(
|
|
382
|
+
reqHeaders: Record<string, string | string[] | undefined>,
|
|
383
|
+
resHeaders: Record<string, number | string | string[] | undefined>,
|
|
384
|
+
): boolean {
|
|
385
|
+
const CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/;
|
|
386
|
+
|
|
387
|
+
const modifiedSince = reqHeaders['if-modified-since'] as string | undefined;
|
|
388
|
+
const noneMatch = reqHeaders['if-none-match'] as string | undefined;
|
|
389
|
+
|
|
390
|
+
// Unconditional request — always treat as stale.
|
|
391
|
+
if (!modifiedSince && !noneMatch) return false;
|
|
392
|
+
|
|
393
|
+
// Explicit no-cache directive forces revalidation even if ETags match.
|
|
394
|
+
const cacheControl = reqHeaders['cache-control'] as string | undefined;
|
|
395
|
+
if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl))
|
|
396
|
+
return false;
|
|
397
|
+
|
|
398
|
+
// --- If-None-Match ---
|
|
399
|
+
if (noneMatch && noneMatch !== '*') {
|
|
400
|
+
const etag = resHeaders['etag'] as string | undefined;
|
|
401
|
+
if (!etag) return false;
|
|
402
|
+
|
|
403
|
+
let etagStale = true;
|
|
404
|
+
for (const match of parseTokenList(noneMatch)) {
|
|
405
|
+
if (match === etag || `W/${match}` === etag || match === `W/${etag}`) {
|
|
406
|
+
etagStale = false;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (etagStale) return false;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- If-Modified-Since ---
|
|
414
|
+
if (modifiedSince) {
|
|
415
|
+
const lastModified = resHeaders['last-modified'] as string | undefined;
|
|
416
|
+
if (!lastModified) return false;
|
|
417
|
+
if (!(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince)))
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Options resolution
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Validate and resolve the `root` path and caller-supplied `options` into a
|
|
430
|
+
* fully populated {@link ResolvedOptions} object.
|
|
431
|
+
*
|
|
432
|
+
* Applies the following normalisation steps:
|
|
433
|
+
* - `root` is resolved to an absolute path via `path.resolve()`.
|
|
434
|
+
* - `fallthrough` and `redirect` default to `false` only when explicitly set
|
|
435
|
+
* to `false`; any other value (including `undefined`) is treated as `true`.
|
|
436
|
+
* - `maxage` falls back to `maxAge` (camelCase alias) and then to `0`.
|
|
437
|
+
*
|
|
438
|
+
* @param root - The root directory or file path to serve from.
|
|
439
|
+
* @param options - Caller-supplied options (partial, merged with defaults).
|
|
440
|
+
* @returns A fully resolved options object.
|
|
441
|
+
* @throws {TypeError} When `root` is falsy or not a string.
|
|
442
|
+
*/
|
|
443
|
+
function resolveOptions(root: string, options?: StaticOptions): ResolvedOptions {
|
|
444
|
+
if (!root)
|
|
445
|
+
throw new TypeError('root path required');
|
|
446
|
+
if (typeof root !== 'string')
|
|
447
|
+
throw new TypeError('root path must be a string');
|
|
448
|
+
|
|
449
|
+
// Deep-merge headers so caller-supplied headers *extend* the defaults
|
|
450
|
+
// rather than replacing them entirely.
|
|
451
|
+
const mergedHeaders = { ...DEFAULT_OPTIONS.headers, ...(options?.headers ?? {}) };
|
|
452
|
+
const opts = { ...DEFAULT_OPTIONS, ...options, headers: mergedHeaders } as ResolvedOptions;
|
|
453
|
+
opts.fallthrough = options?.fallthrough !== false;
|
|
454
|
+
opts.redirect = options?.redirect !== false;
|
|
455
|
+
opts.maxage = options?.maxage ?? options?.maxAge ?? 0;
|
|
456
|
+
opts.root = nodePath.resolve(root);
|
|
457
|
+
return opts;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// Directory listing
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Callback signature used by {@link writeIndexOf}.
|
|
466
|
+
*
|
|
467
|
+
* On success, `html` contains the rendered HTML page and `err` is `null`.
|
|
468
|
+
* On failure, `html` is `null` and `err` carries the filesystem error.
|
|
469
|
+
*/
|
|
470
|
+
type IndexCallback = (html: string | null, err: NodeJS.ErrnoException | null) => void;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Asynchronously render an Apache-style directory-listing HTML page for the
|
|
474
|
+
* given directory and invoke `callback` with the result.
|
|
475
|
+
*
|
|
476
|
+
* The generated page lists every entry in `directoryPath`, with icons, names,
|
|
477
|
+
* last-modification timestamps, and file sizes. An optional parent-directory
|
|
478
|
+
* link is shown when `parentUrlPath` is provided.
|
|
479
|
+
*
|
|
480
|
+
* > **TODO** — Implement column sorting via `?C=N`, `?C=M`, `?C=S`, `?C=D`
|
|
481
|
+
* > (name / modified / size / description) combined with `?O=A` / `?O=D`
|
|
482
|
+
* > (ascending / descending). The current implementation always sorts by name
|
|
483
|
+
* > ascending.
|
|
484
|
+
*
|
|
485
|
+
* @param urlPath - The URL path displayed in the page title and heading.
|
|
486
|
+
* @param directoryPath - Absolute filesystem path of the directory to list.
|
|
487
|
+
* @param parentUrlPath - URL of the parent directory, or `null` when at root.
|
|
488
|
+
* @param callback - Called with `(html, null)` on success or
|
|
489
|
+
* `(null, err)` on failure.
|
|
490
|
+
*/
|
|
491
|
+
function writeIndexOf(
|
|
492
|
+
urlPath: string,
|
|
493
|
+
directoryPath: string,
|
|
494
|
+
parentUrlPath: string | null,
|
|
495
|
+
callback: IndexCallback,
|
|
496
|
+
): void {
|
|
497
|
+
// BUG FIX: the original signature had a `queries` parameter as first arg and
|
|
498
|
+
// a `path` parameter (shadowing the `path` module) as second. Both were
|
|
499
|
+
// unused or misused. The signature is corrected here: `urlPath` is the
|
|
500
|
+
// display path, `directoryPath` is the fs path, `parentUrlPath` is optional.
|
|
501
|
+
|
|
502
|
+
fs.readdir(directoryPath, (err, files) => {
|
|
503
|
+
if (err) return callback(null, err);
|
|
504
|
+
|
|
505
|
+
let html = '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">\n';
|
|
506
|
+
html += '<html>\n';
|
|
507
|
+
html += `<head><title>Index of ${urlPath}</title></head>\n`;
|
|
508
|
+
html += `<body><h1>Index of ${urlPath}</h1><table>\n`;
|
|
509
|
+
html += '<tr>'
|
|
510
|
+
+ '<th valign="top"><img src="/icons/blank.gif" alt="[ICO]"></th>'
|
|
511
|
+
+ '<th><a href="?C=N;O=D">Name</a></th>'
|
|
512
|
+
+ '<th><a href="?C=M;O=A">Last modified</a></th>'
|
|
513
|
+
+ '<th><a href="?C=S;O=A">Size</a></th>'
|
|
514
|
+
+ '<th><a href="?C=D;O=A">Description</a></th>'
|
|
515
|
+
+ '</tr>\n';
|
|
516
|
+
html += '<tr><th colspan="5"><hr></th></tr>\n';
|
|
517
|
+
|
|
518
|
+
if (parentUrlPath)
|
|
519
|
+
html += `<tr>`
|
|
520
|
+
+ `<td valign="top"><img src="/icons/back.gif" alt="[PARENTDIR]"></td>`
|
|
521
|
+
+ `<td><a href="${parentUrlPath}">Parent Directory</a></td>`
|
|
522
|
+
+ `<td> </td><td align="right"> - </td><td> </td>`
|
|
523
|
+
+ `</tr>\n`;
|
|
524
|
+
|
|
525
|
+
for (const file of files) {
|
|
526
|
+
// BUG FIX: the original code passed `pathname` (undefined in this scope)
|
|
527
|
+
// to mime.lookup(). The correct argument is the full path of the entry.
|
|
528
|
+
// BUG FIX: the original code used `path.join(...)` where `path` was
|
|
529
|
+
// shadowed by the parameter name. Use `nodePath` (the imported module).
|
|
530
|
+
const fullPath = nodePath.join(directoryPath, file);
|
|
531
|
+
let stat: fs.Stats;
|
|
532
|
+
try {
|
|
533
|
+
stat = fs.statSync(fullPath);
|
|
534
|
+
} catch {
|
|
535
|
+
continue; // skip entries that disappeared between readdir and stat
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const mimeType = mime.lookup(fullPath, '');
|
|
539
|
+
const mediaType = mimeType.includes('/') ? mimeType.split('/')[0] : '';
|
|
540
|
+
const alt = stat.isDirectory() ? 'folder' : (mediaType || 'unknown');
|
|
541
|
+
const icon = `/icons/${alt}.gif`;
|
|
542
|
+
const name = file + (stat.isDirectory() ? '/' : '');
|
|
543
|
+
// BUG FIX: `stat.modified` does not exist on `fs.Stats`.
|
|
544
|
+
// The correct property is `stat.mtime`.
|
|
545
|
+
const modified = stat.mtime.toUTCString();
|
|
546
|
+
const size = stat.isDirectory() ? '-' : String(stat.size);
|
|
547
|
+
|
|
548
|
+
html += `<tr>`
|
|
549
|
+
+ `<td valign="top"><img src="${icon}" alt="[${alt.toUpperCase()}]"></td>`
|
|
550
|
+
+ `<td><a href="${name}">${name}</a></td>` // BUG FIX: link text was empty in original
|
|
551
|
+
+ `<td align="right">${modified}</td>`
|
|
552
|
+
+ `<td align="right">${size}</td>`
|
|
553
|
+
+ `<td> </td>`
|
|
554
|
+
+ `</tr>\n`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
html += '<tr><th colspan="5"><hr></th></tr>\n';
|
|
558
|
+
html += '</table><address>Expediate/1.0.0</address></body></html>\n';
|
|
559
|
+
|
|
560
|
+
// BUG FIX: the original code called go(html) without `return`, so execution
|
|
561
|
+
// continued to HTTP.NOT_FOUND immediately after — the callback was invoked
|
|
562
|
+
// and then the caller would also receive NOT_FOUND.
|
|
563
|
+
return callback(html, null);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// Core send logic
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Set response headers and stream a file to the client.
|
|
573
|
+
*
|
|
574
|
+
* This is the inner send function shared by {@link sendFile} and the
|
|
575
|
+
* {@link serveStatic} middleware. It handles:
|
|
576
|
+
* - Custom and security headers from `opts`.
|
|
577
|
+
* - `Cache-Control` with optional `immutable` directive.
|
|
578
|
+
* - `Last-Modified` and `ETag` generation.
|
|
579
|
+
* - MIME-type detection via the `mime` package.
|
|
580
|
+
* - Conditional GET evaluation (`If-None-Match`, `If-Modified-Since`).
|
|
581
|
+
* - `HEAD` requests (headers sent, no body).
|
|
582
|
+
* - Streaming the file body with proper error handling and stream cleanup.
|
|
583
|
+
*
|
|
584
|
+
* @param req - The incoming router request.
|
|
585
|
+
* @param res - The outgoing router response.
|
|
586
|
+
* @param pathname - Absolute filesystem path of the file to send.
|
|
587
|
+
* @param stat - Pre-fetched `fs.Stats` for `pathname`.
|
|
588
|
+
* @param opts - Fully resolved static-serving options.
|
|
589
|
+
*/
|
|
590
|
+
function sendIt(
|
|
591
|
+
req: RouterRequest,
|
|
592
|
+
res: RouterResponse,
|
|
593
|
+
pathname: string,
|
|
594
|
+
stat: fs.Stats,
|
|
595
|
+
opts: ResolvedOptions,
|
|
596
|
+
): void {
|
|
597
|
+
const len = stat.size;
|
|
598
|
+
const etag = createETag(stat);
|
|
599
|
+
|
|
600
|
+
// Apply caller-supplied headers first so they can be overridden by the
|
|
601
|
+
// cache/content headers below if necessary.
|
|
602
|
+
if (opts.headers) {
|
|
603
|
+
for (const [key, value] of Object.entries(opts.headers))
|
|
604
|
+
res.setHeader(key, value);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Cache-Control
|
|
608
|
+
if (!res.getHeader('Cache-Control') && opts.maxage) {
|
|
609
|
+
let cacheControl = `public, max-age=${Math.floor(opts.maxage / 1000)}`;
|
|
610
|
+
if (opts.immutable) cacheControl += ', immutable';
|
|
611
|
+
res.setHeader('Cache-Control', cacheControl);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Last-Modified
|
|
615
|
+
if (!res.getHeader('Last-Modified') && opts.lastModified !== false)
|
|
616
|
+
res.setHeader('Last-Modified', stat.mtime.toUTCString());
|
|
617
|
+
|
|
618
|
+
// ETag
|
|
619
|
+
if (!res.getHeader('ETag') && opts.etag !== false)
|
|
620
|
+
res.setHeader('ETag', etag);
|
|
621
|
+
|
|
622
|
+
// Content-Type
|
|
623
|
+
if (!res.getHeader('Content-Type')) {
|
|
624
|
+
if (opts.contentType) {
|
|
625
|
+
res.setHeader('Content-Type', opts.contentType);
|
|
626
|
+
} else {
|
|
627
|
+
const type = mime.lookup(pathname, '');
|
|
628
|
+
if (type) {
|
|
629
|
+
const charset = mime.charsets(type);
|
|
630
|
+
res.setHeader('Content-Type', charset ? `${type}; charset=${charset}` : type);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Conditional GET — two independent RFC 7232 mechanisms evaluated in order.
|
|
636
|
+
|
|
637
|
+
// 1. Cache-validation headers (If-None-Match / If-Modified-Since).
|
|
638
|
+
// When the cached response is still fresh, respond with 304 Not Modified.
|
|
639
|
+
// This must be tested BEFORE the precondition headers so that a browser
|
|
640
|
+
// performing a normal ETag revalidation is not incorrectly rejected.
|
|
641
|
+
if (isCacheFresh(
|
|
642
|
+
req.headers as Record<string, string | undefined>,
|
|
643
|
+
res.getHeaders(),
|
|
644
|
+
)) {
|
|
645
|
+
removeContentHeaders(res);
|
|
646
|
+
HTTP.NOT_MODIFIED(res, opts); return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 2. Precondition headers (If-Match / If-Unmodified-Since).
|
|
650
|
+
// Only evaluate when these specific headers are present; If-None-Match and
|
|
651
|
+
// If-Modified-Since are handled above by isCacheFresh and must NOT trigger
|
|
652
|
+
// a 412 when conditionMatch returns false (they are cache-validation headers,
|
|
653
|
+
// not write-precondition headers per RFC 7232).
|
|
654
|
+
const hasPrecondition = !!(
|
|
655
|
+
(req.headers as Record<string, string | undefined>)['if-match'] ||
|
|
656
|
+
(req.headers as Record<string, string | undefined>)['if-unmodified-since']
|
|
657
|
+
);
|
|
658
|
+
if (hasPrecondition && !conditionMatch(
|
|
659
|
+
req.headers as Record<string, string | undefined>,
|
|
660
|
+
res.getHeaders(),
|
|
661
|
+
)) {
|
|
662
|
+
HTTP.PRECONDITION_FAILS(res, opts); return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// --- Send the body ---
|
|
666
|
+
|
|
667
|
+
res.setHeader('Content-Length', len);
|
|
668
|
+
|
|
669
|
+
// HEAD requests: headers only, no body.
|
|
670
|
+
if (req.method === 'HEAD') {
|
|
671
|
+
res.end();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
let finished = false;
|
|
676
|
+
const stream = fs.createReadStream(pathname);
|
|
677
|
+
|
|
678
|
+
res.on('finish', () => {
|
|
679
|
+
finished = true;
|
|
680
|
+
destroyReadStream(stream);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
stream.on('error', (err: NodeJS.ErrnoException) => {
|
|
684
|
+
if (finished) return;
|
|
685
|
+
console.warn('static stream error:', pathname, err);
|
|
686
|
+
HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
|
|
687
|
+
finished = true;
|
|
688
|
+
destroyReadStream(stream);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
stream.on('end', () => res.end());
|
|
692
|
+
stream.pipe(res);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
// Public API
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Send a single file from the filesystem as an HTTP response.
|
|
701
|
+
*
|
|
702
|
+
* Unlike the {@link serveStatic} middleware, this function resolves the
|
|
703
|
+
* response for a **specific** `pathname` that has already been determined by
|
|
704
|
+
* the caller. It is useful for custom routing logic where the file path is
|
|
705
|
+
* computed dynamically.
|
|
706
|
+
*
|
|
707
|
+
* Behaviour:
|
|
708
|
+
* - When `pathname` refers to a **regular file**, it is served via
|
|
709
|
+
* {@link sendIt} (including ETag, Last-Modified, conditional GET, and
|
|
710
|
+
* MIME-type detection).
|
|
711
|
+
* - When `pathname` refers to a **directory** and `opts.indexOf` is `true`,
|
|
712
|
+
* an Apache-style directory listing is rendered.
|
|
713
|
+
* - Otherwise, a 404 Not Found response is sent.
|
|
714
|
+
*
|
|
715
|
+
* @param req - The incoming router request.
|
|
716
|
+
* @param res - The outgoing router response.
|
|
717
|
+
* @param pathname - Absolute filesystem path of the file to send.
|
|
718
|
+
* @param opts - Fully resolved static-serving options.
|
|
719
|
+
*/
|
|
720
|
+
export function sendFile(
|
|
721
|
+
req: RouterRequest,
|
|
722
|
+
res: RouterResponse,
|
|
723
|
+
pathname: string,
|
|
724
|
+
opts: ResolvedOptions,
|
|
725
|
+
): void {
|
|
726
|
+
fs.stat(pathname, (err, stat) => {
|
|
727
|
+
if (err) {
|
|
728
|
+
if (
|
|
729
|
+
err.code === 'ENOENT' ||
|
|
730
|
+
err.code === 'ENAMETOOLONG' ||
|
|
731
|
+
err.code === 'ENOTDIR'
|
|
732
|
+
) return HTTP.NOT_FOUND(res, opts);
|
|
733
|
+
return HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (stat.isDirectory()) {
|
|
737
|
+
if (opts.indexOf) {
|
|
738
|
+
const parentUrl = req.path !== '/' ? nodePath.dirname(req.path) : null;
|
|
739
|
+
return writeIndexOf(req.path, pathname, parentUrl, (html, indexErr) => {
|
|
740
|
+
if (indexErr)
|
|
741
|
+
return HTTP.INTERNAL_ERROR(res, opts, indexErr.code ?? 'UNKNOWN');
|
|
742
|
+
// BUG FIX: the original code called `res.send(200, {...}).send(html)`.
|
|
743
|
+
// `res.send()` does not accept a status code as first argument; the
|
|
744
|
+
// correct call is `res.status(200, headers).send(html)`.
|
|
745
|
+
return res.status(200, opts.headers).send(html!);
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
// Directory listing is disabled — treat as not found.
|
|
749
|
+
return HTTP.NOT_FOUND(res, opts);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
sendIt(req, res, pathname, stat, opts);
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Middleware factory that serves files from a directory on the filesystem.
|
|
758
|
+
*
|
|
759
|
+
* Mount this middleware with a path prefix to expose a public directory:
|
|
760
|
+
* ```ts
|
|
761
|
+
* app.use('/public', serveStatic('./dist'));
|
|
762
|
+
* ```
|
|
763
|
+
*
|
|
764
|
+
* Features:
|
|
765
|
+
* - **Method filtering** — only `GET` and `HEAD` are served; other methods
|
|
766
|
+
* receive 405 Method Not Allowed (or are forwarded to `next()` when
|
|
767
|
+
* `fallthrough` is `true`).
|
|
768
|
+
* - **Directory traversal protection** — any path containing `..` is
|
|
769
|
+
* rejected with 403 Forbidden.
|
|
770
|
+
* - **Dot-file handling** — configurable via the `dotfiles` option
|
|
771
|
+
* (`'allow'`, `'deny'`, or `'hide'`).
|
|
772
|
+
* - **Directory redirect** — a path resolving to a directory is automatically
|
|
773
|
+
* redirected to its `index.html` when `redirect` is `true`.
|
|
774
|
+
* - **Conditional GET** — ETag and Last-Modified headers enable browser and
|
|
775
|
+
* proxy caching with proper revalidation.
|
|
776
|
+
* - **Cache-Control** — configurable via `maxage` / `immutable` options.
|
|
777
|
+
* - **MIME-type detection** — via the `mime` npm package.
|
|
778
|
+
*
|
|
779
|
+
* @param root - Path to the directory containing the files to serve.
|
|
780
|
+
* Resolved to an absolute path internally.
|
|
781
|
+
* @param options - Optional configuration (see {@link StaticOptions}).
|
|
782
|
+
* @returns An Express-compatible middleware function.
|
|
783
|
+
* @throws {TypeError} When `root` is missing or not a string.
|
|
784
|
+
*/
|
|
785
|
+
export function serveStatic(root: string, options?: StaticOptions): Middleware {
|
|
786
|
+
// BUG FIX: `static` is a reserved word in strict mode (`'use strict'`).
|
|
787
|
+
// The function has been renamed to `serveStatic`.
|
|
788
|
+
|
|
789
|
+
const opts = resolveOptions(root, options);
|
|
790
|
+
|
|
791
|
+
return function (req: RouterRequest, res: RouterResponse, next: () => void): void {
|
|
792
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
793
|
+
if (opts.fallthrough)
|
|
794
|
+
return next();
|
|
795
|
+
return HTTP.NOT_ALLOWED(res, opts);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Decode the current path (after any prefix stripping by parent routers).
|
|
799
|
+
const originalUrl = decodeURIComponent(req.path ?? req.url ?? '/');
|
|
800
|
+
let pathname = originalUrl;
|
|
801
|
+
|
|
802
|
+
// When the URL ends without a trailing slash but the bare mount point was
|
|
803
|
+
// requested, clear the pathname so the root directory is considered.
|
|
804
|
+
if (pathname === '/' && !originalUrl.endsWith('/'))
|
|
805
|
+
pathname = '';
|
|
806
|
+
|
|
807
|
+
// Reject any path containing a directory-traversal component.
|
|
808
|
+
if (UP_PATH_REGEXP.test(pathname))
|
|
809
|
+
return HTTP.FORBIDDEN(res, opts);
|
|
810
|
+
|
|
811
|
+
// Resolve to an absolute filesystem path.
|
|
812
|
+
pathname = nodePath.resolve(nodePath.normalize(`${opts.root}/${pathname}`));
|
|
813
|
+
|
|
814
|
+
// Dot-file handling.
|
|
815
|
+
// BUG FIX: the original condition was `!opts.dotfiles != 'allow'` which
|
|
816
|
+
// always evaluates to `true` because `!opts.dotfiles` is a boolean and a
|
|
817
|
+
// boolean is never strictly/loosely equal to a string. The correct
|
|
818
|
+
// condition is `opts.dotfiles !== 'allow'`.
|
|
819
|
+
if (opts.dotfiles !== 'allow' && pathname.includes('/.')) {
|
|
820
|
+
if (opts.dotfiles === 'deny')
|
|
821
|
+
return HTTP.FORBIDDEN(res, opts);
|
|
822
|
+
return HTTP.NOT_FOUND(res, opts);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
fs.stat(pathname, (err, stat) => {
|
|
826
|
+
if (err) {
|
|
827
|
+
if (
|
|
828
|
+
err.code === 'ENOENT' ||
|
|
829
|
+
err.code === 'ENAMETOOLONG' ||
|
|
830
|
+
err.code === 'ENOTDIR'
|
|
831
|
+
) {
|
|
832
|
+
if (opts.fallthrough)
|
|
833
|
+
return next();
|
|
834
|
+
return HTTP.NOT_FOUND(res, opts);
|
|
835
|
+
}
|
|
836
|
+
return HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// When the resolved path is a directory, redirect to its index.html.
|
|
840
|
+
if (stat.isDirectory()) {
|
|
841
|
+
if (!opts.redirect)
|
|
842
|
+
return HTTP.NOT_FOUND(res, opts);
|
|
843
|
+
return sendFile(req, res, nodePath.join(pathname, 'index.html'), opts);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
sendIt(req, res, pathname, stat, opts);
|
|
847
|
+
});
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Middleware factory that serves a **single, fixed file** as the response to
|
|
853
|
+
* every request, regardless of the request path.
|
|
854
|
+
*
|
|
855
|
+
* Use this when you want every route to return the same file — for example,
|
|
856
|
+
* serving a compiled single-page application's `index.html` as a catch-all:
|
|
857
|
+
* ```ts
|
|
858
|
+
* app.get('/**', serveFile('./dist/index.html'));
|
|
859
|
+
* ```
|
|
860
|
+
*
|
|
861
|
+
* Features:
|
|
862
|
+
* - **Method filtering** — only `GET` and `HEAD` are served.
|
|
863
|
+
* - **Conditional GET** — ETag and Last-Modified are generated from the file
|
|
864
|
+
* metadata on every request.
|
|
865
|
+
* - **MIME-type detection** — via the `mime` npm package.
|
|
866
|
+
*
|
|
867
|
+
* @param filePath - Path to the file to serve. Resolved to an absolute path.
|
|
868
|
+
* @param options - Optional configuration (see {@link StaticOptions}).
|
|
869
|
+
* @returns An Express-compatible middleware function.
|
|
870
|
+
* @throws {TypeError} When `filePath` is missing or not a string.
|
|
871
|
+
*/
|
|
872
|
+
export function serveFile(filePath: string, options?: StaticOptions): Middleware {
|
|
873
|
+
const opts = resolveOptions(filePath, options);
|
|
874
|
+
|
|
875
|
+
return function (req: RouterRequest, res: RouterResponse, next: () => void): void {
|
|
876
|
+
// BUG FIX: the original `file()` function had the method-check logic
|
|
877
|
+
// inverted. `methOk` was set to `true` when the method was NOT GET/HEAD
|
|
878
|
+
// (i.e. the invalid case), and then `if (!methOk)` blocked valid methods.
|
|
879
|
+
// The corrected logic: reject when the method is NOT GET or HEAD.
|
|
880
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
881
|
+
if (opts.fallthrough)
|
|
882
|
+
return next();
|
|
883
|
+
return HTTP.NOT_ALLOWED(res, opts);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const pathname = opts.root;
|
|
887
|
+
fs.stat(pathname, (err, stat) => {
|
|
888
|
+
if (err)
|
|
889
|
+
return HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
|
|
890
|
+
|
|
891
|
+
// BUG FIX: the original code referenced `err.code` inside the
|
|
892
|
+
// `stat.isDirectory()` branch, where `err` is guaranteed to be `null`
|
|
893
|
+
// (we only reach this branch when `fs.stat` succeeded). The correct
|
|
894
|
+
// response for a misconfigured directory path is a plain 500 with a
|
|
895
|
+
// descriptive message.
|
|
896
|
+
if (stat.isDirectory())
|
|
897
|
+
return HTTP.INTERNAL_ERROR(res, opts, 'EISDIR');
|
|
898
|
+
|
|
899
|
+
sendIt(req, res, pathname, stat, opts);
|
|
900
|
+
});
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export default { serveStatic, serveFile, sendFile };
|