koa-classic-server 2.6.0 → 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 +635 -0
  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 +118 -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 +1114 -378
  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
+ }
6
197
 
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
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
+ }
256
+
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,9 +458,172 @@ 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
- * Uses fs.promises.stat (which follows symlinks) only when dirent.isSymbolicLink() is true.
620
+ * Uses fs.promises.stat (which follows symlinks) when dirent.isSymbolicLink() is true,
621
+ * or when the dirent type is unknown (DT_UNKNOWN / type 0).
622
+ *
623
+ * DT_UNKNOWN occurs on overlayfs, NFS, FUSE, NixOS buildFHSEnv, ecryptfs,
624
+ * and any filesystem that doesn't fill d_type in the kernel's getdents64 syscall.
625
+ * On standard filesystems (ext4, btrfs, xfs, APFS, NTFS), d_type is always
626
+ * filled correctly, so the stat() fallback is never reached.
177
627
  */
178
628
  async function isFileOrSymlinkToFile(dirent, dirPath) {
179
629
  if (dirent.isFile()) return true;
@@ -185,28 +635,206 @@ module.exports = function koaClassicServer(
185
635
  return false; // Broken or circular symlink
186
636
  }
187
637
  }
638
+ // DT_UNKNOWN fallback: when none of the type methods return true,
639
+ // the filesystem didn't report d_type — resolve via stat()
640
+ if (!dirent.isDirectory() && !dirent.isBlockDevice() && !dirent.isCharacterDevice() && !dirent.isFIFO() && !dirent.isSocket()) {
641
+ try {
642
+ const realStat = await fs.promises.stat(path.join(dirPath, dirent.name));
643
+ return realStat.isFile();
644
+ } catch {
645
+ return false;
646
+ }
647
+ }
188
648
  return false;
189
649
  }
190
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
+
191
767
  /**
192
- * Returns true if dirent is a directory or a symlink pointing to a directory.
193
- * Uses fs.promises.stat (which follows symlinks) only when dirent.isSymbolicLink() is true.
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).
194
777
  */
195
- async function isDirOrSymlinkToDir(dirent, dirPath) {
196
- if (dirent.isDirectory()) return true;
197
- if (dirent.isSymbolicLink()) {
198
- try {
199
- const realStat = await fs.promises.stat(path.join(dirPath, dirent.name));
200
- return realStat.isDirectory();
201
- } catch {
202
- return false; // Broken or circular symlink
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
+ }
203
832
  }
204
833
  }
205
- return false;
834
+ return null;
206
835
  }
207
836
 
