koa-classic-server 2.6.1 → 3.0.0-alpha.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 (40) hide show
  1. package/README.md +68 -10
  2. package/__tests__/caching-headers.test.js +30 -30
  3. package/__tests__/compression-fixtures/data.json +1 -0
  4. package/__tests__/compression-fixtures/large.txt +1 -0
  5. package/__tests__/compression-fixtures/small.txt +1 -0
  6. package/__tests__/compression.test.js +270 -0
  7. package/__tests__/customTest/serversToLoad.util.js +1 -1
  8. package/__tests__/deprecation-warnings.test.js +71 -183
  9. package/__tests__/dt-unknown.test.js +20 -9
  10. package/__tests__/hidden-fixtures/.dot-dir/inside.txt +1 -0
  11. package/__tests__/hidden-fixtures/.env +2 -0
  12. package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +1 -0
  13. package/__tests__/hidden-fixtures/config/secrets/password.txt +1 -0
  14. package/__tests__/hidden-fixtures/data.key +1 -0
  15. package/__tests__/hidden-fixtures/file.secret +1 -0
  16. package/__tests__/hidden-fixtures/index.html +1 -0
  17. package/__tests__/hidden-fixtures/normal.txt +1 -0
  18. package/__tests__/hidden-fixtures/subdir/.env +1 -0
  19. package/__tests__/hidden-fixtures/subdir/regular.txt +1 -0
  20. package/__tests__/hidden-option.test.js +422 -0
  21. package/__tests__/index-option.test.js +18 -16
  22. package/__tests__/index.test.js +8 -4
  23. package/__tests__/range-fixtures/sample.txt +1 -0
  24. package/__tests__/range.test.js +223 -0
  25. package/__tests__/security-headers.test.js +153 -0
  26. package/__tests__/security.test.js +145 -159
  27. package/__tests__/server-cache-fixtures/large.txt +1 -0
  28. package/__tests__/server-cache-fixtures/small.txt +1 -0
  29. package/__tests__/server-cache.test.js +423 -0
  30. package/__tests__/symlink.test.js +8 -5
  31. package/docs/ACTION_PLAN.md +293 -0
  32. package/docs/CHANGELOG.md +84 -0
  33. package/docs/EXAMPLES_INDEX_OPTION.md +2 -2
  34. package/docs/FLOW_DIAGRAM.md +13 -13
  35. package/docs/OPTIMIZATION_ROADMAP_for_V3.md +864 -0
  36. package/docs/PERFORMANCE_COMPARISON.md +7 -7
  37. package/eslint.config.mjs +17 -0
  38. package/index.cjs +1096 -391
  39. package/index.mjs +1 -5
  40. package/package.json +4 -1
package/index.cjs CHANGED
@@ -2,25 +2,303 @@
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");
5
7
  const mime = require("mime-types");
