koa-classic-server 2.6.1 → 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.
Files changed (57) hide show
  1. package/CLAUDE.md +101 -0
  2. package/README.md +564 -591
  3. package/__tests__/benchmark-results-v3.0.0.txt +372 -0
  4. package/__tests__/benchmark.js +1 -1
  5. package/__tests__/caching-headers.test.js +30 -30
  6. package/__tests__/compression-fixtures/data.json +1 -0
  7. package/__tests__/compression-fixtures/large.txt +1 -0
  8. package/__tests__/compression-fixtures/small.txt +1 -0
  9. package/__tests__/compression.test.js +284 -0
  10. package/__tests__/customTest/serversToLoad.util.js +5 -5
  11. package/__tests__/demo-regex-index.js +4 -4
  12. package/__tests__/deprecation-warnings.test.js +71 -183
  13. package/__tests__/directory-sorting-links.test.js +1 -1
  14. package/__tests__/dt-unknown.test.js +39 -28
  15. package/__tests__/ejs.test.js +1 -1
  16. package/__tests__/hidden-fixtures/.dot-dir/inside.txt +1 -0
  17. package/__tests__/hidden-fixtures/.env +2 -0
  18. package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +1 -0
  19. package/__tests__/hidden-fixtures/config/secrets/password.txt +1 -0
  20. package/__tests__/hidden-fixtures/data.key +1 -0
  21. package/__tests__/hidden-fixtures/file.secret +1 -0
  22. package/__tests__/hidden-fixtures/index.html +1 -0
  23. package/__tests__/hidden-fixtures/normal.txt +1 -0
  24. package/__tests__/hidden-fixtures/subdir/.env +1 -0
  25. package/__tests__/hidden-fixtures/subdir/regular.txt +1 -0
  26. package/__tests__/hidden-option.test.js +407 -0
  27. package/__tests__/hideExtension.test.js +70 -13
  28. package/__tests__/index-option.test.js +18 -16
  29. package/__tests__/index.test.js +14 -10
  30. package/__tests__/listing.test.js +437 -0
  31. package/__tests__/logger.test.js +232 -0
  32. package/__tests__/range-fixtures/sample.txt +1 -0
  33. package/__tests__/range.test.js +223 -0
  34. package/__tests__/security-headers.test.js +165 -0
  35. package/__tests__/security.test.js +148 -162
  36. package/__tests__/server-cache-fixtures/large.txt +1 -0
  37. package/__tests__/server-cache-fixtures/small.txt +1 -0
  38. package/__tests__/server-cache.test.js +594 -0
  39. package/__tests__/symlink.test.js +18 -15
  40. package/__tests__/template-timeout.test.js +321 -0
  41. package/docs/ACTION_PLAN.md +293 -0
  42. package/docs/CHANGELOG.md +289 -0
  43. package/docs/CODE_REVIEW.md +2 -0
  44. package/docs/DOCUMENTATION.md +259 -32
  45. package/docs/EXAMPLES_INDEX_OPTION.md +3 -3
  46. package/docs/FLOW_DIAGRAM.md +15 -13
  47. package/docs/INDEX_OPTION_PRIORITY.md +2 -2
  48. package/docs/OPTIMIZATION_HTTP_CACHING.md +2 -0
  49. package/docs/OPTIMIZATION_ROADMAP_for_V3.md +864 -0
  50. package/docs/PERFORMANCE_COMPARISON.md +7 -7
  51. package/docs/security_improvement_for_V3.md +421 -0
  52. package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +5 -5
  53. package/docs/template-engine/esempi-incrementali.js +1 -1
  54. package/eslint.config.mjs +17 -0
  55. package/index.cjs +1507 -429
  56. package/index.mjs +1 -5
  57. package/package.json +9 -1
package/index.cjs CHANGED
@@ -2,25 +2,483 @@
2
2
  const { URL } = require("url");
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
+ const crypto = require("crypto");
6
+ const zlib = require("zlib");
7
+ const util = require("util");
5
8
  const mime = require("mime-types");
6
-
7
- // koa-classic-server - Performance optimized version
8
- // Version: 2.0.0
9
- // Optimizations applied (v2.0.0):
10
- // - All sync operations converted to async (non-blocking event loop)
11
- // - String concatenation replaced with array join (30-40% less memory)
12
- // - HTTP caching with ETag and Last-Modified (80-95% bandwidth reduction)
13
- // - Conditional requests support (304 Not Modified)
9
+ const { Readable } = require('stream');
10
+
11
+ const _brotliCompressAsync = util.promisify(zlib.brotliCompress);
12
+ const _gzipAsync = util.promisify(zlib.gzip);
13
+
14
+ // Pre-computed module-level constants
15
+ const _LOG_1024 = Math.log(1024);
16
+
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;
22
+
23
+ // Default list of MIME types that benefit from compression.
24
+ // User-provided compression.mimeTypes replaces this list entirely.
25
+ const DEFAULT_COMPRESSIBLE_MIME_TYPES = [
26
+ 'text/html',
27
+ 'text/css',
28
+ 'text/javascript',
29
+ 'text/plain',
30
+ 'text/xml',
31
+ 'text/csv',
32
+ 'application/javascript',
33
+ 'application/json',
34
+ 'application/xml',
35
+ 'application/wasm',
36
+ 'image/svg+xml',
37
+ ];
38
+
39
+ // CSS for the directory listing page — extracted so its SHA-256 hash can be
40
+ // computed once at module load time and placed in the Content-Security-Policy header.
41
+ const LISTING_CSS = `
42
+ body {
43
+ font-family: Arial, sans-serif;
44
+ margin: 20px;
45
+ }
46
+ h1 {
47
+ border-bottom: 1px solid #ddd;
48
+ padding-bottom: 10px;
49
+ }
50
+ table {
51
+ border-collapse: collapse;
52
+ width: 100%;
53
+ max-width: 800px;
54
+ }
55
+ thead {
56
+ background-color: #f5f5f5;
57
+ border-bottom: 2px solid #ddd;
58
+ }
59
+ th {
60
+ text-align: left;
61
+ padding: 10px;
62
+ font-weight: bold;
63
+ border-bottom: 2px solid #ddd;
64
+ }
65
+ td {
66
+ padding: 8px 10px;
67
+ border-bottom: 1px solid #eee;
68
+ }
69
+ tr:hover {
70
+ background-color: #f9f9f9;
71
+ }
72
+ a {
73
+ color: #0066cc;
74
+ text-decoration: none;
75
+ }
76
+ a:hover {
77
+ text-decoration: underline;
78
+ }
79
+ th:nth-child(1), td:nth-child(1) { width: 50%; }
80
+ th:nth-child(2), td:nth-child(2) { width: 30%; }
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
+ }
112
+ `;
113
+
114
+ // SHA-256 hash of the listing CSS, computed once at startup (zero per-request overhead).
115
+ const _listingCssHash = 'sha256-' + crypto.createHash('sha256').update(LISTING_CSS, 'utf8').digest('base64');
116
+
117
+ // CSP for the directory listing page (has inline CSS → hash-based allowance).
118
+ const LISTING_CSP = `default-src 'none'; style-src '${_listingCssHash}'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`;
119
+
120
+ // CSP for error/404 pages (no inline CSS → fully restrictive).
121
+ const NOT_FOUND_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
122
+
123
+ // Sets security headers on all middleware-generated HTML pages (listing + error).
124
+ // Must NOT be called for user files served from disk.
125
+ function setGeneratedPageHeaders(ctx, csp) {
126
+ ctx.set('Content-Security-Policy', csp);
127
+ ctx.set('X-Content-Type-Options', 'nosniff');
128
+ ctx.set('X-Frame-Options', 'DENY');
129
+ ctx.set('Referrer-Policy', 'no-referrer');
130
+ ctx.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
131
+ }
132
+
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>
137
+ <html>
138
+ <head>
139
+ <meta charset="UTF-8">
140
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
141
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
142
+ <title>${title}</title>
143
+ </head>
144
+ <body>
145
+ <h1>${heading}</h1>
146
+ <h3>${message}</h3>
147
+ </body>
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.');
154
+
155
+ function sendNotFound(ctx) {
156
+ setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
157
+ ctx.status = 404;
158
+ ctx.body = _NOT_FOUND_HTML;
159
+ }
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).
14
205
  //