208
837
  return async (ctx, next) => {
209
- // Check if method is allowed
210
838
  if (!options.method.includes(ctx.method)) {
211
839
  await next();
212
840
  return;
@@ -214,7 +842,8 @@ module.exports = function koaClassicServer(
214
842
 
215
843
  // Construct full URL based on useOriginalUrl option
216
844
  const urlToUse = options.useOriginalUrl ? ctx.originalUrl : ctx.url;
217
- const fullUrl = ctx.protocol + '://' + ctx.host + urlToUse;
845
+ const _origin = ctx.protocol + '://' + ctx.host;
846
+ const fullUrl = _origin + urlToUse;
218
847
  let pageHref = '';
219
848
  if (fullUrl.charAt(fullUrl.length - 1) === '/') {
220
849
  pageHref = new URL(fullUrl.slice(0, -1));
@@ -224,10 +853,9 @@ module.exports = function koaClassicServer(
224
853
 
225
854
  // Check URL prefix
226
855
  const a_pathname = pageHref.pathname.split("/");
227
- const a_urlPrefix = options.urlPrefix.split("/");
228
856
 
229
- for (const key in a_urlPrefix) {
230
- if (a_urlPrefix[key] !== a_pathname[key]) {
857
+ for (let i = 0; i < _urlPrefixParts.length; i++) {
858
+ if (_urlPrefixParts[i] !== a_pathname[i]) {
231
859
  await next();
232
860
  return;
233
861
  }
@@ -236,7 +864,7 @@ module.exports = function koaClassicServer(
236
864
  // Create pageHrefOutPrefix without URL prefix
237
865
  let pageHrefOutPrefix = pageHref;
238
866
  if (options.urlPrefix !== "") {
239
- let a_pathnameOutPrefix = a_pathname.slice(a_urlPrefix.length);
867
+ let a_pathnameOutPrefix = a_pathname.slice(_urlPrefixParts.length);
240
868
  let s_pathnameOutPrefix = a_pathnameOutPrefix.join("/");
241
869
  let hrefOutPrefix = pageHref.origin + '/' + s_pathnameOutPrefix;
242
870
  pageHrefOutPrefix = new URL(hrefOutPrefix);
@@ -253,8 +881,7 @@ module.exports = function koaClassicServer(
253
881
  }
254
882
  }
255
883
 
256
- // Path Traversal Protection
257
- // Construct safe file path
884
+ // Path traversal protection: build and validate safe file path
258
885
  let requestedPath = "";
259
886
  if (pageHrefOutPrefix.pathname === "/") {
260
887
  requestedPath = "";
@@ -262,37 +889,58 @@ module.exports = function koaClassicServer(
262
889
  requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
263
890
  }
264
891
 
265
- // 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
+
266
900
  const normalizedPath = path.normalize(requestedPath);
267
901
  const fullPath = path.join(normalizedRootDir, normalizedPath);
268
902
 
269
- // 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).
270
906
  if (!fullPath.startsWith(normalizedRootDir)) {
271
907
  ctx.status = 403;
272
908
  ctx.body = 'Forbidden';
273
909
  return;
274
910
  }
275
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
+
276
925
  let toOpen = fullPath;
277
926
 
278
927
  // hideExtension logic: redirect URLs with extension and resolve clean URLs
279
- // Track if original URL had trailing slash (stripped by pageHref construction above)
280
- const originalUrlPath = new URL(ctx.protocol + '://' + ctx.host + urlToUse).pathname;
281
- const hadTrailingSlash = originalUrlPath.length > 1 && originalUrlPath.endsWith('/');
282
-
283
928
  if (options.hideExtension) {
284
929
  const hideExt = options.hideExtension.ext;
285
930
  const hideRedirect = options.hideExtension.redirect;
286
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
+
287
936
  // Check if URL ends with the configured extension → redirect to clean URL
288
937
  // Use the original path (before trailing slash stripping) for accurate matching
289
- const pathForExtCheck = hadTrailingSlash ? originalUrlPath.slice(0, -1) : requestedPath;
938
+ const pathForExtCheck = hadTrailingSlash ? rawPath.slice(0, -1) : requestedPath;
290
939
  if (pathForExtCheck.endsWith(hideExt)) {
291
940
  // Build redirect target using ctx.originalUrl (always, regardless of useOriginalUrl)
292
- const originalUrlObj = new URL(ctx.protocol + '://' + ctx.host + ctx.originalUrl);
941
+ const originalUrlObj = new URL(_origin + ctx.originalUrl);
293
942
  let redirectPath = originalUrlObj.pathname;
294
943
 
295
- // Remove the extension from the path
296
944
  redirectPath = redirectPath.slice(0, redirectPath.length - hideExt.length);
297
945
 
298
946
  // Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
@@ -337,27 +985,39 @@ module.exports = function koaClassicServer(
337
985
  }
338
986
  }
339
987
 
340
- // OPTIMIZATION: Check if file/directory exists (async, non-blocking)
988
+ // Check if path exists
341
989
  let stat;
342
990
  try {
343
991
  stat = await fs.promises.stat(toOpen);
344
- } catch (error) {
992
+ } catch {
345
993
  // File/directory doesn't exist or can't be accessed
346
- ctx.status = 404;
347
- ctx.body = requestedUrlNotFound();
994
+ sendNotFound(ctx);
348
995
  return;
349
996
  }
350
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
+
351
1008
  if (stat.isDirectory()) {
352
1009
  // Handle directory
353
1010
  if (options.showDirContents) {
354
- // NEW: Enhanced index file search with array and RegExp support
1011
+ // Search for index file matching configured patterns
355
1012
  if (options.index && options.index.length > 0) {
356
1013
  const indexFile = await findIndexFile(toOpen, options.index);
357
1014
  if (indexFile) {
358
- const indexPath = path.join(toOpen, indexFile.name);
359
- await loadFile(indexPath, indexFile.stat);
360
- 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
+ }
361
1021
  }
362
1022
  }
363
1023
 
@@ -365,96 +1025,17 @@ module.exports = function koaClassicServer(
365
1025
  ctx.body = await show_dir(toOpen, ctx);
366
1026
  } else {
367
1027
  // Directory listing disabled
368
- ctx.status = 404;
369
- ctx.body = requestedUrlNotFound();
1028
+ sendNotFound(ctx);
370
1029
  }
371
1030
  return;
372
1031
  } else {
373
- // Handle file
374
1032
  await loadFile(toOpen, stat);
375
1033
  return;
376
1034
  }
377
1035
 
378
1036
  // Internal functions
379
1037
 
380
- /**
381
- * Find index file in directory with priority support
382
- * @param {string} dirPath - Directory path to search
383
- * @param {Array<string|RegExp>} indexPatterns - Array of patterns (strings or RegExp)
384
- * @returns {Promise<{name: string, stat: fs.Stats}|null>} - First matching file or null
385
- */
386
- async function findIndexFile(dirPath, indexPatterns) {
387
- try {
388
- // Read directory contents
389
- const files = await fs.promises.readdir(dirPath, { withFileTypes: true });
390
-
391
- // Filter files, following symlinks to determine effective type
392
- const fileCheckResults = await Promise.all(
393
- files.map(async dirent => ({
394
- name: dirent.name,
395
- isFile: await isFileOrSymlinkToFile(dirent, dirPath)
396
- }))
397
- );
398
- const fileNames = fileCheckResults
399
- .filter(entry => entry.isFile)
400
- .map(entry => entry.name);
401
-
402
- // Search with priority order (first pattern wins)
403
- for (const pattern of indexPatterns) {
404
- let matchedFile = null;
405
-
406
- if (typeof pattern === 'string') {
407
- // Exact string match (case-sensitive)
408
- if (fileNames.includes(pattern)) {
409
- matchedFile = pattern;
410
- }
411
- } else if (pattern instanceof RegExp) {
412
- // RegExp match (supports case-insensitive with /i flag)
413
- matchedFile = fileNames.find(fileName => pattern.test(fileName));
414
- }
415
-
416
- // If match found, verify it's a file and return it
417
- if (matchedFile) {
418
- try {
419
- const filePath = path.join(dirPath, matchedFile);
420
- const fileStat = await fs.promises.stat(filePath);
421
- if (fileStat.isFile()) {
422
- return { name: matchedFile, stat: fileStat };
423
- }
424
- } catch (error) {
425
- // File was deleted between readdir and stat, continue to next pattern
426
- continue;
427
- }
428
- }
429
- }
430
-
431
- // No match found
432
- return null;
433
- } catch (error) {
434
- console.error('Error finding index file:', error);
435
- return null;
436
- }
437
- }
438
-
439
- function requestedUrlNotFound() {
440
- return `
441
- <!DOCTYPE html>
442
- <html>
443
- <head>
444
- <meta charset="UTF-8">
445
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
446
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
447
- <title>URL not found</title>
448
- </head>
449
- <body>
450
- <h1>Not Found</h1>
451
- <h3>The requested URL was not found on this server.</h3>
452
- </body>
453
- </html>
454
- `;
455
- }
456
-
457
- // OPTIMIZATION: loadFile now receives stat to avoid double stat call
1038
+ // Accepts a pre-fetched stat to avoid a redundant stat call
458
1039
  async function loadFile(toOpen, fileStat) {
459
1040
  // Get file stat if not provided
460
1041
  if (!fileStat) {
@@ -462,131 +1043,326 @@ module.exports = function koaClassicServer(
462
1043
  fileStat = await fs.promises.stat(toOpen);
463
1044
  } catch (error) {
464
1045
  console.error('File stat error:', error);
465
- ctx.status = 404;
466
- ctx.body = requestedUrlNotFound();
1046
+ sendNotFound(ctx);
467
1047
  return;
468
1048
  }
469
1049
  }
470
1050
 
471
- // 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.
472
1076
  if (options.template.ext.length > 0 && options.template.render) {
473
1077
  const fileExt = path.extname(toOpen).slice(1); // Remove leading dot
474
1078
 
475
1079
  if (fileExt && options.template.ext.includes(fileExt)) {
476
1080
  try {
477
- await options.template.render(ctx, next, toOpen);
1081
+ await options.template.render(ctx, next, toOpen, rawBuffer);
478
1082
  return;
479
1083
  } catch (error) {
480
1084
  console.error('Template rendering error:', error);
1085
+ setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
481
1086
  ctx.status = 500;
482
- 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
+ `;
483
1101
  return;
484
1102
  }
485
1103
  }
486
1104
  }
487
1105
 
488
- // OPTIMIZATION: HTTP Caching Headers
489
- if (options.browserCacheEnabled) {
490
- // Generate ETag from mtime timestamp + file size
491
- // This ensures ETag changes when file is modified or resized
492
- 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}"`;
493
1108
 
494
- // Format Last-Modified header (RFC 7231)
495
- const lastModified = fileStat.mtime.toUTCString();
1109
+ // Advertise range support on all file responses (including 304)
1110
+ ctx.set('Accept-Ranges', 'bytes');
496
1111
 
497
- // Set caching headers
498
- ctx.set('ETag', etag);
499
- ctx.set('Last-Modified', lastModified);
1112
+ // Cache-Control set early — applies to all responses (200, 206, 304)
1113
+ if (options.browserCacheEnabled) {
500
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
+ }
501
1121
 
502
- // OPTIMIZATION: Handle conditional requests (304 Not Modified)
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
+ }
503
1146
 
504
- // Check If-None-Match header (ETag validation)
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
+ }
1204
+
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}"`;
1209
+
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)
505
1216
  const clientEtag = ctx.get('If-None-Match');
506
- if (clientEtag && clientEtag === etag) {
507
- // File hasn't changed - return 304 Not Modified
1217
+ if (clientEtag && clientEtag === fullEtag) {
508
1218
  ctx.status = 304;
509
1219
  return;
510
1220
  }
511
1221
 
512
- // Check If-Modified-Since header (date validation)
1222
+ // Check If-Modified-Since (date validation)
513
1223
  const clientModifiedSince = ctx.get('If-Modified-Since');
514
1224
  if (clientModifiedSince) {
515
1225
  const clientDate = new Date(clientModifiedSince);
516
- const fileDate = new Date(fileStat.mtime);
517
-
518
- // Compare timestamps (ignore milliseconds for better compatibility)
519
- if (fileDate.getTime() <= clientDate.getTime()) {
520
- // File hasn't been modified - return 304 Not Modified
1226
+ if (fileStat.mtime.getTime() <= clientDate.getTime()) {
521
1227
  ctx.status = 304;
522
1228
  return;
523
1229
  }
524
1230
  }
525
- } else {
526
- // BUGFIX: When caching is disabled, explicitly prevent browser caching
527
- // Without these headers, browsers may use heuristic caching and serve stale content
528
- ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate');
529
- ctx.set('Pragma', 'no-cache'); // HTTP 1.0 compatibility
530
- ctx.set('Expires', '0'); // Proxies
531
1231
  }
532
1232
 
533
- // Verify file is still readable (race condition protection)
534
- try {
535
- await fs.promises.access(toOpen, fs.constants.R_OK);
536
- } catch (error) {
537
- console.error('File access error:', error);
538
- ctx.status = 404;
539
- ctx.body = requestedUrlNotFound();
540
- return;
541
- }
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
+ }
542
1296
 
543
- // Serve static file
544
- let mimeType = mime.lookup(toOpen);
545
- 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
+ }
546
1305
 
547
- // Handle stream errors
548
- src.on('error', (err) => {
549
- console.error('Stream error:', err);
550
- if (!ctx.headerSent) {
551
- ctx.status = 500;
552
- 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()
553
1326
  }
554
- });
555
-
556
- ctx.response.set("content-type", mimeType);
557
- ctx.response.set("content-length", fileStat.size);
558
-
559
- // Content-Disposition properly quoted with only basename
560
- const filename = path.basename(toOpen);
561
- const safeFilename = filename.replace(/"/g, '\\"'); // Escape quotes
562
- ctx.response.set(
563
- "content-disposition",
564
- `inline; filename="${safeFilename}"`
565
- );
566
1327
 
567
- 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
+ }
568
1355
  }
569
1356
 
570
- // Helper function to format file size in human-readable format
571
- function formatSize(bytes) {
572
- if (bytes === 0) return '0 B';
573
- if (bytes === undefined || bytes === null) return '-';
574
1357
 
575
- const k = 1024;
576
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
577
- const i = Math.floor(Math.log(bytes) / Math.log(k));
578
-
579
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
580
- }
581
-
582
- // OPTIMIZATION: show_dir is now async and uses array join instead of string concatenation
583
1358
  async function show_dir(toOpen, ctx) {
584
1359
  let dir;
585
1360
  try {
586
- // OPTIMIZATION: Use async readdir (non-blocking)
587
1361
  dir = await fs.promises.readdir(toOpen, { withFileTypes: true });
588
1362
  } catch (error) {
589
1363
  console.error('Directory read error:', error);
1364
+ ctx.status = 500;
1365
+ setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
590
1366
  return `
591
1367
  <!DOCTYPE html>
592
1368
  <html>
@@ -602,6 +1378,10 @@ module.exports = function koaClassicServer(
602
1378
  `;
603
1379
  }
604
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
+
605
1385
  // Get sorting parameters from query string
606
1386
  const sortBy = ctx.query.sort || 'name';
607
1387
  const sortOrder = ctx.query.order || 'asc';
@@ -626,8 +1406,6 @@ module.exports = function koaClassicServer(
626
1406
  return '';
627
1407
  }
628
1408
 
629
- // OPTIMIZATION: Use array + join instead of string concatenation
630
- // This reduces memory allocation from O(n²) to O(n)
631
1409
  const parts = [];
632
1410
  parts.push("<table>");
633
1411
  parts.push("<thead>");
@@ -653,76 +1431,88 @@ module.exports = function koaClassicServer(
653
1431
  if (dir.length === 0) {
654
1432
  parts.push(`<tr><td>empty folder</td><td></td><td></td></tr>`);
655
1433
  } else {
656
- let a_sy = Object.getOwnPropertySymbols(dir[0]);
657
- const sy_type = a_sy[0];
658
-
659
- // Collect all items data first (for sorting)
660
- const items = [];
661
- for (const item of dir) {
662
- const s_name = item.name.toString();
663
- const type = item[sy_type];
664
-
665
- if (type !== 1 && type !== 2 && type !== 3) {
666
- console.error("Unknown file type:", type);
667
- continue;
668
- }
669
-
670
- const itemPath = path.join(toOpen, s_name);
671
- let itemUri = "";
672
- // Build item URI without query parameters
673
- const baseUrl = pageHref.origin + pageHref.pathname;
674
- if (baseUrl === pageHref.origin + options.urlPrefix + "/" || baseUrl === pageHref.origin + options.urlPrefix) {
675
- itemUri = `${pageHref.origin + options.urlPrefix}/${encodeURIComponent(s_name)}`;
676
- } else {
677
- itemUri = `${baseUrl}/${encodeURIComponent(s_name)}`;
678
- }
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)}`;
1452
+ } else {
1453
+ itemUri = `${_listingBaseUrl}/${encodeURIComponent(s_name)}`;
1454
+ }
679
1455
 
680
- // Resolve symlinks to their effective type
681
- let effectiveType = type;
682
- let isBrokenSymlink = false;
683
- if (type === 3) {
684
- try {
685
- const realStat = await fs.promises.stat(itemPath);
686
- if (realStat.isFile()) effectiveType = 1;
687
- else if (realStat.isDirectory()) effectiveType = 2;
688
- } catch {
689
- isBrokenSymlink = true; // Broken or circular symlink
690
- }
691
- }
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
+ }
1474
+ }
692
1475
 
693
- // Get file size
694
- let sizeStr = '-';
695
- let sizeBytes = 0;
696
- if (!isBrokenSymlink) {
697
- try {
698
- const itemStat = await fs.promises.stat(itemPath);
699
- if (effectiveType === 1) {
700
- sizeBytes = itemStat.size;
701
- sizeStr = formatSize(sizeBytes);
702
- } else {
703
- sizeStr = '-';
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
+ }
704
1494
  }
705
- } catch (error) {
706
- sizeStr = '-';
707
- }
708
- }
709
1495
 
710
- const mimeType = effectiveType === 2 ? "DIR" : (mime.lookup(itemPath) || 'unknown');
711
- const isReserved = pageHrefOutPrefix.pathname === '/' && options.urlsReserved.includes('/' + s_name) && (effectiveType === 2 || type === 3);
712
-
713
- items.push({
714
- name: s_name,
715
- type: type,
716
- effectiveType: effectiveType,
717
- isSymlink: type === 3,
718
- isBrokenSymlink: isBrokenSymlink,
719
- mimeType: mimeType,
720
- sizeStr: sizeStr,
721
- sizeBytes: sizeBytes,
722
- itemUri: itemUri,
723
- isReserved: isReserved
724
- });
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);
725
1514
  }
1515
+ const items = rawItems.filter(Boolean);
726
1516
 
727
1517
  // Sort items based on query parameters
728
1518
  items.sort((a, b) => {
@@ -750,7 +1540,6 @@ module.exports = function koaClassicServer(
750
1540
  }
751
1541
  }
752
1542
 
753
- // Apply sort order (asc/desc)
754
1543
  return sortOrder === 'desc' ? -comparison : comparison;
755
1544
  });
756
1545
 
@@ -784,7 +1573,6 @@ module.exports = function koaClassicServer(
784
1573
  parts.push("</tbody>");
785
1574
  parts.push("</table>");
786
1575
 
787
- // OPTIMIZATION: Single join operation instead of multiple concatenations
788
1576
  const tableHtml = parts.join('');
789
1577
 
790
1578
  const html = `
@@ -795,48 +1583,7 @@ module.exports = function koaClassicServer(
795
1583
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
796
1584
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
797
1585
  <title>Index of ${escapeHtml(pageHrefOutPrefix.pathname)}</title>
798
- <style>
799
- body {
800
- font-family: Arial, sans-serif;
801
- margin: 20px;
802
- }
803
- h1 {
804
- border-bottom: 1px solid #ddd;
805
- padding-bottom: 10px;
806
- }
807
- table {
808
- border-collapse: collapse;
809
- width: 100%;
810
- max-width: 800px;
811
- }
812
- thead {
813
- background-color: #f5f5f5;
814
- border-bottom: 2px solid #ddd;
815
- }
816
- th {
817
- text-align: left;
818
- padding: 10px;
819
- font-weight: bold;
820
- border-bottom: 2px solid #ddd;
821
- }
822
- td {
823
- padding: 8px 10px;
824
- border-bottom: 1px solid #eee;
825
- }
826
- tr:hover {
827
- background-color: #f9f9f9;
828
- }
829
- a {
830
- color: #0066cc;
831
- text-decoration: none;
832
- }
833
- a:hover {
834
- text-decoration: underline;
835
- }
836
- th:nth-child(1), td:nth-child(1) { width: 50%; }
837
- th:nth-child(2), td:nth-child(2) { width: 30%; }
838
- th:nth-child(3), td:nth-child(3) { width: 20%; text-align: right; }
839
- </style>
1586
+ <style>${LISTING_CSS}</style>
840
1587
  </head>
841
1588
  <body>
842
1589
  <h1>Index of ${escapeHtml(pageHrefOutPrefix.pathname)}</h1>
@@ -845,20 +1592,9 @@ module.exports = function koaClassicServer(
845
1592
  </html>
846
1593
  `;
847
1594
 
1595
+ setGeneratedPageHeaders(ctx, LISTING_CSP);
848
1596
  return html;
849
1597
  }
850
1598
 
851
- // Helper function to escape HTML and prevent XSS
852
- function escapeHtml(unsafe) {
853
- if (typeof unsafe !== 'string') {
854
- return unsafe;
855
- }
856
- return unsafe
857
- .replace(/&/g, "&amp;")
858
- .replace(/</g, "&lt;")
859
- .replace(/>/g, "&gt;")
860
- .replace(/"/g, "&quot;")
861
- .replace(/'/g, "&#039;");
862
- }
863
1599
  };
864
1600
  };