8
+ const { Readable } = require('stream');
9
+
10
+ // Pre-computed module-level constants
11
+ const _LOG_1024 = Math.log(1024);
12
+
13
+ // Emitted at most once per process lifetime when hidden.dotFiles.default or
14
+ // hidden.dotDirs.default are not explicitly set by the caller.
15
+ let _hiddenDefaultWarnEmitted = false;
16
+
17
+ // Default list of MIME types that benefit from compression.
18
+ // User-provided compression.mimeTypes replaces this list entirely.
19
+ const DEFAULT_COMPRESSIBLE_MIME_TYPES = [
20
+ 'text/html',
21
+ 'text/css',
22
+ 'text/javascript',
23
+ 'text/plain',
24
+ 'text/xml',
25
+ 'text/csv',
26
+ 'application/javascript',
27
+ 'application/json',
28
+ 'application/xml',
29
+ 'application/wasm',
30
+ 'image/svg+xml',
31
+ ];
32
+
33
+ // CSS for the directory listing page — extracted so its SHA-256 hash can be
34
+ // computed once at module load time and placed in the Content-Security-Policy header.
35
+ const LISTING_CSS = `
36
+ body {
37
+ font-family: Arial, sans-serif;
38
+ margin: 20px;
39
+ }
40
+ h1 {
41
+ border-bottom: 1px solid #ddd;
42
+ padding-bottom: 10px;
43
+ }
44
+ table {
45
+ border-collapse: collapse;
46
+ width: 100%;
47
+ max-width: 800px;
48
+ }
49
+ thead {
50
+ background-color: #f5f5f5;
51
+ border-bottom: 2px solid #ddd;
52
+ }
53
+ th {
54
+ text-align: left;
55
+ padding: 10px;
56
+ font-weight: bold;
57
+ border-bottom: 2px solid #ddd;
58
+ }
59
+ td {
60
+ padding: 8px 10px;
61
+ border-bottom: 1px solid #eee;
62
+ }
63
+ tr:hover {
64
+ background-color: #f9f9f9;
65
+ }
66
+ a {
67
+ color: #0066cc;
68
+ text-decoration: none;
69
+ }
70
+ a:hover {
71
+ text-decoration: underline;
72
+ }
73
+ th:nth-child(1), td:nth-child(1) { width: 50%; }
74
+ th:nth-child(2), td:nth-child(2) { width: 30%; }
75
+ th:nth-child(3), td:nth-child(3) { width: 20%; text-align: right; }
76
+ `;
77
+
78
+ // SHA-256 hash of the listing CSS, computed once at startup (zero per-request overhead).
79
+ const _listingCssHash = 'sha256-' + crypto.createHash('sha256').update(LISTING_CSS, 'utf8').digest('base64');
80
+
81
+ // CSP for the directory listing page (has inline CSS → hash-based allowance).
82
+ const LISTING_CSP = `default-src 'none'; style-src '${_listingCssHash}'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`;
83
+
84
+ // CSP for error/404 pages (no inline CSS → fully restrictive).
85
+ const NOT_FOUND_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
86
+
87
+ // Sets security headers on all middleware-generated HTML pages (listing + error).
88
+ // Must NOT be called for user files served from disk.
89
+ function setGeneratedPageHeaders(ctx, csp) {
90
+ ctx.set('Content-Security-Policy', csp);
91
+ ctx.set('X-Content-Type-Options', 'nosniff');
92
+ ctx.set('X-Frame-Options', 'DENY');
93
+ ctx.set('Referrer-Policy', 'no-referrer');
94
+ ctx.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
95
+ }
96
+
97
+ // Pre-computed 404 HTML body — identical on every call, no need to regenerate.
98
+ const _NOT_FOUND_HTML = `<!DOCTYPE html>
99
+ <html>
100
+ <head>
101
+ <meta charset="UTF-8">
102
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
103
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
104
+ <title>URL not found</title>
105
+ </head>
106
+ <body>
107
+ <h1>Not Found</h1>
108
+ <h3>The requested URL was not found on this server.</h3>
109
+ </body>
110
+ </html>`;
111
+
112
+ function sendNotFound(ctx) {
113
+ setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
114
+ ctx.status = 404;
115
+ ctx.body = _NOT_FOUND_HTML;
116
+ }
117
+
118
+ // Single-pass HTML escaping — one regex scan, one allocation, lookup table compiled once.
119
+ const _HTML_ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
120
+ const _HTML_ESCAPE_RE = /[&<>"']/g;
121
+
122
+ function escapeHtml(unsafe) {
123
+ if (typeof unsafe !== 'string') return unsafe;
124
+ return unsafe.replace(_HTML_ESCAPE_RE, c => _HTML_ESCAPE_MAP[c]);
125
+ }
126
+
127
+ // Pure helper — depends only on _LOG_1024 (module scope), safe to hoist.
128
+ function formatSize(bytes) {
129
+ if (bytes === 0) return '0 B';
130
+ if (bytes === undefined || bytes === null) return '-';
131
+
132
+ const k = 1024;
133
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
134
+ const i = Math.floor(Math.log(bytes) / _LOG_1024);
135
+
136
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
137
+ }
138
+
139
+ // Returns the dirent numeric type using the official Node.js API instead of
140
+ // the internal Symbol hack: 1=file, 2=dir, 3=symlink, 0=DT_UNKNOWN.
141
+ function getDirentType(dirent) {
142
+ if (dirent.isFile()) return 1;
143
+ if (dirent.isDirectory()) return 2;
144
+ if (dirent.isSymbolicLink()) return 3;
145
+ return 0;
146
+ }
147
+
148
+ /**
149
+ * Parse a "Range: bytes=..." header against a known file size.
150
+ * Only single ranges are supported; multi-range requests are treated as invalid.
151
+ *
152
+ * Returns:
153
+ * { start, end } — valid range (both inclusive, 0-based)
154
+ * 'invalid' — malformed or multi-range → caller should serve full 200
155
+ * 'unsatisfiable' — out of bounds → caller should return 416
156
+ */
157
+ function parseRangeHeader(rangeHeader, fileSize) {
158
+ if (!rangeHeader.startsWith('bytes=')) return 'invalid';
159
+
160
+ const spec = rangeHeader.slice(6);
161
+
162
+ // Reject multi-range (comma-separated)
163
+ if (spec.includes(',')) return 'invalid';
164
+
165
+ const dashIdx = spec.indexOf('-');
166
+ if (dashIdx === -1) return 'invalid';
167
+
168
+ const startStr = spec.slice(0, dashIdx);
169
+ const endStr = spec.slice(dashIdx + 1);
170
+
171
+ let start, end;
172
+
173
+ if (startStr === '') {
174
+ // Suffix range: bytes=-N (last N bytes)
175
+ if (endStr === '') return 'invalid';
176
+ const suffix = parseInt(endStr, 10);
177
+ if (isNaN(suffix) || suffix <= 0) return 'invalid';
178
+ if (fileSize === 0) return 'unsatisfiable';
179
+ start = suffix >= fileSize ? 0 : fileSize - suffix;
180
+ end = fileSize - 1;
181
+ } else {
182
+ start = parseInt(startStr, 10);
183
+ if (isNaN(start) || start < 0) return 'invalid';
184
+ if (fileSize === 0 || start >= fileSize) return 'unsatisfiable';
185
+
186
+ if (endStr === '') {
187
+ // Open range: bytes=N-
188
+ end = fileSize - 1;
189
+ } else {
190
+ end = parseInt(endStr, 10);
191
+ if (isNaN(end) || end < 0) return 'invalid';
192
+ if (start > end) return 'invalid';
193
+ // Clamp end to file size - 1
194
+ if (end >= fileSize) end = fileSize - 1;
195
+ }
196
+ }
197
+
198
+ return { start, end };
199
+ }
200
+
201
+ // LFU cache with O(1) eviction using frequency buckets.
202
+ // peek(key) — read without touching frequency (for staleness checks)
203
+ // get(key) — read and increment frequency
204
+ // set(key, entry) — insert, evicting LFU entries if needed
205
+ // delete(key) — remove explicitly (e.g. stale entry before re-insert)
206
+ class LFUCache {
207
+ constructor(maxSize, warnInterval, cacheLabel) {
208
+ this.maxSize = maxSize;
209
+ this.warnInterval = warnInterval;
210
+ this.cacheLabel = cacheLabel;
211
+ this.currentSize = 0;
212
+ this._keyMap = new Map(); // key → { buffer, mtime, size, freq }
213
+ this._freqMap = new Map(); // freq → Set<key>
214
+ this._minFreq = 0;
215
+ this._lastWarnAt = 0;
216
+ }
217
+
218
+ get size() { return this._keyMap.size; }
219
+
220
+ // Returns entry without incrementing frequency — safe for staleness checks.
221
+ peek(key) {
222
+ return this._keyMap.get(key);
223
+ }
224
+
225
+ // Returns entry and increments its frequency.
226
+ get(key) {
227
+ if (!this._keyMap.has(key)) return undefined;
228
+ this._incrementFreq(key);
229
+ return this._keyMap.get(key);
230
+ }
231
+
232
+ set(key, entry) {
233
+ while (this.currentSize + entry.buffer.length > this.maxSize && this._keyMap.size > 0) {
234
+ this._evictOne();
235
+ }
236
+ if (this.currentSize + entry.buffer.length > this.maxSize) return; // entry too large for cache
237
+
238
+ this._keyMap.set(key, { ...entry, freq: 1 });
239
+ this._addToFreqBucket(key, 1);
240
+ this.currentSize += entry.buffer.length;
241
+ this._minFreq = 1;
242
+ }
243
+
244
+ delete(key) {
245
+ if (!this._keyMap.has(key)) return;
246
+ const { freq, buffer } = this._keyMap.get(key);
247
+ this.currentSize -= buffer.length;
248
+ this._keyMap.delete(key);
249
+ const bucket = this._freqMap.get(freq);
250
+ if (bucket) {
251
+ bucket.delete(key);
252
+ if (bucket.size === 0) this._freqMap.delete(freq);
253
+ }
254
+ // _minFreq may be stale after external delete — reset to 1 on next set()
255
+ }
6
256
 
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)
14
- //
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
257
+ _incrementFreq(key) {
258
+ const entry = this._keyMap.get(key);
259
+ const oldFreq = entry.freq;
260
+ const newFreq = oldFreq + 1;
261
+ entry.freq = newFreq;
262
+ const oldBucket = this._freqMap.get(oldFreq);
263
+ oldBucket.delete(key);
264
+ if (oldBucket.size === 0) {
265
+ this._freqMap.delete(oldFreq);
266
+ if (this._minFreq === oldFreq) this._minFreq = newFreq;
267
+ }
268
+ this._addToFreqBucket(key, newFreq);
269
+ }
270
+
271
+ _addToFreqBucket(key, freq) {
272
+ if (!this._freqMap.has(freq)) this._freqMap.set(freq, new Set());
273
+ this._freqMap.get(freq).add(key);
274
+ }
275
+
276
+ _evictOne() {
277
+ // Recover from stale _minFreq (can happen after consecutive evictions)
278
+ while (this._freqMap.size > 0 && (!this._freqMap.has(this._minFreq) || this._freqMap.get(this._minFreq).size === 0)) {
279
+ this._freqMap.delete(this._minFreq);
280
+ if (this._freqMap.size === 0) return;
281
+ this._minFreq = Math.min(...this._freqMap.keys());
282
+ }
283
+ const bucket = this._freqMap.get(this._minFreq);
284
+ if (!bucket || bucket.size === 0) return;
285
+
286
+ const evictKey = bucket.values().next().value; // FIFO within same freq
287
+ const { buffer } = this._keyMap.get(evictKey);
288
+ this.currentSize -= buffer.length;
289
+ this._keyMap.delete(evictKey);
290
+ bucket.delete(evictKey);
291
+ if (bucket.size === 0) this._freqMap.delete(this._minFreq);
292
+
293
+ if (this.warnInterval !== false) {
294
+ const now = Date.now();
295
+ if (now - this._lastWarnAt >= this.warnInterval) {
296
+ console.warn(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
297
+ this._lastWarnAt = now;
298
+ }
299
+ }
300
+ }
301
+ }
24
302
 
25
303
  module.exports = function koaClassicServer(
26
304
  rootDir,
@@ -30,14 +308,11 @@ module.exports = function koaClassicServer(
30
308
  opts = {
31
309
  method: ['GET'], // Supported methods, otherwise next() will be called
32
310
  showDirContents: true, // Show or hide directory contents
33
- index: ["index.html"], // Index file name(s) - ARRAY FORMAT (recommended):
311
+ index: ["index.html"], // Index file name(s) - must be an ARRAY:
34
312
  // - 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]/]
313
+ // - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
314
+ // - Mixed array: ["index.html", /index\.[eE][jJ][sS]/]
37
315
  // 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
316
  urlPrefix: "", // URL path prefix
42
317
  urlsReserved: [], // Reserved paths (first level only)
43
318
  template: {
@@ -55,14 +330,45 @@ module.exports = function koaClassicServer(
55
330
  ext: '.ejs', // Extension to hide (required, string, case-sensitive, must start with '.')
56
331
  redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
57
332
  },
333
+ hidden: { // Block files/dirs from listing and serving (HTTP 404)
334
+ dotFiles: { // Dot-files (names starting with '.'): hidden by default
335
+ default: 'hidden', // 'hidden' | 'visible' — system default: 'hidden'
336
+ whitelist: [], // Always visible (string exact/glob or RegExp). Overrides default and alwaysHide.
337
+ blacklist: [], // Always hidden (string or RegExp). Overrides whitelist.
338
+ },
339
+ dotDirs: { // Dot-directories: visible by default
340
+ default: 'visible', // 'hidden' | 'visible' — system default: 'visible'
341
+ whitelist: [],
342
+ blacklist: [],
343
+ },
344
+ alwaysHide: [], // Path-aware patterns (string glob or RegExp) for any file/dir.
345
+ // Secondary to dotFiles/dotDirs whitelist and blacklist.
346
+ // Examples: ['*.secret', 'config/secrets/**', /\.key$/]
347
+ },
348
+ serverCache: { // Server-side in-memory caches (independent of browser HTTP caching)
349
+ rawFile: {
350
+ enabled: false, // enable in-memory cache of raw file buffers
351
+ maxSize: 52428800, // max total RAM used by this cache (bytes; default: 50 MB)
352
+ maxFileSize: 1048576, // files larger than this are never cached (bytes; default: 1 MB)
353
+ warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
354
+ },
355
+ compressedFile: { // cache for HTTP br/gzip responses — not for .zip/.tar files on disk
356
+ enabled: true, // enable in-memory cache of compressed response buffers
357
+ maxSize: 104857600, // max total RAM used by this cache (bytes; default: 100 MB)
358
+ warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
359
+ },
360
+ },
361
+ compression: { // Response compression (gzip / brotli) — to enable/disable caching → serverCache.compressedFile
362
+ enabled: true, // master switch (false = disable all compression)
363
+ encodings: ['br', 'gzip'], // algorithms in priority order; [] = disable
364
+ minSize: 1024, // min file size in bytes to compress; false = no minimum
365
+ mimeTypes: [], // compressible MIME types (replaces default list if provided)
366
+ },
367
+ // compression: false // shorthand to disable all compression
58
368
 
59
- // DEPRECATED OPTIONS (maintained for backward compatibility):
60
- // cacheMaxAge: use browserCacheMaxAge instead
61
- // enableCaching: use browserCacheEnabled instead
62
369
  }
63
370
  */
64
371
  ) {
65
- // Validate rootDir
66
372
  if (!rootDir || typeof rootDir !== 'string') {
67
373
  throw new TypeError('rootDir must be a non-empty string');
68
374
  }
@@ -70,10 +376,8 @@ module.exports = function koaClassicServer(
70
376
  throw new Error('rootDir must be an absolute path');
71
377
  }
72
378
 
73
- // Normalize rootDir to prevent issues
74
379
  const normalizedRootDir = path.resolve(rootDir);
75
380
 
76
- // Set default options
77
381
  const options = opts || {};
78
382
  options.template = opts.template || {};
79
383
 
@@ -82,18 +386,15 @@ module.exports = function koaClassicServer(
82
386
 
83
387
  // Normalize index option to array format
84
388
  if (typeof options.index === 'string') {
85
- // DEPRECATION WARNING: String format is deprecated
86
389
  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.'
390
+ // v3.0.0: non-empty string format removed
391
+ throw new Error(
392
+ '[koa-classic-server] The "index" option no longer accepts a string in v3.0.0.\n' +
393
+ ` Replace with: index: ["${options.index}"]`
93
394
  );
94
395
  }
95
- // Single string → convert to array with one element
96
- options.index = options.index ? [options.index] : [];
396
+ // Empty string → silently treat as no index (empty array)
397
+ options.index = [];
97
398
  } else if (Array.isArray(options.index)) {
98
399
  // Already an array → validate elements are strings or RegExp
99
400
  options.index = options.index.filter(item =>
@@ -105,39 +406,25 @@ module.exports = function koaClassicServer(
105
406
  }
106
407
 
107
408
  options.urlPrefix = typeof options.urlPrefix === 'string' ? options.urlPrefix : "";
409
+ const _urlPrefixParts = options.urlPrefix.split("/");
108
410
  options.urlsReserved = Array.isArray(options.urlsReserved) ? options.urlsReserved : [];
109
411
  options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
110
412
  options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
111
413
 
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.'
414
+ // v3.0.0: removed legacy option names — throw to surface the breaking change clearly
415
+ if ('cacheMaxAge' in opts) {
416
+ throw new Error(
417
+ '[koa-classic-server] The "cacheMaxAge" option was removed in v3.0.0.\n' +
418
+ ' Replace with: browserCacheMaxAge: ' + opts.cacheMaxAge
125
419
  );
126
- options.browserCacheMaxAge = opts.cacheMaxAge;
127
420
  }
128
-
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.'
421
+ if ('enableCaching' in opts) {
422
+ throw new Error(
423
+ '[koa-classic-server] The "enableCaching" option was removed in v3.0.0.\n' +
424
+ ' Replace with: browserCacheEnabled: ' + opts.enableCaching
136
425
  );
137
- options.browserCacheEnabled = opts.enableCaching;
138
426
  }
139
427
 
140
- // Set new option names (with defaults)
141
428
  options.browserCacheMaxAge = typeof options.browserCacheMaxAge === 'number' && options.browserCacheMaxAge >= 0 ? options.browserCacheMaxAge : 3600;
142
429
  options.browserCacheEnabled = typeof options.browserCacheEnabled === 'boolean' ? options.browserCacheEnabled : false;
143
430
  options.useOriginalUrl = typeof options.useOriginalUrl === 'boolean' ? options.useOriginalUrl : true;
@@ -171,6 +458,163 @@ module.exports = function koaClassicServer(
171
458
  }
172
459
  }
173
460
 
461
+ // Normalize and validate the hidden option into a clean internal structure.
462
+ function normalizeHiddenConfig(hidden) {
463
+ if (!hidden || typeof hidden !== 'object' || Array.isArray(hidden)) {
464
+ return {
465
+ dotFiles: { default: 'hidden', whitelist: [], blacklist: [] },
466
+ dotDirs: { default: 'visible', whitelist: [], blacklist: [] },
467
+ alwaysHide: []
468
+ };
469
+ }
470
+
471
+ const filterPatternList = (arr) =>
472
+ Array.isArray(arr)
473
+ ? arr.filter(p => typeof p === 'string' || p instanceof RegExp)
474
+ : [];
475
+
476
+ function normalizeCategory(input, systemDefault, categoryName) {
477
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
478
+ return { default: systemDefault, whitelist: [], blacklist: [] };
479
+ }
480
+ if (input.default !== undefined && input.default !== 'hidden' && input.default !== 'visible') {
481
+ throw new Error(
482
+ `[koa-classic-server] hidden.${categoryName}.default must be "hidden" or "visible". Got: "${input.default}"`
483
+ );
484
+ }
485
+ return {
486
+ default: input.default !== undefined ? input.default : systemDefault,
487
+ whitelist: filterPatternList(input.whitelist),
488
+ blacklist: filterPatternList(input.blacklist),
489
+ };
490
+ }
491
+
492
+ return {
493
+ dotFiles: normalizeCategory(hidden.dotFiles, 'hidden', 'dotFiles'),
494
+ dotDirs: normalizeCategory(hidden.dotDirs, 'visible', 'dotDirs'),
495
+ alwaysHide: filterPatternList(hidden.alwaysHide),
496
+ };
497
+ }
498
+
499
+ const hiddenConfig = normalizeHiddenConfig(options.hidden);
500
+
501
+ // One-time per-process warning when the caller relies on implicit defaults for
502
+ // hidden.dotFiles.default or hidden.dotDirs.default. Since v3.0.0 dotFiles are
503
+ // hidden by default ('hidden') while dotDirs are visible by default ('visible').
504
+ // Explicitly declaring the values makes intent clear and silences the warning.
505
+ if (!_hiddenDefaultWarnEmitted) {
506
+ const dotFilesImplicit = opts.hidden?.dotFiles?.default === undefined;
507
+ const dotDirsImplicit = opts.hidden?.dotDirs?.default === undefined;
508
+ if (dotFilesImplicit || dotDirsImplicit) {
509
+ _hiddenDefaultWarnEmitted = true;
510
+ console.warn(
511
+ '\x1b[33m%s\x1b[0m',
512
+ '[koa-classic-server] WARNING: hidden.dotFiles.default and/or hidden.dotDirs.default are not explicitly set.\n' +
513
+ ' Since v3.0.0 the defaults are: dotFiles → "hidden", dotDirs → "visible".\n' +
514
+ ' To suppress this warning, add to your configuration:\n' +
515
+ ' hidden: { dotFiles: { default: \'hidden\' }, dotDirs: { default: \'visible\' } }\n' +
516
+ ' (adjust values to match your desired behaviour)'
517
+ );
518
+ }
519
+ }
520
+
521
+ // Returns true if `name` matches any pattern in the list.
522
+ // Patterns are matched against the bare filename (case-sensitive).
523
+ // Each entry can be a string (exact match or simple glob with * and ?) or a RegExp.
524
+ function matchesNameList(name, patterns) {
525
+ for (const pattern of patterns) {
526
+ if (pattern instanceof RegExp) {
527
+ if (pattern.test(name)) return true;
528
+ } else if (typeof pattern === 'string') {
529
+ if (nameGlobMatch(name, pattern)) return true;
530
+ }
531
+ }
532
+ return false;
533
+ }
534
+
535
+ // Matches a bare filename against a simple glob pattern (* = any chars except /, ? = one char).
536
+ function nameGlobMatch(name, pattern) {
537
+ if (!pattern.includes('*') && !pattern.includes('?')) {
538
+ return name === pattern;
539
+ }
540
+ const regexStr = '^' +
541
+ pattern
542
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
543
+ .replace(/\*/g, '[^/]*')
544
+ .replace(/\?/g, '[^/]')
545
+ + '$';
546
+ return new RegExp(regexStr).test(name);
547
+ }
548
+
549
+ // Returns true if `relPath` matches any pattern in the list.
550
+ // Patterns are matched against the full relative path from rootDir (case-sensitive).
551
+ // Each entry can be a string glob or a RegExp.
552
+ function matchesPathList(relPath, patterns) {
553
+ for (const pattern of patterns) {
554
+ if (pattern instanceof RegExp) {
555
+ if (pattern.test(relPath)) return true;
556
+ } else if (typeof pattern === 'string') {
557
+ if (pathGlobMatch(relPath, pattern)) return true;
558
+ }
559
+ }
560
+ return false;
561
+ }
562
+
563
+ /**
564
+ * Matches a relative path against a glob pattern (path-aware).
565
+ * - Pattern without '/': matches the basename at any depth (e.g. '*.secret')
566
+ * - Pattern with '/': anchored to rootDir (e.g. 'config/secrets/**')
567
+ * - '*' matches any characters except '/'
568
+ * - '**' matches any characters including '/'
569
+ * - '?' matches any single character except '/'
570
+ */
571
+ function pathGlobMatch(relPath, pattern) {
572
+ const hasSlash = pattern.includes('/');
573
+ const escaped = pattern
574
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
575
+ .replace(/\*\*/g, '\x00')
576
+ .replace(/\*/g, '[^/]*')
577
+ .replace(/\?/g, '[^/]')
578
+ .replace(/\x00/g, '.*');
579
+
580
+ const regexStr = hasSlash
581
+ ? '^' + escaped + '($|/)' // path-anchored from root
582
+ : '(^|/)' + escaped + '$'; // basename match at any depth
583
+
584
+ return new RegExp(regexStr).test(relPath);
585
+ }
586
+
587
+ /**
588
+ * Returns true if a filesystem entry should be hidden (blocked from listing and serving).
589
+ *
590
+ * Priority (highest to lowest):
591
+ * 1. blacklist (dotFiles/dotDirs) — always hidden, beats everything
592
+ * 2. whitelist (dotFiles/dotDirs) — always visible, overrides alwaysHide and default
593
+ * 3. alwaysHide — path-aware, overrides default
594
+ * 4. default (dotFiles/dotDirs) — 'hidden' or 'visible' for unmatched dot-entries
595
+ *
596
+ * Non-dot entries are only affected by alwaysHide.
597
+ *
598
+ * @param {string} name - Basename of the file or directory
599
+ * @param {string} relPath - Relative path from rootDir (e.g. "subdir/.env")
600
+ * @param {boolean} isDir - True if the entry is a directory
601
+ */
602
+ function isHiddenEntry(name, relPath, isDir) {
603
+ const isDot = name.startsWith('.');
604
+
605
+ if (isDot) {
606
+ const category = isDir ? hiddenConfig.dotDirs : hiddenConfig.dotFiles;
607
+
608
+ if (matchesNameList(name, category.blacklist)) return true;
609
+ if (matchesNameList(name, category.whitelist)) return false;
610
+ if (matchesPathList(relPath, hiddenConfig.alwaysHide)) return true;
611
+
612
+ return category.default === 'hidden';
613
+ }
614
+
615
+ return matchesPathList(relPath, hiddenConfig.alwaysHide);
616
+ }
617
+
174
618
  /**
175
619
  * Returns true if dirent is a regular file or a symlink pointing to a regular file.
176
620
  * Uses fs.promises.stat (which follows symlinks) when dirent.isSymbolicLink() is true,
@@ -204,35 +648,193 @@ module.exports = function koaClassicServer(
204
648
  return false;
205
649
  }
206
650
 
651
+ // Normalize and validate the compression option into a clean internal structure.
652
+ // compression: false is a valid shorthand for { enabled: false }.
653
+ function normalizeCompressionConfig(compression) {
654
+ if (compression === false) return { enabled: false };
655
+
656
+ if (!compression || typeof compression !== 'object' || Array.isArray(compression)) {
657
+ return {
658
+ enabled: true,
659
+ encodings: ['br', 'gzip'], // priority order: brotli first, gzip as fallback
660
+ minSize: 1024, // bytes; skip compression for files smaller than this
661
+ mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
662
+ };
663
+ }
664
+
665
+ const enabled = typeof compression.enabled === 'boolean' ? compression.enabled : true;
666
+ if (!enabled) return { enabled: false };
667
+
668
+ const encodings = Array.isArray(compression.encodings)
669
+ ? compression.encodings.filter(e => e === 'br' || e === 'gzip')
670
+ : ['br', 'gzip'];
671
+
672
+ const minSize = compression.minSize === false ? false
673
+ : (typeof compression.minSize === 'number' && compression.minSize >= 0 ? compression.minSize : 1024);
674
+
675
+ const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
676
+ ? compression.mimeTypes
677
+ : DEFAULT_COMPRESSIBLE_MIME_TYPES;
678
+
679
+ return { enabled, encodings, minSize, mimeTypes: new Set(mimeTypes) };
680
+ }
681
+
682
+ // Normalize and validate the serverCache option into a clean internal structure.
683
+ function normalizeServerCacheConfig(serverCache) {
684
+ const defaultRawFile = {
685
+ enabled: false,
686
+ maxSize: 52428800, // 50 MB
687
+ maxFileSize: 1048576, // 1 MB
688
+ warnInterval: 60000,
689
+ };
690
+ const defaultCompressedFile = {
691
+ enabled: true,
692
+ maxSize: 104857600, // 100 MB
693
+ warnInterval: 60000,
694
+ };
695
+
696
+ if (!serverCache || typeof serverCache !== 'object' || Array.isArray(serverCache)) {
697
+ return { rawFile: defaultRawFile, compressedFile: defaultCompressedFile };
698
+ }
699
+
700
+ const rf = serverCache.rawFile;
701
+ const rawFile = (!rf || typeof rf !== 'object' || Array.isArray(rf)) ? defaultRawFile : {
702
+ enabled: typeof rf.enabled === 'boolean' ? rf.enabled : false,
703
+ maxSize: typeof rf.maxSize === 'number' && rf.maxSize > 0 ? rf.maxSize : 52428800,
704
+ maxFileSize: typeof rf.maxFileSize === 'number' && rf.maxFileSize > 0 ? rf.maxFileSize : 1048576,
705
+ warnInterval: rf.warnInterval === false ? false : (typeof rf.warnInterval === 'number' ? rf.warnInterval : 60000),
706
+ };
707
+
708
+ const cf = serverCache.compressedFile;
709
+ const compressedFile = (!cf || typeof cf !== 'object' || Array.isArray(cf)) ? defaultCompressedFile : {
710
+ enabled: typeof cf.enabled === 'boolean' ? cf.enabled : true,
711
+ maxSize: typeof cf.maxSize === 'number' && cf.maxSize > 0 ? cf.maxSize : 104857600,
712
+ warnInterval: cf.warnInterval === false ? false : (typeof cf.warnInterval === 'number' ? cf.warnInterval : 60000),
713
+ };
714
+
715
+ return { rawFile, compressedFile };
716
+ }
717
+
718
+ const compressionConfig = normalizeCompressionConfig(options.compression);
719
+ const serverCacheConfig = normalizeServerCacheConfig(options.serverCache);
720
+
721
+ // In-memory LFU cache for raw file buffers (serverCache.rawFile).
722
+ // Key: absoluteFilePath — O(1) eviction via frequency-bucket structure.
723
+ const _rawFileCache = new LFUCache(
724
+ serverCacheConfig.rawFile.maxSize,
725
+ serverCacheConfig.rawFile.warnInterval,
726
+ 'rawFile'
727
+ );
728
+
729
+ // In-memory LFU cache for compressed file buffers (serverCache.compressedFile).
730
+ // Key: `${absoluteFilePath}:${encoding}` — O(1) eviction via frequency-bucket structure.
731
+ const _compressedFileCache = new LFUCache(
732
+ serverCacheConfig.compressedFile.maxSize,
733
+ serverCacheConfig.compressedFile.warnInterval,
734
+ 'compressedFile'
735
+ );
736
+
737
+ // Returns the client's preferred encoding based on Accept-Encoding header,
738
+ // filtered against the enabled encodings list. Returns null if no match.
739
+ function getClientEncoding(acceptEncoding) {
740
+ if (!acceptEncoding) return null;
741
+ for (const enc of compressionConfig.encodings) {
742
+ if (acceptEncoding.includes(enc)) return enc;
743
+ }
744
+ return null;
745
+ }
746
+
747
+ // Compress a Buffer using the given encoding ('br' or 'gzip').
748
+ // Uses maximum quality — appropriate for serverCache mode (cost paid once).
749
+ function compressBuffer(data, encoding) {
750
+ return new Promise((resolve, reject) => {
751
+ if (encoding === 'br') {
752
+ zlib.brotliCompress(
753
+ data,
754
+ { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } },
755
+ (err, result) => { if (err) reject(err); else resolve(result); }
756
+ );
757
+ } else {
758
+ zlib.gzip(
759
+ data,
760
+ { level: zlib.constants.Z_BEST_COMPRESSION },
761
+ (err, result) => { if (err) reject(err); else resolve(result); }
762
+ );
763
+ }
764
+ });
765
+ }
766
+
207
767
  /**
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).
768
+ * Build a Content-Disposition header value for inline serving.
769
+ *
770
+ * Uses both the legacy quoted-string form (ASCII fallback) and the RFC 5987
771
+ * extended form (UTF-8 percent-encoded) for maximum browser compatibility:
772
+ * inline; filename="ascii-safe"; filename*=UTF-8''percent-encoded
773
+ *
774
+ * The quoted-string form escapes only double-quotes; the RFC 5987 form
775
+ * percent-encodes every byte that is not an unreserved URI character.
776
+ * Browsers that support filename* prefer it over filename (RFC 6266 §4.1).
211
777
  */
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
220
- }
221
- }
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;
778
+ function buildContentDisposition(filename) {
779
+ // quoted-string fallback: escape " and \ so the value is always valid ASCII
780
+ const asciiSafe = filename.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
781
+
782
+ // RFC 5987 extended value: UTF-8 percent-encode everything except
783
+ // unreserved chars (ALPHA / DIGIT / "-" / "." / "_" / "~")
784
+ const rfc5987 = encodeURIComponent(filename)
785
+ .replace(/['()]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
786
+
787
+ return `inline; filename="${asciiSafe}"; filename*=UTF-8''${rfc5987}`;
788
+ }
789
+
790
+ // Find the first matching index file in a directory.
791
+ // Fast-path: string patterns use a direct stat() — no readdir needed.
792
+ // Slow-path: RegExp patterns trigger a single lazy readdir(), shared across
793
+ // all RegExp patterns in the array.
794
+ async function findIndexFile(dirPath, indexPatterns) {
795
+ let fileNames = null; // populated lazily on first RegExp pattern
796
+
797
+ for (const pattern of indexPatterns) {
798
+ if (typeof pattern === 'string') {
799
+ // Fast path: stat directly, zero readdir
800
+ try {
801
+ const fileStat = await fs.promises.stat(path.join(dirPath, pattern));
802
+ if (fileStat.isFile()) return { name: pattern, stat: fileStat };
803
+ } catch {
804
+ continue; // file doesn't exist, try next pattern
805
+ }
806
+ } else if (pattern instanceof RegExp) {
807
+ // Slow path: readdir once (lazy), reused for subsequent RegExp patterns
808
+ if (!fileNames) {
809
+ try {
810
+ const dirents = await fs.promises.readdir(dirPath, { withFileTypes: true });
811
+ const checkResults = await Promise.all(
812
+ dirents.map(async dirent => ({
813
+ name: dirent.name,
814
+ isFile: await isFileOrSymlinkToFile(dirent, dirPath)
815
+ }))
816
+ );
817
+ fileNames = checkResults.filter(e => e.isFile).map(e => e.name);
818
+ } catch (error) {
819
+ console.error('Error finding index file:', error);
820
+ return null;
821
+ }
822
+ }
823
+ const matchedFile = fileNames.find(name => pattern.test(name));
824
+ if (matchedFile) {
825
+ try {
826
+ const fileStat = await fs.promises.stat(path.join(dirPath, matchedFile));
827
+ if (fileStat.isFile()) return { name: matchedFile, stat: fileStat };
828
+ } catch {
829
+ continue; // file deleted between readdir and stat
830
+ }
831
+ }
229
832
  }
230
833
  }
231
- return false;
834
+ return null;
232
835
  }
233
836
 
234
837
  return async (ctx, next) => {
235
- // Check if method is allowed
236
838
  if (!options.method.includes(ctx.method)) {
237
839
  await next();
238
840
  return;
@@ -240,7 +842,8 @@ module.exports = function koaClassicServer(
240
842
 
241
843
  // Construct full URL based on useOriginalUrl option
242
844
  const urlToUse = options.useOriginalUrl ? ctx.originalUrl : ctx.url;
243
- const fullUrl = ctx.protocol + '://' + ctx.host + urlToUse;
845
+ const _origin = ctx.protocol + '://' + ctx.host;
846
+ const fullUrl = _origin + urlToUse;
244
847
  let pageHref = '';
245
848
  if (fullUrl.charAt(fullUrl.length - 1) === '/') {
246
849
  pageHref = new URL(fullUrl.slice(0, -1));
@@ -250,10 +853,9 @@ module.exports = function koaClassicServer(
250
853
 
251
854
  // Check URL prefix
252
855
  const a_pathname = pageHref.pathname.split("/");
253
- const a_urlPrefix = options.urlPrefix.split("/");
254
856
 
255
- for (const key in a_urlPrefix) {
256
- if (a_urlPrefix[key] !== a_pathname[key]) {
857
+ for (let i = 0; i < _urlPrefixParts.length; i++) {
858
+ if (_urlPrefixParts[i] !== a_pathname[i]) {
257
859
  await next();
258
860
  return;
259
861
  }
@@ -262,7 +864,7 @@ module.exports = function koaClassicServer(
262
864
  // Create pageHrefOutPrefix without URL prefix
263
865
  let pageHrefOutPrefix = pageHref;
264
866
  if (options.urlPrefix !== "") {
265
- let a_pathnameOutPrefix = a_pathname.slice(a_urlPrefix.length);
867
+ let a_pathnameOutPrefix = a_pathname.slice(_urlPrefixParts.length);
266
868
  let s_pathnameOutPrefix = a_pathnameOutPrefix.join("/");
267
869
  let hrefOutPrefix = pageHref.origin + '/' + s_pathnameOutPrefix;
268
870
  pageHrefOutPrefix = new URL(hrefOutPrefix);
@@ -279,8 +881,7 @@ module.exports = function koaClassicServer(
279
881
  }
280
882
  }
281
883
 
282
- // Path Traversal Protection
283
- // Construct safe file path
884
+ // Path traversal protection: build and validate safe file path
284
885
  let requestedPath = "";
285
886
  if (pageHrefOutPrefix.pathname === "/") {
286
887
  requestedPath = "";
@@ -288,37 +889,58 @@ module.exports = function koaClassicServer(
288
889
  requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
289
890
  }
290
891
 
291
- // Normalize path and prevent path traversal
892
+ // Null byte guard: path.normalize() throws ERR_INVALID_ARG_VALUE for paths
893
+ // containing \0. Reject early with 400 Bad Request before it reaches fs calls.
894
+ if (requestedPath.includes('\0')) {
895
+ ctx.status = 400;
896
+ ctx.body = 'Bad Request';
897
+ return;
898
+ }
899
+
292
900
  const normalizedPath = path.normalize(requestedPath);
293
901
  const fullPath = path.join(normalizedRootDir, normalizedPath);
294
902
 
295
- // Security check: ensure resolved path is within rootDir
903
+ // Security check: ensure resolved path is within rootDir.
904
+ // Covers: ../ traversal, URL-encoded variants (%2e%2e%2f), and on Windows
905
+ // backslash sequences (path.normalize converts \ to / before the check).
296
906
  if (!fullPath.startsWith(normalizedRootDir)) {
297
907
  ctx.status = 403;
298
908
  ctx.body = 'Forbidden';
299
909
  return;
300
910
  }
301
911
 
912
+ // Hidden check: block requests that traverse a hidden directory
913
+ if (requestedPath !== '') {
914
+ const segments = normalizedPath.split(path.sep).filter(Boolean);
915
+ for (let i = 0; i < segments.length - 1; i++) {
916
+ const segName = segments[i];
917
+ const segRelPath = segments.slice(0, i + 1).join('/');
918
+ if (isHiddenEntry(segName, segRelPath, true)) {
919
+ sendNotFound(ctx);
920
+ return;
921
+ }
922
+ }
923
+ }
924
+
302
925
  let toOpen = fullPath;
303
926
 
304
927
  // 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
928
  if (options.hideExtension) {
310
929
  const hideExt = options.hideExtension.ext;
311
930
  const hideRedirect = options.hideExtension.redirect;
312
931
 
932
+ // Trailing slash check via string — avoids a full new URL() construction
933
+ const rawPath = urlToUse.split('?')[0];
934
+ const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
935
+
313
936
  // Check if URL ends with the configured extension → redirect to clean URL
314
937
  // Use the original path (before trailing slash stripping) for accurate matching
315
- const pathForExtCheck = hadTrailingSlash ? originalUrlPath.slice(0, -1) : requestedPath;
938
+ const pathForExtCheck = hadTrailingSlash ? rawPath.slice(0, -1) : requestedPath;
316
939
  if (pathForExtCheck.endsWith(hideExt)) {
317
940
  // Build redirect target using ctx.originalUrl (always, regardless of useOriginalUrl)
318
- const originalUrlObj = new URL(ctx.protocol + '://' + ctx.host + ctx.originalUrl);
941
+ const originalUrlObj = new URL(_origin + ctx.originalUrl);
319
942
  let redirectPath = originalUrlObj.pathname;
320
943
 
321
- // Remove the extension from the path
322
944
  redirectPath = redirectPath.slice(0, redirectPath.length - hideExt.length);
323
945
 
324
946
  // Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
@@ -363,27 +985,39 @@ module.exports = function koaClassicServer(
363
985
  }
364
986
  }
365
987
 
366
- // OPTIMIZATION: Check if file/directory exists (async, non-blocking)
988
+ // Check if path exists
367
989
  let stat;
368
990
  try {
369
991
  stat = await fs.promises.stat(toOpen);
370
- } catch (error) {
992
+ } catch {
371
993
  // File/directory doesn't exist or can't be accessed
372
- ctx.status = 404;
373
- ctx.body = requestedUrlNotFound();
994
+ sendNotFound(ctx);
374
995
  return;
375
996
  }
376
997
 
998
+ // Hidden check: block access to the requested file or directory itself
999
+ if (requestedPath !== '') {
1000
+ const entryName = path.basename(toOpen);
1001
+ const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
1002
+ if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
1003
+ sendNotFound(ctx);
1004
+ return;
1005
+ }
1006
+ }
1007
+
377
1008
  if (stat.isDirectory()) {
378
1009
  // Handle directory
379
1010
  if (options.showDirContents) {
380
- // NEW: Enhanced index file search with array and RegExp support
1011
+ // Search for index file matching configured patterns
381
1012
  if (options.index && options.index.length > 0) {
382
1013
  const indexFile = await findIndexFile(toOpen, options.index);
383
1014
  if (indexFile) {
384
- const indexPath = path.join(toOpen, indexFile.name);
385
- await loadFile(indexPath, indexFile.stat);
386
- return;
1015
+ const indexRelPath = path.relative(normalizedRootDir, path.join(toOpen, indexFile.name)).split(path.sep).join('/');
1016
+ if (!isHiddenEntry(indexFile.name, indexRelPath, false)) {
1017
+ const indexPath = path.join(toOpen, indexFile.name);
1018
+ await loadFile(indexPath, indexFile.stat);
1019
+ return;
1020
+ }
387
1021
  }
388
1022
  }
389
1023
 
@@ -391,96 +1025,17 @@ module.exports = function koaClassicServer(
391
1025
  ctx.body = await show_dir(toOpen, ctx);
392
1026
  } else {
393
1027
  // Directory listing disabled
394
- ctx.status = 404;
395
- ctx.body = requestedUrlNotFound();
1028
+ sendNotFound(ctx);
396
1029
  }
397
1030
  return;
398
1031
  } else {
399
- // Handle file
400
1032
  await loadFile(toOpen, stat);
401
1033
  return;
402
1034
  }
403
1035
 
404
1036
  // Internal functions
405
1037
 
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
- }
441
-
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
- }
454
- }
455
- }
456
-
457
- // No match found
458
- return null;
459
- } catch (error) {
460
- console.error('Error finding index file:', error);
461
- return null;
462
- }
463
- }
464
-
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
- }
482
-
483
- // OPTIMIZATION: loadFile now receives stat to avoid double stat call
1038
+ // Accepts a pre-fetched stat to avoid a redundant stat call
484
1039
  async function loadFile(toOpen, fileStat) {
485
1040
  // Get file stat if not provided
486
1041
  if (!fileStat) {
@@ -488,131 +1043,326 @@ module.exports = function koaClassicServer(
488
1043
  fileStat = await fs.promises.stat(toOpen);
489
1044
  } catch (error) {
490
1045
  console.error('File stat error:', error);
491
- ctx.status = 404;
492
- ctx.body = requestedUrlNotFound();
1046
+ sendNotFound(ctx);
493
1047
  return;
494
1048
  }
495
1049
  }
496
1050
 
497
- // Template rendering
1051
+ // Populate rawFile cache (before template check so buffer is available as 4th param to render).
1052
+ // Only for files within maxFileSize; large files are always streamed.
1053
+ let rawBuffer = null;
1054
+ if (serverCacheConfig.rawFile.enabled && fileStat.size <= serverCacheConfig.rawFile.maxFileSize) {
1055
+ const cached = _rawFileCache.peek(toOpen);
1056
+ if (cached && cached.mtime === fileStat.mtime.getTime() && cached.size === fileStat.size) {
1057
+ _rawFileCache.get(toOpen); // increment frequency
1058
+ rawBuffer = cached.buffer;
1059
+ } else {
1060
+ try {
1061
+ rawBuffer = await fs.promises.readFile(toOpen);
1062
+ if (cached) _rawFileCache.delete(toOpen); // remove stale entry before re-inserting
1063
+ _rawFileCache.set(toOpen, {
1064
+ buffer: rawBuffer,
1065
+ mtime: fileStat.mtime.getTime(),
1066
+ size: fileStat.size,
1067
+ });
1068
+ } catch {
1069
+ rawBuffer = null; // Fall through to disk reads later
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ // Template rendering — rawBuffer passed as optional 4th param so render functions
1075
+ // can skip their own fs.readFile() call when the file is already in memory.
498
1076
  if (options.template.ext.length > 0 && options.template.render) {
499
1077
  const fileExt = path.extname(toOpen).slice(1); // Remove leading dot
500
1078
 
501
1079
  if (fileExt && options.template.ext.includes(fileExt)) {
502
1080
  try {
503
- await options.template.render(ctx, next, toOpen);
1081
+ await options.template.render(ctx, next, toOpen, rawBuffer);
504
1082
  return;
505
1083
  } catch (error) {
506
1084
  console.error('Template rendering error:', error);
1085
+ setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
507
1086
  ctx.status = 500;
508
- ctx.body = 'Internal Server Error - Template Rendering Failed';
1087
+ ctx.body = `
1088
+ <!DOCTYPE html>
1089
+ <html>
1090
+ <head>
1091
+ <meta charset="UTF-8">
1092
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1093
+ <title>Internal Server Error</title>
1094
+ </head>
1095
+ <body>
1096
+ <h1>Internal Server Error</h1>
1097
+ <h3>Template rendering failed for the requested resource.</h3>
1098
+ </body>
1099
+ </html>
1100
+ `;
509
1101
  return;
510
1102
  }
511
1103
  }
512
1104
  }
513
1105
 
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}"`;
1106
+ // baseEtag encoding-independent; used only for If-Range (Range requests skip compression)
1107
+ const baseEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}"`;
519
1108
 
520
- // Format Last-Modified header (RFC 7231)
521
- const lastModified = fileStat.mtime.toUTCString();
1109
+ // Advertise range support on all file responses (including 304)
1110
+ ctx.set('Accept-Ranges', 'bytes');
522
1111
 
523
- // Set caching headers
524
- ctx.set('ETag', etag);
525
- ctx.set('Last-Modified', lastModified);
1112
+ // Cache-Control set early — applies to all responses (200, 206, 304)
1113
+ if (options.browserCacheEnabled) {
526
1114
  ctx.set('Cache-Control', `public, max-age=${options.browserCacheMaxAge}, must-revalidate`);
1115
+ } else {
1116
+ // Explicitly disable caching: without these headers browsers may use heuristic caching
1117
+ ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate');
1118
+ ctx.set('Pragma', 'no-cache'); // HTTP 1.0 compatibility
1119
+ ctx.set('Expires', '0'); // Proxies
1120
+ }
1121
+
1122
+ // Verify file is still readable (race condition protection).
1123
+ // Skip if rawBuffer already loaded — the successful readFile() is equivalent proof.
1124
+ if (!rawBuffer) {
1125
+ try {
1126
+ await fs.promises.access(toOpen, fs.constants.R_OK);
1127
+ } catch (error) {
1128
+ console.error('File access error:', error);
1129
+ sendNotFound(ctx);
1130
+ return;
1131
+ }
1132
+ }
1133
+
1134
+ // Range request handling (HTTP 206 Partial Content — compression skipped for ranges)
1135
+ const rangeHeader = ctx.get('Range');
1136
+ if (rangeHeader) {
1137
+ const fileSize = fileStat.size;
1138
+ const parsed = parseRangeHeader(rangeHeader, fileSize);
1139
+
1140
+ if (parsed === 'unsatisfiable') {
1141
+ ctx.status = 416;
1142
+ ctx.set('Content-Range', `bytes */${fileSize}`);
1143
+ ctx.body = '';
1144
+ return;
1145
+ }
1146
+
1147
+ if (parsed !== 'invalid') {
1148
+ // Honor If-Range: serve range only when baseEtag matches (or If-Range absent)
1149
+ const ifRange = ctx.get('If-Range');
1150
+ if (!ifRange || ifRange === baseEtag) {
1151
+ const { start, end } = parsed;
1152
+ const rangeLength = end - start + 1;
1153
+ const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
1154
+ const filename = path.basename(toOpen);
1155
+
1156
+ ctx.status = 206;
1157
+ ctx.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
1158
+ ctx.set('Content-Type', mimeType);
1159
+ ctx.set('Content-Length', String(rangeLength));
1160
+ ctx.set('Content-Disposition', buildContentDisposition(filename));
1161
+
1162
+ if (ctx.method !== 'HEAD') {
1163
+ if (rawBuffer) {
1164
+ // Serve range slice from in-memory buffer — zero disk I/O
1165
+ ctx.body = rawBuffer.slice(start, end + 1);
1166
+ } else {
1167
+ const src = fs.createReadStream(toOpen, { start, end });
1168
+ src.on('error', (err) => {
1169
+ console.error('Stream error:', err);
1170
+ if (!ctx.headerSent) {
1171
+ ctx.status = 500;
1172
+ ctx.body = 'Error reading file';
1173
+ }
1174
+ });
1175
+ ctx.body = src;
1176
+ }
1177
+ } else {
1178
+ // HEAD: send 206 headers only — body assignment resets Content-Length,
1179
+ // so we restore it afterwards.
1180
+ ctx.body = Buffer.alloc(0);
1181
+ ctx.set('Content-Length', String(rangeLength));
1182
+ }
1183
+ return;
1184
+ }
1185
+ // If-Range mismatch → fall through to full 200 response
1186
+ }
1187
+ // Invalid Range → fall through to full 200 response
1188
+ }
1189
+
1190
+ // Determine MIME type and compression encoding for the full-file response
1191
+ const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
1192
+ const filename = path.basename(toOpen);
1193
+
1194
+ // Resolve compression: enabled + compressible MIME + meets minSize + client supports it
1195
+ let encoding = null; // 'br' | 'gzip' | null
1196
+ if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
1197
+ const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
1198
+ const meetsMinSize = compressionConfig.minSize === false
1199
+ || fileStat.size >= compressionConfig.minSize;
1200
+ if (isCompressibleMime && meetsMinSize) {
1201
+ encoding = getClientEncoding(ctx.get('Accept-Encoding'));
1202
+ }
1203
+ }
527
1204
 
528
- // OPTIMIZATION: Handle conditional requests (304 Not Modified)
1205
+ // fullEtag is encoding-specific to avoid false 304 hits across representations.
1206
+ // Proxies use Vary: Accept-Encoding to cache separate versions per encoding.
1207
+ const etagSuffix = encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : '';
1208
+ const fullEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}${etagSuffix}"`;
529
1209
 
530
- // Check If-None-Match header (ETag validation)
1210
+ // ETag, Last-Modified, and 304 check — deferred until encoding is known
1211
+ if (options.browserCacheEnabled) {
1212
+ ctx.set('ETag', fullEtag);
1213
+ ctx.set('Last-Modified', fileStat.mtime.toUTCString());
1214
+
1215
+ // Check If-None-Match (ETag validation)
531
1216
  const clientEtag = ctx.get('If-None-Match');
532
- if (clientEtag && clientEtag === etag) {
533
- // File hasn't changed - return 304 Not Modified
1217
+ if (clientEtag && clientEtag === fullEtag) {
534
1218
  ctx.status = 304;
535
1219
  return;
536
1220
  }
537
1221
 
538
- // Check If-Modified-Since header (date validation)
1222
+ // Check If-Modified-Since (date validation)
539
1223
  const clientModifiedSince = ctx.get('If-Modified-Since');
540
1224
  if (clientModifiedSince) {
541
1225
  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
1226
+ if (fileStat.mtime.getTime() <= clientDate.getTime()) {
547
1227
  ctx.status = 304;
548
1228
  return;
549
1229
  }
550
1230
  }
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
1231
  }
558
1232
 
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
- }
1233
+ // Common response headers
1234
+ ctx.set('Content-Type', mimeType);
1235
+ ctx.set('Content-Disposition', buildContentDisposition(filename));
1236
+
1237
+ if (encoding) {
1238
+ // ── Compressed response ───────────────────────────────────────────────
1239
+ ctx.set('Content-Encoding', encoding);
1240
+ ctx.set('Vary', 'Accept-Encoding'); // Required so proxies cache per-encoding
1241
+
1242
+ if (serverCacheConfig.compressedFile.enabled) {
1243
+ // compressedFile cache mode: compress once → buffer in RAM → Content-Length known
1244
+ const cacheKey = `${toOpen}:${encoding}`;
1245
+ const cached = _compressedFileCache.peek(cacheKey);
1246
+ const stale = !cached
1247
+ || cached.mtime !== fileStat.mtime.getTime()
1248
+ || cached.size !== fileStat.size;
1249
+
1250
+ let buf;
1251
+ if (!stale) {
1252
+ _compressedFileCache.get(cacheKey); // increment frequency
1253
+ buf = cached.buffer; // Serve from cache
1254
+ } else {
1255
+ try {
1256
+ // Use rawFile buffer if available — avoids redundant disk read
1257
+ const rawData = rawBuffer || await fs.promises.readFile(toOpen);
1258
+ buf = await compressBuffer(rawData, encoding);
1259
+
1260
+ if (cached) _compressedFileCache.delete(cacheKey); // remove stale entry before re-inserting
1261
+ _compressedFileCache.set(cacheKey, {
1262
+ buffer: buf,
1263
+ mtime: fileStat.mtime.getTime(),
1264
+ size: fileStat.size,
1265
+ });
1266
+ } catch (err) {
1267
+ console.error('Compression error:', err);
1268
+ // Fall back to uncompressed on any compression failure
1269
+ ctx.remove('Content-Encoding');
1270
+ ctx.remove('Vary');
1271
+ if (rawBuffer) {
1272
+ ctx.set('Content-Length', String(rawBuffer.length));
1273
+ if (ctx.method !== 'HEAD') {
1274
+ ctx.body = rawBuffer;
1275
+ } else {
1276
+ ctx.body = Buffer.alloc(0);
1277
+ ctx.set('Content-Length', String(rawBuffer.length));
1278
+ }
1279
+ } else {
1280
+ ctx.set('Content-Length', String(fileStat.size));
1281
+ if (ctx.method !== 'HEAD') {
1282
+ const src = fs.createReadStream(toOpen);
1283
+ src.on('error', (streamErr) => {
1284
+ console.error('Stream error:', streamErr);
1285
+ if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1286
+ });
1287
+ ctx.body = src;
1288
+ } else {
1289
+ ctx.body = Buffer.alloc(0);
1290
+ ctx.set('Content-Length', String(fileStat.size));
1291
+ }
1292
+ }
1293
+ return;
1294
+ }
1295
+ }
568
1296
 
569
- // Serve static file
570
- let mimeType = mime.lookup(toOpen);
571
- const src = fs.createReadStream(toOpen);
1297
+ ctx.set('Content-Length', String(buf.length));
1298
+ if (ctx.method !== 'HEAD') {
1299
+ ctx.body = buf;
1300
+ } else {
1301
+ // HEAD: set correct Content-Length; body assignment would reset it, restore after
1302
+ ctx.body = Buffer.alloc(0);
1303
+ ctx.set('Content-Length', String(buf.length));
1304
+ }
572
1305
 
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';
1306
+ } else {
1307
+ // Streaming mode: pipe through zlib transform — Content-Length not known in advance
1308
+ if (ctx.method !== 'HEAD') {
1309
+ const compress = encoding === 'br'
1310
+ ? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })
1311
+ : zlib.createGzip({ level: 6 });
1312
+ if (rawBuffer) {
1313
+ // Compress from in-memory buffer — no disk I/O
1314
+ const src = Readable.from(rawBuffer);
1315
+ ctx.body = src.pipe(compress);
1316
+ } else {
1317
+ const src = fs.createReadStream(toOpen);
1318
+ src.on('error', (err) => {
1319
+ console.error('Stream error:', err);
1320
+ if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1321
+ });
1322
+ ctx.body = src.pipe(compress);
1323
+ }
1324
+ }
1325
+ // HEAD + streaming: no Content-Length available; Koa sends headers only via res.end()
579
1326
  }
580
- });
581
-
582
- ctx.response.set("content-type", mimeType);
583
- ctx.response.set("content-length", fileStat.size);
584
1327
 
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;
1328
+ } else {
1329
+ // ── Uncompressed response ─────────────────────────────────────────────
1330
+ if (rawBuffer) {
1331
+ // Serve directly from in-memory buffer — zero disk I/O
1332
+ ctx.set('Content-Length', String(rawBuffer.length));
1333
+ if (ctx.method !== 'HEAD') {
1334
+ ctx.body = rawBuffer;
1335
+ } else {
1336
+ ctx.body = Buffer.alloc(0);
1337
+ ctx.set('Content-Length', String(rawBuffer.length));
1338
+ }
1339
+ } else {
1340
+ ctx.set('Content-Length', String(fileStat.size));
1341
+ if (ctx.method !== 'HEAD') {
1342
+ const src = fs.createReadStream(toOpen);
1343
+ src.on('error', (err) => {
1344
+ console.error('Stream error:', err);
1345
+ if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1346
+ });
1347
+ ctx.body = src;
1348
+ } else {
1349
+ // HEAD: body assignment resets Content-Length — restore after
1350
+ ctx.body = Buffer.alloc(0);
1351
+ ctx.set('Content-Length', String(fileStat.size));
1352
+ }
1353
+ }
1354
+ }
594
1355
  }
595
1356
 
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
-
605
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
606
- }
607
1357
 
608
- // OPTIMIZATION: show_dir is now async and uses array join instead of string concatenation
609
1358
  async function show_dir(toOpen, ctx) {
610
1359
  let dir;
611
1360
  try {
612
- // OPTIMIZATION: Use async readdir (non-blocking)
613
1361
  dir = await fs.promises.readdir(toOpen, { withFileTypes: true });
614
1362
  } catch (error) {
615
1363
  console.error('Directory read error:', error);
1364
+ ctx.status = 500;
1365
+ setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
616
1366
  return `
617
1367
  <!DOCTYPE html>
618
1368
  <html>
@@ -628,6 +1378,10 @@ module.exports = function koaClassicServer(
628
1378
  `;
629
1379
  }
630
1380
 
1381
+ // Relative path of this directory from rootDir (used for alwaysHide path matching)
1382
+ const rawDirRel = path.relative(normalizedRootDir, toOpen);
1383
+ const dirRelPath = (rawDirRel === '' || rawDirRel === '.') ? '' : rawDirRel.split(path.sep).join('/');
1384
+
631
1385
  // Get sorting parameters from query string
632
1386
  const sortBy = ctx.query.sort || 'name';
633
1387
  const sortOrder = ctx.query.order || 'asc';
@@ -652,8 +1406,6 @@ module.exports = function koaClassicServer(
652
1406
  return '';
653
1407
  }
654
1408
 
655
- // OPTIMIZATION: Use array + join instead of string concatenation
656
- // This reduces memory allocation from O(n²) to O(n)
657
1409
  const parts = [];
658
1410
  parts.push("<table>");
659
1411
  parts.push("<thead>");
@@ -679,81 +1431,88 @@ module.exports = function koaClassicServer(
679
1431
  if (dir.length === 0) {
680
1432
  parts.push(`<tr><td>empty folder</td><td></td><td></td></tr>`);
681
1433
  } 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
1434
+ const _listingBaseUrl = pageHref.origin + pageHref.pathname;
1435
+ const _listingOriginPrefix = pageHref.origin + options.urlPrefix;
1436
+
1437
+ // Collect item data with stat I/O in parallel (batched to avoid
1438
+ // overwhelming the filesystem on very large directories).
1439
+ const BATCH_SIZE = 64;
1440
+ const rawItems = [];
1441
+ for (let bi = 0; bi < dir.length; bi += BATCH_SIZE) {
1442
+ const batch = await Promise.all(
1443
+ dir.slice(bi, bi + BATCH_SIZE).map(async (item) => {
1444
+ const s_name = item.name.toString();
1445
+ const type = getDirentType(item);
1446
+ const itemPath = path.join(toOpen, s_name);
1447
+
1448
+ // Build item URI without query parameters
1449
+ let itemUri;
1450
+ if (_listingBaseUrl === _listingOriginPrefix + "/" || _listingBaseUrl === _listingOriginPrefix) {
1451
+ itemUri = `${_listingOriginPrefix}/${encodeURIComponent(s_name)}`;
718
1452
  } else {
719
- continue; // DT_UNKNOWN entry that can't be stat'd — skip it
1453
+ itemUri = `${_listingBaseUrl}/${encodeURIComponent(s_name)}`;
720
1454
  }
721
- }
722
- }
723
1455
 
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 = '-';
1456
+ // Resolve symlinks and DT_UNKNOWN entries to their effective type.
1457
+ // cachedStat is reused below to avoid a second stat() on the same path.
1458
+ let effectiveType = type;
1459
+ let isBrokenSymlink = false;
1460
+ let cachedStat = null;
1461
+ if (type === 3 || type === 0) {
1462
+ // type 3 = symlink, type 0 = DT_UNKNOWN (overlayfs, NFS, FUSE, NixOS buildFHSEnv, ecryptfs)
1463
+ try {
1464
+ cachedStat = await fs.promises.stat(itemPath);
1465
+ if (cachedStat.isFile()) effectiveType = 1;
1466
+ else if (cachedStat.isDirectory()) effectiveType = 2;
1467
+ } catch {
1468
+ if (type === 3) {
1469
+ isBrokenSymlink = true; // Broken or circular symlink
1470
+ } else {
1471
+ return null; // DT_UNKNOWN entry that can't be stat'd — skip it
1472
+ }
1473
+ }
735
1474
  }
736
- } catch (error) {
737
- sizeStr = '-';
738
- }
739
- }
740
1475
 
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
- });
1476
+ // Hidden check: skip entries that should not appear in directory listing
1477
+ const itemIsDir = effectiveType === 2;
1478
+ const itemRelPath = dirRelPath ? dirRelPath + '/' + s_name : s_name;
1479
+ if (isHiddenEntry(s_name, itemRelPath, itemIsDir)) return null;
1480
+
1481
+ // Get file size — reuse cachedStat if already available (avoids double stat for symlinks)
1482
+ let sizeStr = '-';
1483
+ let sizeBytes = 0;
1484
+ if (!isBrokenSymlink) {
1485
+ try {
1486
+ const itemStat = cachedStat || await fs.promises.stat(itemPath);
1487
+ if (effectiveType === 1) {
1488
+ sizeBytes = itemStat.size;
1489
+ sizeStr = formatSize(sizeBytes);
1490
+ }
1491
+ } catch {
1492
+ sizeStr = '-';
1493
+ }
1494
+ }
1495
+
1496
+ const mimeType = effectiveType === 2 ? "DIR" : (mime.lookup(itemPath) || 'unknown');
1497
+ const isReserved = pageHrefOutPrefix.pathname === '/' && options.urlsReserved.includes('/' + s_name) && (effectiveType === 2 || type === 3);
1498
+
1499
+ return {
1500
+ name: s_name,
1501
+ type,
1502
+ effectiveType,
1503
+ isSymlink: type === 3,
1504
+ isBrokenSymlink,
1505
+ mimeType,
1506
+ sizeStr,
1507
+ sizeBytes,
1508
+ itemUri,
1509
+ isReserved
1510
+ };
1511
+ })
1512
+ );
1513
+ rawItems.push(...batch);
756
1514
  }