15
- // Security fixes (from v1.2.0):
16
- // - Path Traversal vulnerability protection
17
- // - Status code 404 properly set
18
- // - Template rendering error handling
19
- // - Race condition file access protection
20
- // - Proper file extension extraction
21
- // - fs.readdir error handling
22
- // - Content-Disposition properly quoted
23
- // - XSS protection in directory listing
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
+
263
+ // Single-pass HTML escaping — one regex scan, one allocation, lookup table compiled once.
264
+ const _HTML_ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
265
+ const _HTML_ESCAPE_RE = /[&<>"']/g;
266
+
267
+ function escapeHtml(unsafe) {
268
+ if (typeof unsafe !== 'string') return unsafe;
269
+ return unsafe.replace(_HTML_ESCAPE_RE, c => _HTML_ESCAPE_MAP[c]);
270
+ }
271
+
272
+ // Pure helper — depends only on _LOG_1024 (module scope), safe to hoist.
273
+ function formatSize(bytes) {
274
+ if (bytes === 0) return '0 B';
275
+ if (bytes === undefined || bytes === null) return '-';
276
+
277
+ const k = 1024;
278
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
279
+ const i = Math.floor(Math.log(bytes) / _LOG_1024);
280
+
281
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
282
+ }
283
+
284
+ // Returns the dirent numeric type using the official Node.js API instead of
285
+ // the internal Symbol hack: 1=file, 2=dir, 3=symlink, 0=DT_UNKNOWN.
286
+ function getDirentType(dirent) {
287
+ if (dirent.isFile()) return 1;
288
+ if (dirent.isDirectory()) return 2;
289
+ if (dirent.isSymbolicLink()) return 3;
290
+ return 0;
291
+ }
292
+
293
+ /**
294
+ * Parse a "Range: bytes=..." header against a known file size.
295
+ * Only single ranges are supported; multi-range requests are treated as invalid.
296
+ *
297
+ * Returns:
298
+ * { start, end } — valid range (both inclusive, 0-based)
299
+ * 'invalid' — malformed or multi-range → caller should serve full 200
300
+ * 'unsatisfiable' — out of bounds → caller should return 416
301
+ */
302
+ function parseRangeHeader(rangeHeader, fileSize) {
303
+ if (!rangeHeader.startsWith('bytes=')) return 'invalid';
304
+
305
+ const spec = rangeHeader.slice(6);
306
+
307
+ // Reject multi-range (comma-separated)
308
+ if (spec.includes(',')) return 'invalid';
309
+
310
+ const dashIdx = spec.indexOf('-');
311
+ if (dashIdx === -1) return 'invalid';
312
+
313
+ const startStr = spec.slice(0, dashIdx);
314
+ const endStr = spec.slice(dashIdx + 1);
315
+
316
+ let start, end;
317
+
318
+ if (startStr === '') {
319
+ // Suffix range: bytes=-N (last N bytes)
320
+ if (endStr === '') return 'invalid';
321
+ const suffix = parseInt(endStr, 10);
322
+ if (isNaN(suffix) || suffix <= 0) return 'invalid';
323
+ if (fileSize === 0) return 'unsatisfiable';
324
+ start = suffix >= fileSize ? 0 : fileSize - suffix;
325
+ end = fileSize - 1;
326
+ } else {
327
+ start = parseInt(startStr, 10);
328
+ if (isNaN(start) || start < 0) return 'invalid';
329
+ if (fileSize === 0 || start >= fileSize) return 'unsatisfiable';
330
+
331
+ if (endStr === '') {
332
+ // Open range: bytes=N-
333
+ end = fileSize - 1;
334
+ } else {
335
+ end = parseInt(endStr, 10);
336
+ if (isNaN(end) || end < 0) return 'invalid';
337
+ if (start > end) return 'invalid';
338
+ // Clamp end to file size - 1
339
+ if (end >= fileSize) end = fileSize - 1;
340
+ }
341
+ }
342
+
343
+ return { start, end };
344
+ }
345
+
346
+ // LFU cache with O(1) eviction using frequency buckets.
347
+ // peek(key) — read without touching frequency (for staleness checks)
348
+ // get(key) — read and increment frequency
349
+ // set(key, entry) — insert, evicting LFU entries if needed
350
+ // delete(key) — remove explicitly (e.g. stale entry before re-insert)
351
+ class LFUCache {
352
+ constructor(maxSize, warnInterval, cacheLabel, logger) {
353
+ this.maxSize = maxSize;
354
+ this.warnInterval = warnInterval;
355
+ this.cacheLabel = cacheLabel;
356
+ this.logger = logger || console;
357
+ this.currentSize = 0;
358
+ this._keyMap = new Map(); // key → { buffer, mtime, size, insertedAt, freq }
359
+ this._freqMap = new Map(); // freq → Set<key>
360
+ this._minFreq = 0;
361
+ this._lastWarnAt = 0;
362
+ }
363
+
364
+ get size() { return this._keyMap.size; }
365
+
366
+ // Returns entry without incrementing frequency — safe for staleness checks.
367
+ peek(key) {
368
+ return this._keyMap.get(key);
369
+ }
370
+
371
+ // Returns entry and increments its frequency.
372
+ get(key) {
373
+ if (!this._keyMap.has(key)) return undefined;
374
+ this._incrementFreq(key);
375
+ return this._keyMap.get(key);
376
+ }
377
+
378
+ set(key, entry) {
379
+ while (this.currentSize + entry.buffer.length > this.maxSize && this._keyMap.size > 0) {
380
+ this._evictOne();
381
+ }
382
+ if (this.currentSize + entry.buffer.length > this.maxSize) return; // entry too large for cache
383
+
384
+ this._keyMap.set(key, { ...entry, freq: 1 });
385
+ this._addToFreqBucket(key, 1);
386
+ this.currentSize += entry.buffer.length;
387
+ this._minFreq = 1;
388
+ }
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
+
410
+ delete(key) {
411
+ if (!this._keyMap.has(key)) return;
412
+ const { freq, buffer } = this._keyMap.get(key);
413
+ this.currentSize -= buffer.length;
414
+ this._keyMap.delete(key);
415
+ const bucket = this._freqMap.get(freq);
416
+ if (bucket) {
417
+ bucket.delete(key);
418
+ if (bucket.size === 0) this._freqMap.delete(freq);
419
+ }
420
+ // _minFreq may be stale after external delete — reset to 1 on next set()
421
+ }
422
+
423
+ _incrementFreq(key) {
424
+ const entry = this._keyMap.get(key);
425
+ const oldFreq = entry.freq;
426
+ const newFreq = oldFreq + 1;
427
+ entry.freq = newFreq;
428
+ const oldBucket = this._freqMap.get(oldFreq);
429
+ oldBucket.delete(key);
430
+ if (oldBucket.size === 0) {
431
+ this._freqMap.delete(oldFreq);
432
+ if (this._minFreq === oldFreq) this._minFreq = newFreq;
433
+ }
434
+ this._addToFreqBucket(key, newFreq);
435
+ }
436
+
437
+ _addToFreqBucket(key, freq) {
438
+ if (!this._freqMap.has(freq)) this._freqMap.set(freq, new Set());
439
+ this._freqMap.get(freq).add(key);
440
+ }
441
+ _evictOne() {
442
+ // Recover from stale _minFreq (can happen after consecutive evictions)
443
+ while (this._freqMap.size > 0 && (!this._freqMap.has(this._minFreq) || this._freqMap.get(this._minFreq).size === 0)) {
444
+ this._freqMap.delete(this._minFreq);
445
+ if (this._freqMap.size === 0) return;
446
+ this._minFreq = Math.min(...this._freqMap.keys());
447
+ }
448
+ const bucket = this._freqMap.get(this._minFreq);
449
+ if (!bucket || bucket.size === 0) return;
450
+
451
+ const evictKey = bucket.values().next().value; // FIFO within same freq
452
+ const { buffer } = this._keyMap.get(evictKey);
453
+ this.currentSize -= buffer.length;
454
+ this._keyMap.delete(evictKey);
455
+ bucket.delete(evictKey);
456
+ if (bucket.size === 0) this._freqMap.delete(this._minFreq);
457
+
458
+ if (this.warnInterval !== false) {
459
+ const now = Date.now();
460
+ if (now - this._lastWarnAt >= this.warnInterval) {
461
+ this.logger.warn(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
462
+ this._lastWarnAt = now;
463
+ }
464
+ }
465
+ }
466
+ }
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
+ }
24
482
 
25
483
  module.exports = function koaClassicServer(
26
484
  rootDir,
@@ -29,20 +487,41 @@ module.exports = function koaClassicServer(
29
487
  opts STRUCTURE
30
488
  opts = {
31
489
  method: ['GET'], // Supported methods, otherwise next() will be called
32
- showDirContents: true, // Show or hide directory contents
33
- index: ["index.html"], // Index file name(s) - ARRAY FORMAT (recommended):
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
+ },
510
+ index: ["index.html"], // Index file name(s) - must be an ARRAY:
34
511
  // - Array of strings: ["index.html", "index.htm", "default.html"]
35
- // - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
36
- // - Mixed array: ["index.html", /index\.[eE][jJ][sS]/]
512
+ // - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
513
+ // - Mixed array: ["index.html", /index\.[eE][jJ][sS]/]
37
514
  // Priority is determined by array order (first match wins)
38
- //
39
- // DEPRECATED: String format "index.html" is still supported but
40
- // will be removed in future versions. Use array format instead.
41
515
  urlPrefix: "", // URL path prefix
42
516
  urlsReserved: [], // Reserved paths (first level only)
43
517
  template: {
44
- render: undefined, // Template rendering function: async (ctx, next, filePath) => {}
518
+ render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
45
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.
46
525
  },
47
526
  browserCacheMaxAge: 3600, // Browser Cache-Control max-age in seconds (default: 1 hour)
48
527
  browserCacheEnabled: false, // Enable browser HTTP caching headers (ETag, Last-Modified)
@@ -55,14 +534,57 @@ module.exports = function koaClassicServer(
55
534
  ext: '.ejs', // Extension to hide (required, string, case-sensitive, must start with '.')
56
535
  redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
57
536
  },
537
+ hidden: { // Block files/dirs from listing and serving (HTTP 404)
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".
542
+ whitelist: [], // Always visible (string exact/glob or RegExp). Overrides default and alwaysHide.
543
+ blacklist: [], // Always hidden (string or RegExp). Overrides whitelist.
544
+ },
545
+ dotDirs: { // Dot-directories: visible by default
546
+ default: 'visible', // 'hidden' | 'visible' — system default: 'visible'
547
+ whitelist: [],
548
+ blacklist: [],
549
+ },
550
+ alwaysHide: [], // Path-aware patterns (string glob or RegExp) for any file/dir.
551
+ // Secondary to dotFiles/dotDirs whitelist and blacklist.
552
+ // Examples: ['*.secret', 'config/secrets/**', /\.key$/]
553
+ },
554
+ serverCache: { // Server-side in-memory caches (independent of browser HTTP caching)
555
+ rawFile: {
556
+ enabled: false, // enable in-memory cache of raw file buffers
557
+ maxSize: 52428800, // max total RAM used by this cache (bytes; default: 50 MB)
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.
564
+ warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
565
+ },
566
+ compressedFile: { // cache for HTTP br/gzip responses — not for .zip/.tar files on disk
567
+ enabled: true, // enable in-memory cache of compressed response buffers
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.
570
+ warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
571
+ },
572
+ },
573
+ compression: { // Response compression (gzip / brotli) — to enable/disable caching → serverCache.compressedFile
574
+ enabled: true, // master switch (false = disable all compression)
575
+ encodings: ['br', 'gzip'], // algorithms in priority order; [] = disable
576
+ minFileSize: 1024, // min file size in bytes to compress; false = no minimum
577
+ mimeTypes: [], // compressible MIME types (replaces default list if provided)
578
+ },
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.
58
584
 
59
- // DEPRECATED OPTIONS (maintained for backward compatibility):
60
- // cacheMaxAge: use browserCacheMaxAge instead
61
- // enableCaching: use browserCacheEnabled instead
62
585
  }
63
586
  */
64
587
  ) {
65
- // Validate rootDir
66
588
  if (!rootDir || typeof rootDir !== 'string') {
67
589
  throw new TypeError('rootDir must be a non-empty string');
68
590
  }
@@ -70,30 +592,97 @@ module.exports = function koaClassicServer(
70
592
  throw new Error('rootDir must be an absolute path');
71
593
  }
72
594
 
73
- // Normalize rootDir to prevent issues
74
595
  const normalizedRootDir = path.resolve(rootDir);
75
596
 
76
- // Set default options
77
597
  const options = opts || {};
78
598
  options.template = opts.template || {};
79
599
 
600
+ const _logger = normalizeLogger(options.logger);
601
+
80
602
  options.method = Array.isArray(options.method) ? options.method : ['GET'];
81
- options.showDirContents = typeof options.showDirContents === 'boolean' ? options.showDirContents : true;
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
+ };
82
674
 
