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.
- package/README.md +68 -10
- package/__tests__/caching-headers.test.js +30 -30
- package/__tests__/compression-fixtures/data.json +1 -0
- package/__tests__/compression-fixtures/large.txt +1 -0
- package/__tests__/compression-fixtures/small.txt +1 -0
- package/__tests__/compression.test.js +270 -0
- package/__tests__/customTest/serversToLoad.util.js +1 -1
- package/__tests__/deprecation-warnings.test.js +71 -183
- package/__tests__/dt-unknown.test.js +635 -0
- package/__tests__/hidden-fixtures/.dot-dir/inside.txt +1 -0
- package/__tests__/hidden-fixtures/.env +2 -0
- package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +1 -0
- package/__tests__/hidden-fixtures/config/secrets/password.txt +1 -0
- package/__tests__/hidden-fixtures/data.key +1 -0
- package/__tests__/hidden-fixtures/file.secret +1 -0
- package/__tests__/hidden-fixtures/index.html +1 -0
- package/__tests__/hidden-fixtures/normal.txt +1 -0
- package/__tests__/hidden-fixtures/subdir/.env +1 -0
- package/__tests__/hidden-fixtures/subdir/regular.txt +1 -0
- package/__tests__/hidden-option.test.js +422 -0
- package/__tests__/index-option.test.js +18 -16
- package/__tests__/index.test.js +8 -4
- package/__tests__/range-fixtures/sample.txt +1 -0
- package/__tests__/range.test.js +223 -0
- package/__tests__/security-headers.test.js +153 -0
- package/__tests__/security.test.js +145 -159
- package/__tests__/server-cache-fixtures/large.txt +1 -0
- package/__tests__/server-cache-fixtures/small.txt +1 -0
- package/__tests__/server-cache.test.js +423 -0
- package/__tests__/symlink.test.js +8 -5
- package/docs/ACTION_PLAN.md +293 -0
- package/docs/CHANGELOG.md +118 -0
- package/docs/EXAMPLES_INDEX_OPTION.md +2 -2
- package/docs/FLOW_DIAGRAM.md +13 -13
- package/docs/OPTIMIZATION_ROADMAP_for_V3.md +864 -0
- package/docs/PERFORMANCE_COMPARISON.md +7 -7
- package/eslint.config.mjs +17 -0
- package/index.cjs +1114 -378
- package/index.mjs +1 -5
- 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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
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) -
|
|
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:
|
|
36
|
-
// - Mixed array:
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
'[koa-classic-server]
|
|
90
|
-
`
|
|
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
|
-
//
|
|
96
|
-
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
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
'
|
|
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)
|
|
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
|
-
*
|
|
193
|
-
*
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
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
|
|
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 (
|
|
230
|
-
if (
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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 ?
|
|
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(
|
|
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
|
-
//
|
|
988
|
+
// Check if path exists
|
|
341
989
|
let stat;
|
|
342
990
|
try {
|
|
343
991
|
stat = await fs.promises.stat(toOpen);
|
|
344
|
-
} catch
|
|
992
|
+
} catch {
|
|
345
993
|
// File/directory doesn't exist or can't be accessed
|
|
346
|
-
ctx
|
|
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
|
-
//
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
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
|
|
466
|
-
ctx.body = requestedUrlNotFound();
|
|
1046
|
+
sendNotFound(ctx);
|
|
467
1047
|
return;
|
|
468
1048
|
}
|
|
469
1049
|
}
|
|
470
1050
|
|
|
471
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
489
|
-
|
|
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
|
-
|
|
495
|
-
|
|
1109
|
+
// Advertise range support on all file responses (including 304)
|
|
1110
|
+
ctx.set('Accept-Ranges', 'bytes');
|
|
496
1111
|
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
ctx.
|
|
540
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
// Collect
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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, "&")
|
|
858
|
-
.replace(/</g, "<")
|
|
859
|
-
.replace(/>/g, ">")
|
|
860
|
-
.replace(/"/g, """)
|
|
861
|
-
.replace(/'/g, "'");
|
|
862
|
-
}
|
|
863
1599
|
};
|
|
864
1600
|
};
|