1515
+ const items = rawItems.filter(Boolean);
757
1516
 
758
1517
  // Sort items based on query parameters
759
1518
  items.sort((a, b) => {
@@ -781,7 +1540,6 @@ module.exports = function koaClassicServer(
781
1540
  }
782
1541
  }
783
1542
 
784
- // Apply sort order (asc/desc)
785
1543
  return sortOrder === 'desc' ? -comparison : comparison;
786
1544
  });
787
1545
 
@@ -815,7 +1573,6 @@ module.exports = function koaClassicServer(
815
1573
  parts.push("</tbody>");
816
1574
  parts.push("</table>");
817
1575
 
818
- // OPTIMIZATION: Single join operation instead of multiple concatenations
819
1576
  const tableHtml = parts.join('');
820
1577
 
821
1578
  const html = `
@@ -826,48 +1583,7 @@ module.exports = function koaClassicServer(
826
1583
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
827
1584
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
828
1585
  <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>
1586
+ <style>${LISTING_CSS}</style>
871
1587
  </head>
872
1588
  <body>
873
1589
  <h1>Index of ${escapeHtml(pageHrefOutPrefix.pathname)}</h1>
@@ -876,20 +1592,9 @@ module.exports = function koaClassicServer(
876
1592
  </html>
877
1593
  `;
878
1594
 
1595
+ setGeneratedPageHeaders(ctx, LISTING_CSP);
879
1596
  return html;
880
1597
  }
881
1598
 
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
1599
  };
895
1600
  };