83
675
  // Normalize index option to array format
84
676
  if (typeof options.index === 'string') {
85
- // DEPRECATION WARNING: String format is deprecated
86
677
  if (options.index) {
87
- console.warn(
88
- '\x1b[33m%s\x1b[0m',
89
- '[koa-classic-server] DEPRECATION WARNING: Passing a string to the "index" option is deprecated and may be removed in future versions.\n' +
90
- ` Current usage: index: "${options.index}"\n` +
91
- ` Recommended: index: ["${options.index}"]\n` +
92
- ' Please update your configuration to use an array format.'
678
+ // v3.0.0: non-empty string format removed
679
+ throw new Error(
680
+ '[koa-classic-server] The "index" option no longer accepts a string in v3.0.0.\n' +
681
+ ` Replace with: index: ["${options.index}"]`
93
682
  );
94
683
  }
95
- // Single string → convert to array with one element
96
- options.index = options.index ? [options.index] : [];
684
+ // Empty string → silently treat as no index (empty array)
685
+ options.index = [];
97
686
  } else if (Array.isArray(options.index)) {
98
687
  // Already an array → validate elements are strings or RegExp
99
688
  options.index = options.index.filter(item =>
@@ -105,39 +694,38 @@ module.exports = function koaClassicServer(
105
694
  }
106
695
 
107
696
  options.urlPrefix = typeof options.urlPrefix === 'string' ? options.urlPrefix : "";
697
+ const _urlPrefixParts = options.urlPrefix.split("/");
108
698
  options.urlsReserved = Array.isArray(options.urlsReserved) ? options.urlsReserved : [];
109
699
  options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
110
700
  options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
111
701
 
112
- // OPTIMIZATION: HTTP Caching options
113
- // NOTE: Default browserCacheEnabled is false for development environments.
114
- // For production deployments, it's strongly recommended to enable caching
115
- // by setting browserCacheEnabled: true to benefit from reduced bandwidth and improved performance.
116
-
117
- // DEPRECATION: Handle legacy option names for backward compatibility
118
- if ('cacheMaxAge' in opts && !('browserCacheMaxAge' in opts)) {
119
- console.warn(
120
- '\x1b[33m%s\x1b[0m',
121
- '[koa-classic-server] DEPRECATION WARNING: The "cacheMaxAge" option is deprecated and will be removed in future versions.\n' +
122
- ' Current usage: cacheMaxAge: ' + opts.cacheMaxAge + '\n' +
123
- ' Recommended: browserCacheMaxAge: ' + opts.cacheMaxAge + '\n' +
124
- ' Please update your configuration to use the new option name.'
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)
125
712
  );
126
- options.browserCacheMaxAge = opts.cacheMaxAge;
127
713
  }
128
714
 
129
- if ('enableCaching' in opts && !('browserCacheEnabled' in opts)) {
130
- console.warn(
131
- '\x1b[33m%s\x1b[0m',
132
- '[koa-classic-server] DEPRECATION WARNING: The "enableCaching" option is deprecated and will be removed in future versions.\n' +
133
- ' Current usage: enableCaching: ' + opts.enableCaching + '\n' +
134
- ' Recommended: browserCacheEnabled: ' + opts.enableCaching + '\n' +
135
- ' Please update your configuration to use the new option name.'
715
+ // v3.0.0: removed legacy option names throw to surface the breaking change clearly
716
+ if ('cacheMaxAge' in opts) {
717
+ throw new Error(
718
+ '[koa-classic-server] The "cacheMaxAge" option was removed in v3.0.0.\n' +
719
+ ' Replace with: browserCacheMaxAge: ' + opts.cacheMaxAge
720
+ );
721
+ }
722
+ if ('enableCaching' in opts) {
723
+ throw new Error(
724
+ '[koa-classic-server] The "enableCaching" option was removed in v3.0.0.\n' +
725
+ ' Replace with: browserCacheEnabled: ' + opts.enableCaching
136
726
  );
137
- options.browserCacheEnabled = opts.enableCaching;
138
727
  }
139
728
 
140
- // Set new option names (with defaults)
141
729
  options.browserCacheMaxAge = typeof options.browserCacheMaxAge === 'number' && options.browserCacheMaxAge >= 0 ? options.browserCacheMaxAge : 3600;
142
730
  options.browserCacheEnabled = typeof options.browserCacheEnabled === 'boolean' ? options.browserCacheEnabled : false;
143
731
  options.useOriginalUrl = typeof options.useOriginalUrl === 'boolean' ? options.useOriginalUrl : true;
@@ -152,13 +740,12 @@ module.exports = function koaClassicServer(
152
740
  }
153
741
  // Normalize ext: add leading dot if missing
154
742
  if (!options.hideExtension.ext.startsWith('.')) {
155
- console.warn(
156
- '\x1b[33m%s\x1b[0m',
743
+ _logger.warn(...warnPayload(_logger,
157
744
  '[koa-classic-server] WARNING: hideExtension.ext should start with a dot.\n' +
158
745
  ` Current usage: ext: "${options.hideExtension.ext}"\n` +
159
746
  ` Corrected to: ext: ".${options.hideExtension.ext}"\n` +
160
747
  ' Please update your configuration.'
161
- );
748
+ ));
162
749
  options.hideExtension.ext = '.' + options.hideExtension.ext;
163
750
  }
164
751
  // Validate redirect code
@@ -171,6 +758,155 @@ module.exports = function koaClassicServer(
171
758
  }
172
759
  }
173
760
 
761
+ // Normalize and validate the hidden option into a clean internal structure.
762
+ function normalizeHiddenConfig(hidden) {
763
+ if (!hidden || typeof hidden !== 'object' || Array.isArray(hidden)) {
764
+ return {
765
+ dotFiles: { default: 'visible', whitelist: [], blacklist: [] },
766
+ dotDirs: { default: 'visible', whitelist: [], blacklist: [] },
767
+ alwaysHide: []
768
+ };
769
+ }
770
+
771
+ const filterPatternList = (arr) =>
772
+ Array.isArray(arr)
773
+ ? arr.filter(p => typeof p === 'string' || p instanceof RegExp)
774
+ : [];
775
+
776
+ function normalizeCategory(input, systemDefault, categoryName) {
777
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
778
+ return { default: systemDefault, whitelist: [], blacklist: [] };
779
+ }
780
+ if (input.default !== undefined && input.default !== 'hidden' && input.default !== 'visible') {
781
+ throw new Error(
782
+ `[koa-classic-server] hidden.${categoryName}.default must be "hidden" or "visible". Got: "${input.default}"`
783
+ );
784
+ }
785
+ return {
786
+ default: input.default !== undefined ? input.default : systemDefault,
787
+ whitelist: filterPatternList(input.whitelist),
788
+ blacklist: filterPatternList(input.blacklist),
789
+ };
790
+ }
791
+
792
+ return {
793
+ dotFiles: normalizeCategory(hidden.dotFiles, 'visible', 'dotFiles'),
794
+ dotDirs: normalizeCategory(hidden.dotDirs, 'visible', 'dotDirs'),
795
+ alwaysHide: filterPatternList(hidden.alwaysHide),
796
+ };
797
+ }
798
+
799
+ const hiddenConfig = normalizeHiddenConfig(options.hidden);
800
+
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) {
805
+ for (const pattern of patterns) {
806
+ if (pattern instanceof RegExp) {
807
+ if (pattern.test(value)) return true;
808
+ } else if (typeof pattern === 'string') {
809
+ if (globMatch(value, pattern)) return true;
810
+ }
811
+ }
812
+ return false;
813
+ }
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
+
826
+ // Matches a bare filename against a simple glob pattern (* = any chars except /, ? = one char).
827
+ function nameGlobMatch(name, pattern) {
828
+ if (!pattern.includes('*') && !pattern.includes('?')) {
829
+ return name === pattern;
830
+ }
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);
843
+ }
844
+
845
+ // Match against a list using path-aware glob semantics (anchored to rootDir, supports **).
846
+ function matchesPathList(relPath, patterns) {
847
+ return matchesPatternList(relPath, patterns, pathGlobMatch);
848
+ }
849
+
850
+ /**
851
+ * Matches a relative path against a glob pattern (path-aware).
852
+ * - Pattern without '/': matches the basename at any depth (e.g. '*.secret')
853
+ * - Pattern with '/': anchored to rootDir (e.g. 'config/secrets/**')
854
+ * - '*' matches any characters except '/'
855
+ * - '**' matches any characters including '/'
856
+ * - '?' matches any single character except '/'
857
+ */
858
+ function pathGlobMatch(relPath, pattern) {
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);
877
+ }
878
+
879
+ /**
880
+ * Returns true if a filesystem entry should be hidden (blocked from listing and serving).
881
+ *
882
+ * Priority (highest to lowest):
883
+ * 1. blacklist (dotFiles/dotDirs) — always hidden, beats everything
884
+ * 2. whitelist (dotFiles/dotDirs) — always visible, overrides alwaysHide and default
885
+ * 3. alwaysHide — path-aware, overrides default
886
+ * 4. default (dotFiles/dotDirs) — 'hidden' or 'visible' for unmatched dot-entries
887
+ *
888
+ * Non-dot entries are only affected by alwaysHide.
889
+ *
890
+ * @param {string} name - Basename of the file or directory
891
+ * @param {string} relPath - Relative path from rootDir (e.g. "subdir/.env")
892
+ * @param {boolean} isDir - True if the entry is a directory
893
+ */
894
+ function isHiddenEntry(name, relPath, isDir) {
895
+ const isDot = name.startsWith('.');
896
+
897
+ if (isDot) {
898
+ const category = isDir ? hiddenConfig.dotDirs : hiddenConfig.dotFiles;
899
+
900
+ if (matchesNameList(name, category.blacklist)) return true;
901
+ if (matchesNameList(name, category.whitelist)) return false;
902
+ if (matchesPathList(relPath, hiddenConfig.alwaysHide)) return true;
903
+
904
+ return category.default === 'hidden';
905
+ }
906
+
907
+ return matchesPathList(relPath, hiddenConfig.alwaysHide);
908
+ }
909
+
174
910
  /**
175
911
  * Returns true if dirent is a regular file or a symlink pointing to a regular file.
176
912
  * Uses fs.promises.stat (which follows symlinks) when dirent.isSymbolicLink() is true,
@@ -204,35 +940,209 @@ module.exports = function koaClassicServer(
204
940
  return false;
205
941
  }
206
942
 
207
- /**
208
- * Returns true if dirent is a directory or a symlink pointing to a directory.
209
- * Uses fs.promises.stat (which follows symlinks) when dirent.isSymbolicLink() is true,
210
- * or when the dirent type is unknown (DT_UNKNOWN / type 0).
211
- */
212
- async function isDirOrSymlinkToDir(dirent, dirPath) {
213
- if (dirent.isDirectory()) return true;
214
- if (dirent.isSymbolicLink()) {
215
- try {
216
- const realStat = await fs.promises.stat(path.join(dirPath, dirent.name));
217
- return realStat.isDirectory();
218
- } catch {
219
- return false; // Broken or circular symlink
943
+ // Normalize and validate the compression option into a clean internal structure.
944
+ // compression: false is a valid shorthand for { enabled: false }.
945
+ function normalizeCompressionConfig(compression) {
946
+ if (compression === false) return { enabled: false };
947
+
948
+ if (!compression || typeof compression !== 'object' || Array.isArray(compression)) {
949
+ return {
950
+ enabled: true,
951
+ encodings: ['br', 'gzip'], // priority order: brotli first, gzip as fallback
952
+ minFileSize: 1024, // bytes; skip compression for files smaller than this
953
+ mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
954
+ };
955
+ }
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
+
965
+ const enabled = typeof compression.enabled === 'boolean' ? compression.enabled : true;
966
+ if (!enabled) return { enabled: false };
967
+
968
+ const encodings = Array.isArray(compression.encodings)
969
+ ? compression.encodings.filter(e => e === 'br' || e === 'gzip')
970
+ : ['br', 'gzip'];
971
+
972
+ const minFileSize = compression.minFileSize === false ? false
973
+ : (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0 ? compression.minFileSize : 1024);
974
+
975
+ const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
976
+ ? compression.mimeTypes
977
+ : DEFAULT_COMPRESSIBLE_MIME_TYPES;
978
+
979
+ return { enabled, encodings, minFileSize, mimeTypes: new Set(mimeTypes) };
980
+ }
981
+
982
+ // Normalize and validate the serverCache option into a clean internal structure.
983
+ function normalizeServerCacheConfig(serverCache) {
984
+ const defaultRawFile = {
985
+ enabled: false,
986
+ maxSize: 52428800, // 50 MB
987
+ maxFileSize: 1048576, // 1 MB
988
+ maxAge: 0,
989
+ warnInterval: 60000,
990
+ };
991
+ const defaultCompressedFile = {
992
+ enabled: true,
993
+ maxSize: 104857600, // 100 MB
994
+ maxAge: 0,
995
+ warnInterval: 60000,
996
+ };
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
+ );
220
1005
  }
1006
+ return value;
221
1007
  }
222
- // DT_UNKNOWN fallback: resolve via stat() when type is unknown
223
- if (!dirent.isFile() && !dirent.isBlockDevice() && !dirent.isCharacterDevice() && !dirent.isFIFO() && !dirent.isSocket()) {
224
- try {
225
- const realStat = await fs.promises.stat(path.join(dirPath, dirent.name));
226
- return realStat.isDirectory();
227
- } catch {
228
- return false;
1008
+
1009
+ if (!serverCache || typeof serverCache !== 'object' || Array.isArray(serverCache)) {
1010
+ return { rawFile: defaultRawFile, compressedFile: defaultCompressedFile };
1011
+ }
1012
+
1013
+ const rf = serverCache.rawFile;
1014
+ const rawFile = (!rf || typeof rf !== 'object' || Array.isArray(rf)) ? defaultRawFile : {
1015
+ enabled: typeof rf.enabled === 'boolean' ? rf.enabled : false,
1016
+ maxSize: typeof rf.maxSize === 'number' && rf.maxSize > 0 ? rf.maxSize : 52428800,
1017
+ maxFileSize: typeof rf.maxFileSize === 'number' && rf.maxFileSize > 0 ? rf.maxFileSize : 1048576,
1018
+ maxAge: validateMaxAge(rf.maxAge, 'rawFile'),
1019
+ warnInterval: rf.warnInterval === false ? false : (typeof rf.warnInterval === 'number' ? rf.warnInterval : 60000),
1020
+ };
1021
+
1022
+ const cf = serverCache.compressedFile;
1023
+ const compressedFile = (!cf || typeof cf !== 'object' || Array.isArray(cf)) ? defaultCompressedFile : {
1024
+ enabled: typeof cf.enabled === 'boolean' ? cf.enabled : true,
1025
+ maxSize: typeof cf.maxSize === 'number' && cf.maxSize > 0 ? cf.maxSize : 104857600,
1026
+ maxAge: validateMaxAge(cf.maxAge, 'compressedFile'),
1027
+ warnInterval: cf.warnInterval === false ? false : (typeof cf.warnInterval === 'number' ? cf.warnInterval : 60000),
1028
+ };
1029
+
1030
+ return { rawFile, compressedFile };
1031
+ }
1032
+
1033
+ const compressionConfig = normalizeCompressionConfig(options.compression);
1034
+ const serverCacheConfig = normalizeServerCacheConfig(options.serverCache);
1035
+
1036
+ // In-memory LFU cache for raw file buffers (serverCache.rawFile).
1037
+ // Key: absoluteFilePath — O(1) eviction via frequency-bucket structure.
1038
+ const _rawFileCache = new LFUCache(
1039
+ serverCacheConfig.rawFile.maxSize,
1040
+ serverCacheConfig.rawFile.warnInterval,
1041
+ 'rawFile',
1042
+ _logger
1043
+ );
1044
+
1045
+ // In-memory LFU cache for compressed file buffers (serverCache.compressedFile).
1046
+ // Key: `${absoluteFilePath}:${encoding}` — O(1) eviction via frequency-bucket structure.
1047
+ const _compressedFileCache = new LFUCache(
1048
+ serverCacheConfig.compressedFile.maxSize,
1049
+ serverCacheConfig.compressedFile.warnInterval,
1050
+ 'compressedFile',
1051
+ _logger
1052
+ );
1053
+
1054
+ // Returns the client's preferred encoding based on Accept-Encoding header,
1055
+ // filtered against the enabled encodings list. Returns null if no match.
1056
+ function getClientEncoding(acceptEncoding) {
1057
+ if (!acceptEncoding) return null;
1058
+ for (const enc of compressionConfig.encodings) {
1059
+ if (acceptEncoding.includes(enc)) return enc;
1060
+ }
1061
+ return null;
1062
+ }
1063
+
1064
+ // Compress a Buffer using the given encoding ('br' or 'gzip').
1065
+ // Quality is maxed out: serverCache pays this cost once per file, not per request.
1066
+ function compressBuffer(data, encoding) {
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 });
1073
+ }
1074
+
1075
+ /**
1076
+ * Build a Content-Disposition header value for inline serving.
1077
+ *
1078
+ * Uses both the legacy quoted-string form (ASCII fallback) and the RFC 5987
1079
+ * extended form (UTF-8 percent-encoded) for maximum browser compatibility:
1080
+ * inline; filename="ascii-safe"; filename*=UTF-8''percent-encoded
1081
+ *
1082
+ * The quoted-string form escapes only double-quotes; the RFC 5987 form
1083
+ * percent-encodes every byte that is not an unreserved URI character.
1084
+ * Browsers that support filename* prefer it over filename (RFC 6266 §4.1).
1085
+ */
1086
+ function buildContentDisposition(filename) {
1087
+ // quoted-string fallback: escape " and \ so the value is always valid ASCII
1088
+ const asciiSafe = filename.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1089
+
1090
+ // RFC 5987 extended value: UTF-8 percent-encode everything except
1091
+ // unreserved chars (ALPHA / DIGIT / "-" / "." / "_" / "~")
1092
+ const rfc5987 = encodeURIComponent(filename)
1093
+ .replace(/['()]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
1094
+
1095
+ return `inline; filename="${asciiSafe}"; filename*=UTF-8''${rfc5987}`;
1096
+ }
1097
+
1098
+ // Find the first matching index file in a directory.
1099
+ // Fast-path: string patterns use a direct stat() — no readdir needed.
1100
+ // Slow-path: RegExp patterns trigger a single lazy readdir(), shared across
1101
+ // all RegExp patterns in the array.
1102
+ async function findIndexFile(dirPath, indexPatterns) {
1103
+ let fileNames = null; // populated lazily on first RegExp pattern
1104
+
1105
+ for (const pattern of indexPatterns) {
1106
+ if (typeof pattern === 'string') {
1107
+ // Fast path: stat directly, zero readdir
1108
+ try {
1109
+ const fileStat = await fs.promises.stat(path.join(dirPath, pattern));
1110
+ if (fileStat.isFile()) return { name: pattern, stat: fileStat };
1111
+ } catch {
1112
+ continue; // file doesn't exist, try next pattern
1113
+ }
1114
+ } else if (pattern instanceof RegExp) {
1115
+ // Slow path: readdir once (lazy), reused for subsequent RegExp patterns
1116
+ if (!fileNames) {
1117
+ try {
1118
+ const dirents = await fs.promises.readdir(dirPath, { withFileTypes: true });
1119
+ const checkResults = await Promise.all(
1120
+ dirents.map(async dirent => ({
1121
+ name: dirent.name,
1122
+ isFile: await isFileOrSymlinkToFile(dirent, dirPath)
1123
+ }))
1124
+ );
1125
+ fileNames = checkResults.filter(e => e.isFile).map(e => e.name);
1126
+ } catch (error) {
1127
+ _logger.error('Error finding index file:', error);
1128
+ return null;
1129
+ }
1130
+ }
1131
+ const matchedFile = fileNames.find(name => pattern.test(name));
1132
+ if (matchedFile) {
1133
+ try {
1134
+ const fileStat = await fs.promises.stat(path.join(dirPath, matchedFile));
1135
+ if (fileStat.isFile()) return { name: matchedFile, stat: fileStat };
1136
+ } catch {
1137
+ continue; // file deleted between readdir and stat
1138
+ }
1139
+ }
229
1140
  }
230
1141
  }
231
- return false;
1142
+ return null;
232
1143
  }
233
1144
 
234
1145
  return async (ctx, next) => {
235
- // Check if method is allowed
236
1146
  if (!options.method.includes(ctx.method)) {
237
1147
  await next();
238
1148
  return;
@@ -240,7 +1150,8 @@ module.exports = function koaClassicServer(
240
1150
 
241
1151
  // Construct full URL based on useOriginalUrl option
242
1152
  const urlToUse = options.useOriginalUrl ? ctx.originalUrl : ctx.url;
243
- const fullUrl = ctx.protocol + '://' + ctx.host + urlToUse;
1153
+ const _origin = ctx.protocol + '://' + ctx.host;
1154
+ const fullUrl = _origin + urlToUse;
244
1155
  let pageHref = '';
245
1156
  if (fullUrl.charAt(fullUrl.length - 1) === '/') {
246
1157
  pageHref = new URL(fullUrl.slice(0, -1));
@@ -250,10 +1161,9 @@ module.exports = function koaClassicServer(
250
1161
 
251
1162
  // Check URL prefix
252
1163
  const a_pathname = pageHref.pathname.split("/");
253
- const a_urlPrefix = options.urlPrefix.split("/");
254
1164
 
255
- for (const key in a_urlPrefix) {
256
- if (a_urlPrefix[key] !== a_pathname[key]) {
1165
+ for (let i = 0; i < _urlPrefixParts.length; i++) {
1166
+ if (_urlPrefixParts[i] !== a_pathname[i]) {
257
1167
  await next();
258
1168
  return;
259
1169
  }
@@ -262,7 +1172,7 @@ module.exports = function koaClassicServer(
262
1172
  // Create pageHrefOutPrefix without URL prefix
263
1173
  let pageHrefOutPrefix = pageHref;
264
1174
  if (options.urlPrefix !== "") {
265
- let a_pathnameOutPrefix = a_pathname.slice(a_urlPrefix.length);
1175
+ let a_pathnameOutPrefix = a_pathname.slice(_urlPrefixParts.length);
266
1176
  let s_pathnameOutPrefix = a_pathnameOutPrefix.join("/");
267
1177
  let hrefOutPrefix = pageHref.origin + '/' + s_pathnameOutPrefix;
268
1178
  pageHrefOutPrefix = new URL(hrefOutPrefix);
@@ -279,8 +1189,7 @@ module.exports = function koaClassicServer(
279
1189
  }
280
1190
  }
281
1191
 
282
- // Path Traversal Protection
283
- // Construct safe file path
1192
+ // Path traversal protection: build and validate safe file path
284
1193
  let requestedPath = "";
285
1194
  if (pageHrefOutPrefix.pathname === "/") {
286
1195
  requestedPath = "";
@@ -288,37 +1197,69 @@ module.exports = function koaClassicServer(
288
1197
  requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
289
1198
  }
290
1199
 
291
- // Normalize path and prevent path traversal
1200
+ // Null byte guard: path.normalize() throws ERR_INVALID_ARG_VALUE for paths
1201
+ // containing \0. Reject early with 400 Bad Request before it reaches fs calls.
1202
+ if (requestedPath.includes('\0')) {
1203
+ ctx.status = 400;
1204
+ ctx.body = 'Bad Request';
1205
+ return;
1206
+ }
1207
+
292
1208
  const normalizedPath = path.normalize(requestedPath);
293
1209
  const fullPath = path.join(normalizedRootDir, normalizedPath);
294
1210
 
295
- // Security check: ensure resolved path is within rootDir
1211
+ // Security check: ensure resolved path is within rootDir.
1212
+ // Covers: ../ traversal, URL-encoded variants (%2e%2e%2f), and on Windows
1213
+ // backslash sequences (path.normalize converts \ to / before the check).
296
1214
  if (!fullPath.startsWith(normalizedRootDir)) {
297
1215
  ctx.status = 403;
298
1216
  ctx.body = 'Forbidden';
299
1217
  return;
300
1218
  }
301
1219
 
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().
1223
+ if (requestedPath !== '') {
1224
+ const segments = normalizedPath.split(path.sep).filter(Boolean);
1225
+ for (let i = 0; i < segments.length - 1; i++) {
1226
+ const segName = segments[i];
1227
+ const segRelPath = segments.slice(0, i + 1).join('/');
1228
+ if (isHiddenEntry(segName, segRelPath, true)) {
1229
+ sendNotFound(ctx);
1230
+ return;
1231
+ }
1232
+ }
1233
+ }
1234
+
302
1235
  let toOpen = fullPath;
303
1236
 
304
1237
  // hideExtension logic: redirect URLs with extension and resolve clean URLs
305
- // Track if original URL had trailing slash (stripped by pageHref construction above)
306
- const originalUrlPath = new URL(ctx.protocol + '://' + ctx.host + urlToUse).pathname;
307
- const hadTrailingSlash = originalUrlPath.length > 1 && originalUrlPath.endsWith('/');
308
-
309
1238
  if (options.hideExtension) {
310
1239
  const hideExt = options.hideExtension.ext;
311
1240
  const hideRedirect = options.hideExtension.redirect;
312
1241
 
313
- // Check if URL ends with the configured extension redirect to clean URL
314
- // Use the original path (before trailing slash stripping) for accurate matching
315
- const pathForExtCheck = hadTrailingSlash ? originalUrlPath.slice(0, -1) : requestedPath;
1242
+ // Trailing slash check via string avoids a full new URL() construction
1243
+ const rawPath = urlToUse.split('?')[0];
1244
+ const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
1245
+
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.
1249
+ const pathForExtCheck = hadTrailingSlash ? rawPath.slice(0, -1) : requestedPath;
316
1250
  if (pathForExtCheck.endsWith(hideExt)) {
317
1251
  // Build redirect target using ctx.originalUrl (always, regardless of useOriginalUrl)
318
- const originalUrlObj = new URL(ctx.protocol + '://' + ctx.host + ctx.originalUrl);
1252
+ const originalUrlObj = new URL(_origin + ctx.originalUrl);
319
1253
  let redirectPath = originalUrlObj.pathname;
320
1254
 
321
- // Remove the extension from the path
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
+
322
1263
  redirectPath = redirectPath.slice(0, redirectPath.length - hideExt.length);
323
1264
 
324
1265
  // Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
@@ -363,27 +1304,39 @@ module.exports = function koaClassicServer(
363
1304
  }
364
1305
  }
365
1306
 
366
- // OPTIMIZATION: Check if file/directory exists (async, non-blocking)
1307
+ // Check if path exists
367
1308
  let stat;
368
1309
  try {
369
1310
  stat = await fs.promises.stat(toOpen);
370
- } catch (error) {
1311
+ } catch {
371
1312
  // File/directory doesn't exist or can't be accessed
372
- ctx.status = 404;
373
- ctx.body = requestedUrlNotFound();
1313
+ sendNotFound(ctx);
374
1314
  return;
375
1315
  }
376
1316
 
1317
+ // Hidden check: block access to the requested file or directory itself
1318
+ if (requestedPath !== '') {
1319
+ const entryName = path.basename(toOpen);
1320
+ const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
1321
+ if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
1322
+ sendNotFound(ctx);
1323
+ return;
1324
+ }
1325
+ }
1326
+
377
1327
  if (stat.isDirectory()) {
378
1328
  // Handle directory
379
- if (options.showDirContents) {
380
- // NEW: Enhanced index file search with array and RegExp support
1329
+ if (options.dirListing.enabled) {
1330
+ // Search for index file matching configured patterns
381
1331
  if (options.index && options.index.length > 0) {
382
1332
  const indexFile = await findIndexFile(toOpen, options.index);
383
1333
  if (indexFile) {
384
- const indexPath = path.join(toOpen, indexFile.name);
385
- await loadFile(indexPath, indexFile.stat);
386
- return;
1334
+ const indexRelPath = path.relative(normalizedRootDir, path.join(toOpen, indexFile.name)).split(path.sep).join('/');
1335
+ if (!isHiddenEntry(indexFile.name, indexRelPath, false)) {
1336
+ const indexPath = path.join(toOpen, indexFile.name);
1337
+ await loadFile(indexPath, indexFile.stat);
1338
+ return;
1339
+ }
387
1340
  }
388
1341
  }
389
1342
 
@@ -391,228 +1344,337 @@ module.exports = function koaClassicServer(
391
1344
  ctx.body = await show_dir(toOpen, ctx);
392
1345
  } else {
393
1346
  // Directory listing disabled
394
- ctx.status = 404;
395
- ctx.body = requestedUrlNotFound();
1347
+ sendNotFound(ctx);
396
1348
  }
397
1349
  return;
398
1350
  } else {
399
- // Handle file
400
1351
  await loadFile(toOpen, stat);
401
1352
  return;
402
1353
  }
403
1354
 
404
1355
  // Internal functions
405
1356
 
406
- /**
407
- * Find index file in directory with priority support
408
- * @param {string} dirPath - Directory path to search
409
- * @param {Array<string|RegExp>} indexPatterns - Array of patterns (strings or RegExp)
410
- * @returns {Promise<{name: string, stat: fs.Stats}|null>} - First matching file or null
411
- */
412
- async function findIndexFile(dirPath, indexPatterns) {
413
- try {
414
- // Read directory contents
415
- const files = await fs.promises.readdir(dirPath, { withFileTypes: true });
416
-
417
- // Filter files, following symlinks to determine effective type
418
- const fileCheckResults = await Promise.all(
419
- files.map(async dirent => ({
420
- name: dirent.name,
421
- isFile: await isFileOrSymlinkToFile(dirent, dirPath)
422
- }))
423
- );
424
- const fileNames = fileCheckResults
425
- .filter(entry => entry.isFile)
426
- .map(entry => entry.name);
427
-
428
- // Search with priority order (first pattern wins)
429
- for (const pattern of indexPatterns) {
430
- let matchedFile = null;
431
-
432
- if (typeof pattern === 'string') {
433
- // Exact string match (case-sensitive)
434
- if (fileNames.includes(pattern)) {
435
- matchedFile = pattern;
436
- }
437
- } else if (pattern instanceof RegExp) {
438
- // RegExp match (supports case-insensitive with /i flag)
439
- matchedFile = fileNames.find(fileName => pattern.test(fileName));
440
- }
1357
+ // Accepts a pre-fetched stat to avoid a redundant stat call
1358
+ async function loadFile(toOpen, fileStat) {
1359
+ // Get file stat if not provided
1360
+ if (!fileStat) {
1361
+ try {
1362
+ fileStat = await fs.promises.stat(toOpen);
1363
+ } catch (error) {
1364
+ _logger.error('File stat error:', error);
1365
+ sendNotFound(ctx);
1366
+ return;
1367
+ }
1368
+ }
441
1369
 
442
- // If match found, verify it's a file and return it
443
- if (matchedFile) {
444
- try {
445
- const filePath = path.join(dirPath, matchedFile);
446
- const fileStat = await fs.promises.stat(filePath);
447
- if (fileStat.isFile()) {
448
- return { name: matchedFile, stat: fileStat };
449
- }
450
- } catch (error) {
451
- // File was deleted between readdir and stat, continue to next pattern
452
- continue;
453
- }
1370
+ // Populate rawFile cache (before template check so buffer is available as 4th param to render).
1371
+ // Only for files within maxFileSize; large files are always streamed.
1372
+ let rawBuffer = null;
1373
+ if (serverCacheConfig.rawFile.enabled && fileStat.size <= serverCacheConfig.rawFile.maxFileSize) {
1374
+ const cached = _rawFileCache.peek(toOpen);
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) {
1382
+ _rawFileCache.get(toOpen); // increment frequency
1383
+ rawBuffer = cached.buffer;
1384
+ } else {
1385
+ try {
1386
+ rawBuffer = await fs.promises.readFile(toOpen);
1387
+ refreshOrInsert(_rawFileCache, toOpen, {
1388
+ buffer: rawBuffer,
1389
+ mtime: fileStat.mtime.getTime(),
1390
+ size: fileStat.size,
1391
+ insertedAt: Date.now(),
1392
+ }, cached, staleByAge);
1393
+ } catch {
1394
+ rawBuffer = null; // Fall through to disk reads later
454
1395
  }
455
1396
  }
1397
+ }
456
1398
 
457
- // No match found
458
- return null;
459
- } catch (error) {
460
- console.error('Error finding index file:', error);
461
- return null;
1399
+ if (await tryRenderTemplate(ctx, next, toOpen, rawBuffer, options.template, _logger)) {
1400
+ return;
462
1401
  }
463
- }
464
1402
 
465
- function requestedUrlNotFound() {
466
- return `
467
- <!DOCTYPE html>
468
- <html>
469
- <head>
470
- <meta charset="UTF-8">
471
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
472
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
473
- <title>URL not found</title>
474
- </head>
475
- <body>
476
- <h1>Not Found</h1>
477
- <h3>The requested URL was not found on this server.</h3>
478
- </body>
479
- </html>
480
- `;
481
- }
1403
+ // baseEtag — encoding-independent; used only for If-Range (Range requests skip compression)
1404
+ const baseEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}"`;
482
1405
 
483
- // OPTIMIZATION: loadFile now receives stat to avoid double stat call
484
- async function loadFile(toOpen, fileStat) {
485
- // Get file stat if not provided
486
- if (!fileStat) {
1406
+ // Advertise range support on all file responses (including 304)
1407
+ ctx.set('Accept-Ranges', 'bytes');
1408
+
1409
+ // Cache-Control set early — applies to all responses (200, 206, 304)
1410
+ if (options.browserCacheEnabled) {
1411
+ ctx.set('Cache-Control', `public, max-age=${options.browserCacheMaxAge}, must-revalidate`);
1412
+ } else {
1413
+ // Explicitly disable caching: without these headers browsers may use heuristic caching
1414
+ ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate');
1415
+ ctx.set('Pragma', 'no-cache'); // HTTP 1.0 compatibility
1416
+ ctx.set('Expires', '0'); // Proxies
1417
+ }
1418
+
1419
+ // Verify file is still readable (race condition protection).
1420
+ // Skip if rawBuffer already loaded — the successful readFile() is equivalent proof.
1421
+ if (!rawBuffer) {
487
1422
  try {
488
- fileStat = await fs.promises.stat(toOpen);
1423
+ await fs.promises.access(toOpen, fs.constants.R_OK);
489
1424
  } catch (error) {
490
- console.error('File stat error:', error);
491
- ctx.status = 404;
492
- ctx.body = requestedUrlNotFound();
1425
+ _logger.error('File access error:', error);
1426
+ sendNotFound(ctx);
493
1427
  return;
494
1428
  }
495
1429
  }
496
1430
 
497
- // Template rendering
498
- if (options.template.ext.length > 0 && options.template.render) {
499
- const fileExt = path.extname(toOpen).slice(1); // Remove leading dot
1431
+ // Range request handling (HTTP 206 Partial Content — compression skipped for ranges)
1432
+ const rangeHeader = ctx.get('Range');
1433
+ if (rangeHeader) {
1434
+ const fileSize = fileStat.size;
1435
+ const parsed = parseRangeHeader(rangeHeader, fileSize);
500
1436
 
501
- if (fileExt && options.template.ext.includes(fileExt)) {
502
- try {
503
- await options.template.render(ctx, next, toOpen);
504
- return;
505
- } catch (error) {
506
- console.error('Template rendering error:', error);
507
- ctx.status = 500;
508
- ctx.body = 'Internal Server Error - Template Rendering Failed';
1437
+ if (parsed === 'unsatisfiable') {
1438
+ ctx.status = 416;
1439
+ ctx.set('Content-Range', `bytes */${fileSize}`);
1440
+ ctx.body = '';
1441
+ return;
1442
+ }
1443
+
1444
+ if (parsed !== 'invalid') {
1445
+ // Honor If-Range: serve range only when baseEtag matches (or If-Range absent)
1446
+ const ifRange = ctx.get('If-Range');
1447
+ if (!ifRange || ifRange === baseEtag) {
1448
+ const { start, end } = parsed;
1449
+ const rangeLength = end - start + 1;
1450
+ const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
1451
+ const filename = path.basename(toOpen);
1452
+
1453
+ ctx.status = 206;
1454
+ ctx.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
1455
+ ctx.set('Content-Type', mimeType);
1456
+ ctx.set('Content-Length', String(rangeLength));
1457
+ ctx.set('Content-Disposition', buildContentDisposition(filename));
1458
+
1459
+ if (ctx.method !== 'HEAD') {
1460
+ if (rawBuffer) {
1461
+ // Serve range slice from in-memory buffer — zero disk I/O
1462
+ ctx.body = rawBuffer.slice(start, end + 1);
1463
+ } else {
1464
+ const src = fs.createReadStream(toOpen, { start, end });
1465
+ src.on('error', (err) => {
1466
+ _logger.error('Stream error:', err);
1467
+ if (!ctx.headerSent) {
1468
+ ctx.status = 500;
1469
+ ctx.body = 'Error reading file';
1470
+ }
1471
+ });
1472
+ ctx.body = src;
1473
+ }
1474
+ } else {
1475
+ // HEAD: send 206 headers only — body assignment resets Content-Length,
1476
+ // so we restore it afterwards.
1477
+ ctx.body = Buffer.alloc(0);
1478
+ ctx.set('Content-Length', String(rangeLength));
1479
+ }
509
1480
  return;
510
1481
  }
1482
+ // If-Range mismatch → fall through to full 200 response
511
1483
  }
1484
+ // Invalid Range → fall through to full 200 response
512
1485
  }
513
1486
 
514
- // OPTIMIZATION: HTTP Caching Headers
515
- if (options.browserCacheEnabled) {
516
- // Generate ETag from mtime timestamp + file size
517
- // This ensures ETag changes when file is modified or resized
518
- const etag = `"${fileStat.mtime.getTime()}-${fileStat.size}"`;
1487
+ // Determine MIME type and compression encoding for the full-file response
1488
+ const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
1489
+ const filename = path.basename(toOpen);
519
1490
 
520
- // Format Last-Modified header (RFC 7231)
521
- const lastModified = fileStat.mtime.toUTCString();
1491
+ // Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
1492
+ let encoding = null; // 'br' | 'gzip' | null
1493
+ if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
1494
+ const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
1495
+ const meetsMinSize = compressionConfig.minFileSize === false
1496
+ || fileStat.size >= compressionConfig.minFileSize;
1497
+ if (isCompressibleMime && meetsMinSize) {
1498
+ encoding = getClientEncoding(ctx.get('Accept-Encoding'));
1499
+ }
1500
+ }
522
1501
 
523
- // Set caching headers
524
- ctx.set('ETag', etag);
525
- ctx.set('Last-Modified', lastModified);
526
- ctx.set('Cache-Control', `public, max-age=${options.browserCacheMaxAge}, must-revalidate`);
1502
+ // fullEtag is encoding-specific to avoid false 304 hits across representations.
1503
+ // Proxies use Vary: Accept-Encoding to cache separate versions per encoding.
1504
+ const etagSuffix = encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : '';
1505
+ const fullEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}${etagSuffix}"`;
527
1506
 
528
- // OPTIMIZATION: Handle conditional requests (304 Not Modified)
1507
+ // ETag, Last-Modified, and 304 check — deferred until encoding is known
1508
+ if (options.browserCacheEnabled) {
1509
+ ctx.set('ETag', fullEtag);
1510
+ ctx.set('Last-Modified', fileStat.mtime.toUTCString());
529
1511
 
530
- // Check If-None-Match header (ETag validation)
1512
+ // Check If-None-Match (ETag validation)
531
1513
  const clientEtag = ctx.get('If-None-Match');
532
- if (clientEtag && clientEtag === etag) {
533
- // File hasn't changed - return 304 Not Modified
1514
+ if (clientEtag && clientEtag === fullEtag) {
534
1515
  ctx.status = 304;
535
1516
  return;
536
1517
  }
537
1518
 
538
- // Check If-Modified-Since header (date validation)
1519
+ // Check If-Modified-Since (date validation)
539
1520
  const clientModifiedSince = ctx.get('If-Modified-Since');
540
1521
  if (clientModifiedSince) {
541
1522
  const clientDate = new Date(clientModifiedSince);
542
- const fileDate = new Date(fileStat.mtime);
543
-
544
- // Compare timestamps (ignore milliseconds for better compatibility)
545
- if (fileDate.getTime() <= clientDate.getTime()) {
546
- // File hasn't been modified - return 304 Not Modified
1523
+ if (fileStat.mtime.getTime() <= clientDate.getTime()) {
547
1524
  ctx.status = 304;
548
1525
  return;
549
1526
  }
550
1527
  }
551
- } else {
552
- // BUGFIX: When caching is disabled, explicitly prevent browser caching
553
- // Without these headers, browsers may use heuristic caching and serve stale content
554
- ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate');
555
- ctx.set('Pragma', 'no-cache'); // HTTP 1.0 compatibility
556
- ctx.set('Expires', '0'); // Proxies
557
1528
  }
558
1529
 
559
- // Verify file is still readable (race condition protection)
560
- try {
561
- await fs.promises.access(toOpen, fs.constants.R_OK);
562
- } catch (error) {
563
- console.error('File access error:', error);
564
- ctx.status = 404;
565
- ctx.body = requestedUrlNotFound();
566
- return;
567
- }
1530
+ // Common response headers
1531
+ ctx.set('Content-Type', mimeType);
1532
+ ctx.set('Content-Disposition', buildContentDisposition(filename));
1533
+
1534
+ if (encoding) {
1535
+ // ── Compressed response ───────────────────────────────────────────────
1536
+ ctx.set('Content-Encoding', encoding);
1537
+ ctx.set('Vary', 'Accept-Encoding'); // Required so proxies cache per-encoding
1538
+
1539
+ if (serverCacheConfig.compressedFile.enabled) {
1540
+ // compressedFile cache mode: compress once → buffer in RAM → Content-Length known
1541
+ const cacheKey = `${toOpen}:${encoding}`;
1542
+ const cached = _compressedFileCache.peek(cacheKey);
1543
+ const maxAge = serverCacheConfig.compressedFile.maxAge;
1544
+ const staleByAge = maxAge > 0 && cached && (Date.now() - cached.insertedAt) >= maxAge;
1545
+ const stale = !cached
1546
+ || cached.mtime !== fileStat.mtime.getTime()
1547
+ || cached.size !== fileStat.size
1548
+ || staleByAge;
1549
+
1550
+ let buf;
1551
+ if (!stale) {
1552
+ _compressedFileCache.get(cacheKey); // increment frequency
1553
+ buf = cached.buffer; // Serve from cache
1554
+ } else {
1555
+ try {
1556
+ // Use rawFile buffer if available — avoids redundant disk read
1557
+ const rawData = rawBuffer || await fs.promises.readFile(toOpen);
1558
+ buf = await compressBuffer(rawData, encoding);
1559
+
1560
+ refreshOrInsert(_compressedFileCache, cacheKey, {
1561
+ buffer: buf,
1562
+ mtime: fileStat.mtime.getTime(),
1563
+ size: fileStat.size,
1564
+ insertedAt: Date.now(),
1565
+ }, cached, staleByAge);
1566
+ } catch (err) {
1567
+ _logger.error('Compression error:', err);
1568
+ // Fall back to uncompressed on any compression failure
1569
+ ctx.remove('Content-Encoding');
1570
+ ctx.remove('Vary');
1571
+ if (rawBuffer) {
1572
+ ctx.set('Content-Length', String(rawBuffer.length));
1573
+ if (ctx.method !== 'HEAD') {
1574
+ ctx.body = rawBuffer;
1575
+ } else {
1576
+ ctx.body = Buffer.alloc(0);
1577
+ ctx.set('Content-Length', String(rawBuffer.length));
1578
+ }
1579
+ } else {
1580
+ ctx.set('Content-Length', String(fileStat.size));
1581
+ if (ctx.method !== 'HEAD') {
1582
+ const src = fs.createReadStream(toOpen);
1583
+ src.on('error', (streamErr) => {
1584
+ _logger.error('Stream error:', streamErr);
1585
+ if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1586
+ });
1587
+ ctx.body = src;
1588
+ } else {
1589
+ ctx.body = Buffer.alloc(0);
1590
+ ctx.set('Content-Length', String(fileStat.size));
1591
+ }
1592
+ }
1593
+ return;
1594
+ }
1595
+ }
568
1596
 
569
- // Serve static file
570
- let mimeType = mime.lookup(toOpen);
571
- const src = fs.createReadStream(toOpen);
1597
+ ctx.set('Content-Length', String(buf.length));
1598
+ if (ctx.method !== 'HEAD') {
1599
+ ctx.body = buf;
1600
+ } else {
1601
+ // HEAD: set correct Content-Length; body assignment would reset it, restore after
1602
+ ctx.body = Buffer.alloc(0);
1603
+ ctx.set('Content-Length', String(buf.length));
1604
+ }
572
1605
 
573
- // Handle stream errors
574
- src.on('error', (err) => {
575
- console.error('Stream error:', err);
576
- if (!ctx.headerSent) {
577
- ctx.status = 500;
578
- ctx.body = 'Error reading file';
1606
+ } else {
1607
+ // Streaming mode: pipe through zlib transform — Content-Length not known in advance
1608
+ if (ctx.method !== 'HEAD') {
1609
+ const compress = encoding === 'br'
1610
+ ? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })
1611
+ : zlib.createGzip({ level: 6 });
1612
+ if (rawBuffer) {
1613
+ // Compress from in-memory buffer — no disk I/O
1614
+ const src = Readable.from(rawBuffer);
1615
+ ctx.body = src.pipe(compress);
1616
+ } else {
1617
+ const src = fs.createReadStream(toOpen);
1618
+ src.on('error', (err) => {
1619
+ _logger.error('Stream error:', err);
1620
+ if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1621
+ });
1622
+ ctx.body = src.pipe(compress);
1623
+ }
1624
+ }
1625
+ // HEAD + streaming: no Content-Length available; Koa sends headers only via res.end()
579
1626
  }
580
- });
581
-
582
- ctx.response.set("content-type", mimeType);
583
- ctx.response.set("content-length", fileStat.size);
584
1627
 
585
- // Content-Disposition properly quoted with only basename
586
- const filename = path.basename(toOpen);
587
- const safeFilename = filename.replace(/"/g, '\\"'); // Escape quotes
588
- ctx.response.set(
589
- "content-disposition",
590
- `inline; filename="${safeFilename}"`
591
- );
592
-
593
- ctx.body = src;
1628
+ } else {
1629
+ // ── Uncompressed response ─────────────────────────────────────────────
1630
+ if (rawBuffer) {
1631
+ // Serve directly from in-memory buffer — zero disk I/O
1632
+ ctx.set('Content-Length', String(rawBuffer.length));
1633
+ if (ctx.method !== 'HEAD') {
1634
+ ctx.body = rawBuffer;
1635
+ } else {
1636
+ ctx.body = Buffer.alloc(0);
1637
+ ctx.set('Content-Length', String(rawBuffer.length));
1638
+ }
1639
+ } else {
1640
+ ctx.set('Content-Length', String(fileStat.size));
1641
+ if (ctx.method !== 'HEAD') {
1642
+ const src = fs.createReadStream(toOpen);
1643
+ src.on('error', (err) => {
1644
+ _logger.error('Stream error:', err);
1645
+ if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1646
+ });
1647
+ ctx.body = src;
1648
+ } else {
1649
+ // HEAD: body assignment resets Content-Length — restore after
1650
+ ctx.body = Buffer.alloc(0);
1651
+ ctx.set('Content-Length', String(fileStat.size));
1652
+ }
1653
+ }
1654
+ }
594
1655
  }
595
1656
 
596
- // Helper function to format file size in human-readable format
597
- function formatSize(bytes) {
598
- if (bytes === 0) return '0 B';
599
- if (bytes === undefined || bytes === null) return '-';
600
-
601
- const k = 1024;
602
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
603
- const i = Math.floor(Math.log(bytes) / Math.log(k));
604
1657
 
605
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
606
- }
607
-
608
- // OPTIMIZATION: show_dir is now async and uses array join instead of string concatenation
609
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)
610
1664
  let dir;
1665
+ let truncated = false;
611
1666
  try {
612
- // OPTIMIZATION: Use async readdir (non-blocking)
613
- dir = await fs.promises.readdir(toOpen, { withFileTypes: true });
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
+ }
614
1674
  } catch (error) {
615
- console.error('Directory read error:', error);
1675
+ _logger.error('Directory read error:', error);
1676
+ ctx.status = 500;
1677
+ setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
616
1678
  return `
617
1679
  <!DOCTYPE html>
618
1680
  <html>
@@ -628,14 +1690,25 @@ module.exports = function koaClassicServer(
628
1690
  `;
629
1691
  }
630
1692
 
1693
+ // Relative path of this directory from rootDir (used for alwaysHide path matching)
1694
+ const rawDirRel = path.relative(normalizedRootDir, toOpen);
1695
+ const dirRelPath = (rawDirRel === '' || rawDirRel === '.') ? '' : rawDirRel.split(path.sep).join('/');
1696
+
631
1697
  // Get sorting parameters from query string
632
1698
  const sortBy = ctx.query.sort || 'name';
633
1699
  const sortOrder = ctx.query.order || 'asc';
634
1700
 
635
- // Build base URL for sorting links (without query params)
636
1701
  const baseUrl = pageHrefOutPrefix.pathname;
637
1702
 
638
- // Helper to create sorting URL
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
+
639
1712
  function getSortUrl(column) {
640
1713
  let newOrder = 'asc';
641
1714
  if (sortBy === column && sortOrder === 'asc') {
@@ -644,7 +1717,6 @@ module.exports = function koaClassicServer(
644
1717
  return `${baseUrl}?sort=${column}&order=${newOrder}`;
645
1718
  }
646
1719
 
647
- // Helper to get sort indicator
648
1720
  function getSortIndicator(column) {
649
1721
  if (sortBy === column) {
650
1722
  return sortOrder === 'asc' ? ' ↑' : ' ↓';
@@ -652,9 +1724,13 @@ module.exports = function koaClassicServer(
652
1724
  return '';
653
1725
  }
654
1726
 
655
- // OPTIMIZATION: Use array + join instead of string concatenation
656
- // This reduces memory allocation from O(n²) to O(n)
657
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
+ }
658
1734
  parts.push("<table>");
659
1735
  parts.push("<thead>");
660
1736
  parts.push("<tr>");
@@ -679,114 +1755,128 @@ module.exports = function koaClassicServer(
679
1755
  if (dir.length === 0) {
680
1756
  parts.push(`<tr><td>empty folder</td><td></td><td></td></tr>`);
681
1757
  } else {
682
- let a_sy = Object.getOwnPropertySymbols(dir[0]);
683
- const sy_type = a_sy[0];
684
-
685
- // Collect all items data first (for sorting)
686
- const items = [];
687
- for (const item of dir) {
688
- const s_name = item.name.toString();
689
- const type = item[sy_type];
690
-
691
- if (type !== 0 && type !== 1 && type !== 2 && type !== 3) {
692
- console.error("Unknown file type:", type);
693
- continue;
694
- }
695
-
696
- const itemPath = path.join(toOpen, s_name);
697
- let itemUri = "";
698
- // Build item URI without query parameters
699
- const baseUrl = pageHref.origin + pageHref.pathname;
700
- if (baseUrl === pageHref.origin + options.urlPrefix + "/" || baseUrl === pageHref.origin + options.urlPrefix) {
701
- itemUri = `${pageHref.origin + options.urlPrefix}/${encodeURIComponent(s_name)}`;
702
- } else {
703
- itemUri = `${baseUrl}/${encodeURIComponent(s_name)}`;
704
- }
705
-
706
- // Resolve symlinks and DT_UNKNOWN entries to their effective type
707
- let effectiveType = type;
708
- let isBrokenSymlink = false;
709
- if (type === 3 || type === 0) {
710
- // type 3 = symlink, type 0 = DT_UNKNOWN (overlayfs, NFS, FUSE, NixOS buildFHSEnv, ecryptfs)
711
- try {
712
- const realStat = await fs.promises.stat(itemPath);
713
- if (realStat.isFile()) effectiveType = 1;
714
- else if (realStat.isDirectory()) effectiveType = 2;
715
- } catch {
716
- if (type === 3) {
717
- isBrokenSymlink = true; // Broken or circular symlink
1758
+ const _listingBaseUrl = pageHref.origin + pageHref.pathname;
1759
+ const _listingOriginPrefix = pageHref.origin + options.urlPrefix;
1760
+
1761
+ // Collect item data with stat I/O in parallel (batched to avoid
1762
+ // overwhelming the filesystem on very large directories).
1763
+ const BATCH_SIZE = 64;
1764
+ const rawItems = [];
1765
+ for (let bi = 0; bi < dir.length; bi += BATCH_SIZE) {
1766
+ const batch = await Promise.all(
1767
+ dir.slice(bi, bi + BATCH_SIZE).map(async (item) => {
1768
+ const s_name = item.name.toString();
1769
+ const type = getDirentType(item);
1770
+ const itemPath = path.join(toOpen, s_name);
1771
+
1772
+ // Build item URI without query parameters
1773
+ let itemUri;
1774
+ if (_listingBaseUrl === _listingOriginPrefix + "/" || _listingBaseUrl === _listingOriginPrefix) {
1775
+ itemUri = `${_listingOriginPrefix}/${encodeURIComponent(s_name)}`;
718
1776
  } else {
719
- continue; // DT_UNKNOWN entry that can't be stat'd — skip it
1777
+ itemUri = `${_listingBaseUrl}/${encodeURIComponent(s_name)}`;
720
1778
  }
721
- }
722
- }
723
1779
 
724
- // Get file size
725
- let sizeStr = '-';
726
- let sizeBytes = 0;
727
- if (!isBrokenSymlink) {
728
- try {
729
- const itemStat = await fs.promises.stat(itemPath);
730
- if (effectiveType === 1) {
731
- sizeBytes = itemStat.size;
732
- sizeStr = formatSize(sizeBytes);
733
- } else {
734
- sizeStr = '-';
1780
+ // Resolve symlinks and DT_UNKNOWN entries to their effective type.
1781
+ // cachedStat is reused below to avoid a second stat() on the same path.
1782
+ let effectiveType = type;
1783
+ let isBrokenSymlink = false;
1784
+ let cachedStat = null;
1785
+ if (type === 3 || type === 0) {
1786
+ // type 3 = symlink, type 0 = DT_UNKNOWN (overlayfs, NFS, FUSE, NixOS buildFHSEnv, ecryptfs)
1787
+ try {
1788
+ cachedStat = await fs.promises.stat(itemPath);
1789
+ if (cachedStat.isFile()) effectiveType = 1;
1790
+ else if (cachedStat.isDirectory()) effectiveType = 2;
1791
+ } catch {
1792
+ if (type === 3) {
1793
+ isBrokenSymlink = true; // Broken or circular symlink
1794
+ } else {
1795
+ return null; // DT_UNKNOWN entry that can't be stat'd — skip it
1796
+ }
1797
+ }
1798
+ }
1799
+
1800
+ // Hidden check: skip entries that should not appear in directory listing
1801
+ const itemIsDir = effectiveType === 2;
1802
+ const itemRelPath = dirRelPath ? dirRelPath + '/' + s_name : s_name;
1803
+ if (isHiddenEntry(s_name, itemRelPath, itemIsDir)) return null;
1804
+
1805
+ // Get file size — reuse cachedStat if already available (avoids double stat for symlinks)
1806
+ let sizeStr = '-';
1807
+ let sizeBytes = 0;
1808
+ if (!isBrokenSymlink) {
1809
+ try {
1810
+ const itemStat = cachedStat || await fs.promises.stat(itemPath);
1811
+ if (effectiveType === 1) {
1812
+ sizeBytes = itemStat.size;
1813
+ sizeStr = formatSize(sizeBytes);
1814
+ }
1815
+ } catch {
1816
+ sizeStr = '-';
1817
+ }
735
1818
  }
736
- } catch (error) {
737
- sizeStr = '-';
738
- }
739
- }
740
1819
 
741
- const mimeType = effectiveType === 2 ? "DIR" : (mime.lookup(itemPath) || 'unknown');
742
- const isReserved = pageHrefOutPrefix.pathname === '/' && options.urlsReserved.includes('/' + s_name) && (effectiveType === 2 || type === 3);
743
-
744
- items.push({
745
- name: s_name,
746
- type: type,
747
- effectiveType: effectiveType,
748
- isSymlink: type === 3,
749
- isBrokenSymlink: isBrokenSymlink,
750
- mimeType: mimeType,
751
- sizeStr: sizeStr,
752
- sizeBytes: sizeBytes,
753
- itemUri: itemUri,
754
- isReserved: isReserved
755
- });
1820
+ const mimeType = effectiveType === 2 ? "DIR" : (mime.lookup(itemPath) || 'unknown');
1821
+ const isReserved = pageHrefOutPrefix.pathname === '/' && options.urlsReserved.includes('/' + s_name) && (effectiveType === 2 || type === 3);
1822
+
1823
+ return {
1824
+ name: s_name,
1825
+ type,
1826
+ effectiveType,
1827
+ isSymlink: type === 3,
1828
+ isBrokenSymlink,
1829
+ mimeType,
1830
+ sizeStr,
1831
+ sizeBytes,
1832
+ itemUri,
1833
+ isReserved
1834
+ };
1835
+ })
1836
+ );
1837
+ rawItems.push(...batch);
756
1838
  }
1839
+ const items = rawItems.filter(Boolean);
1840
+
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
+ };
757
1850
 
758
- // Sort items based on query parameters
759
1851
  items.sort((a, b) => {
760
1852
  let comparison = 0;
761
1853
 
762
1854
  if (sortBy === 'name') {
763
1855
  comparison = a.name.localeCompare(b.name);
764
1856
  } else if (sortBy === 'type') {
765
- // Sort directories first, then by mime type (using effectiveType for symlinks)
766
- if (a.effectiveType === 2 && b.effectiveType !== 2) {
767
- comparison = -1;
768
- } else if (a.effectiveType !== 2 && b.effectiveType === 2) {
769
- comparison = 1;
770
- } else {
771
- comparison = a.mimeType.localeCompare(b.mimeType);
772
- }
1857
+ comparison = compareDirsFirst(a, b, (x, y) => x.mimeType.localeCompare(y.mimeType));
773
1858
  } else if (sortBy === 'size') {
774
- // Directories always at top when sorting by size (using effectiveType for symlinks)
775
- if (a.effectiveType === 2 && b.effectiveType !== 2) {
776
- comparison = -1;
777
- } else if (a.effectiveType !== 2 && b.effectiveType === 2) {
778
- comparison = 1;
779
- } else {
780
- comparison = a.sizeBytes - b.sizeBytes;
781
- }
1859
+ comparison = compareDirsFirst(a, b, (x, y) => x.sizeBytes - y.sizeBytes);
782
1860
  }
783
1861
 
784
- // Apply sort order (asc/desc)
785
1862
  return sortOrder === 'desc' ? -comparison : comparison;
786
1863
  });
787
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
+
788
1878
  // Generate HTML for sorted items
789
- for (const item of items) {
1879
+ for (const item of visibleItems) {
790
1880
  let rowStart = '';
791
1881
  if (item.effectiveType === 1) {
792
1882
  rowStart = `<tr><td> FILE `;
@@ -815,7 +1905,47 @@ module.exports = function koaClassicServer(
815
1905
  parts.push("</tbody>");
816
1906
  parts.push("</table>");
817
1907
 
818
- // OPTIMIZATION: Single join operation instead of multiple concatenations
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
+
819
1949
  const tableHtml = parts.join('');
820
1950
 
821
1951
  const html = `
@@ -826,48 +1956,7 @@ module.exports = function koaClassicServer(
826
1956
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
827
1957
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
828
1958
  <title>Index of ${escapeHtml(pageHrefOutPrefix.pathname)}</title>
829
- <style>
830
- body {
831
- font-family: Arial, sans-serif;
832
- margin: 20px;
833
- }
834
- h1 {
835
- border-bottom: 1px solid #ddd;
836
- padding-bottom: 10px;
837
- }
838
- table {
839
- border-collapse: collapse;
840
- width: 100%;
841
- max-width: 800px;
842
- }
843
- thead {
844
- background-color: #f5f5f5;
845
- border-bottom: 2px solid #ddd;
846
- }
847
- th {
848
- text-align: left;
849
- padding: 10px;
850
- font-weight: bold;
851
- border-bottom: 2px solid #ddd;
852
- }
853
- td {
854
- padding: 8px 10px;
855
- border-bottom: 1px solid #eee;
856
- }
857
- tr:hover {
858
- background-color: #f9f9f9;
859
- }
860
- a {
861
- color: #0066cc;
862
- text-decoration: none;
863
- }
864
- a:hover {
865
- text-decoration: underline;
866
- }
867
- th:nth-child(1), td:nth-child(1) { width: 50%; }
868
- th:nth-child(2), td:nth-child(2) { width: 30%; }
869
- th:nth-child(3), td:nth-child(3) { width: 20%; text-align: right; }
870
- </style>
1959
+ <style>${LISTING_CSS}</style>
871
1960
  </head>
872
1961
  <body>
873
1962
  <h1>Index of ${escapeHtml(pageHrefOutPrefix.pathname)}</h1>
@@ -876,20 +1965,9 @@ module.exports = function koaClassicServer(
876
1965
  </html>
877
1966
  `;
878
1967
 
1968
+ setGeneratedPageHeaders(ctx, LISTING_CSP);
879
1969
  return html;
880
1970
  }
881
1971
 
882
- // Helper function to escape HTML and prevent XSS
883
- function escapeHtml(unsafe) {
884
- if (typeof unsafe !== 'string') {
885
- return unsafe;
886
- }
887
- return unsafe
888
- .replace(/&/g, "&amp;")
889
- .replace(/</g, "&lt;")
890
- .replace(/>/g, "&gt;")
891
- .replace(/"/g, "&quot;")
892
- .replace(/'/g, "&#039;");
893
- }
894
1972
  };
895
1973
  };