koa-classic-server 4.1.0 → 4.2.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/README.md +25 -0
- package/index.cjs +197 -63
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -150,6 +150,25 @@ app.use(koaClassicServer(path.join(__dirname, 'www'), {
|
|
|
150
150
|
// .env, .git/config, *.key → 404
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
+
### Custom error pages
|
|
154
|
+
|
|
155
|
+
Branded 404/500/504 pages from self-contained `.html` files (v4.2+). Keep them **outside**
|
|
156
|
+
`rootDir` so they are not themselves reachable via URL; edit them anytime — changes are
|
|
157
|
+
picked up without a restart, and an unreadable file falls back to the built-in page:
|
|
158
|
+
|
|
159
|
+
```javascript
|
|
160
|
+
app.use(koaClassicServer(path.join(__dirname, 'www'), {
|
|
161
|
+
errorPages: {
|
|
162
|
+
404: path.join(__dirname, 'errors', '404.html'),
|
|
163
|
+
500: path.join(__dirname, 'errors', '500.html'),
|
|
164
|
+
},
|
|
165
|
+
}));
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
One rule: each page must be a single self-contained file (inline CSS, no external
|
|
169
|
+
css/js/img references). Custom pages are served without the built-in pages'
|
|
170
|
+
`Content-Security-Policy`, so an external reference would really load.
|
|
171
|
+
|
|
153
172
|
### Mount under a prefix, pass through some routes
|
|
154
173
|
|
|
155
174
|
```javascript
|
|
@@ -299,6 +318,12 @@ koaClassicServer(rootDir, {
|
|
|
299
318
|
symlinks: 'follow', // 'follow' | 'follow-within-root' | 'deny'
|
|
300
319
|
staticSecurityHeaders: { nosniff: false }, // set nosniff on static responses
|
|
301
320
|
|
|
321
|
+
errorPages: { // custom error pages (v4.2+); omitted → built-ins
|
|
322
|
+
// 404: './errors/404.html', // supported statuses: 404, 500, 504
|
|
323
|
+
// 500: './errors/500.html', // self-contained .html (inline CSS, no external refs)
|
|
324
|
+
// 504: './errors/504.html', // editable without a restart; unreadable → built-in
|
|
325
|
+
},
|
|
326
|
+
|
|
302
327
|
template: {
|
|
303
328
|
ext: [], // e.g. ['ejs']
|
|
304
329
|
renderTimeout: 30000, // ms; on timeout the request fails closed (0 = off)
|
package/index.cjs
CHANGED
|
@@ -121,9 +121,12 @@ const LISTING_CSP = `default-src 'none'; style-src '${_listingCssHash}'; frame-a
|
|
|
121
121
|
const NOT_FOUND_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
|
|
122
122
|
|
|
123
123
|
// Sets security headers on all middleware-generated HTML pages (listing + error).
|
|
124
|
-
// Must NOT be called for user files served from disk.
|
|
124
|
+
// Must NOT be called for user files served from disk. `csp` may be null for
|
|
125
|
+
// operator-authored custom error pages (options.errorPages): the built-in pages'
|
|
126
|
+
// `default-src 'none'` would block their inline styles, so they get the non-CSP
|
|
127
|
+
// headers only — the self-contained requirement is documented, not enforced.
|
|
125
128
|
function setGeneratedPageHeaders(ctx, csp) {
|
|
126
|
-
ctx.set('Content-Security-Policy', csp);
|
|
129
|
+
if (csp) ctx.set('Content-Security-Policy', csp);
|
|
127
130
|
ctx.set('X-Content-Type-Options', 'nosniff');
|
|
128
131
|
ctx.set('X-Frame-Options', 'DENY');
|
|
129
132
|
ctx.set('Referrer-Policy', 'no-referrer');
|
|
@@ -153,10 +156,38 @@ const _GATEWAY_TIMEOUT_HTML = buildErrorHtml('Gateway Timeout', 'Gateway T
|
|
|
153
156
|
const _TEMPLATE_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'Template rendering failed for the requested resource.');
|
|
154
157
|
const _INTERNAL_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'The server encountered an unexpected condition.');
|
|
155
158
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
// Statuses an operator can override via options.errorPages, each with its
|
|
160
|
+
// built-in fallback page. 400 is deliberately absent: it answers malformed /
|
|
161
|
+
// hostile requests and stays minimal by design. _TEMPLATE_ERROR_HTML is a
|
|
162
|
+
// call-site-specific 500 body (template failures), not a separate status.
|
|
163
|
+
const _BUILTIN_ERROR_HTML = {
|
|
164
|
+
404: _NOT_FOUND_HTML,
|
|
165
|
+
500: _INTERNAL_ERROR_HTML,
|
|
166
|
+
504: _GATEWAY_TIMEOUT_HTML,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Representation / caching headers a partially-built response may have left
|
|
170
|
+
// behind by the time an error is detected: a stale Content-Encoding would
|
|
171
|
+
// corrupt the error page (its body is never compressed), a public
|
|
172
|
+
// Cache-Control could get the 404/500 cached by proxies under the resource's
|
|
173
|
+
// URL. Scrubbed on every error page write.
|
|
174
|
+
const ERROR_PAGE_SCRUB_HEADERS = [
|
|
175
|
+
'Content-Encoding', 'Content-Disposition', 'Content-Range', 'Vary',
|
|
176
|
+
'ETag', 'Last-Modified', 'Accept-Ranges',
|
|
177
|
+
'Cache-Control', 'Pragma', 'Expires',
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
// Single writer for every middleware-generated error response (404 / 500 / 504).
|
|
181
|
+
// `customBuffer` is the operator's page from options.errorPages (or null →
|
|
182
|
+
// `builtinHtml` is served). Assumes the response is still writable — callers
|
|
183
|
+
// keep their own headerSent / writableEnded guards.
|
|
184
|
+
function writeErrorPage(ctx, status, customBuffer, builtinHtml) {
|
|
185
|
+
for (const h of ERROR_PAGE_SCRUB_HEADERS) ctx.remove(h);
|
|
186
|
+
if (status >= 500) ctx.set('Cache-Control', 'no-store');
|
|
187
|
+
setGeneratedPageHeaders(ctx, customBuffer ? null : NOT_FOUND_CSP);
|
|
188
|
+
ctx.set('Content-Type', 'text/html; charset=utf-8');
|
|
189
|
+
ctx.status = status;
|
|
190
|
+
ctx.body = customBuffer || builtinHtml;
|
|
160
191
|
}
|
|
161
192
|
|
|
162
193
|
// Plain-text 400 for malformed requests (bad percent-encoding, invalid Host,
|
|
@@ -212,16 +243,16 @@ function warnConfigDeprecation(logger, message) {
|
|
|
212
243
|
|
|
213
244
|
// Sends an error response for a failed template render. If headers were already
|
|
214
245
|
// flushed by the render itself, destroys the underlying socket instead (the
|
|
215
|
-
// status/body can no longer be changed at that point).
|
|
216
|
-
|
|
246
|
+
// status/body can no longer be changed at that point). Page selection (custom
|
|
247
|
+
// vs built-in) goes through the instance's sendErrorPage; `builtinHtml` keeps
|
|
248
|
+
// the call-site-specific fallback body (timeout vs render failure).
|
|
249
|
+
async function sendTemplateError(ctx, status, builtinHtml, logMsg, err, logger, sendErrorPage) {
|
|
217
250
|
logger.error(logMsg, err);
|
|
218
251
|
if (ctx.headerSent || ctx.res.writableEnded) {
|
|
219
252
|
ctx.res.destroy();
|
|
220
253
|
return;
|
|
221
254
|
}
|
|
222
|
-
|
|
223
|
-
ctx.status = status;
|
|
224
|
-
ctx.body = html;
|
|
255
|
+
await sendErrorPage(ctx, status, builtinHtml);
|
|
225
256
|
}
|
|
226
257
|
|
|
227
258
|
// Rewrites an already-rendered response into an RFC 9110 §9.3.2 compliant HEAD
|
|
@@ -256,7 +287,7 @@ function stripBodyForHead(ctx) {
|
|
|
256
287
|
// client disconnect. Cooperative renders that propagate the signal to fetch/db
|
|
257
288
|
// release backend resources promptly; non-cooperative renders still get a 504
|
|
258
289
|
// response, but their work continues in the background.
|
|
259
|
-
async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger) {
|
|
290
|
+
async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger, sendErrorPage) {
|
|
260
291
|
if (templateOpts.ext.length === 0 || !templateOpts.render) return false;
|
|
261
292
|
|
|
262
293
|
const fileExt = path.extname(filePath).slice(1);
|
|
@@ -304,11 +335,11 @@ async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, l
|
|
|
304
335
|
}
|
|
305
336
|
} catch (error) {
|
|
306
337
|
if (timedOut || error.code === 'ETEMPLATETIMEOUT') {
|
|
307
|
-
sendTemplateError(ctx, 504, _GATEWAY_TIMEOUT_HTML,
|
|
308
|
-
'Template render timeout after ' + timeoutMs + 'ms:', filePath, logger);
|
|
338
|
+
await sendTemplateError(ctx, 504, _GATEWAY_TIMEOUT_HTML,
|
|
339
|
+
'Template render timeout after ' + timeoutMs + 'ms:', filePath, logger, sendErrorPage);
|
|
309
340
|
} else {
|
|
310
|
-
sendTemplateError(ctx, 500, _TEMPLATE_ERROR_HTML,
|
|
311
|
-
'Template rendering error:', error, logger);
|
|
341
|
+
await sendTemplateError(ctx, 500, _TEMPLATE_ERROR_HTML,
|
|
342
|
+
'Template rendering error:', error, logger, sendErrorPage);
|
|
312
343
|
}
|
|
313
344
|
} finally {
|
|
314
345
|
if (timer) clearTimeout(timer);
|
|
@@ -691,6 +722,25 @@ module.exports = function koaClassicServer(
|
|
|
691
722
|
ext: '.ejs', // Extension to hide (required, string, case-sensitive, must start with '.')
|
|
692
723
|
redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
|
|
693
724
|
},
|
|
725
|
+
errorPages: { // Custom error pages (V4.2+). Opt-in — omitted statuses keep the built-in pages.
|
|
726
|
+
// Keys: the statuses the middleware generates error pages for: 404, 500, 504.
|
|
727
|
+
// Any other key throws at factory time. (400 replies to malformed/hostile
|
|
728
|
+
// requests are deliberately NOT customizable — they stay minimal.)
|
|
729
|
+
// Values: filesystem path (absolute, or relative to process.cwd()) to a
|
|
730
|
+
// SELF-CONTAINED .html file: one single file, inline CSS only, no external
|
|
731
|
+
// css/js/img references. Documented requirement, not enforced — custom
|
|
732
|
+
// pages are served WITHOUT the built-in pages' Content-Security-Policy
|
|
733
|
+
// (which would block their inline styles); the other generated-page
|
|
734
|
+
// security headers (nosniff, X-Frame-Options, ...) are still sent.
|
|
735
|
+
// The file may live outside rootDir (recommended: keeps it unreachable via URL).
|
|
736
|
+
// Read and validated at factory time (missing/unreadable → throw), cached in
|
|
737
|
+
// RAM, and re-read automatically when its mtime/size changes — editable
|
|
738
|
+
// without a restart. If it becomes unreadable at request time, the built-in
|
|
739
|
+
// page is served instead (fallback; throttled warning via logger).
|
|
740
|
+
// 404: './errors/404.html',
|
|
741
|
+
// 500: './errors/500.html',
|
|
742
|
+
// 504: './errors/504.html',
|
|
743
|
+
},
|
|
694
744
|
hidden: { // Block files/dirs from listing and serving (HTTP 404)
|
|
695
745
|
dotFiles: { // Dot-files (names starting with '.'): visible by default — design philosophy
|
|
696
746
|
default: 'visible', // 'hidden' | 'visible' — system default: 'visible'
|
|
@@ -1414,6 +1464,98 @@ module.exports = function koaClassicServer(
|
|
|
1414
1464
|
nosniff: !!(_ssh && _ssh.nosniff),
|
|
1415
1465
|
};
|
|
1416
1466
|
|
|
1467
|
+
// ── errorPages (V4.2+) — operator-supplied custom error pages ──
|
|
1468
|
+
// Validated and read once at factory time (a typo must fail at startup, not
|
|
1469
|
+
// on the first 404), then kept in RAM and refreshed when mtime/size changes.
|
|
1470
|
+
const _errorPages = new Map(); // status → { path, buffer, mtimeMs, size }
|
|
1471
|
+
const userErrorPages = opts.errorPages;
|
|
1472
|
+
if (userErrorPages !== undefined) {
|
|
1473
|
+
if (typeof userErrorPages !== 'object' || userErrorPages === null || Array.isArray(userErrorPages)) {
|
|
1474
|
+
throw new Error(
|
|
1475
|
+
'[koa-classic-server] options.errorPages must be an object mapping a supported HTTP status ' +
|
|
1476
|
+
"to an .html file path. Example: errorPages: { 404: './errors/404.html' }"
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1479
|
+
for (const key of Object.keys(userErrorPages)) {
|
|
1480
|
+
const status = Number(key);
|
|
1481
|
+
if (_BUILTIN_ERROR_HTML[status] === undefined) {
|
|
1482
|
+
throw new Error(
|
|
1483
|
+
`[koa-classic-server] errorPages: unsupported status "${key}". ` +
|
|
1484
|
+
'Supported statuses: ' + Object.keys(_BUILTIN_ERROR_HTML).join(', ') + '. ' +
|
|
1485
|
+
'(400 is deliberately not customizable: replies to malformed requests stay minimal.)'
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
const value = userErrorPages[key];
|
|
1489
|
+
if (typeof value !== 'string' || value === '') {
|
|
1490
|
+
throw new Error(
|
|
1491
|
+
`[koa-classic-server] errorPages[${key}] must be a non-empty string path to an .html file. ` +
|
|
1492
|
+
'Got: ' + (value === '' ? 'an empty string' : value === null ? 'null' : typeof value)
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
const resolved = path.resolve(value);
|
|
1496
|
+
let pageStat, pageBuffer;
|
|
1497
|
+
try {
|
|
1498
|
+
pageStat = fs.statSync(resolved);
|
|
1499
|
+
if (!pageStat.isFile()) throw new Error('not a regular file');
|
|
1500
|
+
pageBuffer = fs.readFileSync(resolved);
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
throw new Error(
|
|
1503
|
+
`[koa-classic-server] errorPages[${key}]: cannot read "${resolved}" (${err.message}). ` +
|
|
1504
|
+
'The file must exist and be readable at startup.'
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
_errorPages.set(status, { path: resolved, buffer: pageBuffer, mtimeMs: pageStat.mtimeMs, size: pageStat.size });
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// Runtime read-failure warnings, throttled per status: a 404 flood against a
|
|
1512
|
+
// deleted custom page must not flood the log.
|
|
1513
|
+
const _errorPageWarnAt = new Map(); // status → last warn timestamp (ms)
|
|
1514
|
+
function warnErrorPageUnreadable(status, entry, err) {
|
|
1515
|
+
const now = Date.now();
|
|
1516
|
+
if (now - (_errorPageWarnAt.get(status) || 0) < 60000) return;
|
|
1517
|
+
_errorPageWarnAt.set(status, now);
|
|
1518
|
+
_logger.warn(...warnPayload(_logger,
|
|
1519
|
+
`[koa-classic-server] errorPages[${status}]: cannot read "${entry.path}" — ` +
|
|
1520
|
+
`serving the built-in page instead. (${err.message})`));
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// Returns the custom page buffer for `status`, re-read from disk when the
|
|
1524
|
+
// file changed (mtime+size — pages are editable without a restart). Returns
|
|
1525
|
+
// null when no custom page is configured for the status, or when the file is
|
|
1526
|
+
// no longer readable (the caller falls back to the built-in page).
|
|
1527
|
+
async function getCustomErrorPage(status) {
|
|
1528
|
+
const entry = _errorPages.get(status);
|
|
1529
|
+
if (!entry) return null;
|
|
1530
|
+
try {
|
|
1531
|
+
const st = await fs.promises.stat(entry.path);
|
|
1532
|
+
if (st.mtimeMs !== entry.mtimeMs || st.size !== entry.size) {
|
|
1533
|
+
entry.buffer = await fs.promises.readFile(entry.path);
|
|
1534
|
+
entry.mtimeMs = st.mtimeMs;
|
|
1535
|
+
entry.size = st.size;
|
|
1536
|
+
}
|
|
1537
|
+
return entry.buffer;
|
|
1538
|
+
} catch (err) {
|
|
1539
|
+
warnErrorPageUnreadable(status, entry, err);
|
|
1540
|
+
return null;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// Single sender for every middleware-generated error response. `builtinHtml`
|
|
1545
|
+
// lets a call site keep its specific fallback body (e.g. the template-failure
|
|
1546
|
+
// 500) while still honoring the operator's custom page when configured.
|
|
1547
|
+
async function sendErrorPage(ctx, status, builtinHtml = _BUILTIN_ERROR_HTML[status]) {
|
|
1548
|
+
writeErrorPage(ctx, status, await getCustomErrorPage(status), builtinHtml);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Sync variant for stream-error callbacks, where no await is possible: serves
|
|
1552
|
+
// the last-loaded custom buffer without the freshness stat — an acceptable
|
|
1553
|
+
// trade for a branch that only fires when the disk fails mid-response.
|
|
1554
|
+
function sendErrorPageSync(ctx, status) {
|
|
1555
|
+
const entry = _errorPages.get(status);
|
|
1556
|
+
writeErrorPage(ctx, status, entry ? entry.buffer : null, _BUILTIN_ERROR_HTML[status]);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1417
1559
|
const compressionConfig = normalizeCompressionConfig(options.compression);
|
|
1418
1560
|
const serverCacheConfig = normalizeServerCacheConfig(options.serverCache);
|
|
1419
1561
|
|
|
@@ -1471,7 +1613,7 @@ module.exports = function koaClassicServer(
|
|
|
1471
1613
|
ctx.body = pipeline(src, compress, (err) => {
|
|
1472
1614
|
if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
|
|
1473
1615
|
_logger.error('Stream error:', err);
|
|
1474
|
-
if (!ctx.headerSent)
|
|
1616
|
+
if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
|
|
1475
1617
|
});
|
|
1476
1618
|
}
|
|
1477
1619
|
|
|
@@ -1695,7 +1837,7 @@ module.exports = function koaClassicServer(
|
|
|
1695
1837
|
// Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
|
|
1696
1838
|
// matching the symlink-escape and hidden-entry outcomes.
|
|
1697
1839
|
if (!_isWithinRoot(fullPath, normalizedRootDir)) {
|
|
1698
|
-
|
|
1840
|
+
await sendErrorPage(ctx, 404);
|
|
1699
1841
|
return;
|
|
1700
1842
|
}
|
|
1701
1843
|
|
|
@@ -1708,7 +1850,7 @@ module.exports = function koaClassicServer(
|
|
|
1708
1850
|
const segName = segments[i];
|
|
1709
1851
|
const segRelPath = segments.slice(0, i + 1).join('/');
|
|
1710
1852
|
if (isHiddenEntry(segName, segRelPath, true)) {
|
|
1711
|
-
|
|
1853
|
+
await sendErrorPage(ctx, 404);
|
|
1712
1854
|
return;
|
|
1713
1855
|
}
|
|
1714
1856
|
}
|
|
@@ -1823,7 +1965,7 @@ module.exports = function koaClassicServer(
|
|
|
1823
1965
|
stat = await fs.promises.stat(toOpen);
|
|
1824
1966
|
} catch {
|
|
1825
1967
|
// File/directory doesn't exist or can't be accessed
|
|
1826
|
-
|
|
1968
|
+
await sendErrorPage(ctx, 404);
|
|
1827
1969
|
return;
|
|
1828
1970
|
}
|
|
1829
1971
|
|
|
@@ -1832,7 +1974,7 @@ module.exports = function koaClassicServer(
|
|
|
1832
1974
|
const entryName = path.basename(toOpen);
|
|
1833
1975
|
const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
|
|
1834
1976
|
if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
|
|
1835
|
-
|
|
1977
|
+
await sendErrorPage(ctx, 404);
|
|
1836
1978
|
return;
|
|
1837
1979
|
}
|
|
1838
1980
|
}
|
|
@@ -1842,7 +1984,7 @@ module.exports = function koaClassicServer(
|
|
|
1842
1984
|
// resolved below rootDir). The `_symlinkMode !== 'follow'` guard short-circuits
|
|
1843
1985
|
// before any await so the default 'follow' mode stays truly zero-overhead.
|
|
1844
1986
|
if (_symlinkMode !== 'follow' && !(await symlinkAllowed(toOpen))) {
|
|
1845
|
-
|
|
1987
|
+
await sendErrorPage(ctx, 404);
|
|
1846
1988
|
return;
|
|
1847
1989
|
}
|
|
1848
1990
|
|
|
@@ -1891,7 +2033,7 @@ module.exports = function koaClassicServer(
|
|
|
1891
2033
|
// symlink escaping rootDir — validate before serving it.
|
|
1892
2034
|
// Guarded so the default 'follow' mode skips the await entirely.
|
|
1893
2035
|
if (_symlinkMode !== 'follow' && !(await symlinkAllowed(indexPath))) {
|
|
1894
|
-
|
|
2036
|
+
await sendErrorPage(ctx, 404);
|
|
1895
2037
|
return;
|
|
1896
2038
|
}
|
|
1897
2039
|
await loadFile(indexPath, indexFile.stat);
|
|
@@ -1900,11 +2042,14 @@ module.exports = function koaClassicServer(
|
|
|
1900
2042
|
}
|
|
1901
2043
|
}
|
|
1902
2044
|
|
|
1903
|
-
// No index file found, show directory listing
|
|
1904
|
-
|
|
2045
|
+
// No index file found, show directory listing. On a readdir
|
|
2046
|
+
// failure show_dir writes the 500 error page itself and returns
|
|
2047
|
+
// undefined — don't clobber that body with the listing assignment.
|
|
2048
|
+
const listing = await show_dir(toOpen, ctx);
|
|
2049
|
+
if (listing !== undefined) ctx.body = listing;
|
|
1905
2050
|
} else {
|
|
1906
2051
|
// Directory listing disabled
|
|
1907
|
-
|
|
2052
|
+
await sendErrorPage(ctx, 404);
|
|
1908
2053
|
}
|
|
1909
2054
|
return;
|
|
1910
2055
|
} else {
|
|
@@ -1914,7 +2059,7 @@ module.exports = function koaClassicServer(
|
|
|
1914
2059
|
// from not-found) rather than serving the file at a non-canonical
|
|
1915
2060
|
// URL. Disabled by dirListing.trailingSlash: false (v3 behavior).
|
|
1916
2061
|
if (options.dirListing.trailingSlash && _pathEndsWithSlash) {
|
|
1917
|
-
|
|
2062
|
+
await sendErrorPage(ctx, 404);
|
|
1918
2063
|
return;
|
|
1919
2064
|
}
|
|
1920
2065
|
await loadFile(toOpen, stat);
|
|
@@ -1926,17 +2071,10 @@ module.exports = function koaClassicServer(
|
|
|
1926
2071
|
ctx.res.destroy(); // response already in flight — nothing sane left to send
|
|
1927
2072
|
return;
|
|
1928
2073
|
}
|
|
1929
|
-
//
|
|
1930
|
-
// have left behind
|
|
1931
|
-
//
|
|
1932
|
-
|
|
1933
|
-
ctx.remove(h);
|
|
1934
|
-
}
|
|
1935
|
-
ctx.set('Cache-Control', 'no-store');
|
|
1936
|
-
ctx.set('Content-Type', 'text/html; charset=utf-8');
|
|
1937
|
-
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
1938
|
-
ctx.status = 500;
|
|
1939
|
-
ctx.body = _INTERNAL_ERROR_HTML;
|
|
2074
|
+
// writeErrorPage scrubs the representation/caching headers a
|
|
2075
|
+
// partially-built response may have left behind (stale
|
|
2076
|
+
// Content-Encoding, public Cache-Control, ...) and sets no-store.
|
|
2077
|
+
await sendErrorPage(ctx, 500);
|
|
1940
2078
|
}
|
|
1941
2079
|
|
|
1942
2080
|
// Internal functions
|
|
@@ -1949,7 +2087,7 @@ module.exports = function koaClassicServer(
|
|
|
1949
2087
|
fileStat = await fs.promises.stat(toOpen);
|
|
1950
2088
|
} catch (error) {
|
|
1951
2089
|
_logger.error('File stat error:', error);
|
|
1952
|
-
|
|
2090
|
+
await sendErrorPage(ctx, 404);
|
|
1953
2091
|
return;
|
|
1954
2092
|
}
|
|
1955
2093
|
}
|
|
@@ -1993,7 +2131,7 @@ module.exports = function koaClassicServer(
|
|
|
1993
2131
|
}
|
|
1994
2132
|
}
|
|
1995
2133
|
|
|
1996
|
-
if (await tryRenderTemplate(ctx, next, toOpen, rawBuffer, options.template, _logger)) {
|
|
2134
|
+
if (await tryRenderTemplate(ctx, next, toOpen, rawBuffer, options.template, _logger, sendErrorPage)) {
|
|
1997
2135
|
return;
|
|
1998
2136
|
}
|
|
1999
2137
|
|
|
@@ -2029,7 +2167,7 @@ module.exports = function koaClassicServer(
|
|
|
2029
2167
|
await fs.promises.access(toOpen, fs.constants.R_OK);
|
|
2030
2168
|
} catch (error) {
|
|
2031
2169
|
_logger.error('File access error:', error);
|
|
2032
|
-
|
|
2170
|
+
await sendErrorPage(ctx, 404);
|
|
2033
2171
|
return;
|
|
2034
2172
|
}
|
|
2035
2173
|
}
|
|
@@ -2133,10 +2271,7 @@ module.exports = function koaClassicServer(
|
|
|
2133
2271
|
const src = fs.createReadStream(toOpen, { start, end });
|
|
2134
2272
|
src.on('error', (err) => {
|
|
2135
2273
|
_logger.error('Stream error:', err);
|
|
2136
|
-
if (!ctx.headerSent)
|
|
2137
|
-
ctx.status = 500;
|
|
2138
|
-
ctx.body = 'Error reading file';
|
|
2139
|
-
}
|
|
2274
|
+
if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
|
|
2140
2275
|
});
|
|
2141
2276
|
ctx.body = src;
|
|
2142
2277
|
}
|
|
@@ -2264,7 +2399,7 @@ module.exports = function koaClassicServer(
|
|
|
2264
2399
|
abandonAccumulation();
|
|
2265
2400
|
if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
|
|
2266
2401
|
_logger.error('Stream error:', err);
|
|
2267
|
-
if (!ctx.headerSent)
|
|
2402
|
+
if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
|
|
2268
2403
|
});
|
|
2269
2404
|
} catch (err) {
|
|
2270
2405
|
// Defensive: nothing between add() and pipeline() is expected to
|
|
@@ -2319,7 +2454,7 @@ module.exports = function koaClassicServer(
|
|
|
2319
2454
|
const src = fs.createReadStream(toOpen);
|
|
2320
2455
|
src.on('error', (streamErr) => {
|
|
2321
2456
|
_logger.error('Stream error:', streamErr);
|
|
2322
|
-
if (!ctx.headerSent)
|
|
2457
|
+
if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
|
|
2323
2458
|
});
|
|
2324
2459
|
ctx.body = src;
|
|
2325
2460
|
} else {
|
|
@@ -2371,7 +2506,7 @@ module.exports = function koaClassicServer(
|
|
|
2371
2506
|
const src = fs.createReadStream(toOpen);
|
|
2372
2507
|
src.on('error', (err) => {
|
|
2373
2508
|
_logger.error('Stream error:', err);
|
|
2374
|
-
if (!ctx.headerSent)
|
|
2509
|
+
if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
|
|
2375
2510
|
});
|
|
2376
2511
|
ctx.body = src;
|
|
2377
2512
|
} else {
|
|
@@ -2402,21 +2537,13 @@ module.exports = function koaClassicServer(
|
|
|
2402
2537
|
}
|
|
2403
2538
|
} catch (error) {
|
|
2404
2539
|
_logger.error('Directory read error:', error);
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
<title>Error</title>
|
|
2413
|
-
</head>
|
|
2414
|
-
<body>
|
|
2415
|
-
<h1>Error Reading Directory</h1>
|
|
2416
|
-
<p>Unable to access directory contents.</p>
|
|
2417
|
-
</body>
|
|
2418
|
-
</html>
|
|
2419
|
-
`;
|
|
2540
|
+
// Route through the unified writer so this 500 honors errorPages[500]
|
|
2541
|
+
// (custom page when configured), the no-store / header-scrub, and the
|
|
2542
|
+
// generated-page security headers — like every other 500. sendErrorPage
|
|
2543
|
+
// sets ctx.status/headers/body itself; returning undefined signals the
|
|
2544
|
+
// caller not to overwrite the body it just wrote.
|
|
2545
|
+
await sendErrorPage(ctx, 500);
|
|
2546
|
+
return undefined;
|
|
2420
2547
|
}
|
|
2421
2548
|
|
|
2422
2549
|
// Relative path of this directory from rootDir (used for alwaysHide path matching)
|
|
@@ -2736,4 +2863,11 @@ module.exports._internals = {
|
|
|
2736
2863
|
singleFlight,
|
|
2737
2864
|
refreshOrInsert,
|
|
2738
2865
|
escapeHtml,
|
|
2866
|
+
// writeErrorPage is the shared output path for every middleware-generated error
|
|
2867
|
+
// response (sendErrorPage / sendErrorPageSync delegate to it). Exposed so its
|
|
2868
|
+
// contract — header scrub, no-store on >=500, Content-Type, custom-vs-built-in
|
|
2869
|
+
// body, CSP only for the built-in page — can be asserted deterministically: the
|
|
2870
|
+
// stream-failure branches that also use it can't be, because Koa 3 tears the
|
|
2871
|
+
// socket down on a mid-stream body error before the client sees the response.
|
|
2872
|
+
writeErrorPage,
|
|
2739
2873
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koa-classic-server",
|
|
3
|
-
"version": "4.1
|
|
3
|
+
"version": "4.2.1",
|
|
4
4
|
"description": "High-performance Koa middleware for serving static files with classic directory listing (similar, but not identical, to Apache 2), HTTP caching, template engine support, and comprehensive security fixes",
|
|
5
5
|
"main": "index.cjs",
|
|
6
6
|
"exports": {
|