koa-classic-server 3.0.0-alpha.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +101 -0
- package/README.md +550 -635
- package/__tests__/benchmark-results-v3.0.0.txt +372 -0
- package/__tests__/benchmark.js +1 -1
- package/__tests__/compression.test.js +17 -3
- package/__tests__/customTest/serversToLoad.util.js +4 -4
- package/__tests__/demo-regex-index.js +4 -4
- package/__tests__/directory-sorting-links.test.js +1 -1
- package/__tests__/dt-unknown.test.js +19 -19
- package/__tests__/ejs.test.js +1 -1
- package/__tests__/head-method.test.js +160 -0
- package/__tests__/hidden-option.test.js +48 -63
- package/__tests__/hideExtension.test.js +70 -13
- package/__tests__/index.test.js +6 -6
- package/__tests__/listing.test.js +437 -0
- package/__tests__/logger.test.js +232 -0
- package/__tests__/range.test.js +2 -2
- package/__tests__/security-headers.test.js +20 -8
- package/__tests__/security.test.js +5 -5
- package/__tests__/server-cache.test.js +178 -7
- package/__tests__/symlink.test.js +10 -10
- package/__tests__/template-timeout.test.js +321 -0
- package/docs/CHANGELOG.md +235 -4
- package/docs/CODE_REVIEW.md +2 -0
- package/docs/DOCUMENTATION.md +259 -32
- package/docs/EXAMPLES_INDEX_OPTION.md +1 -1
- package/docs/FLOW_DIAGRAM.md +2 -0
- package/docs/INDEX_OPTION_PRIORITY.md +2 -2
- package/docs/OPTIMIZATION_HTTP_CACHING.md +2 -0
- package/docs/security_improvement_for_V3.md +421 -0
- package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +5 -5
- package/docs/template-engine/esempi-incrementali.js +1 -1
- package/index.cjs +587 -178
- package/package.json +6 -1
package/index.cjs
CHANGED
|
@@ -4,15 +4,21 @@ const fs = require("fs");
|
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const crypto = require("crypto");
|
|
6
6
|
const zlib = require("zlib");
|
|
7
|
+
const util = require("util");
|
|
7
8
|
const mime = require("mime-types");
|
|
8
9
|
const { Readable } = require('stream');
|
|
9
10
|
|
|
11
|
+
const _brotliCompressAsync = util.promisify(zlib.brotliCompress);
|
|
12
|
+
const _gzipAsync = util.promisify(zlib.gzip);
|
|
13
|
+
|
|
10
14
|
// Pre-computed module-level constants
|
|
11
15
|
const _LOG_1024 = Math.log(1024);
|
|
12
16
|
|
|
13
|
-
// Emitted at most once per process lifetime when
|
|
14
|
-
//
|
|
15
|
-
|
|
17
|
+
// Emitted at most once per process lifetime when the caller passes the v2-era
|
|
18
|
+
// `showDirContents` option instead of the v3 `dirListing.enabled`. The old
|
|
19
|
+
// name is accepted as a backward-compatibility alias and may be removed in a
|
|
20
|
+
// future major version.
|
|
21
|
+
let _showDirContentsDeprecationWarned = false;
|
|
16
22
|
|
|
17
23
|
// Default list of MIME types that benefit from compression.
|
|
18
24
|
// User-provided compression.mimeTypes replaces this list entirely.
|
|
@@ -73,6 +79,36 @@ const LISTING_CSS = `
|
|
|
73
79
|
th:nth-child(1), td:nth-child(1) { width: 50%; }
|
|
74
80
|
th:nth-child(2), td:nth-child(2) { width: 30%; }
|
|
75
81
|
th:nth-child(3), td:nth-child(3) { width: 20%; text-align: right; }
|
|
82
|
+
.kcs-banner {
|
|
83
|
+
max-width: 800px;
|
|
84
|
+
margin: 10px 0;
|
|
85
|
+
padding: 10px 14px;
|
|
86
|
+
background-color: #fff7e0;
|
|
87
|
+
border-left: 4px solid #e0a800;
|
|
88
|
+
font-size: 14px;
|
|
89
|
+
color: #5a4a00;
|
|
90
|
+
}
|
|
91
|
+
.kcs-pagination {
|
|
92
|
+
max-width: 800px;
|
|
93
|
+
margin: 16px 0;
|
|
94
|
+
font-size: 14px;
|
|
95
|
+
}
|
|
96
|
+
.kcs-pagination a, .kcs-pagination span {
|
|
97
|
+
display: inline-block;
|
|
98
|
+
padding: 4px 8px;
|
|
99
|
+
margin-right: 4px;
|
|
100
|
+
}
|
|
101
|
+
.kcs-pagination .kcs-page-current {
|
|
102
|
+
font-weight: bold;
|
|
103
|
+
background-color: #f0f0f0;
|
|
104
|
+
border-radius: 3px;
|
|
105
|
+
}
|
|
106
|
+
.kcs-pagination .kcs-page-ellipsis {
|
|
107
|
+
color: #888;
|
|
108
|
+
}
|
|
109
|
+
.kcs-pagination .kcs-page-disabled {
|
|
110
|
+
color: #bbb;
|
|
111
|
+
}
|
|
76
112
|
`;
|
|
77
113
|
|
|
78
114
|
// SHA-256 hash of the listing CSS, computed once at startup (zero per-request overhead).
|
|
@@ -94,20 +130,27 @@ function setGeneratedPageHeaders(ctx, csp) {
|
|
|
94
130
|
ctx.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
|
|
95
131
|
}
|
|
96
132
|
|
|
97
|
-
//
|
|
98
|
-
|
|
133
|
+
// Builds a minimal error page used by the middleware (404 / 500 / 504).
|
|
134
|
+
// Each page is pre-computed once at module load and reused on every request.
|
|
135
|
+
function buildErrorHtml(title, heading, message) {
|
|
136
|
+
return `<!DOCTYPE html>
|
|
99
137
|
<html>
|
|
100
138
|
<head>
|
|
101
139
|
<meta charset="UTF-8">
|
|
102
140
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
103
141
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
104
|
-
<title
|
|
142
|
+
<title>${title}</title>
|
|
105
143
|
</head>
|
|
106
144
|
<body>
|
|
107
|
-
<h1
|
|
108
|
-
<h3
|
|
145
|
+
<h1>${heading}</h1>
|
|
146
|
+
<h3>${message}</h3>
|
|
109
147
|
</body>
|
|
110
148
|
</html>`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const _NOT_FOUND_HTML = buildErrorHtml('URL not found', 'Not Found', 'The requested URL was not found on this server.');
|
|
152
|
+
const _GATEWAY_TIMEOUT_HTML = buildErrorHtml('Gateway Timeout', 'Gateway Timeout', 'The template took too long to render.');
|
|
153
|
+
const _TEMPLATE_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'Template rendering failed for the requested resource.');
|
|
111
154
|
|
|
112
155
|
function sendNotFound(ctx) {
|
|
113
156
|
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
@@ -115,6 +158,144 @@ function sendNotFound(ctx) {
|
|
|
115
158
|
ctx.body = _NOT_FOUND_HTML;
|
|
116
159
|
}
|
|
117
160
|
|
|
161
|
+
// Validates and returns a logger compatible with our contract. The minimum
|
|
162
|
+
// surface is `{ error: Function, warn: Function }` — any object exposing both
|
|
163
|
+
// (console, pino, winston, bunyan, ...) is accepted as-is.
|
|
164
|
+
function normalizeLogger(logger) {
|
|
165
|
+
if (logger === undefined) return console;
|
|
166
|
+
if (!logger || typeof logger !== 'object' || Array.isArray(logger)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'[koa-classic-server] options.logger must be an object exposing error() and warn() methods.'
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (typeof logger.error !== 'function' || typeof logger.warn !== 'function') {
|
|
172
|
+
throw new Error(
|
|
173
|
+
'[koa-classic-server] options.logger must implement both error() and warn() methods.'
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return logger;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Yellow ANSI wrap, but only when writing to the actual console TTY. Structured
|
|
180
|
+
// loggers (pino/winston/...) would otherwise receive escape bytes as noise.
|
|
181
|
+
function warnPayload(logger, message) {
|
|
182
|
+
return logger === console
|
|
183
|
+
? ['\x1b[33m%s\x1b[0m', message]
|
|
184
|
+
: [message];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Sends an error response for a failed template render. If headers were already
|
|
188
|
+
// flushed by the render itself, destroys the underlying socket instead (the
|
|
189
|
+
// status/body can no longer be changed at that point).
|
|
190
|
+
function sendTemplateError(ctx, status, html, logMsg, err, logger) {
|
|
191
|
+
logger.error(logMsg, err);
|
|
192
|
+
if (ctx.headerSent || ctx.res.writableEnded) {
|
|
193
|
+
ctx.res.destroy();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
197
|
+
ctx.status = status;
|
|
198
|
+
ctx.body = html;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Rewrites an already-rendered response into an RFC 9110 §9.3.2 compliant HEAD
|
|
202
|
+
// response: the status and headers produced by the render are preserved, the
|
|
203
|
+
// body is replaced with an empty buffer (so no content is sent), and
|
|
204
|
+
// Content-Length is restored to the byte length the GET body would have had.
|
|
205
|
+
// Reassigning ctx.body to a non-stream value also makes Koa auto-destroy a
|
|
206
|
+
// previous stream body, so no file descriptor leaks. Stream / non-buffer bodies
|
|
207
|
+
// (uncommon for template renders) carry no Content-Length, matching the static
|
|
208
|
+
// streaming-HEAD branch.
|
|
209
|
+
function stripBodyForHead(ctx) {
|
|
210
|
+
if (ctx.headerSent) return; // render already flushed — status/headers are locked
|
|
211
|
+
const body = ctx.body;
|
|
212
|
+
if (body == null) return; // render produced no body (redirect, pass-through, ...) — leave status as-is
|
|
213
|
+
const hasKnownLength = typeof body === 'string' || Buffer.isBuffer(body);
|
|
214
|
+
const length = hasKnownLength ? Buffer.byteLength(body) : null;
|
|
215
|
+
ctx.body = Buffer.alloc(0);
|
|
216
|
+
if (length !== null) {
|
|
217
|
+
ctx.set('Content-Length', String(length)); // body setter zeroed it — restore the real length
|
|
218
|
+
} else {
|
|
219
|
+
ctx.remove('Content-Length'); // unknown length — omit, like static streaming HEAD
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Attempts to render the requested file through the user's template engine.
|
|
224
|
+
// Returns true if the request was handled (success, timeout, or error response
|
|
225
|
+
// already written), false if no template applies (caller should continue with
|
|
226
|
+
// normal file serving).
|
|
227
|
+
//
|
|
228
|
+
// The render function is invoked with (ctx, next, filePath, rawBuffer, signal).
|
|
229
|
+
// The signal aborts on timeout (when templateOpts.renderTimeout > 0) and on
|
|
230
|
+
// client disconnect. Cooperative renders that propagate the signal to fetch/db
|
|
231
|
+
// release backend resources promptly; non-cooperative renders still get a 504
|
|
232
|
+
// response, but their work continues in the background.
|
|
233
|
+
async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger) {
|
|
234
|
+
if (templateOpts.ext.length === 0 || !templateOpts.render) return false;
|
|
235
|
+
|
|
236
|
+
const fileExt = path.extname(filePath).slice(1);
|
|
237
|
+
if (!fileExt || !templateOpts.ext.includes(fileExt)) return false;
|
|
238
|
+
|
|
239
|
+
// RFC 9110 §9.3.2: HEAD must mirror GET (same status + headers, no body). The
|
|
240
|
+
// user's render is run exactly as for GET — by presenting ctx.method as GET for
|
|
241
|
+
// the duration of the render — so it resolves, validates, and sets Content-Type
|
|
242
|
+
// / status identically; stripBodyForHead() then discards the body and restores
|
|
243
|
+
// Content-Length. Without this, a render that early-returns on non-GET never
|
|
244
|
+
// sets ctx.body, leaving ctx.status at Koa's default 404 for HEAD even though
|
|
245
|
+
// GET returns 200.
|
|
246
|
+
const isHeadRequest = ctx.method === 'HEAD';
|
|
247
|
+
if (isHeadRequest) ctx.method = 'GET';
|
|
248
|
+
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const onClientClose = () => controller.abort();
|
|
251
|
+
ctx.req.on('close', onClientClose);
|
|
252
|
+
|
|
253
|
+
const timeoutMs = templateOpts.renderTimeout;
|
|
254
|
+
let timer = null;
|
|
255
|
+
let timedOut = false;
|
|
256
|
+
|
|
257
|
+
const renderPromise = Promise.resolve().then(() =>
|
|
258
|
+
templateOpts.render(ctx, next, filePath, rawBuffer, controller.signal)
|
|
259
|
+
);
|
|
260
|
+
renderPromise.catch(() => {}); // swallow rejections that arrive after we've already responded
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
if (timeoutMs > 0) {
|
|
264
|
+
await Promise.race([
|
|
265
|
+
renderPromise,
|
|
266
|
+
new Promise((_, reject) => {
|
|
267
|
+
timer = setTimeout(() => {
|
|
268
|
+
timedOut = true;
|
|
269
|
+
controller.abort();
|
|
270
|
+
const err = new Error('Template render timeout');
|
|
271
|
+
err.code = 'ETEMPLATETIMEOUT';
|
|
272
|
+
reject(err);
|
|
273
|
+
}, timeoutMs);
|
|
274
|
+
})
|
|
275
|
+
]);
|
|
276
|
+
} else {
|
|
277
|
+
await renderPromise;
|
|
278
|
+
}
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (timedOut || error.code === 'ETEMPLATETIMEOUT') {
|
|
281
|
+
sendTemplateError(ctx, 504, _GATEWAY_TIMEOUT_HTML,
|
|
282
|
+
'Template render timeout after ' + timeoutMs + 'ms:', filePath, logger);
|
|
283
|
+
} else {
|
|
284
|
+
sendTemplateError(ctx, 500, _TEMPLATE_ERROR_HTML,
|
|
285
|
+
'Template rendering error:', error, logger);
|
|
286
|
+
}
|
|
287
|
+
} finally {
|
|
288
|
+
if (timer) clearTimeout(timer);
|
|
289
|
+
ctx.req.removeListener('close', onClientClose);
|
|
290
|
+
if (isHeadRequest) {
|
|
291
|
+
ctx.method = 'HEAD';
|
|
292
|
+
stripBodyForHead(ctx);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
118
299
|
// Single-pass HTML escaping — one regex scan, one allocation, lookup table compiled once.
|
|
119
300
|
const _HTML_ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
120
301
|
const _HTML_ESCAPE_RE = /[&<>"']/g;
|
|
@@ -204,12 +385,13 @@ function parseRangeHeader(rangeHeader, fileSize) {
|
|
|
204
385
|
// set(key, entry) — insert, evicting LFU entries if needed
|
|
205
386
|
// delete(key) — remove explicitly (e.g. stale entry before re-insert)
|
|
206
387
|
class LFUCache {
|
|
207
|
-
constructor(maxSize, warnInterval, cacheLabel) {
|
|
388
|
+
constructor(maxSize, warnInterval, cacheLabel, logger) {
|
|
208
389
|
this.maxSize = maxSize;
|
|
209
390
|
this.warnInterval = warnInterval;
|
|
210
391
|
this.cacheLabel = cacheLabel;
|
|
392
|
+
this.logger = logger || console;
|
|
211
393
|
this.currentSize = 0;
|
|
212
|
-
this._keyMap = new Map(); // key → { buffer, mtime, size, freq }
|
|
394
|
+
this._keyMap = new Map(); // key → { buffer, mtime, size, insertedAt, freq }
|
|
213
395
|
this._freqMap = new Map(); // freq → Set<key>
|
|
214
396
|
this._minFreq = 0;
|
|
215
397
|
this._lastWarnAt = 0;
|
|
@@ -241,6 +423,26 @@ class LFUCache {
|
|
|
241
423
|
this._minFreq = 1;
|
|
242
424
|
}
|
|
243
425
|
|
|
426
|
+
// In-place update of an existing entry that preserves its current frequency.
|
|
427
|
+
// Used when refreshing a stale-by-maxAge entry so popular files don't fall to
|
|
428
|
+
// the bottom of the LFU bucket just because they got re-read from disk.
|
|
429
|
+
// Returns true on success, false if the new buffer doesn't fit in maxSize
|
|
430
|
+
// (caller can fall back to delete + set in that case).
|
|
431
|
+
refresh(key, fields) {
|
|
432
|
+
const entry = this._keyMap.get(key);
|
|
433
|
+
if (!entry) return false;
|
|
434
|
+
|
|
435
|
+
const sizeDelta = fields.buffer.length - entry.buffer.length;
|
|
436
|
+
if (this.currentSize + sizeDelta > this.maxSize) return false;
|
|
437
|
+
|
|
438
|
+
entry.buffer = fields.buffer;
|
|
439
|
+
if (fields.mtime !== undefined) entry.mtime = fields.mtime;
|
|
440
|
+
if (fields.size !== undefined) entry.size = fields.size;
|
|
441
|
+
if (fields.insertedAt !== undefined) entry.insertedAt = fields.insertedAt;
|
|
442
|
+
this.currentSize += sizeDelta;
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
|
|
244
446
|
delete(key) {
|
|
245
447
|
if (!this._keyMap.has(key)) return;
|
|
246
448
|
const { freq, buffer } = this._keyMap.get(key);
|
|
@@ -272,7 +474,6 @@ class LFUCache {
|
|
|
272
474
|
if (!this._freqMap.has(freq)) this._freqMap.set(freq, new Set());
|
|
273
475
|
this._freqMap.get(freq).add(key);
|
|
274
476
|
}
|
|
275
|
-
|
|
276
477
|
_evictOne() {
|
|
277
478
|
// Recover from stale _minFreq (can happen after consecutive evictions)
|
|
278
479
|
while (this._freqMap.size > 0 && (!this._freqMap.has(this._minFreq) || this._freqMap.get(this._minFreq).size === 0)) {
|
|
@@ -293,13 +494,28 @@ class LFUCache {
|
|
|
293
494
|
if (this.warnInterval !== false) {
|
|
294
495
|
const now = Date.now();
|
|
295
496
|
if (now - this._lastWarnAt >= this.warnInterval) {
|
|
296
|
-
|
|
497
|
+
this.logger.warn(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
|
|
297
498
|
this._lastWarnAt = now;
|
|
298
499
|
}
|
|
299
500
|
}
|
|
300
501
|
}
|
|
301
502
|
}
|
|
302
503
|
|
|
504
|
+
// Upserts a fresh entry into an LFUCache. When the previous entry was only
|
|
505
|
+
// stale-by-age (mtime + size unchanged), updates in place so the existing
|
|
506
|
+
// frequency counter survives — important for popular files refreshed by maxAge.
|
|
507
|
+
// Otherwise falls back to delete + set (frequency resets to 1).
|
|
508
|
+
function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
|
|
509
|
+
const canRefreshInPlace = cached
|
|
510
|
+
&& staleByAge
|
|
511
|
+
&& cached.mtime === newEntry.mtime
|
|
512
|
+
&& cached.size === newEntry.size;
|
|
513
|
+
if (!canRefreshInPlace || !cache.refresh(key, newEntry)) {
|
|
514
|
+
if (cached) cache.delete(key);
|
|
515
|
+
cache.set(key, newEntry);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
303
519
|
module.exports = function koaClassicServer(
|
|
304
520
|
rootDir,
|
|
305
521
|
opts = {}
|
|
@@ -307,7 +523,26 @@ module.exports = function koaClassicServer(
|
|
|
307
523
|
opts STRUCTURE
|
|
308
524
|
opts = {
|
|
309
525
|
method: ['GET'], // Supported methods, otherwise next() will be called
|
|
310
|
-
|
|
526
|
+
dirListing: { // Directory listing configuration (V3+).
|
|
527
|
+
enabled: true, // Render the directory listing HTML when no index file matches.
|
|
528
|
+
// Set to false to return 404 instead of a listing.
|
|
529
|
+
maxEntries: 100000, // Soft cap on entries shown / sorted / stat'd per listing.
|
|
530
|
+
// Implementation: fs.promises.readdir() then slice(0, maxEntries).
|
|
531
|
+
// This is a SAFETY NET against catastrophic operational accidents
|
|
532
|
+
// (broken log rotation, mistakenly mounted huge FS) — not a policy
|
|
533
|
+
// restriction on what the operator can serve. 99% of legitimate
|
|
534
|
+
// deployments never hit this cap. Excess entries are not shown:
|
|
535
|
+
// a banner + the X-Dir-Truncated response header advertise the
|
|
536
|
+
// truncation. Bounds the rendering / CPU cost, NOT the size of the
|
|
537
|
+
// initial readdir() allocation.
|
|
538
|
+
// Must be a finite integer >= 0; 0 = disabled (no cap).
|
|
539
|
+
// For directories writable by untrusted parties, see the v3.1
|
|
540
|
+
// TODO [F-1] in docs/security_improvement_for_V3.md (`readMode`).
|
|
541
|
+
entriesPerPage: 100, // Entries per page in the listing UI. Pagination kicks in only
|
|
542
|
+
// when visible entries > entriesPerPage. Page index via
|
|
543
|
+
// ?page=N (0-based); out-of-range values are clamped silently.
|
|
544
|
+
// Must be a finite integer >= 0; 0 = disabled (no pagination).
|
|
545
|
+
},
|
|
311
546
|
index: ["index.html"], // Index file name(s) - must be an ARRAY:
|
|
312
547
|
// - Array of strings: ["index.html", "index.htm", "default.html"]
|
|
313
548
|
// - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
|
|
@@ -316,8 +551,13 @@ module.exports = function koaClassicServer(
|
|
|
316
551
|
urlPrefix: "", // URL path prefix
|
|
317
552
|
urlsReserved: [], // Reserved paths (first level only)
|
|
318
553
|
template: {
|
|
319
|
-
render: undefined, // Template rendering function: async (ctx, next, filePath) => {}
|
|
554
|
+
render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
|
|
320
555
|
ext: [], // File extensions to process with template.render
|
|
556
|
+
renderTimeout: 30000, // Max ms allowed for template.render (number ≥ 0; 0 = disabled).
|
|
557
|
+
// On timeout responds 504 Gateway Timeout. The render receives an
|
|
558
|
+
// AbortSignal as 5th argument; propagate it to fetch/db/fs to free
|
|
559
|
+
// backend resources. The signal also aborts on client disconnect,
|
|
560
|
+
// even when renderTimeout is 0.
|
|
321
561
|
},
|
|
322
562
|
browserCacheMaxAge: 3600, // Browser Cache-Control max-age in seconds (default: 1 hour)
|
|
323
563
|
browserCacheEnabled: false, // Enable browser HTTP caching headers (ETag, Last-Modified)
|
|
@@ -331,8 +571,10 @@ module.exports = function koaClassicServer(
|
|
|
331
571
|
redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
|
|
332
572
|
},
|
|
333
573
|
hidden: { // Block files/dirs from listing and serving (HTTP 404)
|
|
334
|
-
dotFiles: { // Dot-files (names starting with '.'):
|
|
335
|
-
default: '
|
|
574
|
+
dotFiles: { // Dot-files (names starting with '.'): visible by default — design philosophy
|
|
575
|
+
default: 'visible', // 'hidden' | 'visible' — system default: 'visible'
|
|
576
|
+
// To protect .env / .git / etc., set 'hidden' explicitly OR add to
|
|
577
|
+
// `blacklist` / `alwaysHide`. See README "Security Checklist".
|
|
336
578
|
whitelist: [], // Always visible (string exact/glob or RegExp). Overrides default and alwaysHide.
|
|
337
579
|
blacklist: [], // Always hidden (string or RegExp). Overrides whitelist.
|
|
338
580
|
},
|
|
@@ -350,21 +592,31 @@ module.exports = function koaClassicServer(
|
|
|
350
592
|
enabled: false, // enable in-memory cache of raw file buffers
|
|
351
593
|
maxSize: 52428800, // max total RAM used by this cache (bytes; default: 50 MB)
|
|
352
594
|
maxFileSize: 1048576, // files larger than this are never cached (bytes; default: 1 MB)
|
|
595
|
+
maxAge: 0, // ms after insertion to consider an entry stale; 0 = disabled.
|
|
596
|
+
// Useful on NFS/SMB/overlay FS where mtime+size may not reflect
|
|
597
|
+
// remote changes within the OS attribute-cache window. Limits but
|
|
598
|
+
// does not eliminate staleness — combine with low actimeo on the
|
|
599
|
+
// mount for stricter freshness.
|
|
353
600
|
warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
|
|
354
601
|
},
|
|
355
602
|
compressedFile: { // cache for HTTP br/gzip responses — not for .zip/.tar files on disk
|
|
356
603
|
enabled: true, // enable in-memory cache of compressed response buffers
|
|
357
604
|
maxSize: 104857600, // max total RAM used by this cache (bytes; default: 100 MB)
|
|
605
|
+
maxAge: 0, // ms after insertion to consider an entry stale; 0 = disabled. See rawFile.maxAge.
|
|
358
606
|
warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
|
|
359
607
|
},
|
|
360
608
|
},
|
|
361
609
|
compression: { // Response compression (gzip / brotli) — to enable/disable caching → serverCache.compressedFile
|
|
362
610
|
enabled: true, // master switch (false = disable all compression)
|
|
363
611
|
encodings: ['br', 'gzip'], // algorithms in priority order; [] = disable
|
|
364
|
-
|
|
612
|
+
minFileSize: 1024, // min file size in bytes to compress; false = no minimum
|
|
365
613
|
mimeTypes: [], // compressible MIME types (replaces default list if provided)
|
|
366
614
|
},
|
|
367
615
|
// compression: false // shorthand to disable all compression
|
|
616
|
+
logger: console, // Logger used for internal errors and warnings.
|
|
617
|
+
// Must expose error(...) and warn(...). Pass pino/winston/bunyan
|
|
618
|
+
// or any compatible object to integrate with aggregated logging.
|
|
619
|
+
// Default: the global console.
|
|
368
620
|
|
|
369
621
|
}
|
|
370
622
|
*/
|
|
@@ -381,8 +633,80 @@ module.exports = function koaClassicServer(
|
|
|
381
633
|
const options = opts || {};
|
|
382
634
|
options.template = opts.template || {};
|
|
383
635
|
|
|
636
|
+
const _logger = normalizeLogger(options.logger);
|
|
637
|
+
|
|
384
638
|
options.method = Array.isArray(options.method) ? options.method : ['GET'];
|
|
385
|
-
|
|
639
|
+
|
|
640
|
+
// ── V3 breaking-change guards: helpful errors for V3-alpha-only renamed options ──
|
|
641
|
+
// These were introduced in v3.0.0-alpha.0 only; no v2 user can have them in production.
|
|
642
|
+
if (opts.maxDirEntries !== undefined) {
|
|
643
|
+
throw new Error(
|
|
644
|
+
'[koa-classic-server] options.maxDirEntries was relocated in v3.0.0.\n' +
|
|
645
|
+
` Replace with: dirListing: { maxEntries: ${opts.maxDirEntries} }`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
if (opts.pageSize !== undefined) {
|
|
649
|
+
throw new Error(
|
|
650
|
+
'[koa-classic-server] options.pageSize was relocated and renamed in v3.0.0.\n' +
|
|
651
|
+
` Replace with: dirListing: { entriesPerPage: ${opts.pageSize} }`
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function validateNonNegativeInt(value, optionName, defaultValue) {
|
|
656
|
+
if (value === undefined) return defaultValue;
|
|
657
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || !Number.isInteger(value)) {
|
|
658
|
+
throw new Error(
|
|
659
|
+
`[koa-classic-server] options.${optionName} must be a non-negative integer. ` +
|
|
660
|
+
'Use 0 to disable. Got: ' + String(value)
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
return value;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ── dirListing namespace (V3+) — single source of truth for listing config ──
|
|
667
|
+
const userDirListing = opts.dirListing;
|
|
668
|
+
if (userDirListing !== undefined && (typeof userDirListing !== 'object' || userDirListing === null || Array.isArray(userDirListing))) {
|
|
669
|
+
throw new Error('[koa-classic-server] options.dirListing must be an object.');
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// V3 backward-compat alias: showDirContents (v2-stable) maps to dirListing.enabled.
|
|
673
|
+
// The alias may be removed in a future major version. Emits a one-time deprecation
|
|
674
|
+
// warning per process. Throws if both names are passed (the user picked one of them
|
|
675
|
+
// by mistake — surface the conflict rather than silently choosing one).
|
|
676
|
+
let aliasEnabled; // undefined unless showDirContents was passed
|
|
677
|
+
if (opts.showDirContents !== undefined) {
|
|
678
|
+
if (userDirListing && userDirListing.enabled !== undefined) {
|
|
679
|
+
throw new Error(
|
|
680
|
+
'[koa-classic-server] options.showDirContents and options.dirListing.enabled are both set.\n' +
|
|
681
|
+
' These configure the same thing — pick one.'
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
if (!_showDirContentsDeprecationWarned) {
|
|
685
|
+
_showDirContentsDeprecationWarned = true;
|
|
686
|
+
_logger.warn(...warnPayload(_logger,
|
|
687
|
+
'[koa-classic-server] DEPRECATION: options.showDirContents was renamed to dirListing.enabled in v3.0.0.\n' +
|
|
688
|
+
' The old name is currently accepted as an alias and may be removed in a future major version.\n' +
|
|
689
|
+
` Replace with: dirListing: { enabled: ${opts.showDirContents} }`
|
|
690
|
+
));
|
|
691
|
+
}
|
|
692
|
+
aliasEnabled = !!opts.showDirContents;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
options.dirListing = {
|
|
696
|
+
enabled: userDirListing && userDirListing.enabled !== undefined
|
|
697
|
+
? !!userDirListing.enabled
|
|
698
|
+
: (aliasEnabled !== undefined ? aliasEnabled : true),
|
|
699
|
+
maxEntries: validateNonNegativeInt(
|
|
700
|
+
userDirListing && userDirListing.maxEntries,
|
|
701
|
+
'dirListing.maxEntries',
|
|
702
|
+
10000
|
|
703
|
+
),
|
|
704
|
+
entriesPerPage: validateNonNegativeInt(
|
|
705
|
+
userDirListing && userDirListing.entriesPerPage,
|
|
706
|
+
'dirListing.entriesPerPage',
|
|
707
|
+
100
|
|
708
|
+
),
|
|
709
|
+
};
|
|
386
710
|
|
|
387
711
|
// Normalize index option to array format
|
|
388
712
|
if (typeof options.index === 'string') {
|
|
@@ -411,6 +735,19 @@ module.exports = function koaClassicServer(
|
|
|
411
735
|
options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
|
|
412
736
|
options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
|
|
413
737
|
|
|
738
|
+
if (options.template.renderTimeout === undefined) {
|
|
739
|
+
options.template.renderTimeout = 30000;
|
|
740
|
+
} else if (
|
|
741
|
+
typeof options.template.renderTimeout !== 'number' ||
|
|
742
|
+
!Number.isFinite(options.template.renderTimeout) ||
|
|
743
|
+
options.template.renderTimeout < 0
|
|
744
|
+
) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
'[koa-classic-server] template.renderTimeout must be a finite number >= 0 (ms). ' +
|
|
747
|
+
'Use 0 to disable. Got: ' + String(options.template.renderTimeout)
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
414
751
|
// v3.0.0: removed legacy option names — throw to surface the breaking change clearly
|
|
415
752
|
if ('cacheMaxAge' in opts) {
|
|
416
753
|
throw new Error(
|
|
@@ -439,13 +776,12 @@ module.exports = function koaClassicServer(
|
|
|
439
776
|
}
|
|
440
777
|
// Normalize ext: add leading dot if missing
|
|
441
778
|
if (!options.hideExtension.ext.startsWith('.')) {
|
|
442
|
-
|
|
443
|
-
'\x1b[33m%s\x1b[0m',
|
|
779
|
+
_logger.warn(...warnPayload(_logger,
|
|
444
780
|
'[koa-classic-server] WARNING: hideExtension.ext should start with a dot.\n' +
|
|
445
781
|
` Current usage: ext: "${options.hideExtension.ext}"\n` +
|
|
446
782
|
` Corrected to: ext: ".${options.hideExtension.ext}"\n` +
|
|
447
783
|
' Please update your configuration.'
|
|
448
|
-
);
|
|
784
|
+
));
|
|
449
785
|
options.hideExtension.ext = '.' + options.hideExtension.ext;
|
|
450
786
|
}
|
|
451
787
|
// Validate redirect code
|
|
@@ -462,7 +798,7 @@ module.exports = function koaClassicServer(
|
|
|
462
798
|
function normalizeHiddenConfig(hidden) {
|
|
463
799
|
if (!hidden || typeof hidden !== 'object' || Array.isArray(hidden)) {
|
|
464
800
|
return {
|
|
465
|
-
dotFiles: { default: '
|
|
801
|
+
dotFiles: { default: 'visible', whitelist: [], blacklist: [] },
|
|
466
802
|
dotDirs: { default: 'visible', whitelist: [], blacklist: [] },
|
|
467
803
|
alwaysHide: []
|
|
468
804
|
};
|
|
@@ -490,7 +826,7 @@ module.exports = function koaClassicServer(
|
|
|
490
826
|
}
|
|
491
827
|
|
|
492
828
|
return {
|
|
493
|
-
dotFiles: normalizeCategory(hidden.dotFiles, '
|
|
829
|
+
dotFiles: normalizeCategory(hidden.dotFiles, 'visible', 'dotFiles'),
|
|
494
830
|
dotDirs: normalizeCategory(hidden.dotDirs, 'visible', 'dotDirs'),
|
|
495
831
|
alwaysHide: filterPatternList(hidden.alwaysHide),
|
|
496
832
|
};
|
|
@@ -498,66 +834,53 @@ module.exports = function koaClassicServer(
|
|
|
498
834
|
|
|
499
835
|
const hiddenConfig = normalizeHiddenConfig(options.hidden);
|
|
500
836
|
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
if (!_hiddenDefaultWarnEmitted) {
|
|
506
|
-
const dotFilesImplicit = opts.hidden?.dotFiles?.default === undefined;
|
|
507
|
-
const dotDirsImplicit = opts.hidden?.dotDirs?.default === undefined;
|
|
508
|
-
if (dotFilesImplicit || dotDirsImplicit) {
|
|
509
|
-
_hiddenDefaultWarnEmitted = true;
|
|
510
|
-
console.warn(
|
|
511
|
-
'\x1b[33m%s\x1b[0m',
|
|
512
|
-
'[koa-classic-server] WARNING: hidden.dotFiles.default and/or hidden.dotDirs.default are not explicitly set.\n' +
|
|
513
|
-
' Since v3.0.0 the defaults are: dotFiles → "hidden", dotDirs → "visible".\n' +
|
|
514
|
-
' To suppress this warning, add to your configuration:\n' +
|
|
515
|
-
' hidden: { dotFiles: { default: \'hidden\' }, dotDirs: { default: \'visible\' } }\n' +
|
|
516
|
-
' (adjust values to match your desired behaviour)'
|
|
517
|
-
);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Returns true if `name` matches any pattern in the list.
|
|
522
|
-
// Patterns are matched against the bare filename (case-sensitive).
|
|
523
|
-
// Each entry can be a string (exact match or simple glob with * and ?) or a RegExp.
|
|
524
|
-
function matchesNameList(name, patterns) {
|
|
837
|
+
// Returns true if `value` matches any pattern in the list.
|
|
838
|
+
// RegExp patterns are tested directly; string patterns go through `globMatch`.
|
|
839
|
+
// Non-string non-RegExp entries are ignored (defensive — config validation should reject them).
|
|
840
|
+
function matchesPatternList(value, patterns, globMatch) {
|
|
525
841
|
for (const pattern of patterns) {
|
|
526
842
|
if (pattern instanceof RegExp) {
|
|
527
|
-
if (pattern.test(
|
|
843
|
+
if (pattern.test(value)) return true;
|
|
528
844
|
} else if (typeof pattern === 'string') {
|
|
529
|
-
if (
|
|
845
|
+
if (globMatch(value, pattern)) return true;
|
|
530
846
|
}
|
|
531
847
|
}
|
|
532
848
|
return false;
|
|
533
849
|
}
|
|
534
850
|
|
|
851
|
+
// Match against a list using filename-glob semantics (case-sensitive, no path component).
|
|
852
|
+
function matchesNameList(name, patterns) {
|
|
853
|
+
return matchesPatternList(name, patterns, nameGlobMatch);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Compiled-RegExp caches for glob patterns. Patterns come from `hidden.*` config and are
|
|
857
|
+
// immutable after factory init, so memoization is bounded by the operator's config size
|
|
858
|
+
// and avoids recompiling the same regex on every directory entry during a listing.
|
|
859
|
+
const _nameGlobRegexCache = new Map();
|
|
860
|
+
const _pathGlobRegexCache = new Map();
|
|
861
|
+
|
|
535
862
|
// Matches a bare filename against a simple glob pattern (* = any chars except /, ? = one char).
|
|
536
863
|
function nameGlobMatch(name, pattern) {
|
|
537
864
|
if (!pattern.includes('*') && !pattern.includes('?')) {
|
|
538
865
|
return name === pattern;
|
|
539
866
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
867
|
+
let re = _nameGlobRegexCache.get(pattern);
|
|
868
|
+
if (re === undefined) {
|
|
869
|
+
const regexStr = '^' +
|
|
870
|
+
pattern
|
|
871
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
872
|
+
.replace(/\*/g, '[^/]*')
|
|
873
|
+
.replace(/\?/g, '[^/]')
|
|
874
|
+
+ '$';
|
|
875
|
+
re = new RegExp(regexStr);
|
|
876
|
+
_nameGlobRegexCache.set(pattern, re);
|
|
877
|
+
}
|
|
878
|
+
return re.test(name);
|
|
547
879
|
}
|
|
548
880
|
|
|
549
|
-
//
|
|
550
|
-
// Patterns are matched against the full relative path from rootDir (case-sensitive).
|
|
551
|
-
// Each entry can be a string glob or a RegExp.
|
|
881
|
+
// Match against a list using path-aware glob semantics (anchored to rootDir, supports **).
|
|
552
882
|
function matchesPathList(relPath, patterns) {
|
|
553
|
-
|
|
554
|
-
if (pattern instanceof RegExp) {
|
|
555
|
-
if (pattern.test(relPath)) return true;
|
|
556
|
-
} else if (typeof pattern === 'string') {
|
|
557
|
-
if (pathGlobMatch(relPath, pattern)) return true;
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
return false;
|
|
883
|
+
return matchesPatternList(relPath, patterns, pathGlobMatch);
|
|
561
884
|
}
|
|
562
885
|
|
|
563
886
|
/**
|
|
@@ -569,19 +892,24 @@ module.exports = function koaClassicServer(
|
|
|
569
892
|
* - '?' matches any single character except '/'
|
|
570
893
|
*/
|
|
571
894
|
function pathGlobMatch(relPath, pattern) {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
895
|
+
let re = _pathGlobRegexCache.get(pattern);
|
|
896
|
+
if (re === undefined) {
|
|
897
|
+
const hasSlash = pattern.includes('/');
|
|
898
|
+
const escaped = pattern
|
|
899
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
900
|
+
.replace(/\*\*/g, '\x00')
|
|
901
|
+
.replace(/\*/g, '[^/]*')
|
|
902
|
+
.replace(/\?/g, '[^/]')
|
|
903
|
+
.replace(/\x00/g, '.*');
|
|
904
|
+
|
|
905
|
+
const regexStr = hasSlash
|
|
906
|
+
? '^' + escaped + '($|/)' // path-anchored from root
|
|
907
|
+
: '(^|/)' + escaped + '$'; // basename match at any depth
|
|
908
|
+
|
|
909
|
+
re = new RegExp(regexStr);
|
|
910
|
+
_pathGlobRegexCache.set(pattern, re);
|
|
911
|
+
}
|
|
912
|
+
return re.test(relPath);
|
|
585
913
|
}
|
|
586
914
|
|
|
587
915
|
/**
|
|
@@ -657,11 +985,19 @@ module.exports = function koaClassicServer(
|
|
|
657
985
|
return {
|
|
658
986
|
enabled: true,
|
|
659
987
|
encodings: ['br', 'gzip'], // priority order: brotli first, gzip as fallback
|
|
660
|
-
|
|
988
|
+
minFileSize: 1024, // bytes; skip compression for files smaller than this
|
|
661
989
|
mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
|
|
662
990
|
};
|
|
663
991
|
}
|
|
664
992
|
|
|
993
|
+
// V3 breaking-change guard: catch the v2-alpha name minSize with a helpful migration hint.
|
|
994
|
+
if (compression.minSize !== undefined) {
|
|
995
|
+
throw new Error(
|
|
996
|
+
'[koa-classic-server] options.compression.minSize was renamed in v3.0.0.\n' +
|
|
997
|
+
` Replace with: compression: { minFileSize: ${compression.minSize} }`
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
|
|
665
1001
|
const enabled = typeof compression.enabled === 'boolean' ? compression.enabled : true;
|
|
666
1002
|
if (!enabled) return { enabled: false };
|
|
667
1003
|
|
|
@@ -669,14 +1005,14 @@ module.exports = function koaClassicServer(
|
|
|
669
1005
|
? compression.encodings.filter(e => e === 'br' || e === 'gzip')
|
|
670
1006
|
: ['br', 'gzip'];
|
|
671
1007
|
|
|
672
|
-
const
|
|
673
|
-
: (typeof compression.
|
|
1008
|
+
const minFileSize = compression.minFileSize === false ? false
|
|
1009
|
+
: (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0 ? compression.minFileSize : 1024);
|
|
674
1010
|
|
|
675
1011
|
const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
|
|
676
1012
|
? compression.mimeTypes
|
|
677
1013
|
: DEFAULT_COMPRESSIBLE_MIME_TYPES;
|
|
678
1014
|
|
|
679
|
-
return { enabled, encodings,
|
|
1015
|
+
return { enabled, encodings, minFileSize, mimeTypes: new Set(mimeTypes) };
|
|
680
1016
|
}
|
|
681
1017
|
|
|
682
1018
|
// Normalize and validate the serverCache option into a clean internal structure.
|
|
@@ -685,14 +1021,27 @@ module.exports = function koaClassicServer(
|
|
|
685
1021
|
enabled: false,
|
|
686
1022
|
maxSize: 52428800, // 50 MB
|
|
687
1023
|
maxFileSize: 1048576, // 1 MB
|
|
1024
|
+
maxAge: 0,
|
|
688
1025
|
warnInterval: 60000,
|
|
689
1026
|
};
|
|
690
1027
|
const defaultCompressedFile = {
|
|
691
1028
|
enabled: true,
|
|
692
1029
|
maxSize: 104857600, // 100 MB
|
|
1030
|
+
maxAge: 0,
|
|
693
1031
|
warnInterval: 60000,
|
|
694
1032
|
};
|
|
695
1033
|
|
|
1034
|
+
function validateMaxAge(value, cacheName) {
|
|
1035
|
+
if (value === undefined) return 0;
|
|
1036
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
1037
|
+
throw new Error(
|
|
1038
|
+
`[koa-classic-server] serverCache.${cacheName}.maxAge must be a finite number >= 0 (ms). ` +
|
|
1039
|
+
'Use 0 to disable. Got: ' + String(value)
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
return value;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
696
1045
|
if (!serverCache || typeof serverCache !== 'object' || Array.isArray(serverCache)) {
|
|
697
1046
|
return { rawFile: defaultRawFile, compressedFile: defaultCompressedFile };
|
|
698
1047
|
}
|
|
@@ -702,6 +1051,7 @@ module.exports = function koaClassicServer(
|
|
|
702
1051
|
enabled: typeof rf.enabled === 'boolean' ? rf.enabled : false,
|
|
703
1052
|
maxSize: typeof rf.maxSize === 'number' && rf.maxSize > 0 ? rf.maxSize : 52428800,
|
|
704
1053
|
maxFileSize: typeof rf.maxFileSize === 'number' && rf.maxFileSize > 0 ? rf.maxFileSize : 1048576,
|
|
1054
|
+
maxAge: validateMaxAge(rf.maxAge, 'rawFile'),
|
|
705
1055
|
warnInterval: rf.warnInterval === false ? false : (typeof rf.warnInterval === 'number' ? rf.warnInterval : 60000),
|
|
706
1056
|
};
|
|
707
1057
|
|
|
@@ -709,6 +1059,7 @@ module.exports = function koaClassicServer(
|
|
|
709
1059
|
const compressedFile = (!cf || typeof cf !== 'object' || Array.isArray(cf)) ? defaultCompressedFile : {
|
|
710
1060
|
enabled: typeof cf.enabled === 'boolean' ? cf.enabled : true,
|
|
711
1061
|
maxSize: typeof cf.maxSize === 'number' && cf.maxSize > 0 ? cf.maxSize : 104857600,
|
|
1062
|
+
maxAge: validateMaxAge(cf.maxAge, 'compressedFile'),
|
|
712
1063
|
warnInterval: cf.warnInterval === false ? false : (typeof cf.warnInterval === 'number' ? cf.warnInterval : 60000),
|
|
713
1064
|
};
|
|
714
1065
|
|
|
@@ -723,7 +1074,8 @@ module.exports = function koaClassicServer(
|
|
|
723
1074
|
const _rawFileCache = new LFUCache(
|
|
724
1075
|
serverCacheConfig.rawFile.maxSize,
|
|
725
1076
|
serverCacheConfig.rawFile.warnInterval,
|
|
726
|
-
'rawFile'
|
|
1077
|
+
'rawFile',
|
|
1078
|
+
_logger
|
|
727
1079
|
);
|
|
728
1080
|
|
|
729
1081
|
// In-memory LFU cache for compressed file buffers (serverCache.compressedFile).
|
|
@@ -731,7 +1083,8 @@ module.exports = function koaClassicServer(
|
|
|
731
1083
|
const _compressedFileCache = new LFUCache(
|
|
732
1084
|
serverCacheConfig.compressedFile.maxSize,
|
|
733
1085
|
serverCacheConfig.compressedFile.warnInterval,
|
|
734
|
-
'compressedFile'
|
|
1086
|
+
'compressedFile',
|
|
1087
|
+
_logger
|
|
735
1088
|
);
|
|
736
1089
|
|
|
737
1090
|
// Returns the client's preferred encoding based on Accept-Encoding header,
|
|
@@ -745,23 +1098,14 @@ module.exports = function koaClassicServer(
|
|
|
745
1098
|
}
|
|
746
1099
|
|
|
747
1100
|
// Compress a Buffer using the given encoding ('br' or 'gzip').
|
|
748
|
-
//
|
|
1101
|
+
// Quality is maxed out: serverCache pays this cost once per file, not per request.
|
|
749
1102
|
function compressBuffer(data, encoding) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
zlib.
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
);
|
|
757
|
-
} else {
|
|
758
|
-
zlib.gzip(
|
|
759
|
-
data,
|
|
760
|
-
{ level: zlib.constants.Z_BEST_COMPRESSION },
|
|
761
|
-
(err, result) => { if (err) reject(err); else resolve(result); }
|
|
762
|
-
);
|
|
763
|
-
}
|
|
764
|
-
});
|
|
1103
|
+
if (encoding === 'br') {
|
|
1104
|
+
return _brotliCompressAsync(data, {
|
|
1105
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
return _gzipAsync(data, { level: zlib.constants.Z_BEST_COMPRESSION });
|
|
765
1109
|
}
|
|
766
1110
|
|
|
767
1111
|
/**
|
|
@@ -816,7 +1160,7 @@ module.exports = function koaClassicServer(
|
|
|
816
1160
|
);
|
|
817
1161
|
fileNames = checkResults.filter(e => e.isFile).map(e => e.name);
|
|
818
1162
|
} catch (error) {
|
|
819
|
-
|
|
1163
|
+
_logger.error('Error finding index file:', error);
|
|
820
1164
|
return null;
|
|
821
1165
|
}
|
|
822
1166
|
}
|
|
@@ -909,7 +1253,9 @@ module.exports = function koaClassicServer(
|
|
|
909
1253
|
return;
|
|
910
1254
|
}
|
|
911
1255
|
|
|
912
|
-
// Hidden check: block requests that traverse a hidden directory
|
|
1256
|
+
// Hidden check: block requests that traverse a hidden directory.
|
|
1257
|
+
// Stops at length-1 because the leaf (the file or dir being served) is
|
|
1258
|
+
// checked separately by the file/listing path with its real stat.isDirectory().
|
|
913
1259
|
if (requestedPath !== '') {
|
|
914
1260
|
const segments = normalizedPath.split(path.sep).filter(Boolean);
|
|
915
1261
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
@@ -933,14 +1279,23 @@ module.exports = function koaClassicServer(
|
|
|
933
1279
|
const rawPath = urlToUse.split('?')[0];
|
|
934
1280
|
const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
|
|
935
1281
|
|
|
936
|
-
//
|
|
937
|
-
//
|
|
1282
|
+
// Strip a trailing slash before the extension check so URLs like /foo.html/
|
|
1283
|
+
// still match (the slash is URL formality, not part of the filename) — without
|
|
1284
|
+
// this, /foo.html/ would skip the redirect and 404 trying to open it as a dir.
|
|
938
1285
|
const pathForExtCheck = hadTrailingSlash ? rawPath.slice(0, -1) : requestedPath;
|
|
939
1286
|
if (pathForExtCheck.endsWith(hideExt)) {
|
|
940
1287
|
// Build redirect target using ctx.originalUrl (always, regardless of useOriginalUrl)
|
|
941
1288
|
const originalUrlObj = new URL(_origin + ctx.originalUrl);
|
|
942
1289
|
let redirectPath = originalUrlObj.pathname;
|
|
943
1290
|
|
|
1291
|
+
// Collapse leading slashes: a Location header starting with "//" (or "/\")
|
|
1292
|
+
// is a protocol-relative URL and would let "GET //evil.com/foo.ejs" redirect
|
|
1293
|
+
// off-origin. path.normalize() upstream already collapses these for the
|
|
1294
|
+
// filesystem check, so the source-of-truth URL has a single leading slash.
|
|
1295
|
+
if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
|
|
1296
|
+
redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
|
|
1297
|
+
}
|
|
1298
|
+
|
|
944
1299
|
redirectPath = redirectPath.slice(0, redirectPath.length - hideExt.length);
|
|
945
1300
|
|
|
946
1301
|
// Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
|
|
@@ -1007,7 +1362,7 @@ module.exports = function koaClassicServer(
|
|
|
1007
1362
|
|
|
1008
1363
|
if (stat.isDirectory()) {
|
|
1009
1364
|
// Handle directory
|
|
1010
|
-
if (options.
|
|
1365
|
+
if (options.dirListing.enabled) {
|
|
1011
1366
|
// Search for index file matching configured patterns
|
|
1012
1367
|
if (options.index && options.index.length > 0) {
|
|
1013
1368
|
const indexFile = await findIndexFile(toOpen, options.index);
|
|
@@ -1042,7 +1397,7 @@ module.exports = function koaClassicServer(
|
|
|
1042
1397
|
try {
|
|
1043
1398
|
fileStat = await fs.promises.stat(toOpen);
|
|
1044
1399
|
} catch (error) {
|
|
1045
|
-
|
|
1400
|
+
_logger.error('File stat error:', error);
|
|
1046
1401
|
sendNotFound(ctx);
|
|
1047
1402
|
return;
|
|
1048
1403
|
}
|
|
@@ -1053,54 +1408,32 @@ module.exports = function koaClassicServer(
|
|
|
1053
1408
|
let rawBuffer = null;
|
|
1054
1409
|
if (serverCacheConfig.rawFile.enabled && fileStat.size <= serverCacheConfig.rawFile.maxFileSize) {
|
|
1055
1410
|
const cached = _rawFileCache.peek(toOpen);
|
|
1056
|
-
|
|
1411
|
+
const maxAge = serverCacheConfig.rawFile.maxAge;
|
|
1412
|
+
const staleByAge = maxAge > 0 && cached && (Date.now() - cached.insertedAt) >= maxAge;
|
|
1413
|
+
const fresh = cached
|
|
1414
|
+
&& cached.mtime === fileStat.mtime.getTime()
|
|
1415
|
+
&& cached.size === fileStat.size
|
|
1416
|
+
&& !staleByAge;
|
|
1417
|
+
if (fresh) {
|
|
1057
1418
|
_rawFileCache.get(toOpen); // increment frequency
|
|
1058
1419
|
rawBuffer = cached.buffer;
|
|
1059
1420
|
} else {
|
|
1060
1421
|
try {
|
|
1061
1422
|
rawBuffer = await fs.promises.readFile(toOpen);
|
|
1062
|
-
|
|
1063
|
-
_rawFileCache.set(toOpen, {
|
|
1423
|
+
refreshOrInsert(_rawFileCache, toOpen, {
|
|
1064
1424
|
buffer: rawBuffer,
|
|
1065
1425
|
mtime: fileStat.mtime.getTime(),
|
|
1066
1426
|
size: fileStat.size,
|
|
1067
|
-
|
|
1427
|
+
insertedAt: Date.now(),
|
|
1428
|
+
}, cached, staleByAge);
|
|
1068
1429
|
} catch {
|
|
1069
1430
|
rawBuffer = null; // Fall through to disk reads later
|
|
1070
1431
|
}
|
|
1071
1432
|
}
|
|
1072
1433
|
}
|
|
1073
1434
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
if (options.template.ext.length > 0 && options.template.render) {
|
|
1077
|
-
const fileExt = path.extname(toOpen).slice(1); // Remove leading dot
|
|
1078
|
-
|
|
1079
|
-
if (fileExt && options.template.ext.includes(fileExt)) {
|
|
1080
|
-
try {
|
|
1081
|
-
await options.template.render(ctx, next, toOpen, rawBuffer);
|
|
1082
|
-
return;
|
|
1083
|
-
} catch (error) {
|
|
1084
|
-
console.error('Template rendering error:', error);
|
|
1085
|
-
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
1086
|
-
ctx.status = 500;
|
|
1087
|
-
ctx.body = `
|
|
1088
|
-
<!DOCTYPE html>
|
|
1089
|
-
<html>
|
|
1090
|
-
<head>
|
|
1091
|
-
<meta charset="UTF-8">
|
|
1092
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1093
|
-
<title>Internal Server Error</title>
|
|
1094
|
-
</head>
|
|
1095
|
-
<body>
|
|
1096
|
-
<h1>Internal Server Error</h1>
|
|
1097
|
-
<h3>Template rendering failed for the requested resource.</h3>
|
|
1098
|
-
</body>
|
|
1099
|
-
</html>
|
|
1100
|
-
`;
|
|
1101
|
-
return;
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1435
|
+
if (await tryRenderTemplate(ctx, next, toOpen, rawBuffer, options.template, _logger)) {
|
|
1436
|
+
return;
|
|
1104
1437
|
}
|
|
1105
1438
|
|
|
1106
1439
|
// baseEtag — encoding-independent; used only for If-Range (Range requests skip compression)
|
|
@@ -1125,7 +1458,7 @@ module.exports = function koaClassicServer(
|
|
|
1125
1458
|
try {
|
|
1126
1459
|
await fs.promises.access(toOpen, fs.constants.R_OK);
|
|
1127
1460
|
} catch (error) {
|
|
1128
|
-
|
|
1461
|
+
_logger.error('File access error:', error);
|
|
1129
1462
|
sendNotFound(ctx);
|
|
1130
1463
|
return;
|
|
1131
1464
|
}
|
|
@@ -1166,7 +1499,7 @@ module.exports = function koaClassicServer(
|
|
|
1166
1499
|
} else {
|
|
1167
1500
|
const src = fs.createReadStream(toOpen, { start, end });
|
|
1168
1501
|
src.on('error', (err) => {
|
|
1169
|
-
|
|
1502
|
+
_logger.error('Stream error:', err);
|
|
1170
1503
|
if (!ctx.headerSent) {
|
|
1171
1504
|
ctx.status = 500;
|
|
1172
1505
|
ctx.body = 'Error reading file';
|
|
@@ -1191,12 +1524,12 @@ module.exports = function koaClassicServer(
|
|
|
1191
1524
|
const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
|
|
1192
1525
|
const filename = path.basename(toOpen);
|
|
1193
1526
|
|
|
1194
|
-
// Resolve compression: enabled + compressible MIME + meets
|
|
1527
|
+
// Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
|
|
1195
1528
|
let encoding = null; // 'br' | 'gzip' | null
|
|
1196
1529
|
if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
|
|
1197
1530
|
const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
|
|
1198
|
-
const meetsMinSize = compressionConfig.
|
|
1199
|
-
|| fileStat.size >= compressionConfig.
|
|
1531
|
+
const meetsMinSize = compressionConfig.minFileSize === false
|
|
1532
|
+
|| fileStat.size >= compressionConfig.minFileSize;
|
|
1200
1533
|
if (isCompressibleMime && meetsMinSize) {
|
|
1201
1534
|
encoding = getClientEncoding(ctx.get('Accept-Encoding'));
|
|
1202
1535
|
}
|
|
@@ -1243,9 +1576,12 @@ module.exports = function koaClassicServer(
|
|
|
1243
1576
|
// compressedFile cache mode: compress once → buffer in RAM → Content-Length known
|
|
1244
1577
|
const cacheKey = `${toOpen}:${encoding}`;
|
|
1245
1578
|
const cached = _compressedFileCache.peek(cacheKey);
|
|
1579
|
+
const maxAge = serverCacheConfig.compressedFile.maxAge;
|
|
1580
|
+
const staleByAge = maxAge > 0 && cached && (Date.now() - cached.insertedAt) >= maxAge;
|
|
1246
1581
|
const stale = !cached
|
|
1247
1582
|
|| cached.mtime !== fileStat.mtime.getTime()
|
|
1248
|
-
|| cached.size !== fileStat.size
|
|
1583
|
+
|| cached.size !== fileStat.size
|
|
1584
|
+
|| staleByAge;
|
|
1249
1585
|
|
|
1250
1586
|
let buf;
|
|
1251
1587
|
if (!stale) {
|
|
@@ -1257,14 +1593,14 @@ module.exports = function koaClassicServer(
|
|
|
1257
1593
|
const rawData = rawBuffer || await fs.promises.readFile(toOpen);
|
|
1258
1594
|
buf = await compressBuffer(rawData, encoding);
|
|
1259
1595
|
|
|
1260
|
-
|
|
1261
|
-
_compressedFileCache.set(cacheKey, {
|
|
1596
|
+
refreshOrInsert(_compressedFileCache, cacheKey, {
|
|
1262
1597
|
buffer: buf,
|
|
1263
1598
|
mtime: fileStat.mtime.getTime(),
|
|
1264
1599
|
size: fileStat.size,
|
|
1265
|
-
|
|
1600
|
+
insertedAt: Date.now(),
|
|
1601
|
+
}, cached, staleByAge);
|
|
1266
1602
|
} catch (err) {
|
|
1267
|
-
|
|
1603
|
+
_logger.error('Compression error:', err);
|
|
1268
1604
|
// Fall back to uncompressed on any compression failure
|
|
1269
1605
|
ctx.remove('Content-Encoding');
|
|
1270
1606
|
ctx.remove('Vary');
|
|
@@ -1281,7 +1617,7 @@ module.exports = function koaClassicServer(
|
|
|
1281
1617
|
if (ctx.method !== 'HEAD') {
|
|
1282
1618
|
const src = fs.createReadStream(toOpen);
|
|
1283
1619
|
src.on('error', (streamErr) => {
|
|
1284
|
-
|
|
1620
|
+
_logger.error('Stream error:', streamErr);
|
|
1285
1621
|
if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
|
|
1286
1622
|
});
|
|
1287
1623
|
ctx.body = src;
|
|
@@ -1316,7 +1652,7 @@ module.exports = function koaClassicServer(
|
|
|
1316
1652
|
} else {
|
|
1317
1653
|
const src = fs.createReadStream(toOpen);
|
|
1318
1654
|
src.on('error', (err) => {
|
|
1319
|
-
|
|
1655
|
+
_logger.error('Stream error:', err);
|
|
1320
1656
|
if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
|
|
1321
1657
|
});
|
|
1322
1658
|
ctx.body = src.pipe(compress);
|
|
@@ -1341,7 +1677,7 @@ module.exports = function koaClassicServer(
|
|
|
1341
1677
|
if (ctx.method !== 'HEAD') {
|
|
1342
1678
|
const src = fs.createReadStream(toOpen);
|
|
1343
1679
|
src.on('error', (err) => {
|
|
1344
|
-
|
|
1680
|
+
_logger.error('Stream error:', err);
|
|
1345
1681
|
if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
|
|
1346
1682
|
});
|
|
1347
1683
|
ctx.body = src;
|
|
@@ -1356,11 +1692,23 @@ module.exports = function koaClassicServer(
|
|
|
1356
1692
|
|
|
1357
1693
|
|
|
1358
1694
|
async function show_dir(toOpen, ctx) {
|
|
1695
|
+
// Read the full directory in one syscall, then cap the result.
|
|
1696
|
+
// `dirListing.maxEntries` bounds the visible / sorted / stat'd entries, but
|
|
1697
|
+
// does NOT bound the size of the initial readdir() allocation — see the
|
|
1698
|
+
// adversarial-directory caveat tracked for v3.1 [F-1].
|
|
1699
|
+
const maxDirEntries = options.dirListing.maxEntries; // 0 = disabled (no cap)
|
|
1359
1700
|
let dir;
|
|
1701
|
+
let truncated = false;
|
|
1360
1702
|
try {
|
|
1361
|
-
|
|
1703
|
+
const all = await fs.promises.readdir(toOpen, { withFileTypes: true });
|
|
1704
|
+
if (maxDirEntries > 0 && all.length > maxDirEntries) {
|
|
1705
|
+
truncated = true;
|
|
1706
|
+
dir = all.slice(0, maxDirEntries);
|
|
1707
|
+
} else {
|
|
1708
|
+
dir = all;
|
|
1709
|
+
}
|
|
1362
1710
|
} catch (error) {
|
|
1363
|
-
|
|
1711
|
+
_logger.error('Directory read error:', error);
|
|
1364
1712
|
ctx.status = 500;
|
|
1365
1713
|
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
1366
1714
|
return `
|
|
@@ -1386,10 +1734,17 @@ module.exports = function koaClassicServer(
|
|
|
1386
1734
|
const sortBy = ctx.query.sort || 'name';
|
|
1387
1735
|
const sortOrder = ctx.query.order || 'asc';
|
|
1388
1736
|
|
|
1389
|
-
// Build base URL for sorting links (without query params)
|
|
1390
1737
|
const baseUrl = pageHrefOutPrefix.pathname;
|
|
1391
1738
|
|
|
1392
|
-
//
|
|
1739
|
+
// Preserves sort/order while overriding `page`; omits page when 0.
|
|
1740
|
+
function buildQueryUrl(targetPage) {
|
|
1741
|
+
const params = [];
|
|
1742
|
+
if (ctx.query.sort) params.push(`sort=${encodeURIComponent(ctx.query.sort)}`);
|
|
1743
|
+
if (ctx.query.order) params.push(`order=${encodeURIComponent(ctx.query.order)}`);
|
|
1744
|
+
if (targetPage > 0) params.push(`page=${targetPage}`);
|
|
1745
|
+
return params.length ? `${baseUrl}?${params.join('&')}` : baseUrl;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1393
1748
|
function getSortUrl(column) {
|
|
1394
1749
|
let newOrder = 'asc';
|
|
1395
1750
|
if (sortBy === column && sortOrder === 'asc') {
|
|
@@ -1398,7 +1753,6 @@ module.exports = function koaClassicServer(
|
|
|
1398
1753
|
return `${baseUrl}?sort=${column}&order=${newOrder}`;
|
|
1399
1754
|
}
|
|
1400
1755
|
|
|
1401
|
-
// Helper to get sort indicator
|
|
1402
1756
|
function getSortIndicator(column) {
|
|
1403
1757
|
if (sortBy === column) {
|
|
1404
1758
|
return sortOrder === 'asc' ? ' ↑' : ' ↓';
|
|
@@ -1407,6 +1761,12 @@ module.exports = function koaClassicServer(
|
|
|
1407
1761
|
}
|
|
1408
1762
|
|
|
1409
1763
|
const parts = [];
|
|
1764
|
+
let totalPages = 1; // populated in the non-empty branch below
|
|
1765
|
+
let currentPage = 0;
|
|
1766
|
+
if (truncated) {
|
|
1767
|
+
parts.push(`<div class="kcs-banner">⚠ Showing first ${maxDirEntries} entries (cap reached). More files exist but are not listed. Adjust <code>dirListing.maxEntries</code> to see more.</div>`);
|
|
1768
|
+
ctx.set('X-Dir-Truncated', `${maxDirEntries}`);
|
|
1769
|
+
}
|
|
1410
1770
|
parts.push("<table>");
|
|
1411
1771
|
parts.push("<thead>");
|
|
1412
1772
|
parts.push("<tr>");
|
|
@@ -1514,37 +1874,45 @@ module.exports = function koaClassicServer(
|
|
|
1514
1874
|
}
|
|
1515
1875
|
const items = rawItems.filter(Boolean);
|
|
1516
1876
|
|
|
1517
|
-
//
|
|
1877
|
+
// Places directories before non-directories; falls back to `tieBreaker`
|
|
1878
|
+
// when both items are in the same bucket. `effectiveType === 2` covers plain
|
|
1879
|
+
// dirs and dir-resolved symlinks, matching the rest of the listing logic.
|
|
1880
|
+
const compareDirsFirst = (a, b, tieBreaker) => {
|
|
1881
|
+
const aIsDir = a.effectiveType === 2;
|
|
1882
|
+
const bIsDir = b.effectiveType === 2;
|
|
1883
|
+
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
|
|
1884
|
+
return tieBreaker(a, b);
|
|
1885
|
+
};
|
|
1886
|
+
|
|
1518
1887
|
items.sort((a, b) => {
|
|
1519
1888
|
let comparison = 0;
|
|
1520
1889
|
|
|
1521
1890
|
if (sortBy === 'name') {
|
|
1522
1891
|
comparison = a.name.localeCompare(b.name);
|
|
1523
1892
|
} else if (sortBy === 'type') {
|
|
1524
|
-
|
|
1525
|
-
if (a.effectiveType === 2 && b.effectiveType !== 2) {
|
|
1526
|
-
comparison = -1;
|
|
1527
|
-
} else if (a.effectiveType !== 2 && b.effectiveType === 2) {
|
|
1528
|
-
comparison = 1;
|
|
1529
|
-
} else {
|
|
1530
|
-
comparison = a.mimeType.localeCompare(b.mimeType);
|
|
1531
|
-
}
|
|
1893
|
+
comparison = compareDirsFirst(a, b, (x, y) => x.mimeType.localeCompare(y.mimeType));
|
|
1532
1894
|
} else if (sortBy === 'size') {
|
|
1533
|
-
|
|
1534
|
-
if (a.effectiveType === 2 && b.effectiveType !== 2) {
|
|
1535
|
-
comparison = -1;
|
|
1536
|
-
} else if (a.effectiveType !== 2 && b.effectiveType === 2) {
|
|
1537
|
-
comparison = 1;
|
|
1538
|
-
} else {
|
|
1539
|
-
comparison = a.sizeBytes - b.sizeBytes;
|
|
1540
|
-
}
|
|
1895
|
+
comparison = compareDirsFirst(a, b, (x, y) => x.sizeBytes - y.sizeBytes);
|
|
1541
1896
|
}
|
|
1542
1897
|
|
|
1543
1898
|
return sortOrder === 'desc' ? -comparison : comparison;
|
|
1544
1899
|
});
|
|
1545
1900
|
|
|
1901
|
+
// Pagination — slice the sorted items into the requested page (0-based).
|
|
1902
|
+
const pageSize = options.dirListing.entriesPerPage; // 0 disables pagination
|
|
1903
|
+
totalPages = pageSize > 0 ? Math.max(1, Math.ceil(items.length / pageSize)) : 1;
|
|
1904
|
+
const rawPage = parseInt(ctx.query.page, 10);
|
|
1905
|
+
const requestedPage = Number.isFinite(rawPage) && rawPage >= 0 ? rawPage : 0;
|
|
1906
|
+
currentPage = Math.min(requestedPage, totalPages - 1); // silent clamp
|
|
1907
|
+
const visibleItems = (pageSize > 0 && items.length > pageSize)
|
|
1908
|
+
? items.slice(currentPage * pageSize, (currentPage + 1) * pageSize)
|
|
1909
|
+
: items;
|
|
1910
|
+
if (totalPages > 1) {
|
|
1911
|
+
ctx.set('X-Dir-Pagination', `${currentPage}/${totalPages - 1}`);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1546
1914
|
// Generate HTML for sorted items
|
|
1547
|
-
for (const item of
|
|
1915
|
+
for (const item of visibleItems) {
|
|
1548
1916
|
let rowStart = '';
|
|
1549
1917
|
if (item.effectiveType === 1) {
|
|
1550
1918
|
rowStart = `<tr><td> FILE `;
|
|
@@ -1573,6 +1941,47 @@ module.exports = function koaClassicServer(
|
|
|
1573
1941
|
parts.push("</tbody>");
|
|
1574
1942
|
parts.push("</table>");
|
|
1575
1943
|
|
|
1944
|
+
// Numbered paginator with First/Prev/Next/Last and ellipsis around the current page.
|
|
1945
|
+
// Only emitted when pagination is meaningful (computed above; reuses currentPage/totalPages).
|
|
1946
|
+
if (totalPages > 1) {
|
|
1947
|
+
const pageWindow = 2;
|
|
1948
|
+
const pagesToShow = new Set([0, totalPages - 1]);
|
|
1949
|
+
for (let i = Math.max(0, currentPage - pageWindow); i <= Math.min(totalPages - 1, currentPage + pageWindow); i++) {
|
|
1950
|
+
pagesToShow.add(i);
|
|
1951
|
+
}
|
|
1952
|
+
const sortedPages = [...pagesToShow].sort((a, b) => a - b);
|
|
1953
|
+
|
|
1954
|
+
const pager = ['<nav class="kcs-pagination" aria-label="Pagination">'];
|
|
1955
|
+
if (currentPage > 0) {
|
|
1956
|
+
pager.push(`<a href="${escapeHtml(buildQueryUrl(0))}">« First</a>`);
|
|
1957
|
+
pager.push(`<a href="${escapeHtml(buildQueryUrl(currentPage - 1))}">‹ Prev</a>`);
|
|
1958
|
+
} else {
|
|
1959
|
+
pager.push(`<span class="kcs-page-disabled">« First</span>`);
|
|
1960
|
+
pager.push(`<span class="kcs-page-disabled">‹ Prev</span>`);
|
|
1961
|
+
}
|
|
1962
|
+
let prev = -1;
|
|
1963
|
+
for (const p of sortedPages) {
|
|
1964
|
+
if (prev !== -1 && p - prev > 1) {
|
|
1965
|
+
pager.push(`<span class="kcs-page-ellipsis">…</span>`);
|
|
1966
|
+
}
|
|
1967
|
+
if (p === currentPage) {
|
|
1968
|
+
pager.push(`<span class="kcs-page-current">${p}</span>`);
|
|
1969
|
+
} else {
|
|
1970
|
+
pager.push(`<a href="${escapeHtml(buildQueryUrl(p))}">${p}</a>`);
|
|
1971
|
+
}
|
|
1972
|
+
prev = p;
|
|
1973
|
+
}
|
|
1974
|
+
if (currentPage < totalPages - 1) {
|
|
1975
|
+
pager.push(`<a href="${escapeHtml(buildQueryUrl(currentPage + 1))}">Next ›</a>`);
|
|
1976
|
+
pager.push(`<a href="${escapeHtml(buildQueryUrl(totalPages - 1))}">Last »</a>`);
|
|
1977
|
+
} else {
|
|
1978
|
+
pager.push(`<span class="kcs-page-disabled">Next ›</span>`);
|
|
1979
|
+
pager.push(`<span class="kcs-page-disabled">Last »</span>`);
|
|
1980
|
+
}
|
|
1981
|
+
pager.push('</nav>');
|
|
1982
|
+
parts.push(pager.join(''));
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1576
1985
|
const tableHtml = parts.join('');
|
|
1577
1986
|
|
|
1578
1987
|
const html = `
|