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