koa-classic-server 4.2.1 → 5.1.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 +82 -32
- package/index.cjs +564 -150
- package/package.json +4 -3
package/index.cjs
CHANGED
|
@@ -14,6 +14,26 @@ const _gzipAsync = util.promisify(zlib.gzip);
|
|
|
14
14
|
// Pre-computed module-level constants
|
|
15
15
|
const _LOG_1024 = Math.log(1024);
|
|
16
16
|
|
|
17
|
+
// Redirect statuses accepted for hideExtension.redirect (V5 — anything else
|
|
18
|
+
// throws at factory time; see the validation block for the rationale). The
|
|
19
|
+
// set is exactly what Koa's ctx.redirect() emits AS-IS (statuses.redirect):
|
|
20
|
+
// 300 and 305 are exotic/deprecated but valid and honored; 304 is NOT here
|
|
21
|
+
// because Koa does not treat it as a redirect (it would be rewritten to 302).
|
|
22
|
+
const _VALID_REDIRECT_CODES = new Set([300, 301, 302, 303, 305, 307, 308]);
|
|
23
|
+
|
|
24
|
+
// Shared extension-suffix normalizer (V5 — v4.3 register #10), used by BOTH
|
|
25
|
+
// hideExtension.ext and template.ext entries: the leading dot is optional
|
|
26
|
+
// decoration ('.ejs' and 'ejs' are equivalent; '.ejs' is the preferred,
|
|
27
|
+
// documented form) and compound suffixes ('.tar.gz', '.html.ejs') are
|
|
28
|
+
// first-class — both options match by SUFFIX. Returns the canonical dotted
|
|
29
|
+
// form, or null when the value cannot name a suffix (non-string, empty, '.').
|
|
30
|
+
function normalizeExtSuffix(value) {
|
|
31
|
+
if (typeof value !== 'string') return null;
|
|
32
|
+
const stripped = value.startsWith('.') ? value.slice(1) : value;
|
|
33
|
+
if (stripped === '') return null;
|
|
34
|
+
return '.' + stripped;
|
|
35
|
+
}
|
|
36
|
+
|
|
17
37
|
// Emitted at most once per process lifetime when the caller passes the v2-era
|
|
18
38
|
// `showDirContents` option instead of the v3 `dirListing.enabled`. The old
|
|
19
39
|
// name is accepted as a backward-compatibility alias and may be removed in a
|
|
@@ -183,7 +203,15 @@ const ERROR_PAGE_SCRUB_HEADERS = [
|
|
|
183
203
|
// keep their own headerSent / writableEnded guards.
|
|
184
204
|
function writeErrorPage(ctx, status, customBuffer, builtinHtml) {
|
|
185
205
|
for (const h of ERROR_PAGE_SCRUB_HEADERS) ctx.remove(h);
|
|
186
|
-
|
|
206
|
+
// Every generated error page is non-cacheable: no-store on ALL handled
|
|
207
|
+
// statuses (404 included), not just >= 500 (v5.0 register #1). A 404 is
|
|
208
|
+
// heuristically cacheable by default (RFC 9110 §15.1 / RFC 7231 §6.1), so
|
|
209
|
+
// without an explicit directive a shared cache could keep serving a stale
|
|
210
|
+
// "not found" after the file is created — the same heuristic-caching hazard
|
|
211
|
+
// the file branch (browserCacheEnabled:false) and the listing (v4.3 #5)
|
|
212
|
+
// already defeat explicitly. no-store rather than the listing's no-cache
|
|
213
|
+
// trio: an error page has nothing worth storing to revalidate later.
|
|
214
|
+
ctx.set('Cache-Control', 'no-store');
|
|
187
215
|
setGeneratedPageHeaders(ctx, customBuffer ? null : NOT_FOUND_CSP);
|
|
188
216
|
ctx.set('Content-Type', 'text/html; charset=utf-8');
|
|
189
217
|
ctx.status = status;
|
|
@@ -230,8 +258,9 @@ function warnPayload(logger, message) {
|
|
|
230
258
|
// multiple messages. These options (urlPrefix, urlsReserved, ...) are v2-stable,
|
|
231
259
|
// so a malformed value is TOLERATED with a warning for now rather than thrown:
|
|
232
260
|
// throwing on a stable option would be a breaking change on a minor upgrade.
|
|
233
|
-
//
|
|
234
|
-
//
|
|
261
|
+
// A FUTURE major (maintainer decision 2026-07-18: target 6.0.0, not 5.0.0)
|
|
262
|
+
// will flip `warnConfigDeprecation` into a hard throw (the call sites already
|
|
263
|
+
// carry the final message) — see docs/revisione_codice_v3.1.md #11.
|
|
235
264
|
const _configDeprecationsWarned = new Set();
|
|
236
265
|
function warnConfigDeprecation(logger, message) {
|
|
237
266
|
if (_configDeprecationsWarned.has(message)) return;
|
|
@@ -290,8 +319,12 @@ function stripBodyForHead(ctx) {
|
|
|
290
319
|
async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger, sendErrorPage) {
|
|
291
320
|
if (templateOpts.ext.length === 0 || !templateOpts.render) return false;
|
|
292
321
|
|
|
293
|
-
|
|
294
|
-
|
|
322
|
+
// Suffix match (V5 — v4.3 register #10): ext entries are canonical dotted
|
|
323
|
+
// suffixes ('.ejs'; compound forms like '.html.ejs' match too). The length
|
|
324
|
+
// guard preserves the historical dotfile behavior: a file named exactly
|
|
325
|
+
// '.ejs' has no extension and never matches.
|
|
326
|
+
const baseName = path.basename(filePath);
|
|
327
|
+
if (!templateOpts.ext.some((ext) => baseName.length > ext.length && baseName.endsWith(ext))) return false;
|
|
295
328
|
|
|
296
329
|
// RFC 9110 §9.3.2: HEAD must mirror GET (same status + headers, no body). The
|
|
297
330
|
// user's render is run exactly as for GET — by presenting ctx.method as GET for
|
|
@@ -362,14 +395,74 @@ function escapeHtml(unsafe) {
|
|
|
362
395
|
return unsafe.replace(_HTML_ESCAPE_RE, c => _HTML_ESCAPE_MAP[c]);
|
|
363
396
|
}
|
|
364
397
|
|
|
398
|
+
// Lone (unpaired) surrogates cannot be percent-encoded: encodeURIComponent
|
|
399
|
+
// throws URIError on them. They cannot arrive from URL-decoded client input
|
|
400
|
+
// (the decode guards already reject invalid encodings with a 400), but Windows
|
|
401
|
+
// filenames are WTF-16 and readdir() can return them — one such name must not
|
|
402
|
+
// turn the whole directory listing (or the file's Content-Disposition) into a
|
|
403
|
+
// 500. String.prototype.toWellFormed() (Node >= 20, the engines minimum since
|
|
404
|
+
// v5.0.0) replaces every lone surrogate with U+FFFD. Well-formed strings —
|
|
405
|
+
// including astral pairs like emoji — pass through unchanged.
|
|
406
|
+
function toWellFormedName(name) {
|
|
407
|
+
return name.toWellFormed();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Explicit bidi control characters (embedding/override/isolate,
|
|
411
|
+
// U+202A-U+202E and U+2066-U+2069) can make a listing entry DISPLAY as a
|
|
412
|
+
// different name — "eviltxt.exe" renders roughly as "evilexe.txt"
|
|
413
|
+
// (extension spoofing). In the DISPLAYED name only they are replaced with a
|
|
414
|
+
// visible U+FFFD (the href and the served file are untouched); the caller
|
|
415
|
+
// additionally wraps the name in <bdi> so the directional run of one name
|
|
416
|
+
// (legit RTL included) cannot bleed into the rest of its row. Direction MARKS
|
|
417
|
+
// (U+200E/U+200F) are legitimate in RTL text and are left alone.
|
|
418
|
+
const _BIDI_CONTROLS_RE = /[\u202A-\u202E\u2066-\u2069]/g;
|
|
419
|
+
function listingDisplayName(name) {
|
|
420
|
+
return escapeHtml(name.replace(_BIDI_CONTROLS_RE, '\uFFFD'));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Build a Content-Disposition header value for inline serving.
|
|
425
|
+
*
|
|
426
|
+
* Uses both the legacy quoted-string form (ASCII fallback) and the RFC 5987
|
|
427
|
+
* extended form (UTF-8 percent-encoded) for maximum browser compatibility:
|
|
428
|
+
* inline; filename="ascii-safe"; filename*=UTF-8''percent-encoded
|
|
429
|
+
*
|
|
430
|
+
* The quoted-string form must stay within what Node accepts in a header
|
|
431
|
+
* value (latin1 minus control chars): anything outside — CJK, emoji, \n —
|
|
432
|
+
* would make ctx.set() throw ERR_INVALID_CHAR and turn the response into a
|
|
433
|
+
* 500. Those characters are replaced with '?' (same policy as express's
|
|
434
|
+
* content-disposition package); the real name still round-trips via the
|
|
435
|
+
* RFC 5987 form, which browsers prefer over filename (RFC 6266 §4.1).
|
|
436
|
+
* The name is normalized to well-formed UTF-16 first: a lone surrogate (WTF-16
|
|
437
|
+
* filename on Windows) would otherwise make encodeURIComponent throw.
|
|
438
|
+
*/
|
|
439
|
+
function buildContentDisposition(filename) {
|
|
440
|
+
filename = toWellFormedName(filename);
|
|
441
|
+
|
|
442
|
+
// quoted-string fallback: printable latin1 only (controls and >0xFF → '?'), then escape \ and "
|
|
443
|
+
const asciiSafe = filename
|
|
444
|
+
.replace(/[^\x20-\x7e\xa0-\xff]/g, '?')
|
|
445
|
+
.replace(/\\/g, '\\\\')
|
|
446
|
+
.replace(/"/g, '\\"');
|
|
447
|
+
|
|
448
|
+
// RFC 5987 extended value: UTF-8 percent-encode everything except
|
|
449
|
+
// unreserved chars (ALPHA / DIGIT / "-" / "." / "_" / "~")
|
|
450
|
+
const rfc5987 = encodeURIComponent(filename)
|
|
451
|
+
.replace(/['()]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
|
452
|
+
|
|
453
|
+
return `inline; filename="${asciiSafe}"; filename*=UTF-8''${rfc5987}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
365
456
|
// Pure helper — depends only on _LOG_1024 (module scope), safe to hoist.
|
|
366
457
|
function formatSize(bytes) {
|
|
367
458
|
if (bytes === 0) return '0 B';
|
|
368
459
|
if (bytes === undefined || bytes === null) return '-';
|
|
369
460
|
|
|
370
461
|
const k = 1024;
|
|
371
|
-
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
372
|
-
|
|
462
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
|
463
|
+
// Clamped to the last unit: an off-scale size must degrade to "huge number
|
|
464
|
+
// of EB", never to "N undefined" (#8).
|
|
465
|
+
const i = Math.min(Math.floor(Math.log(bytes) / _LOG_1024), sizes.length - 1);
|
|
373
466
|
|
|
374
467
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
375
468
|
}
|
|
@@ -383,6 +476,10 @@ function getDirentType(dirent) {
|
|
|
383
476
|
return 0;
|
|
384
477
|
}
|
|
385
478
|
|
|
479
|
+
// Range start/end/suffix must be pure digit runs: parseInt alone would accept
|
|
480
|
+
// "1x" as 1 (see the strict-validation note inside parseRangeHeader).
|
|
481
|
+
const _RANGE_DIGITS_RE = /^\d+$/;
|
|
482
|
+
|
|
386
483
|
/**
|
|
387
484
|
* Parse a "Range: bytes=..." header against a known file size.
|
|
388
485
|
* Only single ranges are supported; multi-range requests are treated as invalid.
|
|
@@ -408,25 +505,29 @@ function parseRangeHeader(rangeHeader, fileSize) {
|
|
|
408
505
|
|
|
409
506
|
let start, end;
|
|
410
507
|
|
|
508
|
+
// Strict digit validation (#11): parseInt() would accept garbage prefixes
|
|
509
|
+
// ("bytes=1x-5y" as 1-5) and serve a 206 for a spec that RFC 9110 §14.2
|
|
510
|
+
// says must be ignored (→ full 200 via 'invalid'). Bounds were never at
|
|
511
|
+
// risk — this is pure conformance on malformed input.
|
|
411
512
|
if (startStr === '') {
|
|
412
513
|
// Suffix range: bytes=-N (last N bytes)
|
|
413
|
-
if (endStr
|
|
514
|
+
if (!_RANGE_DIGITS_RE.test(endStr)) return 'invalid'; // also rejects ''
|
|
414
515
|
const suffix = parseInt(endStr, 10);
|
|
415
|
-
if (
|
|
516
|
+
if (suffix <= 0) return 'invalid';
|
|
416
517
|
if (fileSize === 0) return 'unsatisfiable';
|
|
417
518
|
start = suffix >= fileSize ? 0 : fileSize - suffix;
|
|
418
519
|
end = fileSize - 1;
|
|
419
520
|
} else {
|
|
521
|
+
if (!_RANGE_DIGITS_RE.test(startStr)) return 'invalid';
|
|
420
522
|
start = parseInt(startStr, 10);
|
|
421
|
-
if (isNaN(start) || start < 0) return 'invalid';
|
|
422
523
|
if (fileSize === 0 || start >= fileSize) return 'unsatisfiable';
|
|
423
524
|
|
|
424
525
|
if (endStr === '') {
|
|
425
526
|
// Open range: bytes=N-
|
|
426
527
|
end = fileSize - 1;
|
|
427
528
|
} else {
|
|
529
|
+
if (!_RANGE_DIGITS_RE.test(endStr)) return 'invalid';
|
|
428
530
|
end = parseInt(endStr, 10);
|
|
429
|
-
if (isNaN(end) || end < 0) return 'invalid';
|
|
430
531
|
if (start > end) return 'invalid';
|
|
431
532
|
// Clamp end to file size - 1
|
|
432
533
|
if (end >= fileSize) end = fileSize - 1;
|
|
@@ -460,8 +561,12 @@ function ifNoneMatchSatisfied(headerValue, etag) {
|
|
|
460
561
|
// set(key, entry) — insert, evicting LFU entries if needed
|
|
461
562
|
// delete(key) — remove explicitly (e.g. stale entry before re-insert)
|
|
462
563
|
class LFUCache {
|
|
463
|
-
constructor(maxSize, warnInterval, cacheLabel, logger) {
|
|
564
|
+
constructor(maxSize, warnInterval, cacheLabel, logger, maxEntrySize) {
|
|
464
565
|
this.maxSize = maxSize;
|
|
566
|
+
// Per-entry admission cap (bytes). Entries larger than this are never
|
|
567
|
+
// cached (they are still served). Infinity = no per-entry cap: only the
|
|
568
|
+
// physical maxSize bound applies.
|
|
569
|
+
this.maxEntrySize = typeof maxEntrySize === 'number' ? maxEntrySize : Infinity;
|
|
465
570
|
this.warnInterval = warnInterval;
|
|
466
571
|
this.cacheLabel = cacheLabel;
|
|
467
572
|
this.logger = logger || console;
|
|
@@ -487,6 +592,15 @@ class LFUCache {
|
|
|
487
592
|
}
|
|
488
593
|
|
|
489
594
|
set(key, entry) {
|
|
595
|
+
// Invariant: set() must never run against a LIVE key — it would add the
|
|
596
|
+
// new buffer to currentSize without subtracting the overwritten one
|
|
597
|
+
// (accounting inflated forever → chronic premature evictions) and leave
|
|
598
|
+
// the key in two frequency buckets (a ghost that later makes
|
|
599
|
+
// _evictOne() destructure undefined — a throw inside a stream callback,
|
|
600
|
+
// outside any request try). Callers are expected to delete first
|
|
601
|
+
// (refreshOrInsert does, unconditionally); this guard makes the
|
|
602
|
+
// invariant hold by construction for any future caller too.
|
|
603
|
+
if (this._keyMap.has(key)) this.delete(key);
|
|
490
604
|
// An entry larger than the whole cache can never fit: bail out BEFORE the
|
|
491
605
|
// eviction loop, otherwise it would flush every other entry for nothing.
|
|
492
606
|
// Warn (throttled) so the operator learns the cache is undersized for
|
|
@@ -495,6 +609,12 @@ class LFUCache {
|
|
|
495
609
|
this._warnThrottled(`[koa-classic-server] serverCache.${this.cacheLabel}: entry of ${entry.buffer.length} bytes exceeds maxSize (${this.maxSize} bytes) and will never be cached. Consider increasing maxSize.`);
|
|
496
610
|
return;
|
|
497
611
|
}
|
|
612
|
+
// Per-entry admission cap: refuse (and keep serving uncached) instead of
|
|
613
|
+
// letting one oversized entry evict most of the working set on insert.
|
|
614
|
+
if (entry.buffer.length > this.maxEntrySize) {
|
|
615
|
+
this._warnThrottled(`[koa-classic-server] serverCache.${this.cacheLabel}: entry of ${entry.buffer.length} bytes exceeds maxEntrySize (${this.maxEntrySize} bytes) and will not be cached. Increase serverCache.${this.cacheLabel}.maxEntrySize or set it to false.`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
498
618
|
while (this.currentSize + entry.buffer.length > this.maxSize && this._keyMap.size > 0) {
|
|
499
619
|
this._evictOne();
|
|
500
620
|
}
|
|
@@ -514,6 +634,7 @@ class LFUCache {
|
|
|
514
634
|
const entry = this._keyMap.get(key);
|
|
515
635
|
if (!entry) return false;
|
|
516
636
|
|
|
637
|
+
if (fields.buffer.length > this.maxEntrySize) return false;
|
|
517
638
|
const sizeDelta = fields.buffer.length - entry.buffer.length;
|
|
518
639
|
if (this.currentSize + sizeDelta > this.maxSize) return false;
|
|
519
640
|
|
|
@@ -609,22 +730,25 @@ function singleFlight(map, key, work) {
|
|
|
609
730
|
// frequency counter survives — important for popular files refreshed by maxAge.
|
|
610
731
|
// Otherwise falls back to delete + set (frequency resets to 1).
|
|
611
732
|
// Bounded-RAM streaming compressor: constant-memory transform, lower quality
|
|
612
|
-
// than the buffered path (which can afford
|
|
613
|
-
// is cached). Used by every streamed-compression pipeline.
|
|
733
|
+
// than the buffered path (which can afford max quality because it runs once and
|
|
734
|
+
// is cached). Used by every streamed-compression pipeline. `quality` carries the
|
|
735
|
+
// normalized compression.streaming settings (brotliQuality default 4, gzipLevel
|
|
736
|
+
// default 6).
|
|
614
737
|
// LGWIN 19 (512 KB window instead of brotli's default 4 MB): the encoder state
|
|
615
738
|
// is the dominant per-request RAM on this path (~10 MB/stream at the default,
|
|
616
739
|
// ~1.3 GB peak measured under 100 concurrent cold requests), and at Q4 the big
|
|
617
740
|
// window buys nothing on typical text (measured: same output size, ~40% faster).
|
|
618
741
|
// The trade-off: content with identical blocks repeated at >512 KB distance
|
|
619
742
|
// (rare, pathological) compresses worse than with the 4 MB window. gzip's
|
|
620
|
-
// window is 32 KB by design — nothing to bound there.
|
|
621
|
-
|
|
743
|
+
// window is 32 KB by design — nothing to bound there. LGWIN is deliberately
|
|
744
|
+
// NOT configurable: it is what bounds the per-stream RAM.
|
|
745
|
+
function createStreamCompressor(encoding, quality) {
|
|
622
746
|
return encoding === 'br'
|
|
623
747
|
? zlib.createBrotliCompress({ params: {
|
|
624
|
-
[zlib.constants.BROTLI_PARAM_QUALITY]:
|
|
748
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: quality.brotliQuality,
|
|
625
749
|
[zlib.constants.BROTLI_PARAM_LGWIN]: 19,
|
|
626
750
|
} })
|
|
627
|
-
: zlib.createGzip({ level:
|
|
751
|
+
: zlib.createGzip({ level: quality.gzipLevel });
|
|
628
752
|
}
|
|
629
753
|
|
|
630
754
|
function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
|
|
@@ -633,7 +757,13 @@ function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
|
|
|
633
757
|
&& cached.mtime === newEntry.mtime
|
|
634
758
|
&& cached.size === newEntry.size;
|
|
635
759
|
if (!canRefreshInPlace || !cache.refresh(key, newEntry)) {
|
|
636
|
-
|
|
760
|
+
// Unconditional delete: `cached` is the caller's snapshot from request
|
|
761
|
+
// start and may be STALE — on the streamed-tee path by minutes. Another
|
|
762
|
+
// request can have inserted this key meanwhile (a new file version via
|
|
763
|
+
// a second tee leader, or the buffered path after the file shrank), so
|
|
764
|
+
// gating the delete on the snapshot would set() over a live key and
|
|
765
|
+
// corrupt the LFU accounting. delete() is a safe no-op when absent.
|
|
766
|
+
cache.delete(key);
|
|
637
767
|
cache.set(key, newEntry);
|
|
638
768
|
}
|
|
639
769
|
}
|
|
@@ -692,19 +822,23 @@ module.exports = function koaClassicServer(
|
|
|
692
822
|
urlPrefix: "", // URL path prefix. Should start with "/" and NOT end with "/"
|
|
693
823
|
// (e.g. "/static"); "" disables the prefix. A malformed value
|
|
694
824
|
// is tolerated with a deprecation warning for now (behavior
|
|
695
|
-
// unchanged) and WILL throw in
|
|
825
|
+
// unchanged) and WILL throw in a future major (target: 6.0.0).
|
|
696
826
|
urlsReserved: [], // Reserved first-level paths passed through to next().
|
|
697
827
|
// Each entry should be a single first-level path: a leading
|
|
698
828
|
// "/" plus one segment, no further "/" (e.g. "/admin").
|
|
699
829
|
// Malformed entries are tolerated with a deprecation warning
|
|
700
|
-
// for now and WILL throw in
|
|
830
|
+
// for now and WILL throw in a future major, target 6.0.0 (a
|
|
701
831
|
// non-string entry is dropped to avoid a per-request 500).
|
|
702
832
|
template: {
|
|
703
833
|
render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
|
|
704
834
|
// rawBuffer (4th arg, may be null) is READ-ONLY: the same Buffer
|
|
705
835
|
// instance is shared with the server cache and with concurrent
|
|
706
836
|
// requests — mutating it corrupts other responses.
|
|
707
|
-
ext: [], //
|
|
837
|
+
ext: [], // Extension suffixes handled by template.render, e.g. ['.ejs'].
|
|
838
|
+
// Preferred form WITH the leading dot; 'ejs' is equivalent (V5).
|
|
839
|
+
// Matched as a SUFFIX of the file name (case-sensitive), so
|
|
840
|
+
// compound extensions work too: ['.html.ejs'] targets only
|
|
841
|
+
// page.html.ejs-style files.
|
|
708
842
|
renderTimeout: 30000, // Max ms allowed for template.render (number ≥ 0; 0 = disabled).
|
|
709
843
|
// On timeout responds 504 Gateway Timeout. The render receives an
|
|
710
844
|
// AbortSignal as 5th argument; propagate it to fetch/db/fs to free
|
|
@@ -719,8 +853,15 @@ module.exports = function koaClassicServer(
|
|
|
719
853
|
useOriginalUrl: true, // Use ctx.originalUrl (default) or ctx.url
|
|
720
854
|
// Set false for URL rewriting middleware (i18n, routing)
|
|
721
855
|
hideExtension: { // Hide file extension from URLs (clean URLs like mod_rewrite)
|
|
722
|
-
ext: '.ejs', //
|
|
723
|
-
|
|
856
|
+
ext: '.ejs', // Suffix to hide (required, case-sensitive). Preferred form
|
|
857
|
+
// WITH the leading dot; 'ejs' is equivalent (V5). Compound
|
|
858
|
+
// suffixes ('.tar.gz') are supported.
|
|
859
|
+
redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301).
|
|
860
|
+
// Must be one of 300 | 301 | 302 | 303 | 305 | 307 | 308 (the codes
|
|
861
|
+
// Koa emits as-is; 300/305 are exotic/deprecated but valid) — anything
|
|
862
|
+
// else throws at factory time (V5; previously other numbers were
|
|
863
|
+
// tolerated and either became 302 silently or failed every redirect
|
|
864
|
+
// with a 500).
|
|
724
865
|
},
|
|
725
866
|
errorPages: { // Custom error pages (V4.2+). Opt-in — omitted statuses keep the built-in pages.
|
|
726
867
|
// Keys: the statuses the middleware generates error pages for: 404, 500, 504.
|
|
@@ -773,6 +914,14 @@ module.exports = function koaClassicServer(
|
|
|
773
914
|
compressedFile: { // cache for HTTP br/gzip responses — not for .zip/.tar files on disk
|
|
774
915
|
enabled: true, // enable in-memory cache of compressed response buffers
|
|
775
916
|
maxSize: 104857600, // max total RAM used by this cache (bytes; default: 100 MB)
|
|
917
|
+
maxEntrySize: undefined, // max bytes of a SINGLE cached entry, measured on the compressed
|
|
918
|
+
// OUTPUT (V4.3+). Applies to every insertion: buffered path and
|
|
919
|
+
// streamed-tee path alike. Oversized entries are still served,
|
|
920
|
+
// just not cached (throttled warning via logger).
|
|
921
|
+
// undefined (default) → maxSize / 4, so one huge file cannot
|
|
922
|
+
// evict most of the working set on insert; false → no per-entry
|
|
923
|
+
// cap (maxSize still bounds); explicit bytes must be <= maxSize
|
|
924
|
+
// (larger throws at factory time — use false instead).
|
|
776
925
|
maxAge: 0, // ms after insertion to consider an entry stale; 0 = disabled. See rawFile.maxAge.
|
|
777
926
|
warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
|
|
778
927
|
},
|
|
@@ -795,6 +944,15 @@ module.exports = function koaClassicServer(
|
|
|
795
944
|
// so subsequent requests are served from RAM with
|
|
796
945
|
// Content-Length.
|
|
797
946
|
mimeTypes: [], // compressible MIME types (replaces default list if provided)
|
|
947
|
+
buffered: { // quality for the BUFFERED path (V4.3+): file <= maxFileSize,
|
|
948
|
+
brotliQuality: 11, // compressed once, output cached — the cost is paid once per
|
|
949
|
+
gzipLevel: 9, // file, so max quality by default. brotliQuality: integer 0-11;
|
|
950
|
+
}, // gzipLevel: integer 0-9. Out-of-range throws at factory time.
|
|
951
|
+
streaming: { // quality for the STREAMING path (V4.3+): file > maxFileSize, or
|
|
952
|
+
brotliQuality: 4, // compressed cache disabled — the cost is paid on EVERY
|
|
953
|
+
gzipLevel: 6, // non-cached request, so deliberately light by default.
|
|
954
|
+
}, // The brotli window stays fixed at 512 KB (bounds per-stream
|
|
955
|
+
// RAM; not configurable). Same ranges as `buffered`.
|
|
798
956
|
},
|
|
799
957
|
// compression: false // shorthand to disable all compression
|
|
800
958
|
staticSecurityHeaders: { // Opt-in security headers on STATIC file responses (V3.1+).
|
|
@@ -962,7 +1120,7 @@ module.exports = function koaClassicServer(
|
|
|
962
1120
|
// Both are v2-stable options, so instead of throwing (a breaking change on a
|
|
963
1121
|
// minor upgrade — a mis-slashed value that "worked" only by falling through
|
|
964
1122
|
// to a downstream handler would suddenly change behavior), we WARN and leave
|
|
965
|
-
// the runtime behavior exactly as it is today.
|
|
1123
|
+
// the runtime behavior exactly as it is today. A future major (6.0.0) turns these
|
|
966
1124
|
// warnings into throws. The one exception is a non-string urlsReserved entry:
|
|
967
1125
|
// it would 500 on every request (value.substring is not a function), which is
|
|
968
1126
|
// not working behavior, so it is dropped defensively (still warned).
|
|
@@ -1015,8 +1173,36 @@ module.exports = function koaClassicServer(
|
|
|
1015
1173
|
}
|
|
1016
1174
|
options.urlsReserved = cleaned;
|
|
1017
1175
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1176
|
+
if (options.template.render !== undefined && typeof options.template.render !== 'function') {
|
|
1177
|
+
// Dropped SILENTLY before V5 (#10): a non-function render means templates
|
|
1178
|
+
// never run and template SOURCES are served as plain static files.
|
|
1179
|
+
warnConfigDeprecation(_logger,
|
|
1180
|
+
'template.render must be a function; got ' +
|
|
1181
|
+
(options.template.render === null ? 'null' : typeof options.template.render) +
|
|
1182
|
+
' — template rendering is DISABLED (template sources are served as static files).');
|
|
1183
|
+
options.template.render = undefined;
|
|
1184
|
+
}
|
|
1185
|
+
// template.ext entries go through the shared suffix normalizer: leading
|
|
1186
|
+
// dot optional ('.ejs' preferred), compound suffixes supported. Entries
|
|
1187
|
+
// that cannot name a suffix are dropped with a warning — they could never
|
|
1188
|
+
// match a file, so dropping them cannot break working behavior.
|
|
1189
|
+
if (Array.isArray(options.template.ext)) {
|
|
1190
|
+
const normalizedExts = [];
|
|
1191
|
+
for (const value of options.template.ext) {
|
|
1192
|
+
const suffix = normalizeExtSuffix(value);
|
|
1193
|
+
if (suffix === null) {
|
|
1194
|
+
warnConfigDeprecation(_logger,
|
|
1195
|
+
'template.ext entries must be non-empty extension suffixes like ".ejs" (leading dot optional); dropping ' +
|
|
1196
|
+
(typeof value === 'string' ? JSON.stringify(value) : 'a non-string (' + (value === null ? 'null' : typeof value) + ')') +
|
|
1197
|
+
' — it can never match a file.');
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
normalizedExts.push(suffix);
|
|
1201
|
+
}
|
|
1202
|
+
options.template.ext = normalizedExts;
|
|
1203
|
+
} else {
|
|
1204
|
+
options.template.ext = [];
|
|
1205
|
+
}
|
|
1020
1206
|
|
|
1021
1207
|
if (options.template.renderTimeout === undefined) {
|
|
1022
1208
|
options.template.renderTimeout = 30000;
|
|
@@ -1068,23 +1254,34 @@ module.exports = function koaClassicServer(
|
|
|
1068
1254
|
if (typeof options.hideExtension !== 'object' || Array.isArray(options.hideExtension)) {
|
|
1069
1255
|
throw new Error('[koa-classic-server] hideExtension must be an object with an "ext" property. Example: { ext: ".ejs" }');
|
|
1070
1256
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
//
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
'
|
|
1081
|
-
)
|
|
1082
|
-
options.hideExtension.ext = '.' + options.hideExtension.ext;
|
|
1257
|
+
// Shared suffix normalizer (V5, #10): '.ejs' and 'ejs' are EQUIVALENT
|
|
1258
|
+
// (the old missing-dot warning is gone — both forms are legal; '.ejs'
|
|
1259
|
+
// is the preferred, documented one). Compound suffixes ('.tar.gz')
|
|
1260
|
+
// keep working as before. Values that cannot name a suffix ('', '.',
|
|
1261
|
+
// non-string) throw, as the empty/non-string forms always did.
|
|
1262
|
+
const normalizedHideExt = normalizeExtSuffix(options.hideExtension.ext);
|
|
1263
|
+
if (normalizedHideExt === null) {
|
|
1264
|
+
throw new Error(
|
|
1265
|
+
'[koa-classic-server] hideExtension.ext is required and must be a non-empty extension suffix. ' +
|
|
1266
|
+
'Example: { ext: ".ejs" } (the leading dot is optional: "ejs" is equivalent).'
|
|
1267
|
+
);
|
|
1083
1268
|
}
|
|
1084
|
-
|
|
1269
|
+
options.hideExtension.ext = normalizedHideExt;
|
|
1270
|
+
// Validate redirect code (V5 breaking change — v4.3 register #9).
|
|
1271
|
+
// Only real redirect statuses are accepted. Anything else was a
|
|
1272
|
+
// configuration bug with two silent failure modes: a non-integer (or
|
|
1273
|
+
// out-of-range) value made Koa's status setter throw at request time —
|
|
1274
|
+
// a 500 on EVERY hideExtension redirect — while an integer that is not
|
|
1275
|
+
// a redirect status (200, 404, 999, ...) was silently replaced with
|
|
1276
|
+
// 302 by ctx.redirect(). Both now surface at startup instead.
|
|
1085
1277
|
if (options.hideExtension.redirect !== undefined) {
|
|
1086
|
-
if (
|
|
1087
|
-
throw new Error(
|
|
1278
|
+
if (!_VALID_REDIRECT_CODES.has(options.hideExtension.redirect)) {
|
|
1279
|
+
throw new Error(
|
|
1280
|
+
'[koa-classic-server] hideExtension.redirect must be one of: 300, 301, 302, 303, 305, 307, 308. ' +
|
|
1281
|
+
'Got: ' + String(options.hideExtension.redirect) + '\n' +
|
|
1282
|
+
' Before v5.0.0 other values were tolerated: non-redirect codes were silently sent\n' +
|
|
1283
|
+
' as 302, and non-integer values failed every redirect with a 500.'
|
|
1284
|
+
);
|
|
1088
1285
|
}
|
|
1089
1286
|
} else {
|
|
1090
1287
|
options.hideExtension.redirect = 301;
|
|
@@ -1276,6 +1473,54 @@ module.exports = function koaClassicServer(
|
|
|
1276
1473
|
// Normalize and validate the compression option into a clean internal structure.
|
|
1277
1474
|
// compression: false is a valid shorthand for { enabled: false }.
|
|
1278
1475
|
function normalizeCompressionConfig(compression) {
|
|
1476
|
+
// Per-path quality defaults (V4.3+). Two groups because the two execution
|
|
1477
|
+
// modes have opposite cost models: the buffered path compresses once and
|
|
1478
|
+
// caches the result (can afford max quality), the streaming path pays the
|
|
1479
|
+
// CPU on every non-cached request (must stay light).
|
|
1480
|
+
const defaultBuffered = { brotliQuality: 11, gzipLevel: 9 };
|
|
1481
|
+
const defaultStreaming = { brotliQuality: 4, gzipLevel: 6 };
|
|
1482
|
+
|
|
1483
|
+
// Strict validation: these namespaces are new in 4.3.0, so unknown keys
|
|
1484
|
+
// are caught as typos instead of being silently ignored (an operator who
|
|
1485
|
+
// misspells brotliQuality must not believe they lowered the quality).
|
|
1486
|
+
function validateQualityGroup(group, groupName, defaults) {
|
|
1487
|
+
if (group === undefined) return { ...defaults };
|
|
1488
|
+
if (!group || typeof group !== 'object' || Array.isArray(group)) {
|
|
1489
|
+
throw new Error(
|
|
1490
|
+
`[koa-classic-server] compression.${groupName} must be an object, e.g. ` +
|
|
1491
|
+
`{ brotliQuality: ${defaults.brotliQuality}, gzipLevel: ${defaults.gzipLevel} }. Got: ` + String(group)
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
for (const key of Object.keys(group)) {
|
|
1495
|
+
if (key !== 'brotliQuality' && key !== 'gzipLevel') {
|
|
1496
|
+
throw new Error(
|
|
1497
|
+
`[koa-classic-server] compression.${groupName}.${key} is not a valid option. ` +
|
|
1498
|
+
'Valid keys: brotliQuality, gzipLevel.'
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
const out = { ...defaults };
|
|
1503
|
+
if (group.brotliQuality !== undefined) {
|
|
1504
|
+
if (!Number.isInteger(group.brotliQuality) || group.brotliQuality < 0 || group.brotliQuality > 11) {
|
|
1505
|
+
throw new Error(
|
|
1506
|
+
`[koa-classic-server] compression.${groupName}.brotliQuality must be an integer between 0 and 11. ` +
|
|
1507
|
+
'Got: ' + String(group.brotliQuality)
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
out.brotliQuality = group.brotliQuality;
|
|
1511
|
+
}
|
|
1512
|
+
if (group.gzipLevel !== undefined) {
|
|
1513
|
+
if (!Number.isInteger(group.gzipLevel) || group.gzipLevel < 0 || group.gzipLevel > 9) {
|
|
1514
|
+
throw new Error(
|
|
1515
|
+
`[koa-classic-server] compression.${groupName}.gzipLevel must be an integer between 0 and 9. ` +
|
|
1516
|
+
'Got: ' + String(group.gzipLevel)
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
out.gzipLevel = group.gzipLevel;
|
|
1520
|
+
}
|
|
1521
|
+
return out;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1279
1524
|
if (compression === false) return { enabled: false };
|
|
1280
1525
|
|
|
1281
1526
|
if (!compression || typeof compression !== 'object' || Array.isArray(compression)) {
|
|
@@ -1285,6 +1530,8 @@ module.exports = function koaClassicServer(
|
|
|
1285
1530
|
minFileSize: 1024, // bytes; skip compression for files smaller than this
|
|
1286
1531
|
maxFileSize: 10485760, // bytes; above this the buffered+cached path is skipped (streaming instead)
|
|
1287
1532
|
mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
|
|
1533
|
+
buffered: { ...defaultBuffered },
|
|
1534
|
+
streaming: { ...defaultStreaming },
|
|
1288
1535
|
};
|
|
1289
1536
|
}
|
|
1290
1537
|
|
|
@@ -1303,20 +1550,47 @@ module.exports = function koaClassicServer(
|
|
|
1303
1550
|
? compression.encodings.filter(e => e === 'br' || e === 'gzip')
|
|
1304
1551
|
: ['br', 'gzip'];
|
|
1305
1552
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1553
|
+
// minFileSize / maxFileSize: an invalid value used to fall back to the
|
|
1554
|
+
// default SILENTLY (#13) — inconsistent with maxAge / maxEntrySize /
|
|
1555
|
+
// quality, which throw. These are v2-stable options, so the fallback
|
|
1556
|
+
// stays (behavior unchanged) but now warns; a future major (6.0.0) will throw.
|
|
1557
|
+
let minFileSize;
|
|
1558
|
+
if (compression.minFileSize === undefined) {
|
|
1559
|
+
minFileSize = 1024;
|
|
1560
|
+
} else if (compression.minFileSize === false
|
|
1561
|
+
|| (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0)) {
|
|
1562
|
+
minFileSize = compression.minFileSize;
|
|
1563
|
+
} else {
|
|
1564
|
+
warnConfigDeprecation(_logger,
|
|
1565
|
+
'compression.minFileSize must be a number >= 0 (bytes) or false; got ' +
|
|
1566
|
+
String(compression.minFileSize) + ' — falling back to the default 1024 for now.');
|
|
1567
|
+
minFileSize = 1024;
|
|
1568
|
+
}
|
|
1308
1569
|
|
|
1309
1570
|
// Cap for the buffered (whole-file-in-RAM, max-quality, cached) compression
|
|
1310
1571
|
// path. false = no cap. Files above the cap still get compressed via the
|
|
1311
1572
|
// bounded-RAM streaming mode — safety net, not a serving restriction.
|
|
1312
|
-
|
|
1313
|
-
|
|
1573
|
+
let maxFileSize;
|
|
1574
|
+
if (compression.maxFileSize === undefined) {
|
|
1575
|
+
maxFileSize = 10485760;
|
|
1576
|
+
} else if (compression.maxFileSize === false
|
|
1577
|
+
|| (typeof compression.maxFileSize === 'number' && compression.maxFileSize > 0)) {
|
|
1578
|
+
maxFileSize = compression.maxFileSize;
|
|
1579
|
+
} else {
|
|
1580
|
+
warnConfigDeprecation(_logger,
|
|
1581
|
+
'compression.maxFileSize must be a positive number (bytes) or false; got ' +
|
|
1582
|
+
String(compression.maxFileSize) + ' — falling back to the default 10485760 for now.');
|
|
1583
|
+
maxFileSize = 10485760;
|
|
1584
|
+
}
|
|
1314
1585
|
|
|
1315
1586
|
const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
|
|
1316
1587
|
? compression.mimeTypes
|
|
1317
1588
|
: DEFAULT_COMPRESSIBLE_MIME_TYPES;
|
|
1318
1589
|
|
|
1319
|
-
|
|
1590
|
+
const buffered = validateQualityGroup(compression.buffered, 'buffered', defaultBuffered);
|
|
1591
|
+
const streaming = validateQualityGroup(compression.streaming, 'streaming', defaultStreaming);
|
|
1592
|
+
|
|
1593
|
+
return { enabled, encodings, minFileSize, maxFileSize, mimeTypes: new Set(mimeTypes), buffered, streaming };
|
|
1320
1594
|
}
|
|
1321
1595
|
|
|
1322
1596
|
// Normalize and validate the serverCache option into a clean internal structure.
|
|
@@ -1331,6 +1605,7 @@ module.exports = function koaClassicServer(
|
|
|
1331
1605
|
const defaultCompressedFile = {
|
|
1332
1606
|
enabled: true,
|
|
1333
1607
|
maxSize: 104857600, // 100 MB
|
|
1608
|
+
maxEntrySize: 26214400, // maxSize / 4
|
|
1334
1609
|
maxAge: 0,
|
|
1335
1610
|
warnInterval: 60000,
|
|
1336
1611
|
};
|
|
@@ -1346,26 +1621,70 @@ module.exports = function koaClassicServer(
|
|
|
1346
1621
|
return value;
|
|
1347
1622
|
}
|
|
1348
1623
|
|
|
1624
|
+
// maxEntrySize (V4.3+): per-entry admission cap for the compressed cache,
|
|
1625
|
+
// measured on the compressed OUTPUT. Applies to every insertion (buffered
|
|
1626
|
+
// path and streamed-tee path alike). undefined → a quarter of maxSize
|
|
1627
|
+
// (the historical tee-path bound, and it keeps scaling with maxSize);
|
|
1628
|
+
// false → no per-entry cap (normalized to Infinity; maxSize still bounds).
|
|
1629
|
+
function validateMaxEntrySize(value, maxSize) {
|
|
1630
|
+
if (value === undefined) return Math.floor(maxSize / 4);
|
|
1631
|
+
if (value === false) return Infinity;
|
|
1632
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
|
1633
|
+
throw new Error(
|
|
1634
|
+
'[koa-classic-server] serverCache.compressedFile.maxEntrySize must be a positive integer (bytes) or false. ' +
|
|
1635
|
+
'Got: ' + String(value)
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
if (value > maxSize) {
|
|
1639
|
+
throw new Error(
|
|
1640
|
+
`[koa-classic-server] serverCache.compressedFile.maxEntrySize (${value}) exceeds maxSize (${maxSize}) — ` +
|
|
1641
|
+
'a per-entry cap larger than the whole cache is a configuration contradiction. ' +
|
|
1642
|
+
'Use false to disable the per-entry cap.'
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
return value;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1349
1648
|
if (!serverCache || typeof serverCache !== 'object' || Array.isArray(serverCache)) {
|
|
1350
1649
|
return { rawFile: defaultRawFile, compressedFile: defaultCompressedFile };
|
|
1351
1650
|
}
|
|
1352
1651
|
|
|
1652
|
+
// Positive-bytes fields whose invalid values used to fall back to the
|
|
1653
|
+
// default SILENTLY (#13): warn now, behavior unchanged (the fallback is
|
|
1654
|
+
// identical); the next major will throw — same deprecation path as the
|
|
1655
|
+
// other config warnings.
|
|
1656
|
+
function sizeOrWarn(value, fieldName, defaultValue) {
|
|
1657
|
+
if (value === undefined) return defaultValue;
|
|
1658
|
+
if (typeof value === 'number' && value > 0) return value;
|
|
1659
|
+
warnConfigDeprecation(_logger,
|
|
1660
|
+
`serverCache.${fieldName} must be a positive number (bytes); got ` +
|
|
1661
|
+
String(value) + ` — falling back to the default ${defaultValue} for now.`);
|
|
1662
|
+
return defaultValue;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1353
1665
|
const rf = serverCache.rawFile;
|
|
1354
1666
|
const rawFile = (!rf || typeof rf !== 'object' || Array.isArray(rf)) ? defaultRawFile : {
|
|
1355
1667
|
enabled: typeof rf.enabled === 'boolean' ? rf.enabled : false,
|
|
1356
|
-
maxSize:
|
|
1357
|
-
maxFileSize:
|
|
1668
|
+
maxSize: sizeOrWarn(rf.maxSize, 'rawFile.maxSize', 52428800),
|
|
1669
|
+
maxFileSize: sizeOrWarn(rf.maxFileSize, 'rawFile.maxFileSize', 1048576),
|
|
1358
1670
|
maxAge: validateMaxAge(rf.maxAge, 'rawFile'),
|
|
1359
1671
|
warnInterval: rf.warnInterval === false ? false : (typeof rf.warnInterval === 'number' ? rf.warnInterval : 60000),
|
|
1360
1672
|
};
|
|
1361
1673
|
|
|
1362
1674
|
const cf = serverCache.compressedFile;
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1675
|
+
let compressedFile;
|
|
1676
|
+
if (!cf || typeof cf !== 'object' || Array.isArray(cf)) {
|
|
1677
|
+
compressedFile = defaultCompressedFile;
|
|
1678
|
+
} else {
|
|
1679
|
+
const cfMaxSize = sizeOrWarn(cf.maxSize, 'compressedFile.maxSize', 104857600);
|
|
1680
|
+
compressedFile = {
|
|
1681
|
+
enabled: typeof cf.enabled === 'boolean' ? cf.enabled : true,
|
|
1682
|
+
maxSize: cfMaxSize,
|
|
1683
|
+
maxEntrySize: validateMaxEntrySize(cf.maxEntrySize, cfMaxSize),
|
|
1684
|
+
maxAge: validateMaxAge(cf.maxAge, 'compressedFile'),
|
|
1685
|
+
warnInterval: cf.warnInterval === false ? false : (typeof cf.warnInterval === 'number' ? cf.warnInterval : 60000),
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1369
1688
|
|
|
1370
1689
|
return { rawFile, compressedFile };
|
|
1371
1690
|
}
|
|
@@ -1548,12 +1867,35 @@ module.exports = function koaClassicServer(
|
|
|
1548
1867
|
writeErrorPage(ctx, status, await getCustomErrorPage(status), builtinHtml);
|
|
1549
1868
|
}
|
|
1550
1869
|
|
|
1551
|
-
//
|
|
1552
|
-
//
|
|
1553
|
-
//
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1870
|
+
// Pre-opens `filePath` and returns a ReadStream reading from the already-open
|
|
1871
|
+
// descriptor, or null after writing the 404 error page when the open fails.
|
|
1872
|
+
// Rationale (v5.0 register #5): assigning a lazily-opening
|
|
1873
|
+
// fs.createReadStream(path) to ctx.body means an open-time failure (file
|
|
1874
|
+
// vanished after the stat, EACCES, EIO at open) fires only AFTER Koa's
|
|
1875
|
+
// respond() has taken ownership of the body — the error page written by a
|
|
1876
|
+
// stream-error handler can never be sent, Stream.pipeline tears the socket
|
|
1877
|
+
// down, and the client sees a bare ECONNRESET. Opening HERE, while the
|
|
1878
|
+
// response is still fully ours, turns that race into a clean 404 (matching
|
|
1879
|
+
// the access-check contract: "unreadable" is indistinguishable from "not
|
|
1880
|
+
// found"). The path is still passed to fs.createReadStream — Node ignores it
|
|
1881
|
+
// when `fd` is set — so path-based instrumentation keeps working.
|
|
1882
|
+
async function openBodyStream(ctx, filePath, streamOpts) {
|
|
1883
|
+
let handle;
|
|
1884
|
+
try {
|
|
1885
|
+
handle = await fs.promises.open(filePath, 'r');
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
_logger.error('File open error:', err);
|
|
1888
|
+
await sendErrorPage(ctx, 404);
|
|
1889
|
+
return null;
|
|
1890
|
+
}
|
|
1891
|
+
try {
|
|
1892
|
+
// autoClose (default true) closes the FileHandle when the stream ends
|
|
1893
|
+
// or is destroyed — including Koa's teardown on client disconnect.
|
|
1894
|
+
return fs.createReadStream(filePath, { fd: handle, ...streamOpts });
|
|
1895
|
+
} catch (err) {
|
|
1896
|
+
handle.close().catch(() => {}); // nothing consumed the fd — release it
|
|
1897
|
+
throw err; // unexpected: surfaces through the last-resort catch
|
|
1898
|
+
}
|
|
1557
1899
|
}
|
|
1558
1900
|
|
|
1559
1901
|
const compressionConfig = normalizeCompressionConfig(options.compression);
|
|
@@ -1574,7 +1916,8 @@ module.exports = function koaClassicServer(
|
|
|
1574
1916
|
serverCacheConfig.compressedFile.maxSize,
|
|
1575
1917
|
serverCacheConfig.compressedFile.warnInterval,
|
|
1576
1918
|
'compressedFile',
|
|
1577
|
-
_logger
|
|
1919
|
+
_logger,
|
|
1920
|
+
serverCacheConfig.compressedFile.maxEntrySize
|
|
1578
1921
|
);
|
|
1579
1922
|
|
|
1580
1923
|
// Single-flight maps for cache population (thundering-herd protection):
|
|
@@ -1599,21 +1942,26 @@ module.exports = function koaClassicServer(
|
|
|
1599
1942
|
// `encoding` and sets it as the response body. Shared by the cache-disabled
|
|
1600
1943
|
// streaming branch and by tee followers; the tee leader builds its own
|
|
1601
1944
|
// pipeline with the extra accumulator stage.
|
|
1945
|
+
// The disk source is pre-opened (openBodyStream, register #5): an open-time
|
|
1946
|
+
// failure becomes a clean 404 here, so the pipeline callback below only ever
|
|
1947
|
+
// sees mid-flight errors — where the response is already on the wire and
|
|
1948
|
+
// Koa 3 tears the socket down; logging on the operator's logger is all
|
|
1949
|
+
// that is left to do.
|
|
1602
1950
|
// pipeline (NOT pipe): teardown propagates in BOTH directions. When the
|
|
1603
1951
|
// client disconnects mid-transfer, Koa destroys the body (the zlib
|
|
1604
1952
|
// transform) and pipeline destroys `src` too, closing its file descriptor.
|
|
1605
1953
|
// A bare src.pipe(compress) leaves the ReadStream paused with the fd open
|
|
1606
1954
|
// forever — fd leak under aborted downloads. Client disconnects are a
|
|
1607
1955
|
// normal event and are not logged (avoids client-driven log spam).
|
|
1608
|
-
function streamCompressedBody(ctx, toOpen, rawBuffer, encoding) {
|
|
1609
|
-
const compress = createStreamCompressor(encoding);
|
|
1956
|
+
async function streamCompressedBody(ctx, toOpen, rawBuffer, encoding) {
|
|
1610
1957
|
const src = rawBuffer
|
|
1611
1958
|
? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
|
|
1612
|
-
:
|
|
1959
|
+
: await openBodyStream(ctx, toOpen);
|
|
1960
|
+
if (!src) return; // open failed — the 404 page is already written
|
|
1961
|
+
const compress = createStreamCompressor(encoding, compressionConfig.streaming);
|
|
1613
1962
|
ctx.body = pipeline(src, compress, (err) => {
|
|
1614
1963
|
if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
|
|
1615
1964
|
_logger.error('Stream error:', err);
|
|
1616
|
-
if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
|
|
1617
1965
|
});
|
|
1618
1966
|
}
|
|
1619
1967
|
|
|
@@ -1646,38 +1994,16 @@ module.exports = function koaClassicServer(
|
|
|
1646
1994
|
return null;
|
|
1647
1995
|
}
|
|
1648
1996
|
|
|
1649
|
-
// Compress a Buffer using the given encoding ('br' or 'gzip')
|
|
1650
|
-
//
|
|
1997
|
+
// Compress a Buffer using the given encoding ('br' or 'gzip') at the
|
|
1998
|
+
// buffered-path quality (compression.buffered). Defaults are maxed out:
|
|
1999
|
+
// serverCache pays this cost once per file, not per request.
|
|
1651
2000
|
function compressBuffer(data, encoding) {
|
|
1652
2001
|
if (encoding === 'br') {
|
|
1653
2002
|
return _brotliCompressAsync(data, {
|
|
1654
|
-
params: { [zlib.constants.BROTLI_PARAM_QUALITY]:
|
|
2003
|
+
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: compressionConfig.buffered.brotliQuality }
|
|
1655
2004
|
});
|
|
1656
2005
|
}
|
|
1657
|
-
return _gzipAsync(data, { level:
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
/**
|
|
1661
|
-
* Build a Content-Disposition header value for inline serving.
|
|
1662
|
-
*
|
|
1663
|
-
* Uses both the legacy quoted-string form (ASCII fallback) and the RFC 5987
|
|
1664
|
-
* extended form (UTF-8 percent-encoded) for maximum browser compatibility:
|
|
1665
|
-
* inline; filename="ascii-safe"; filename*=UTF-8''percent-encoded
|
|
1666
|
-
*
|
|
1667
|
-
* The quoted-string form escapes only double-quotes; the RFC 5987 form
|
|
1668
|
-
* percent-encodes every byte that is not an unreserved URI character.
|
|
1669
|
-
* Browsers that support filename* prefer it over filename (RFC 6266 §4.1).
|
|
1670
|
-
*/
|
|
1671
|
-
function buildContentDisposition(filename) {
|
|
1672
|
-
// quoted-string fallback: escape " and \ so the value is always valid ASCII
|
|
1673
|
-
const asciiSafe = filename.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1674
|
-
|
|
1675
|
-
// RFC 5987 extended value: UTF-8 percent-encode everything except
|
|
1676
|
-
// unreserved chars (ALPHA / DIGIT / "-" / "." / "_" / "~")
|
|
1677
|
-
const rfc5987 = encodeURIComponent(filename)
|
|
1678
|
-
.replace(/['()]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
|
1679
|
-
|
|
1680
|
-
return `inline; filename="${asciiSafe}"; filename*=UTF-8''${rfc5987}`;
|
|
2006
|
+
return _gzipAsync(data, { level: compressionConfig.buffered.gzipLevel });
|
|
1681
2007
|
}
|
|
1682
2008
|
|
|
1683
2009
|
// Find the first matching index file in a directory.
|
|
@@ -2162,6 +2488,12 @@ module.exports = function koaClassicServer(
|
|
|
2162
2488
|
|
|
2163
2489
|
// Verify file is still readable (race condition protection).
|
|
2164
2490
|
// Skip if rawBuffer already loaded — the successful readFile() is equivalent proof.
|
|
2491
|
+
// The disk-streaming branches additionally PRE-OPEN the file
|
|
2492
|
+
// (openBodyStream, register #5), which closes the residual
|
|
2493
|
+
// access→open TOCTOU there; this probe is kept for the outcomes
|
|
2494
|
+
// that never open the file (compressed-cache hits, 304s, HEAD):
|
|
2495
|
+
// dropping it would let a file made unreadable after caching keep
|
|
2496
|
+
// being served from RAM.
|
|
2165
2497
|
if (!rawBuffer) {
|
|
2166
2498
|
try {
|
|
2167
2499
|
await fs.promises.access(toOpen, fs.constants.R_OK);
|
|
@@ -2200,7 +2532,10 @@ module.exports = function koaClassicServer(
|
|
|
2200
2532
|
// Vary the 200 would; and a shared proxy must not serve the identity variant to
|
|
2201
2533
|
// a client that would have received the compressed one (#7).
|
|
2202
2534
|
if (potentiallyCompressible) {
|
|
2203
|
-
ctx.
|
|
2535
|
+
// ctx.vary() appends (deduplicating) instead of overwriting: a
|
|
2536
|
+
// Vary set by upstream middleware (e.g. "Origin" from a CORS
|
|
2537
|
+
// layer) must survive (#7).
|
|
2538
|
+
ctx.vary('Accept-Encoding');
|
|
2204
2539
|
}
|
|
2205
2540
|
|
|
2206
2541
|
// Preconditions are evaluated BEFORE the Range branch: RFC 9110 §13.2.2 gives the
|
|
@@ -2213,23 +2548,33 @@ module.exports = function koaClassicServer(
|
|
|
2213
2548
|
ctx.set('Last-Modified', fileStat.mtime.toUTCString());
|
|
2214
2549
|
|
|
2215
2550
|
// If-None-Match: "*" | comma-list, weak comparison (RFC 9110 §13.1.2).
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
//
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
const clientModifiedSince = ctx.get('If-Modified-Since');
|
|
2226
|
-
if (clientModifiedSince) {
|
|
2227
|
-
const clientDate = new Date(clientModifiedSince);
|
|
2228
|
-
const mtimeSeconds = Math.floor(fileStat.mtime.getTime() / 1000) * 1000;
|
|
2229
|
-
if (mtimeSeconds <= clientDate.getTime()) {
|
|
2551
|
+
// When the header is present it is the ONLY precondition evaluated:
|
|
2552
|
+
// RFC 9110 §13.1.3 — "A recipient MUST ignore If-Modified-Since if
|
|
2553
|
+
// the request contains an If-None-Match header field". The ETag is
|
|
2554
|
+
// the strong validator; the date has 1-second resolution and would
|
|
2555
|
+
// otherwise 304 a client whose ETag just said it is stale (e.g. two
|
|
2556
|
+
// same-second edits with a size change, or an encoding-suffix change).
|
|
2557
|
+
const ifNoneMatch = ctx.get('If-None-Match');
|
|
2558
|
+
if (ifNoneMatch) {
|
|
2559
|
+
if (ifNoneMatchSatisfied(ifNoneMatch, fullEtag)) {
|
|
2230
2560
|
ctx.status = 304;
|
|
2231
2561
|
return;
|
|
2232
2562
|
}
|
|
2563
|
+
// present but not satisfied → full response, date NOT consulted
|
|
2564
|
+
} else {
|
|
2565
|
+
// If-Modified-Since (date validation). The mtime is truncated to whole seconds
|
|
2566
|
+
// before comparing: Last-Modified is emitted via toUTCString() (second precision
|
|
2567
|
+
// — HTTP dates have no milliseconds), so a client echoing that header back would
|
|
2568
|
+
// otherwise never match a sub-second mtime (e.g. 22:13:20.500 <= 22:13:20.000).
|
|
2569
|
+
const clientModifiedSince = ctx.get('If-Modified-Since');
|
|
2570
|
+
if (clientModifiedSince) {
|
|
2571
|
+
const clientDate = new Date(clientModifiedSince);
|
|
2572
|
+
const mtimeSeconds = Math.floor(fileStat.mtime.getTime() / 1000) * 1000;
|
|
2573
|
+
if (mtimeSeconds <= clientDate.getTime()) {
|
|
2574
|
+
ctx.status = 304;
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2233
2578
|
}
|
|
2234
2579
|
}
|
|
2235
2580
|
|
|
@@ -2268,10 +2613,14 @@ module.exports = function koaClassicServer(
|
|
|
2268
2613
|
// subarray(): zero-copy view; Buffer.slice is deprecated (DEP0158).
|
|
2269
2614
|
ctx.body = rawBuffer.subarray(start, end + 1);
|
|
2270
2615
|
} else {
|
|
2271
|
-
|
|
2616
|
+
// Pre-open (register #5): an open failure is a clean 404
|
|
2617
|
+
// (writeErrorPage scrubs the 206 headers set above).
|
|
2618
|
+
const src = await openBodyStream(ctx, toOpen, { start, end });
|
|
2619
|
+
if (!src) return;
|
|
2620
|
+
// Mid-flight errors only from here on: the socket is torn
|
|
2621
|
+
// down by Koa 3 — log on the operator's logger.
|
|
2272
2622
|
src.on('error', (err) => {
|
|
2273
2623
|
_logger.error('Stream error:', err);
|
|
2274
|
-
if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
|
|
2275
2624
|
});
|
|
2276
2625
|
ctx.body = src;
|
|
2277
2626
|
}
|
|
@@ -2341,19 +2690,27 @@ module.exports = function koaClassicServer(
|
|
|
2341
2690
|
// Follower: another request is already accumulating this exact
|
|
2342
2691
|
// file version. Stream independently (no added latency, no tee
|
|
2343
2692
|
// stage) — the cache will be warm for the NEXT request.
|
|
2344
|
-
streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
|
|
2693
|
+
await streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
|
|
2345
2694
|
return;
|
|
2346
2695
|
}
|
|
2347
|
-
// Leader: stream AND accumulate a copy for the cache.
|
|
2696
|
+
// Leader: stream AND accumulate a copy for the cache. The disk
|
|
2697
|
+
// source is pre-opened BEFORE the tee bookkeeping starts, so an
|
|
2698
|
+
// open failure is a clean 404 with nothing to unwind (register #5).
|
|
2699
|
+
const src = rawBuffer
|
|
2700
|
+
? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
|
|
2701
|
+
: await openBodyStream(ctx, toOpen);
|
|
2702
|
+
if (!src) return;
|
|
2348
2703
|
_inflightStreamTees.add(teeKey);
|
|
2349
2704
|
let acc = [];
|
|
2350
2705
|
let accBytes = 0;
|
|
2351
2706
|
// Two admission bounds, both on the real OUTPUT size:
|
|
2352
|
-
// - per entry: a quarter of the cache, so
|
|
2353
|
-
// evict most of the working set on insert
|
|
2707
|
+
// - per entry: maxEntrySize (default: a quarter of the cache), so
|
|
2708
|
+
// one huge file cannot evict most of the working set on insert.
|
|
2709
|
+
// Checked here — not just in LFUCache.set() — to stop ACCUMULATING
|
|
2710
|
+
// RAM as soon as the budget is blown, not merely refuse the insert;
|
|
2354
2711
|
// - aggregate (_inflightTeeBytes): all in-flight tees together may
|
|
2355
2712
|
// never hold more RAM than the cache's own maxSize.
|
|
2356
|
-
const entryCap =
|
|
2713
|
+
const entryCap = serverCacheConfig.compressedFile.maxEntrySize;
|
|
2357
2714
|
// Stops accumulating and releases this tee's share of the aggregate
|
|
2358
2715
|
// budget. Safe to call more than once.
|
|
2359
2716
|
const abandonAccumulation = () => {
|
|
@@ -2363,10 +2720,7 @@ module.exports = function koaClassicServer(
|
|
|
2363
2720
|
}
|
|
2364
2721
|
};
|
|
2365
2722
|
try {
|
|
2366
|
-
const compress = createStreamCompressor(encoding);
|
|
2367
|
-
const src = rawBuffer
|
|
2368
|
-
? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
|
|
2369
|
-
: fs.createReadStream(toOpen);
|
|
2723
|
+
const compress = createStreamCompressor(encoding, compressionConfig.streaming);
|
|
2370
2724
|
const tee = new Transform({
|
|
2371
2725
|
transform(chunk, _enc, done) {
|
|
2372
2726
|
if (acc) {
|
|
@@ -2398,8 +2752,9 @@ module.exports = function koaClassicServer(
|
|
|
2398
2752
|
}
|
|
2399
2753
|
abandonAccumulation();
|
|
2400
2754
|
if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
|
|
2755
|
+
// Mid-flight error: the response is already on the wire and
|
|
2756
|
+
// Koa 3 tears the socket down — log and nothing else.
|
|
2401
2757
|
_logger.error('Stream error:', err);
|
|
2402
|
-
if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
|
|
2403
2758
|
});
|
|
2404
2759
|
} catch (err) {
|
|
2405
2760
|
// Defensive: nothing between add() and pipeline() is expected to
|
|
@@ -2407,6 +2762,7 @@ module.exports = function koaClassicServer(
|
|
|
2407
2762
|
// the tee for this file version forever.
|
|
2408
2763
|
_inflightStreamTees.delete(teeKey);
|
|
2409
2764
|
abandonAccumulation();
|
|
2765
|
+
src.destroy(); // release the pre-opened fd — nothing consumed it
|
|
2410
2766
|
throw err;
|
|
2411
2767
|
}
|
|
2412
2768
|
return;
|
|
@@ -2451,10 +2807,14 @@ module.exports = function koaClassicServer(
|
|
|
2451
2807
|
} else {
|
|
2452
2808
|
ctx.set('Content-Length', String(fileStat.size));
|
|
2453
2809
|
if (ctx.method !== 'HEAD') {
|
|
2454
|
-
|
|
2810
|
+
// Pre-open (register #5): if the disk is failing hard
|
|
2811
|
+
// enough that the identity fallback cannot even open the
|
|
2812
|
+
// file, the client gets a clean 404 instead of a torn
|
|
2813
|
+
// socket.
|
|
2814
|
+
const src = await openBodyStream(ctx, toOpen);
|
|
2815
|
+
if (!src) return;
|
|
2455
2816
|
src.on('error', (streamErr) => {
|
|
2456
2817
|
_logger.error('Stream error:', streamErr);
|
|
2457
|
-
if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
|
|
2458
2818
|
});
|
|
2459
2819
|
ctx.body = src;
|
|
2460
2820
|
} else {
|
|
@@ -2479,7 +2839,7 @@ module.exports = function koaClassicServer(
|
|
|
2479
2839
|
// Streaming mode (compressed cache disabled): pipe through the zlib
|
|
2480
2840
|
// transform — Content-Length not known in advance, nothing cached.
|
|
2481
2841
|
if (ctx.method !== 'HEAD') {
|
|
2482
|
-
streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
|
|
2842
|
+
await streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
|
|
2483
2843
|
} else {
|
|
2484
2844
|
// HEAD: mirror the GET status and headers (RFC 9110 §9.3.2) — no
|
|
2485
2845
|
// Content-Length, since the compressed size is unknown without
|
|
@@ -2503,10 +2863,15 @@ module.exports = function koaClassicServer(
|
|
|
2503
2863
|
} else {
|
|
2504
2864
|
ctx.set('Content-Length', String(fileStat.size));
|
|
2505
2865
|
if (ctx.method !== 'HEAD') {
|
|
2506
|
-
|
|
2866
|
+
// Pre-open (register #5): an open-time failure (file vanished
|
|
2867
|
+
// after the stat, EACCES, EIO) becomes a clean 404 instead of
|
|
2868
|
+
// an ECONNRESET after respond() owns the body.
|
|
2869
|
+
const src = await openBodyStream(ctx, toOpen);
|
|
2870
|
+
if (!src) return;
|
|
2871
|
+
// Mid-flight errors only from here on (Koa 3 tears the socket
|
|
2872
|
+
// down): log on the operator's logger.
|
|
2507
2873
|
src.on('error', (err) => {
|
|
2508
2874
|
_logger.error('Stream error:', err);
|
|
2509
|
-
if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
|
|
2510
2875
|
});
|
|
2511
2876
|
ctx.body = src;
|
|
2512
2877
|
} else {
|
|
@@ -2550,17 +2915,32 @@ module.exports = function koaClassicServer(
|
|
|
2550
2915
|
const rawDirRel = path.relative(normalizedRootDir, toOpen);
|
|
2551
2916
|
const dirRelPath = (rawDirRel === '' || rawDirRel === '.') ? '' : rawDirRel.split(path.sep).join('/');
|
|
2552
2917
|
|
|
2553
|
-
// Get sorting parameters from query string
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
const
|
|
2918
|
+
// Get sorting parameters from query string. Repeated parameters
|
|
2919
|
+
// (?sort=a&sort=b) arrive as arrays from the query parser: take the
|
|
2920
|
+
// first occurrence so sorting stays deterministic and regenerated
|
|
2921
|
+
// links never embed "a,b" (#12).
|
|
2922
|
+
const firstQueryValue = (v) => Array.isArray(v) ? v[0] : v;
|
|
2923
|
+
const sortParam = firstQueryValue(ctx.query.sort);
|
|
2924
|
+
const orderParam = firstQueryValue(ctx.query.order);
|
|
2925
|
+
const sortBy = sortParam || 'name';
|
|
2926
|
+
const sortOrder = orderParam || 'asc';
|
|
2927
|
+
|
|
2928
|
+
// Base for the listing's self-referencing links (sort headers,
|
|
2929
|
+
// paginator). Built from the WITH-prefix pathname — the out-prefix
|
|
2930
|
+
// one would make these links escape urlPrefix (#2), unlike the
|
|
2931
|
+
// parent/entry links which already use pageHref — and normalized
|
|
2932
|
+
// to exactly one trailing slash: the pathname was slash-stripped
|
|
2933
|
+
// for URL parsing, and linking the canonical /dir/ form spares a
|
|
2934
|
+
// 301 redirect hop on every sort/pagination click.
|
|
2935
|
+
const baseUrl = pageHref.pathname.endsWith('/')
|
|
2936
|
+
? pageHref.pathname
|
|
2937
|
+
: pageHref.pathname + '/';
|
|
2558
2938
|
|
|
2559
2939
|
// Preserves sort/order while overriding `page`; omits page when 0.
|
|
2560
2940
|
function buildQueryUrl(targetPage) {
|
|
2561
2941
|
const params = [];
|
|
2562
|
-
if (
|
|
2563
|
-
if (
|
|
2942
|
+
if (sortParam) params.push(`sort=${encodeURIComponent(sortParam)}`);
|
|
2943
|
+
if (orderParam) params.push(`order=${encodeURIComponent(orderParam)}`);
|
|
2564
2944
|
if (targetPage > 0) params.push(`page=${targetPage}`);
|
|
2565
2945
|
return params.length ? `${baseUrl}?${params.join('&')}` : baseUrl;
|
|
2566
2946
|
}
|
|
@@ -2601,12 +2981,18 @@ module.exports = function koaClassicServer(
|
|
|
2601
2981
|
// (pageHrefOutPrefix.pathname === '/'), not only at the absolute root: with
|
|
2602
2982
|
// urlPrefix '/static', the listing of /static/ must not link to '/', which is
|
|
2603
2983
|
// outside the served tree (#13).
|
|
2604
|
-
|
|
2984
|
+
// PATH-ABSOLUTE (#6): no origin — an absolute URL would embed the
|
|
2985
|
+
// client-controlled Host header (cache-poisoning surface) and pin
|
|
2986
|
+
// http:// behind a TLS-terminating proxy without app.proxy. And
|
|
2987
|
+
// CANONICAL SLASH (#16): the parent is a directory, so link the
|
|
2988
|
+
// /dir/ form directly instead of paying the 301 hop per click
|
|
2989
|
+
// (previously the parent of /sub/ was even the bare origin).
|
|
2605
2990
|
if (pageHrefOutPrefix.pathname !== "/") {
|
|
2606
2991
|
// Build parent directory URL without query parameters
|
|
2607
|
-
const a_pD =
|
|
2992
|
+
const a_pD = pageHref.pathname.split("/");
|
|
2608
2993
|
a_pD.pop();
|
|
2609
|
-
const
|
|
2994
|
+
const joined = a_pD.join("/");
|
|
2995
|
+
const parentDirectory = joined === "" ? "/" : joined + "/";
|
|
2610
2996
|
// Escape HTML to prevent XSS
|
|
2611
2997
|
parts.push(`<tr><td><a href="${escapeHtml(parentDirectory)}"><b>.. Parent Directory</b></a></td><td>DIR</td><td>-</td></tr>`);
|
|
2612
2998
|
}
|
|
@@ -2615,8 +3001,10 @@ module.exports = function koaClassicServer(
|
|
|
2615
3001
|
if (dir.length === 0) {
|
|
2616
3002
|
parts.push(emptyFolderRow);
|
|
2617
3003
|
} else {
|
|
2618
|
-
|
|
2619
|
-
|
|
3004
|
+
// PATH-ABSOLUTE (#6): entry hrefs carry no origin either — the
|
|
3005
|
+
// browser resolves them against the page's real origin/scheme.
|
|
3006
|
+
const _listingBasePath = pageHref.pathname;
|
|
3007
|
+
const _listingPrefixPath = options.urlPrefix; // "" without a prefix
|
|
2620
3008
|
|
|
2621
3009
|
// Collect item data with stat I/O in parallel (batched to avoid
|
|
2622
3010
|
// overwhelming the filesystem on very large directories).
|
|
@@ -2629,12 +3017,12 @@ module.exports = function koaClassicServer(
|
|
|
2629
3017
|
const type = getDirentType(item);
|
|
2630
3018
|
const itemPath = path.join(toOpen, s_name);
|
|
2631
3019
|
|
|
2632
|
-
// Build item URI
|
|
3020
|
+
// Build item URI (path-absolute, no query parameters)
|
|
2633
3021
|
let itemUri;
|
|
2634
|
-
if (
|
|
2635
|
-
itemUri = `${
|
|
3022
|
+
if (_listingBasePath === _listingPrefixPath + "/" || _listingBasePath === _listingPrefixPath) {
|
|
3023
|
+
itemUri = `${_listingPrefixPath}/${encodeURIComponent(toWellFormedName(s_name))}`;
|
|
2636
3024
|
} else {
|
|
2637
|
-
itemUri = `${
|
|
3025
|
+
itemUri = `${_listingBasePath}/${encodeURIComponent(toWellFormedName(s_name))}`;
|
|
2638
3026
|
}
|
|
2639
3027
|
|
|
2640
3028
|
// Resolve symlinks and DT_UNKNOWN entries to their effective type.
|
|
@@ -2657,6 +3045,14 @@ module.exports = function koaClassicServer(
|
|
|
2657
3045
|
}
|
|
2658
3046
|
}
|
|
2659
3047
|
|
|
3048
|
+
// Canonical trailing slash for directories (#16): link
|
|
3049
|
+
// the /dir/ form the trailing-slash redirect would
|
|
3050
|
+
// enforce anyway, so navigating the listing costs no
|
|
3051
|
+
// 301 hop per click. effectiveType 2 covers plain
|
|
3052
|
+
// directories and dir-resolved symlinks, consistent
|
|
3053
|
+
// with the rest of the listing logic.
|
|
3054
|
+
if (effectiveType === 2) itemUri += '/';
|
|
3055
|
+
|
|
2660
3056
|
// Hidden check: skip entries that should not appear in directory listing
|
|
2661
3057
|
const itemIsDir = effectiveType === 2;
|
|
2662
3058
|
const itemRelPath = dirRelPath ? dirRelPath + '/' + s_name : s_name;
|
|
@@ -2741,7 +3137,7 @@ module.exports = function koaClassicServer(
|
|
|
2741
3137
|
// Pagination — slice the sorted items into the requested page (0-based).
|
|
2742
3138
|
const pageSize = options.dirListing.entriesPerPage; // 0 disables pagination
|
|
2743
3139
|
totalPages = pageSize > 0 ? Math.max(1, Math.ceil(items.length / pageSize)) : 1;
|
|
2744
|
-
const rawPage = parseInt(ctx.query.page, 10);
|
|
3140
|
+
const rawPage = parseInt(firstQueryValue(ctx.query.page), 10);
|
|
2745
3141
|
const requestedPage = Number.isFinite(rawPage) && rawPage >= 0 ? rawPage : 0;
|
|
2746
3142
|
currentPage = Math.min(requestedPage, totalPages - 1); // silent clamp
|
|
2747
3143
|
const visibleItems = (pageSize > 0 && items.length > pageSize)
|
|
@@ -2770,12 +3166,12 @@ module.exports = function koaClassicServer(
|
|
|
2770
3166
|
: '';
|
|
2771
3167
|
|
|
2772
3168
|
if (item.isReserved) {
|
|
2773
|
-
parts.push(`${rowStart}
|
|
3169
|
+
parts.push(`${rowStart} <bdi>${listingDisplayName(item.name)}</bdi>${symlinkLabel}</td> <td> DIR BUT RESERVED</td><td>${item.sizeStr}</td></tr>`);
|
|
2774
3170
|
} else if (item.isBrokenSymlink || item.isBlockedSymlink) {
|
|
2775
3171
|
// Broken or policy-blocked symlink: name visible but not clickable
|
|
2776
|
-
parts.push(`${rowStart}
|
|
3172
|
+
parts.push(`${rowStart} <bdi>${listingDisplayName(item.name)}</bdi>${symlinkLabel}</td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
|
|
2777
3173
|
} else {
|
|
2778
|
-
parts.push(`${rowStart} <a href="${escapeHtml(item.itemUri)}">${
|
|
3174
|
+
parts.push(`${rowStart} <bdi><a href="${escapeHtml(item.itemUri)}">${listingDisplayName(item.name)}</a></bdi>${symlinkLabel} </td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
|
|
2779
3175
|
}
|
|
2780
3176
|
}
|
|
2781
3177
|
}
|
|
@@ -2843,6 +3239,14 @@ module.exports = function koaClassicServer(
|
|
|
2843
3239
|
</html>
|
|
2844
3240
|
`;
|
|
2845
3241
|
|
|
3242
|
+
// Listings are dynamic pages — they change with directory content,
|
|
3243
|
+
// sort and page — so an EXPLICIT no-cache policy is sent regardless
|
|
3244
|
+
// of browserCacheEnabled: without it, browsers and shared proxies
|
|
3245
|
+
// may heuristically cache a stale listing (#5). Same header trio the
|
|
3246
|
+
// file path uses when browser caching is disabled.
|
|
3247
|
+
ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
3248
|
+
ctx.set('Pragma', 'no-cache');
|
|
3249
|
+
ctx.set('Expires', '0');
|
|
2846
3250
|
setGeneratedPageHeaders(ctx, LISTING_CSP);
|
|
2847
3251
|
return html;
|
|
2848
3252
|
}
|
|
@@ -2863,11 +3267,21 @@ module.exports._internals = {
|
|
|
2863
3267
|
singleFlight,
|
|
2864
3268
|
refreshOrInsert,
|
|
2865
3269
|
escapeHtml,
|
|
3270
|
+
// toWellFormedName / buildContentDisposition / listingDisplayName: the
|
|
3271
|
+
// lone-surrogate class (#14) cannot be exercised through fixtures on
|
|
3272
|
+
// POSIX (invalid UTF-8 names become U+FFFD at write time, and only
|
|
3273
|
+
// Windows readdir can return WTF-16 names), so their totality is
|
|
3274
|
+
// asserted at unit level here.
|
|
3275
|
+
toWellFormedName,
|
|
3276
|
+
buildContentDisposition,
|
|
3277
|
+
listingDisplayName,
|
|
2866
3278
|
// writeErrorPage is the shared output path for every middleware-generated error
|
|
2867
|
-
// response (sendErrorPage
|
|
2868
|
-
//
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
//
|
|
3279
|
+
// response (sendErrorPage delegates to it). Exposed so its contract — header
|
|
3280
|
+
// scrub, no-store, Content-Type, custom-vs-built-in body, CSP only for the
|
|
3281
|
+
// built-in page — can be asserted deterministically. Mid-flight stream
|
|
3282
|
+
// failures no longer route here at all (v5.0 register #5): the response is
|
|
3283
|
+
// already on the wire and Koa 3 tears the socket down, so those branches
|
|
3284
|
+
// only log; open-time failures are caught BEFORE the body is assigned
|
|
3285
|
+
// (openBodyStream) and go through the regular async sendErrorPage.
|
|
2872
3286
|
writeErrorPage,
|
|
2873
3287
|
};
|