koa-classic-server 3.0.0-alpha.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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__/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 +209 -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 +551 -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,108 @@ 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
|
+
// Attempts to render the requested file through the user's template engine.
|
|
202
|
+
// Returns true if the request was handled (success, timeout, or error response
|
|
203
|
+
// already written), false if no template applies (caller should continue with
|
|
204
|
+
// normal file serving).
|
|
205
|
+
//
|
|
206
|
+
// The render function is invoked with (ctx, next, filePath, rawBuffer, signal).
|
|
207
|
+
// The signal aborts on timeout (when templateOpts.renderTimeout > 0) and on
|
|
208
|
+
// client disconnect. Cooperative renders that propagate the signal to fetch/db
|
|
209
|
+
// release backend resources promptly; non-cooperative renders still get a 504
|
|
210
|
+
// response, but their work continues in the background.
|
|
211
|
+
async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger) {
|
|
212
|
+
if (templateOpts.ext.length === 0 || !templateOpts.render) return false;
|
|
213
|
+
|
|
214
|
+
const fileExt = path.extname(filePath).slice(1);
|
|
215
|
+
if (!fileExt || !templateOpts.ext.includes(fileExt)) return false;
|
|
216
|
+
|
|
217
|
+
const controller = new AbortController();
|
|
218
|
+
const onClientClose = () => controller.abort();
|
|
219
|
+
ctx.req.on('close', onClientClose);
|
|
220
|
+
|
|
221
|
+
const timeoutMs = templateOpts.renderTimeout;
|
|
222
|
+
let timer = null;
|
|
223
|
+
let timedOut = false;
|
|
224
|
+
|
|
225
|
+
const renderPromise = Promise.resolve().then(() =>
|
|
226
|
+
templateOpts.render(ctx, next, filePath, rawBuffer, controller.signal)
|
|
227
|
+
);
|
|
228
|
+
renderPromise.catch(() => {}); // swallow rejections that arrive after we've already responded
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
if (timeoutMs > 0) {
|
|
232
|
+
await Promise.race([
|
|
233
|
+
renderPromise,
|
|
234
|
+
new Promise((_, reject) => {
|
|
235
|
+
timer = setTimeout(() => {
|
|
236
|
+
timedOut = true;
|
|
237
|
+
controller.abort();
|
|
238
|
+
const err = new Error('Template render timeout');
|
|
239
|
+
err.code = 'ETEMPLATETIMEOUT';
|
|
240
|
+
reject(err);
|
|
241
|
+
}, timeoutMs);
|
|
242
|
+
})
|
|
243
|
+
]);
|
|
244
|
+
} else {
|
|
245
|
+
await renderPromise;
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (timedOut || error.code === 'ETEMPLATETIMEOUT') {
|
|
249
|
+
sendTemplateError(ctx, 504, _GATEWAY_TIMEOUT_HTML,
|
|
250
|
+
'Template render timeout after ' + timeoutMs + 'ms:', filePath, logger);
|
|
251
|
+
} else {
|
|
252
|
+
sendTemplateError(ctx, 500, _TEMPLATE_ERROR_HTML,
|
|
253
|
+
'Template rendering error:', error, logger);
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
if (timer) clearTimeout(timer);
|
|
257
|
+
ctx.req.removeListener('close', onClientClose);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
118
263
|
// Single-pass HTML escaping — one regex scan, one allocation, lookup table compiled once.
|
|
119
264
|
const _HTML_ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
120
265
|
const _HTML_ESCAPE_RE = /[&<>"']/g;
|
|
@@ -204,12 +349,13 @@ function parseRangeHeader(rangeHeader, fileSize) {
|
|
|
204
349
|
// set(key, entry) — insert, evicting LFU entries if needed
|
|
205
350
|
// delete(key) — remove explicitly (e.g. stale entry before re-insert)
|
|
206
351
|
class LFUCache {
|
|
207
|
-
constructor(maxSize, warnInterval, cacheLabel) {
|
|
352
|
+
constructor(maxSize, warnInterval, cacheLabel, logger) {
|
|
208
353
|
this.maxSize = maxSize;
|
|
209
354
|
this.warnInterval = warnInterval;
|
|
210
355
|
this.cacheLabel = cacheLabel;
|
|
356
|
+
this.logger = logger || console;
|
|
211
357
|
this.currentSize = 0;
|
|
212
|
-
this._keyMap = new Map(); // key → { buffer, mtime, size, freq }
|
|
358
|
+
this._keyMap = new Map(); // key → { buffer, mtime, size, insertedAt, freq }
|
|
213
359
|
this._freqMap = new Map(); // freq → Set<key>
|
|
214
360
|
this._minFreq = 0;
|
|
215
361
|
this._lastWarnAt = 0;
|
|
@@ -241,6 +387,26 @@ class LFUCache {
|
|
|
241
387
|
this._minFreq = 1;
|
|
242
388
|
}
|
|
243
389
|
|
|
390
|
+
// In-place update of an existing entry that preserves its current frequency.
|
|
391
|
+
// Used when refreshing a stale-by-maxAge entry so popular files don't fall to
|
|
392
|
+
// the bottom of the LFU bucket just because they got re-read from disk.
|
|
393
|
+
// Returns true on success, false if the new buffer doesn't fit in maxSize
|
|
394
|
+
// (caller can fall back to delete + set in that case).
|
|
395
|
+
refresh(key, fields) {
|
|
396
|
+
const entry = this._keyMap.get(key);
|
|
397
|
+
if (!entry) return false;
|
|
398
|
+
|
|
399
|
+
const sizeDelta = fields.buffer.length - entry.buffer.length;
|
|
400
|
+
if (this.currentSize + sizeDelta > this.maxSize) return false;
|
|
401
|
+
|
|
402
|
+
entry.buffer = fields.buffer;
|
|
403
|
+
if (fields.mtime !== undefined) entry.mtime = fields.mtime;
|
|
404
|
+
if (fields.size !== undefined) entry.size = fields.size;
|
|
405
|
+
if (fields.insertedAt !== undefined) entry.insertedAt = fields.insertedAt;
|
|
406
|
+
this.currentSize += sizeDelta;
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
244
410
|
delete(key) {
|
|
245
411
|
if (!this._keyMap.has(key)) return;
|
|
246
412
|
const { freq, buffer } = this._keyMap.get(key);
|
|
@@ -272,7 +438,6 @@ class LFUCache {
|
|
|
272
438
|
if (!this._freqMap.has(freq)) this._freqMap.set(freq, new Set());
|
|
273
439
|
this._freqMap.get(freq).add(key);
|
|
274
440
|
}
|
|
275
|
-
|
|
276
441
|
_evictOne() {
|
|
277
442
|
// Recover from stale _minFreq (can happen after consecutive evictions)
|
|
278
443
|
while (this._freqMap.size > 0 && (!this._freqMap.has(this._minFreq) || this._freqMap.get(this._minFreq).size === 0)) {
|
|
@@ -293,13 +458,28 @@ class LFUCache {
|
|
|
293
458
|
if (this.warnInterval !== false) {
|
|
294
459
|
const now = Date.now();
|
|
295
460
|
if (now - this._lastWarnAt >= this.warnInterval) {
|
|
296
|
-
|
|
461
|
+
this.logger.warn(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
|
|
297
462
|
this._lastWarnAt = now;
|
|
298
463
|
}
|
|
299
464
|
}
|
|
300
465
|
}
|
|
301
466
|
}
|
|
302
467
|
|
|
468
|
+
// Upserts a fresh entry into an LFUCache. When the previous entry was only
|
|
469
|
+
// stale-by-age (mtime + size unchanged), updates in place so the existing
|
|
470
|
+
// frequency counter survives — important for popular files refreshed by maxAge.
|
|
471
|
+
// Otherwise falls back to delete + set (frequency resets to 1).
|
|
472
|
+
function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
|
|
473
|
+
const canRefreshInPlace = cached
|
|
474
|
+
&& staleByAge
|
|
475
|
+
&& cached.mtime === newEntry.mtime
|
|
476
|
+
&& cached.size === newEntry.size;
|
|
477
|
+
if (!canRefreshInPlace || !cache.refresh(key, newEntry)) {
|
|
478
|
+
if (cached) cache.delete(key);
|
|
479
|
+
cache.set(key, newEntry);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
303
483
|
module.exports = function koaClassicServer(
|
|
304
484
|
rootDir,
|
|
305
485
|
opts = {}
|
|
@@ -307,7 +487,26 @@ module.exports = function koaClassicServer(
|
|
|
307
487
|
opts STRUCTURE
|
|
308
488
|
opts = {
|
|
309
489
|
method: ['GET'], // Supported methods, otherwise next() will be called
|
|
310
|
-
|
|
490
|
+
dirListing: { // Directory listing configuration (V3+).
|
|
491
|
+
enabled: true, // Render the directory listing HTML when no index file matches.
|
|
492
|
+
// Set to false to return 404 instead of a listing.
|
|
493
|
+
maxEntries: 100000, // Soft cap on entries shown / sorted / stat'd per listing.
|
|
494
|
+
// Implementation: fs.promises.readdir() then slice(0, maxEntries).
|
|
495
|
+
// This is a SAFETY NET against catastrophic operational accidents
|
|
496
|
+
// (broken log rotation, mistakenly mounted huge FS) — not a policy
|
|
497
|
+
// restriction on what the operator can serve. 99% of legitimate
|
|
498
|
+
// deployments never hit this cap. Excess entries are not shown:
|
|
499
|
+
// a banner + the X-Dir-Truncated response header advertise the
|
|
500
|
+
// truncation. Bounds the rendering / CPU cost, NOT the size of the
|
|
501
|
+
// initial readdir() allocation.
|
|
502
|
+
// Must be a finite integer >= 0; 0 = disabled (no cap).
|
|
503
|
+
// For directories writable by untrusted parties, see the v3.1
|
|
504
|
+
// TODO [F-1] in docs/security_improvement_for_V3.md (`readMode`).
|
|
505
|
+
entriesPerPage: 100, // Entries per page in the listing UI. Pagination kicks in only
|
|
506
|
+
// when visible entries > entriesPerPage. Page index via
|
|
507
|
+
// ?page=N (0-based); out-of-range values are clamped silently.
|
|
508
|
+
// Must be a finite integer >= 0; 0 = disabled (no pagination).
|
|
509
|
+
},
|
|
311
510
|
index: ["index.html"], // Index file name(s) - must be an ARRAY:
|
|
312
511
|
// - Array of strings: ["index.html", "index.htm", "default.html"]
|
|
313
512
|
// - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
|
|
@@ -316,8 +515,13 @@ module.exports = function koaClassicServer(
|
|
|
316
515
|
urlPrefix: "", // URL path prefix
|
|
317
516
|
urlsReserved: [], // Reserved paths (first level only)
|
|
318
517
|
template: {
|
|
319
|
-
render: undefined, // Template rendering function: async (ctx, next, filePath) => {}
|
|
518
|
+
render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
|
|
320
519
|
ext: [], // File extensions to process with template.render
|
|
520
|
+
renderTimeout: 30000, // Max ms allowed for template.render (number ≥ 0; 0 = disabled).
|
|
521
|
+
// On timeout responds 504 Gateway Timeout. The render receives an
|
|
522
|
+
// AbortSignal as 5th argument; propagate it to fetch/db/fs to free
|
|
523
|
+
// backend resources. The signal also aborts on client disconnect,
|
|
524
|
+
// even when renderTimeout is 0.
|
|
321
525
|
},
|
|
322
526
|
browserCacheMaxAge: 3600, // Browser Cache-Control max-age in seconds (default: 1 hour)
|
|
323
527
|
browserCacheEnabled: false, // Enable browser HTTP caching headers (ETag, Last-Modified)
|
|
@@ -331,8 +535,10 @@ module.exports = function koaClassicServer(
|
|
|
331
535
|
redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
|
|
332
536
|
},
|
|
333
537
|
hidden: { // Block files/dirs from listing and serving (HTTP 404)
|
|
334
|
-
dotFiles: { // Dot-files (names starting with '.'):
|
|
335
|
-
default: '
|
|
538
|
+
dotFiles: { // Dot-files (names starting with '.'): visible by default — design philosophy
|
|
539
|
+
default: 'visible', // 'hidden' | 'visible' — system default: 'visible'
|
|
540
|
+
// To protect .env / .git / etc., set 'hidden' explicitly OR add to
|
|
541
|
+
// `blacklist` / `alwaysHide`. See README "Security Checklist".
|
|
336
542
|
whitelist: [], // Always visible (string exact/glob or RegExp). Overrides default and alwaysHide.
|
|
337
543
|
blacklist: [], // Always hidden (string or RegExp). Overrides whitelist.
|
|
338
544
|
},
|
|
@@ -350,21 +556,31 @@ module.exports = function koaClassicServer(
|
|
|
350
556
|
enabled: false, // enable in-memory cache of raw file buffers
|
|
351
557
|
maxSize: 52428800, // max total RAM used by this cache (bytes; default: 50 MB)
|
|
352
558
|
maxFileSize: 1048576, // files larger than this are never cached (bytes; default: 1 MB)
|
|
559
|
+
maxAge: 0, // ms after insertion to consider an entry stale; 0 = disabled.
|
|
560
|
+
// Useful on NFS/SMB/overlay FS where mtime+size may not reflect
|
|
561
|
+
// remote changes within the OS attribute-cache window. Limits but
|
|
562
|
+
// does not eliminate staleness — combine with low actimeo on the
|
|
563
|
+
// mount for stricter freshness.
|
|
353
564
|
warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
|
|
354
565
|
},
|
|
355
566
|
compressedFile: { // cache for HTTP br/gzip responses — not for .zip/.tar files on disk
|
|
356
567
|
enabled: true, // enable in-memory cache of compressed response buffers
|
|
357
568
|
maxSize: 104857600, // max total RAM used by this cache (bytes; default: 100 MB)
|
|
569
|
+
maxAge: 0, // ms after insertion to consider an entry stale; 0 = disabled. See rawFile.maxAge.
|
|
358
570
|
warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
|
|
359
571
|
},
|
|
360
572
|
},
|
|
361
573
|
compression: { // Response compression (gzip / brotli) — to enable/disable caching → serverCache.compressedFile
|
|
362
574
|
enabled: true, // master switch (false = disable all compression)
|
|
363
575
|
encodings: ['br', 'gzip'], // algorithms in priority order; [] = disable
|
|
364
|
-
|
|
576
|
+
minFileSize: 1024, // min file size in bytes to compress; false = no minimum
|
|
365
577
|
mimeTypes: [], // compressible MIME types (replaces default list if provided)
|
|
366
578
|
},
|
|
367
579
|
// compression: false // shorthand to disable all compression
|
|
580
|
+
logger: console, // Logger used for internal errors and warnings.
|
|
581
|
+
// Must expose error(...) and warn(...). Pass pino/winston/bunyan
|
|
582
|
+
// or any compatible object to integrate with aggregated logging.
|
|
583
|
+
// Default: the global console.
|
|
368
584
|
|
|
369
585
|
}
|
|
370
586
|
*/
|
|
@@ -381,8 +597,80 @@ module.exports = function koaClassicServer(
|
|
|
381
597
|
const options = opts || {};
|
|
382
598
|
options.template = opts.template || {};
|
|
383
599
|
|
|
600
|
+
const _logger = normalizeLogger(options.logger);
|
|
601
|
+
|
|
384
602
|
options.method = Array.isArray(options.method) ? options.method : ['GET'];
|
|
385
|
-
|
|
603
|
+
|
|
604
|
+
// ── V3 breaking-change guards: helpful errors for V3-alpha-only renamed options ──
|
|
605
|
+
// These were introduced in v3.0.0-alpha.0 only; no v2 user can have them in production.
|
|
606
|
+
if (opts.maxDirEntries !== undefined) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
'[koa-classic-server] options.maxDirEntries was relocated in v3.0.0.\n' +
|
|
609
|
+
` Replace with: dirListing: { maxEntries: ${opts.maxDirEntries} }`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
if (opts.pageSize !== undefined) {
|
|
613
|
+
throw new Error(
|
|
614
|
+
'[koa-classic-server] options.pageSize was relocated and renamed in v3.0.0.\n' +
|
|
615
|
+
` Replace with: dirListing: { entriesPerPage: ${opts.pageSize} }`
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function validateNonNegativeInt(value, optionName, defaultValue) {
|
|
620
|
+
if (value === undefined) return defaultValue;
|
|
621
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || !Number.isInteger(value)) {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`[koa-classic-server] options.${optionName} must be a non-negative integer. ` +
|
|
624
|
+
'Use 0 to disable. Got: ' + String(value)
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
return value;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── dirListing namespace (V3+) — single source of truth for listing config ──
|
|
631
|
+
const userDirListing = opts.dirListing;
|
|
632
|
+
if (userDirListing !== undefined && (typeof userDirListing !== 'object' || userDirListing === null || Array.isArray(userDirListing))) {
|
|
633
|
+
throw new Error('[koa-classic-server] options.dirListing must be an object.');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// V3 backward-compat alias: showDirContents (v2-stable) maps to dirListing.enabled.
|
|
637
|
+
// The alias may be removed in a future major version. Emits a one-time deprecation
|
|
638
|
+
// warning per process. Throws if both names are passed (the user picked one of them
|
|
639
|
+
// by mistake — surface the conflict rather than silently choosing one).
|
|
640
|
+
let aliasEnabled; // undefined unless showDirContents was passed
|
|
641
|
+
if (opts.showDirContents !== undefined) {
|
|
642
|
+
if (userDirListing && userDirListing.enabled !== undefined) {
|
|
643
|
+
throw new Error(
|
|
644
|
+
'[koa-classic-server] options.showDirContents and options.dirListing.enabled are both set.\n' +
|
|
645
|
+
' These configure the same thing — pick one.'
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
if (!_showDirContentsDeprecationWarned) {
|
|
649
|
+
_showDirContentsDeprecationWarned = true;
|
|
650
|
+
_logger.warn(...warnPayload(_logger,
|
|
651
|
+
'[koa-classic-server] DEPRECATION: options.showDirContents was renamed to dirListing.enabled in v3.0.0.\n' +
|
|
652
|
+
' The old name is currently accepted as an alias and may be removed in a future major version.\n' +
|
|
653
|
+
` Replace with: dirListing: { enabled: ${opts.showDirContents} }`
|
|
654
|
+
));
|
|
655
|
+
}
|
|
656
|
+
aliasEnabled = !!opts.showDirContents;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
options.dirListing = {
|
|
660
|
+
enabled: userDirListing && userDirListing.enabled !== undefined
|
|
661
|
+
? !!userDirListing.enabled
|
|
662
|
+
: (aliasEnabled !== undefined ? aliasEnabled : true),
|
|
663
|
+
maxEntries: validateNonNegativeInt(
|
|
664
|
+
userDirListing && userDirListing.maxEntries,
|
|
665
|
+
'dirListing.maxEntries',
|
|
666
|
+
10000
|
|
667
|
+
),
|
|
668
|
+
entriesPerPage: validateNonNegativeInt(
|
|
669
|
+
userDirListing && userDirListing.entriesPerPage,
|
|
670
|
+
'dirListing.entriesPerPage',
|
|
671
|
+
100
|
|
672
|
+
),
|
|
673
|
+
};
|
|
386
674
|
|
|
387
675
|
// Normalize index option to array format
|
|
388
676
|
if (typeof options.index === 'string') {
|
|
@@ -411,6 +699,19 @@ module.exports = function koaClassicServer(
|
|
|
411
699
|
options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
|
|
412
700
|
options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
|
|
413
701
|
|
|
702
|
+
if (options.template.renderTimeout === undefined) {
|
|
703
|
+
options.template.renderTimeout = 30000;
|
|
704
|
+
} else if (
|
|
705
|
+
typeof options.template.renderTimeout !== 'number' ||
|
|
706
|
+
!Number.isFinite(options.template.renderTimeout) ||
|
|
707
|
+
options.template.renderTimeout < 0
|
|
708
|
+
) {
|
|
709
|
+
throw new Error(
|
|
710
|
+
'[koa-classic-server] template.renderTimeout must be a finite number >= 0 (ms). ' +
|
|
711
|
+
'Use 0 to disable. Got: ' + String(options.template.renderTimeout)
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
414
715
|
// v3.0.0: removed legacy option names — throw to surface the breaking change clearly
|
|
415
716
|
if ('cacheMaxAge' in opts) {
|
|
416
717
|
throw new Error(
|
|
@@ -439,13 +740,12 @@ module.exports = function koaClassicServer(
|
|
|
439
740
|
}
|
|
440
741
|
// Normalize ext: add leading dot if missing
|
|
441
742
|
if (!options.hideExtension.ext.startsWith('.')) {
|
|
442
|
-
|
|
443
|
-
'\x1b[33m%s\x1b[0m',
|
|
743
|
+
_logger.warn(...warnPayload(_logger,
|
|
444
744
|
'[koa-classic-server] WARNING: hideExtension.ext should start with a dot.\n' +
|
|
445
745
|
` Current usage: ext: "${options.hideExtension.ext}"\n` +
|
|
446
746
|
` Corrected to: ext: ".${options.hideExtension.ext}"\n` +
|
|
447
747
|
' Please update your configuration.'
|
|
448
|
-
);
|
|
748
|
+
));
|
|
449
749
|
options.hideExtension.ext = '.' + options.hideExtension.ext;
|
|
450
750
|
}
|
|
451
751
|
// Validate redirect code
|
|
@@ -462,7 +762,7 @@ module.exports = function koaClassicServer(
|
|
|
462
762
|
function normalizeHiddenConfig(hidden) {
|
|
463
763
|
if (!hidden || typeof hidden !== 'object' || Array.isArray(hidden)) {
|
|
464
764
|
return {
|
|
465
|
-
dotFiles: { default: '
|
|
765
|
+
dotFiles: { default: 'visible', whitelist: [], blacklist: [] },
|
|
466
766
|
dotDirs: { default: 'visible', whitelist: [], blacklist: [] },
|
|
467
767
|
alwaysHide: []
|
|
468
768
|
};
|
|
@@ -490,7 +790,7 @@ module.exports = function koaClassicServer(
|
|
|
490
790
|
}
|
|
491
791
|
|
|
492
792
|
return {
|
|
493
|
-
dotFiles: normalizeCategory(hidden.dotFiles, '
|
|
793
|
+
dotFiles: normalizeCategory(hidden.dotFiles, 'visible', 'dotFiles'),
|
|
494
794
|
dotDirs: normalizeCategory(hidden.dotDirs, 'visible', 'dotDirs'),
|
|
495
795
|
alwaysHide: filterPatternList(hidden.alwaysHide),
|
|
496
796
|
};
|
|
@@ -498,66 +798,53 @@ module.exports = function koaClassicServer(
|
|
|
498
798
|
|
|
499
799
|
const hiddenConfig = normalizeHiddenConfig(options.hidden);
|
|
500
800
|
|
|
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) {
|
|
801
|
+
// Returns true if `value` matches any pattern in the list.
|
|
802
|
+
// RegExp patterns are tested directly; string patterns go through `globMatch`.
|
|
803
|
+
// Non-string non-RegExp entries are ignored (defensive — config validation should reject them).
|
|
804
|
+
function matchesPatternList(value, patterns, globMatch) {
|
|
525
805
|
for (const pattern of patterns) {
|
|
526
806
|
if (pattern instanceof RegExp) {
|
|
527
|
-
if (pattern.test(
|
|
807
|
+
if (pattern.test(value)) return true;
|
|
528
808
|
} else if (typeof pattern === 'string') {
|
|
529
|
-
if (
|
|
809
|
+
if (globMatch(value, pattern)) return true;
|
|
530
810
|
}
|
|
531
811
|
}
|
|
532
812
|
return false;
|
|
533
813
|
}
|
|
534
814
|
|
|
815
|
+
// Match against a list using filename-glob semantics (case-sensitive, no path component).
|
|
816
|
+
function matchesNameList(name, patterns) {
|
|
817
|
+
return matchesPatternList(name, patterns, nameGlobMatch);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Compiled-RegExp caches for glob patterns. Patterns come from `hidden.*` config and are
|
|
821
|
+
// immutable after factory init, so memoization is bounded by the operator's config size
|
|
822
|
+
// and avoids recompiling the same regex on every directory entry during a listing.
|
|
823
|
+
const _nameGlobRegexCache = new Map();
|
|
824
|
+
const _pathGlobRegexCache = new Map();
|
|
825
|
+
|
|
535
826
|
// Matches a bare filename against a simple glob pattern (* = any chars except /, ? = one char).
|
|
536
827
|
function nameGlobMatch(name, pattern) {
|
|
537
828
|
if (!pattern.includes('*') && !pattern.includes('?')) {
|
|
538
829
|
return name === pattern;
|
|
539
830
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
831
|
+
let re = _nameGlobRegexCache.get(pattern);
|
|
832
|
+
if (re === undefined) {
|
|
833
|
+
const regexStr = '^' +
|
|
834
|
+
pattern
|
|
835
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
836
|
+
.replace(/\*/g, '[^/]*')
|
|
837
|
+
.replace(/\?/g, '[^/]')
|
|
838
|
+
+ '$';
|
|
839
|
+
re = new RegExp(regexStr);
|
|
840
|
+
_nameGlobRegexCache.set(pattern, re);
|
|
841
|
+
}
|
|
842
|
+
return re.test(name);
|
|
547
843
|
}
|
|
548
844
|
|
|
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.
|
|
845
|
+
// Match against a list using path-aware glob semantics (anchored to rootDir, supports **).
|
|
552
846
|
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;
|
|
847
|
+
return matchesPatternList(relPath, patterns, pathGlobMatch);
|
|
561
848
|
}
|
|
562
849
|
|
|
563
850
|
/**
|
|
@@ -569,19 +856,24 @@ module.exports = function koaClassicServer(
|
|
|
569
856
|
* - '?' matches any single character except '/'
|
|
570
857
|
*/
|
|
571
858
|
function pathGlobMatch(relPath, pattern) {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
859
|
+
let re = _pathGlobRegexCache.get(pattern);
|
|
860
|
+
if (re === undefined) {
|
|
861
|
+
const hasSlash = pattern.includes('/');
|
|
862
|
+
const escaped = pattern
|
|
863
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
864
|
+
.replace(/\*\*/g, '\x00')
|
|
865
|
+
.replace(/\*/g, '[^/]*')
|
|
866
|
+
.replace(/\?/g, '[^/]')
|
|
867
|
+
.replace(/\x00/g, '.*');
|
|
868
|
+
|
|
869
|
+
const regexStr = hasSlash
|
|
870
|
+
? '^' + escaped + '($|/)' // path-anchored from root
|
|
871
|
+
: '(^|/)' + escaped + '$'; // basename match at any depth
|
|
872
|
+
|
|
873
|
+
re = new RegExp(regexStr);
|
|
874
|
+
_pathGlobRegexCache.set(pattern, re);
|
|
875
|
+
}
|
|
876
|
+
return re.test(relPath);
|
|
585
877
|
}
|
|
586
878
|
|
|
587
879
|
/**
|
|
@@ -657,11 +949,19 @@ module.exports = function koaClassicServer(
|
|
|
657
949
|
return {
|
|
658
950
|
enabled: true,
|
|
659
951
|
encodings: ['br', 'gzip'], // priority order: brotli first, gzip as fallback
|
|
660
|
-
|
|
952
|
+
minFileSize: 1024, // bytes; skip compression for files smaller than this
|
|
661
953
|
mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
|
|
662
954
|
};
|
|
663
955
|
}
|
|
664
956
|
|
|
957
|
+
// V3 breaking-change guard: catch the v2-alpha name minSize with a helpful migration hint.
|
|
958
|
+
if (compression.minSize !== undefined) {
|
|
959
|
+
throw new Error(
|
|
960
|
+
'[koa-classic-server] options.compression.minSize was renamed in v3.0.0.\n' +
|
|
961
|
+
` Replace with: compression: { minFileSize: ${compression.minSize} }`
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
665
965
|
const enabled = typeof compression.enabled === 'boolean' ? compression.enabled : true;
|
|
666
966
|
if (!enabled) return { enabled: false };
|
|
667
967
|
|
|
@@ -669,14 +969,14 @@ module.exports = function koaClassicServer(
|
|
|
669
969
|
? compression.encodings.filter(e => e === 'br' || e === 'gzip')
|
|
670
970
|
: ['br', 'gzip'];
|
|
671
971
|
|
|
672
|
-
const
|
|
673
|
-
: (typeof compression.
|
|
972
|
+
const minFileSize = compression.minFileSize === false ? false
|
|
973
|
+
: (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0 ? compression.minFileSize : 1024);
|
|
674
974
|
|
|
675
975
|
const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
|
|
676
976
|
? compression.mimeTypes
|
|
677
977
|
: DEFAULT_COMPRESSIBLE_MIME_TYPES;
|
|
678
978
|
|
|
679
|
-
return { enabled, encodings,
|
|
979
|
+
return { enabled, encodings, minFileSize, mimeTypes: new Set(mimeTypes) };
|
|
680
980
|
}
|
|
681
981
|
|
|
682
982
|
// Normalize and validate the serverCache option into a clean internal structure.
|
|
@@ -685,14 +985,27 @@ module.exports = function koaClassicServer(
|
|
|
685
985
|
enabled: false,
|
|
686
986
|
maxSize: 52428800, // 50 MB
|
|
687
987
|
maxFileSize: 1048576, // 1 MB
|
|
988
|
+
maxAge: 0,
|
|
688
989
|
warnInterval: 60000,
|
|
689
990
|
};
|
|
690
991
|
const defaultCompressedFile = {
|
|
691
992
|
enabled: true,
|
|
692
993
|
maxSize: 104857600, // 100 MB
|
|
994
|
+
maxAge: 0,
|
|
693
995
|
warnInterval: 60000,
|
|
694
996
|
};
|
|
695
997
|
|
|
998
|
+
function validateMaxAge(value, cacheName) {
|
|
999
|
+
if (value === undefined) return 0;
|
|
1000
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
1001
|
+
throw new Error(
|
|
1002
|
+
`[koa-classic-server] serverCache.${cacheName}.maxAge must be a finite number >= 0 (ms). ` +
|
|
1003
|
+
'Use 0 to disable. Got: ' + String(value)
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
return value;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
696
1009
|
if (!serverCache || typeof serverCache !== 'object' || Array.isArray(serverCache)) {
|
|
697
1010
|
return { rawFile: defaultRawFile, compressedFile: defaultCompressedFile };
|
|
698
1011
|
}
|
|
@@ -702,6 +1015,7 @@ module.exports = function koaClassicServer(
|
|
|
702
1015
|
enabled: typeof rf.enabled === 'boolean' ? rf.enabled : false,
|
|
703
1016
|
maxSize: typeof rf.maxSize === 'number' && rf.maxSize > 0 ? rf.maxSize : 52428800,
|
|
704
1017
|
maxFileSize: typeof rf.maxFileSize === 'number' && rf.maxFileSize > 0 ? rf.maxFileSize : 1048576,
|
|
1018
|
+
maxAge: validateMaxAge(rf.maxAge, 'rawFile'),
|
|
705
1019
|
warnInterval: rf.warnInterval === false ? false : (typeof rf.warnInterval === 'number' ? rf.warnInterval : 60000),
|
|
706
1020
|
};
|
|
707
1021
|
|
|
@@ -709,6 +1023,7 @@ module.exports = function koaClassicServer(
|
|
|
709
1023
|
const compressedFile = (!cf || typeof cf !== 'object' || Array.isArray(cf)) ? defaultCompressedFile : {
|
|
710
1024
|
enabled: typeof cf.enabled === 'boolean' ? cf.enabled : true,
|
|
711
1025
|
maxSize: typeof cf.maxSize === 'number' && cf.maxSize > 0 ? cf.maxSize : 104857600,
|
|
1026
|
+
maxAge: validateMaxAge(cf.maxAge, 'compressedFile'),
|
|
712
1027
|
warnInterval: cf.warnInterval === false ? false : (typeof cf.warnInterval === 'number' ? cf.warnInterval : 60000),
|
|
713
1028
|
};
|
|
714
1029
|
|
|
@@ -723,7 +1038,8 @@ module.exports = function koaClassicServer(
|
|
|
723
1038
|
const _rawFileCache = new LFUCache(
|
|
724
1039
|
serverCacheConfig.rawFile.maxSize,
|
|
725
1040
|
serverCacheConfig.rawFile.warnInterval,
|
|
726
|
-
'rawFile'
|
|
1041
|
+
'rawFile',
|
|
1042
|
+
_logger
|
|
727
1043
|
);
|
|
728
1044
|
|
|
729
1045
|
// In-memory LFU cache for compressed file buffers (serverCache.compressedFile).
|
|
@@ -731,7 +1047,8 @@ module.exports = function koaClassicServer(
|
|
|
731
1047
|
const _compressedFileCache = new LFUCache(
|
|
732
1048
|
serverCacheConfig.compressedFile.maxSize,
|
|
733
1049
|
serverCacheConfig.compressedFile.warnInterval,
|
|
734
|
-
'compressedFile'
|
|
1050
|
+
'compressedFile',
|
|
1051
|
+
_logger
|
|
735
1052
|
);
|
|
736
1053
|
|
|
737
1054
|
// Returns the client's preferred encoding based on Accept-Encoding header,
|
|
@@ -745,23 +1062,14 @@ module.exports = function koaClassicServer(
|
|
|
745
1062
|
}
|
|
746
1063
|
|
|
747
1064
|
// Compress a Buffer using the given encoding ('br' or 'gzip').
|
|
748
|
-
//
|
|
1065
|
+
// Quality is maxed out: serverCache pays this cost once per file, not per request.
|
|
749
1066
|
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
|
-
});
|
|
1067
|
+
if (encoding === 'br') {
|
|
1068
|
+
return _brotliCompressAsync(data, {
|
|
1069
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
return _gzipAsync(data, { level: zlib.constants.Z_BEST_COMPRESSION });
|
|
765
1073
|
}
|
|
766
1074
|
|
|
767
1075
|
/**
|
|
@@ -816,7 +1124,7 @@ module.exports = function koaClassicServer(
|
|
|
816
1124
|
);
|
|
817
1125
|
fileNames = checkResults.filter(e => e.isFile).map(e => e.name);
|
|
818
1126
|
} catch (error) {
|
|
819
|
-
|
|
1127
|
+
_logger.error('Error finding index file:', error);
|
|
820
1128
|
return null;
|
|
821
1129
|
}
|
|
822
1130
|
}
|
|
@@ -909,7 +1217,9 @@ module.exports = function koaClassicServer(
|
|
|
909
1217
|
return;
|
|
910
1218
|
}
|
|
911
1219
|
|
|
912
|
-
// Hidden check: block requests that traverse a hidden directory
|
|
1220
|
+
// Hidden check: block requests that traverse a hidden directory.
|
|
1221
|
+
// Stops at length-1 because the leaf (the file or dir being served) is
|
|
1222
|
+
// checked separately by the file/listing path with its real stat.isDirectory().
|
|
913
1223
|
if (requestedPath !== '') {
|
|
914
1224
|
const segments = normalizedPath.split(path.sep).filter(Boolean);
|
|
915
1225
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
@@ -933,14 +1243,23 @@ module.exports = function koaClassicServer(
|
|
|
933
1243
|
const rawPath = urlToUse.split('?')[0];
|
|
934
1244
|
const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
|
|
935
1245
|
|
|
936
|
-
//
|
|
937
|
-
//
|
|
1246
|
+
// Strip a trailing slash before the extension check so URLs like /foo.html/
|
|
1247
|
+
// still match (the slash is URL formality, not part of the filename) — without
|
|
1248
|
+
// this, /foo.html/ would skip the redirect and 404 trying to open it as a dir.
|
|
938
1249
|
const pathForExtCheck = hadTrailingSlash ? rawPath.slice(0, -1) : requestedPath;
|
|
939
1250
|
if (pathForExtCheck.endsWith(hideExt)) {
|
|
940
1251
|
// Build redirect target using ctx.originalUrl (always, regardless of useOriginalUrl)
|
|
941
1252
|
const originalUrlObj = new URL(_origin + ctx.originalUrl);
|
|
942
1253
|
let redirectPath = originalUrlObj.pathname;
|
|
943
1254
|
|
|
1255
|
+
// Collapse leading slashes: a Location header starting with "//" (or "/\")
|
|
1256
|
+
// is a protocol-relative URL and would let "GET //evil.com/foo.ejs" redirect
|
|
1257
|
+
// off-origin. path.normalize() upstream already collapses these for the
|
|
1258
|
+
// filesystem check, so the source-of-truth URL has a single leading slash.
|
|
1259
|
+
if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
|
|
1260
|
+
redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
|
|
1261
|
+
}
|
|
1262
|
+
|
|
944
1263
|
redirectPath = redirectPath.slice(0, redirectPath.length - hideExt.length);
|
|
945
1264
|
|
|
946
1265
|
// Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
|
|
@@ -1007,7 +1326,7 @@ module.exports = function koaClassicServer(
|
|
|
1007
1326
|
|
|
1008
1327
|
if (stat.isDirectory()) {
|
|
1009
1328
|
// Handle directory
|
|
1010
|
-
if (options.
|
|
1329
|
+
if (options.dirListing.enabled) {
|
|
1011
1330
|
// Search for index file matching configured patterns
|
|
1012
1331
|
if (options.index && options.index.length > 0) {
|
|
1013
1332
|
const indexFile = await findIndexFile(toOpen, options.index);
|
|
@@ -1042,7 +1361,7 @@ module.exports = function koaClassicServer(
|
|
|
1042
1361
|
try {
|
|
1043
1362
|
fileStat = await fs.promises.stat(toOpen);
|
|
1044
1363
|
} catch (error) {
|
|
1045
|
-
|
|
1364
|
+
_logger.error('File stat error:', error);
|
|
1046
1365
|
sendNotFound(ctx);
|
|
1047
1366
|
return;
|
|
1048
1367
|
}
|
|
@@ -1053,54 +1372,32 @@ module.exports = function koaClassicServer(
|
|
|
1053
1372
|
let rawBuffer = null;
|
|
1054
1373
|
if (serverCacheConfig.rawFile.enabled && fileStat.size <= serverCacheConfig.rawFile.maxFileSize) {
|
|
1055
1374
|
const cached = _rawFileCache.peek(toOpen);
|
|
1056
|
-
|
|
1375
|
+
const maxAge = serverCacheConfig.rawFile.maxAge;
|
|
1376
|
+
const staleByAge = maxAge > 0 && cached && (Date.now() - cached.insertedAt) >= maxAge;
|
|
1377
|
+
const fresh = cached
|
|
1378
|
+
&& cached.mtime === fileStat.mtime.getTime()
|
|
1379
|
+
&& cached.size === fileStat.size
|
|
1380
|
+
&& !staleByAge;
|
|
1381
|
+
if (fresh) {
|
|
1057
1382
|
_rawFileCache.get(toOpen); // increment frequency
|
|
1058
1383
|
rawBuffer = cached.buffer;
|
|
1059
1384
|
} else {
|
|
1060
1385
|
try {
|
|
1061
1386
|
rawBuffer = await fs.promises.readFile(toOpen);
|
|
1062
|
-
|
|
1063
|
-
_rawFileCache.set(toOpen, {
|
|
1387
|
+
refreshOrInsert(_rawFileCache, toOpen, {
|
|
1064
1388
|
buffer: rawBuffer,
|
|
1065
1389
|
mtime: fileStat.mtime.getTime(),
|
|
1066
1390
|
size: fileStat.size,
|
|
1067
|
-
|
|
1391
|
+
insertedAt: Date.now(),
|
|
1392
|
+
}, cached, staleByAge);
|
|
1068
1393
|
} catch {
|
|
1069
1394
|
rawBuffer = null; // Fall through to disk reads later
|
|
1070
1395
|
}
|
|
1071
1396
|
}
|
|
1072
1397
|
}
|
|
1073
1398
|
|
|
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
|
-
}
|
|
1399
|
+
if (await tryRenderTemplate(ctx, next, toOpen, rawBuffer, options.template, _logger)) {
|
|
1400
|
+
return;
|
|
1104
1401
|
}
|
|
1105
1402
|
|
|
1106
1403
|
// baseEtag — encoding-independent; used only for If-Range (Range requests skip compression)
|
|
@@ -1125,7 +1422,7 @@ module.exports = function koaClassicServer(
|
|
|
1125
1422
|
try {
|
|
1126
1423
|
await fs.promises.access(toOpen, fs.constants.R_OK);
|
|
1127
1424
|
} catch (error) {
|
|
1128
|
-
|
|
1425
|
+
_logger.error('File access error:', error);
|
|
1129
1426
|
sendNotFound(ctx);
|
|
1130
1427
|
return;
|
|
1131
1428
|
}
|
|
@@ -1166,7 +1463,7 @@ module.exports = function koaClassicServer(
|
|
|
1166
1463
|
} else {
|
|
1167
1464
|
const src = fs.createReadStream(toOpen, { start, end });
|
|
1168
1465
|
src.on('error', (err) => {
|
|
1169
|
-
|
|
1466
|
+
_logger.error('Stream error:', err);
|
|
1170
1467
|
if (!ctx.headerSent) {
|
|
1171
1468
|
ctx.status = 500;
|
|
1172
1469
|
ctx.body = 'Error reading file';
|
|
@@ -1191,12 +1488,12 @@ module.exports = function koaClassicServer(
|
|
|
1191
1488
|
const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
|
|
1192
1489
|
const filename = path.basename(toOpen);
|
|
1193
1490
|
|
|
1194
|
-
// Resolve compression: enabled + compressible MIME + meets
|
|
1491
|
+
// Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
|
|
1195
1492
|
let encoding = null; // 'br' | 'gzip' | null
|
|
1196
1493
|
if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
|
|
1197
1494
|
const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
|
|
1198
|
-
const meetsMinSize = compressionConfig.
|
|
1199
|
-
|| fileStat.size >= compressionConfig.
|
|
1495
|
+
const meetsMinSize = compressionConfig.minFileSize === false
|
|
1496
|
+
|| fileStat.size >= compressionConfig.minFileSize;
|
|
1200
1497
|
if (isCompressibleMime && meetsMinSize) {
|
|
1201
1498
|
encoding = getClientEncoding(ctx.get('Accept-Encoding'));
|
|
1202
1499
|
}
|
|
@@ -1243,9 +1540,12 @@ module.exports = function koaClassicServer(
|
|
|
1243
1540
|
// compressedFile cache mode: compress once → buffer in RAM → Content-Length known
|
|
1244
1541
|
const cacheKey = `${toOpen}:${encoding}`;
|
|
1245
1542
|
const cached = _compressedFileCache.peek(cacheKey);
|
|
1543
|
+
const maxAge = serverCacheConfig.compressedFile.maxAge;
|
|
1544
|
+
const staleByAge = maxAge > 0 && cached && (Date.now() - cached.insertedAt) >= maxAge;
|
|
1246
1545
|
const stale = !cached
|
|
1247
1546
|
|| cached.mtime !== fileStat.mtime.getTime()
|
|
1248
|
-
|| cached.size !== fileStat.size
|
|
1547
|
+
|| cached.size !== fileStat.size
|
|
1548
|
+
|| staleByAge;
|
|
1249
1549
|
|
|
1250
1550
|
let buf;
|
|
1251
1551
|
if (!stale) {
|
|
@@ -1257,14 +1557,14 @@ module.exports = function koaClassicServer(
|
|
|
1257
1557
|
const rawData = rawBuffer || await fs.promises.readFile(toOpen);
|
|
1258
1558
|
buf = await compressBuffer(rawData, encoding);
|
|
1259
1559
|
|
|
1260
|
-
|
|
1261
|
-
_compressedFileCache.set(cacheKey, {
|
|
1560
|
+
refreshOrInsert(_compressedFileCache, cacheKey, {
|
|
1262
1561
|
buffer: buf,
|
|
1263
1562
|
mtime: fileStat.mtime.getTime(),
|
|
1264
1563
|
size: fileStat.size,
|
|
1265
|
-
|
|
1564
|
+
insertedAt: Date.now(),
|
|
1565
|
+
}, cached, staleByAge);
|
|
1266
1566
|
} catch (err) {
|
|
1267
|
-
|
|
1567
|
+
_logger.error('Compression error:', err);
|
|
1268
1568
|
// Fall back to uncompressed on any compression failure
|
|
1269
1569
|
ctx.remove('Content-Encoding');
|
|
1270
1570
|
ctx.remove('Vary');
|
|
@@ -1281,7 +1581,7 @@ module.exports = function koaClassicServer(
|
|
|
1281
1581
|
if (ctx.method !== 'HEAD') {
|
|
1282
1582
|
const src = fs.createReadStream(toOpen);
|
|
1283
1583
|
src.on('error', (streamErr) => {
|
|
1284
|
-
|
|
1584
|
+
_logger.error('Stream error:', streamErr);
|
|
1285
1585
|
if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
|
|
1286
1586
|
});
|
|
1287
1587
|
ctx.body = src;
|
|
@@ -1316,7 +1616,7 @@ module.exports = function koaClassicServer(
|
|
|
1316
1616
|
} else {
|
|
1317
1617
|
const src = fs.createReadStream(toOpen);
|
|
1318
1618
|
src.on('error', (err) => {
|
|
1319
|
-
|
|
1619
|
+
_logger.error('Stream error:', err);
|
|
1320
1620
|
if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
|
|
1321
1621
|
});
|
|
1322
1622
|
ctx.body = src.pipe(compress);
|
|
@@ -1341,7 +1641,7 @@ module.exports = function koaClassicServer(
|
|
|
1341
1641
|
if (ctx.method !== 'HEAD') {
|
|
1342
1642
|
const src = fs.createReadStream(toOpen);
|
|
1343
1643
|
src.on('error', (err) => {
|
|
1344
|
-
|
|
1644
|
+
_logger.error('Stream error:', err);
|
|
1345
1645
|
if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
|
|
1346
1646
|
});
|
|
1347
1647
|
ctx.body = src;
|
|
@@ -1356,11 +1656,23 @@ module.exports = function koaClassicServer(
|
|
|
1356
1656
|
|
|
1357
1657
|
|
|
1358
1658
|
async function show_dir(toOpen, ctx) {
|
|
1659
|
+
// Read the full directory in one syscall, then cap the result.
|
|
1660
|
+
// `dirListing.maxEntries` bounds the visible / sorted / stat'd entries, but
|
|
1661
|
+
// does NOT bound the size of the initial readdir() allocation — see the
|
|
1662
|
+
// adversarial-directory caveat tracked for v3.1 [F-1].
|
|
1663
|
+
const maxDirEntries = options.dirListing.maxEntries; // 0 = disabled (no cap)
|
|
1359
1664
|
let dir;
|
|
1665
|
+
let truncated = false;
|
|
1360
1666
|
try {
|
|
1361
|
-
|
|
1667
|
+
const all = await fs.promises.readdir(toOpen, { withFileTypes: true });
|
|
1668
|
+
if (maxDirEntries > 0 && all.length > maxDirEntries) {
|
|
1669
|
+
truncated = true;
|
|
1670
|
+
dir = all.slice(0, maxDirEntries);
|
|
1671
|
+
} else {
|
|
1672
|
+
dir = all;
|
|
1673
|
+
}
|
|
1362
1674
|
} catch (error) {
|
|
1363
|
-
|
|
1675
|
+
_logger.error('Directory read error:', error);
|
|
1364
1676
|
ctx.status = 500;
|
|
1365
1677
|
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
1366
1678
|
return `
|
|
@@ -1386,10 +1698,17 @@ module.exports = function koaClassicServer(
|
|
|
1386
1698
|
const sortBy = ctx.query.sort || 'name';
|
|
1387
1699
|
const sortOrder = ctx.query.order || 'asc';
|
|
1388
1700
|
|
|
1389
|
-
// Build base URL for sorting links (without query params)
|
|
1390
1701
|
const baseUrl = pageHrefOutPrefix.pathname;
|
|
1391
1702
|
|
|
1392
|
-
//
|
|
1703
|
+
// Preserves sort/order while overriding `page`; omits page when 0.
|
|
1704
|
+
function buildQueryUrl(targetPage) {
|
|
1705
|
+
const params = [];
|
|
1706
|
+
if (ctx.query.sort) params.push(`sort=${encodeURIComponent(ctx.query.sort)}`);
|
|
1707
|
+
if (ctx.query.order) params.push(`order=${encodeURIComponent(ctx.query.order)}`);
|
|
1708
|
+
if (targetPage > 0) params.push(`page=${targetPage}`);
|
|
1709
|
+
return params.length ? `${baseUrl}?${params.join('&')}` : baseUrl;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1393
1712
|
function getSortUrl(column) {
|
|
1394
1713
|
let newOrder = 'asc';
|
|
1395
1714
|
if (sortBy === column && sortOrder === 'asc') {
|
|
@@ -1398,7 +1717,6 @@ module.exports = function koaClassicServer(
|
|
|
1398
1717
|
return `${baseUrl}?sort=${column}&order=${newOrder}`;
|
|
1399
1718
|
}
|
|
1400
1719
|
|
|
1401
|
-
// Helper to get sort indicator
|
|
1402
1720
|
function getSortIndicator(column) {
|
|
1403
1721
|
if (sortBy === column) {
|
|
1404
1722
|
return sortOrder === 'asc' ? ' ↑' : ' ↓';
|
|
@@ -1407,6 +1725,12 @@ module.exports = function koaClassicServer(
|
|
|
1407
1725
|
}
|
|
1408
1726
|
|
|
1409
1727
|
const parts = [];
|
|
1728
|
+
let totalPages = 1; // populated in the non-empty branch below
|
|
1729
|
+
let currentPage = 0;
|
|
1730
|
+
if (truncated) {
|
|
1731
|
+
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>`);
|
|
1732
|
+
ctx.set('X-Dir-Truncated', `${maxDirEntries}`);
|
|
1733
|
+
}
|
|
1410
1734
|
parts.push("<table>");
|
|
1411
1735
|
parts.push("<thead>");
|
|
1412
1736
|
parts.push("<tr>");
|
|
@@ -1514,37 +1838,45 @@ module.exports = function koaClassicServer(
|
|
|
1514
1838
|
}
|
|
1515
1839
|
const items = rawItems.filter(Boolean);
|
|
1516
1840
|
|
|
1517
|
-
//
|
|
1841
|
+
// Places directories before non-directories; falls back to `tieBreaker`
|
|
1842
|
+
// when both items are in the same bucket. `effectiveType === 2` covers plain
|
|
1843
|
+
// dirs and dir-resolved symlinks, matching the rest of the listing logic.
|
|
1844
|
+
const compareDirsFirst = (a, b, tieBreaker) => {
|
|
1845
|
+
const aIsDir = a.effectiveType === 2;
|
|
1846
|
+
const bIsDir = b.effectiveType === 2;
|
|
1847
|
+
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
|
|
1848
|
+
return tieBreaker(a, b);
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1518
1851
|
items.sort((a, b) => {
|
|
1519
1852
|
let comparison = 0;
|
|
1520
1853
|
|
|
1521
1854
|
if (sortBy === 'name') {
|
|
1522
1855
|
comparison = a.name.localeCompare(b.name);
|
|
1523
1856
|
} 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
|
-
}
|
|
1857
|
+
comparison = compareDirsFirst(a, b, (x, y) => x.mimeType.localeCompare(y.mimeType));
|
|
1532
1858
|
} 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
|
-
}
|
|
1859
|
+
comparison = compareDirsFirst(a, b, (x, y) => x.sizeBytes - y.sizeBytes);
|
|
1541
1860
|
}
|
|
1542
1861
|
|
|
1543
1862
|
return sortOrder === 'desc' ? -comparison : comparison;
|
|
1544
1863
|
});
|
|
1545
1864
|
|
|
1865
|
+
// Pagination — slice the sorted items into the requested page (0-based).
|
|
1866
|
+
const pageSize = options.dirListing.entriesPerPage; // 0 disables pagination
|
|
1867
|
+
totalPages = pageSize > 0 ? Math.max(1, Math.ceil(items.length / pageSize)) : 1;
|
|
1868
|
+
const rawPage = parseInt(ctx.query.page, 10);
|
|
1869
|
+
const requestedPage = Number.isFinite(rawPage) && rawPage >= 0 ? rawPage : 0;
|
|
1870
|
+
currentPage = Math.min(requestedPage, totalPages - 1); // silent clamp
|
|
1871
|
+
const visibleItems = (pageSize > 0 && items.length > pageSize)
|
|
1872
|
+
? items.slice(currentPage * pageSize, (currentPage + 1) * pageSize)
|
|
1873
|
+
: items;
|
|
1874
|
+
if (totalPages > 1) {
|
|
1875
|
+
ctx.set('X-Dir-Pagination', `${currentPage}/${totalPages - 1}`);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1546
1878
|
// Generate HTML for sorted items
|
|
1547
|
-
for (const item of
|
|
1879
|
+
for (const item of visibleItems) {
|
|
1548
1880
|
let rowStart = '';
|
|
1549
1881
|
if (item.effectiveType === 1) {
|
|
1550
1882
|
rowStart = `<tr><td> FILE `;
|
|
@@ -1573,6 +1905,47 @@ module.exports = function koaClassicServer(
|
|
|
1573
1905
|
parts.push("</tbody>");
|
|
1574
1906
|
parts.push("</table>");
|
|
1575
1907
|
|
|
1908
|
+
// Numbered paginator with First/Prev/Next/Last and ellipsis around the current page.
|
|
1909
|
+
// Only emitted when pagination is meaningful (computed above; reuses currentPage/totalPages).
|
|
1910
|
+
if (totalPages > 1) {
|
|
1911
|
+
const pageWindow = 2;
|
|
1912
|
+
const pagesToShow = new Set([0, totalPages - 1]);
|
|
1913
|
+
for (let i = Math.max(0, currentPage - pageWindow); i <= Math.min(totalPages - 1, currentPage + pageWindow); i++) {
|
|
1914
|
+
pagesToShow.add(i);
|
|
1915
|
+
}
|
|
1916
|
+
const sortedPages = [...pagesToShow].sort((a, b) => a - b);
|
|
1917
|
+
|
|
1918
|
+
const pager = ['<nav class="kcs-pagination" aria-label="Pagination">'];
|
|
1919
|
+
if (currentPage > 0) {
|
|
1920
|
+
pager.push(`<a href="${escapeHtml(buildQueryUrl(0))}">« First</a>`);
|
|
1921
|
+
pager.push(`<a href="${escapeHtml(buildQueryUrl(currentPage - 1))}">‹ Prev</a>`);
|
|
1922
|
+
} else {
|
|
1923
|
+
pager.push(`<span class="kcs-page-disabled">« First</span>`);
|
|
1924
|
+
pager.push(`<span class="kcs-page-disabled">‹ Prev</span>`);
|
|
1925
|
+
}
|
|
1926
|
+
let prev = -1;
|
|
1927
|
+
for (const p of sortedPages) {
|
|
1928
|
+
if (prev !== -1 && p - prev > 1) {
|
|
1929
|
+
pager.push(`<span class="kcs-page-ellipsis">…</span>`);
|
|
1930
|
+
}
|
|
1931
|
+
if (p === currentPage) {
|
|
1932
|
+
pager.push(`<span class="kcs-page-current">${p}</span>`);
|
|
1933
|
+
} else {
|
|
1934
|
+
pager.push(`<a href="${escapeHtml(buildQueryUrl(p))}">${p}</a>`);
|
|
1935
|
+
}
|
|
1936
|
+
prev = p;
|
|
1937
|
+
}
|
|
1938
|
+
if (currentPage < totalPages - 1) {
|
|
1939
|
+
pager.push(`<a href="${escapeHtml(buildQueryUrl(currentPage + 1))}">Next ›</a>`);
|
|
1940
|
+
pager.push(`<a href="${escapeHtml(buildQueryUrl(totalPages - 1))}">Last »</a>`);
|
|
1941
|
+
} else {
|
|
1942
|
+
pager.push(`<span class="kcs-page-disabled">Next ›</span>`);
|
|
1943
|
+
pager.push(`<span class="kcs-page-disabled">Last »</span>`);
|
|
1944
|
+
}
|
|
1945
|
+
pager.push('</nav>');
|
|
1946
|
+
parts.push(pager.join(''));
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1576
1949
|
const tableHtml = parts.join('');
|
|
1577
1950
|
|
|
1578
1951
|
const html = `
|