koa-classic-server 3.1.0 → 4.0.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 +191 -747
- package/index.cjs +669 -263
- package/package.json +3 -2
package/index.cjs
CHANGED
|
@@ -6,7 +6,7 @@ const crypto = require("crypto");
|
|
|
6
6
|
const zlib = require("zlib");
|
|
7
7
|
const util = require("util");
|
|
8
8
|
const mime = require("mime-types");
|
|
9
|
-
const { Readable } = require('stream');
|
|
9
|
+
const { Readable, pipeline } = require('stream');
|
|
10
10
|
|
|
11
11
|
const _brotliCompressAsync = util.promisify(zlib.brotliCompress);
|
|
12
12
|
const _gzipAsync = util.promisify(zlib.gzip);
|
|
@@ -151,6 +151,7 @@ function buildErrorHtml(title, heading, message) {
|
|
|
151
151
|
const _NOT_FOUND_HTML = buildErrorHtml('URL not found', 'Not Found', 'The requested URL was not found on this server.');
|
|
152
152
|
const _GATEWAY_TIMEOUT_HTML = buildErrorHtml('Gateway Timeout', 'Gateway Timeout', 'The template took too long to render.');
|
|
153
153
|
const _TEMPLATE_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'Template rendering failed for the requested resource.');
|
|
154
|
+
const _INTERNAL_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'The server encountered an unexpected condition.');
|
|
154
155
|
|
|
155
156
|
function sendNotFound(ctx) {
|
|
156
157
|
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
@@ -192,6 +193,23 @@ function warnPayload(logger, message) {
|
|
|
192
193
|
: [message];
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
// Config-deprecation warnings, deduplicated once-per-process (per distinct
|
|
197
|
+
// message) so creating many middleware instances doesn't repeat the same nag —
|
|
198
|
+
// same intent as the _showDirContentsDeprecationWarned flag, generalized to
|
|
199
|
+
// multiple messages. These options (urlPrefix, urlsReserved, ...) are v2-stable,
|
|
200
|
+
// so a malformed value is TOLERATED with a warning for now rather than thrown:
|
|
201
|
+
// throwing on a stable option would be a breaking change on a minor upgrade.
|
|
202
|
+
// The next major will flip `warnConfigDeprecation` into a hard throw (the call
|
|
203
|
+
// sites already carry the final message) — see docs/revisione_codice_v3.1.md #11.
|
|
204
|
+
const _configDeprecationsWarned = new Set();
|
|
205
|
+
function warnConfigDeprecation(logger, message) {
|
|
206
|
+
if (_configDeprecationsWarned.has(message)) return;
|
|
207
|
+
_configDeprecationsWarned.add(message);
|
|
208
|
+
logger.warn(...warnPayload(logger,
|
|
209
|
+
'[koa-classic-server] DEPRECATION: ' + message +
|
|
210
|
+
'\n This is tolerated for now and WILL throw in a future major version.'));
|
|
211
|
+
}
|
|
212
|
+
|
|
195
213
|
// Sends an error response for a failed template render. If headers were already
|
|
196
214
|
// flushed by the render itself, destroys the underlying socket instead (the
|
|
197
215
|
// status/body can no longer be changed at that point).
|
|
@@ -387,6 +405,24 @@ function parseRangeHeader(rangeHeader, fileSize) {
|
|
|
387
405
|
return { start, end };
|
|
388
406
|
}
|
|
389
407
|
|
|
408
|
+
// Evaluate an If-None-Match header against our (strong) ETag, per RFC 9110 §13.1.2.
|
|
409
|
+
// The header is either "*" (matches any existing representation) or a comma-separated
|
|
410
|
+
// list of entity-tags. The comparison is the WEAK function: a leading "W/" is ignored
|
|
411
|
+
// on both sides (a proxy/CDN may have weakened the validator). Returns true when the
|
|
412
|
+
// precondition is satisfied, i.e. the client's cached representation is still current
|
|
413
|
+
// and the server should answer 304 (Not Modified) for a GET/HEAD.
|
|
414
|
+
function ifNoneMatchSatisfied(headerValue, etag) {
|
|
415
|
+
if (!headerValue) return false;
|
|
416
|
+
const trimmed = headerValue.trim();
|
|
417
|
+
if (trimmed === '*') return true; // "*" matches any existing representation
|
|
418
|
+
const target = etag.replace(/^W\//, ''); // our ETag is strong, but strip defensively
|
|
419
|
+
for (const part of trimmed.split(',')) {
|
|
420
|
+
const tag = part.trim().replace(/^W\//, '');
|
|
421
|
+
if (tag && tag === target) return true;
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
|
|
390
426
|
// LFU cache with O(1) eviction using frequency buckets.
|
|
391
427
|
// peek(key) — read without touching frequency (for staleness checks)
|
|
392
428
|
// get(key) — read and increment frequency
|
|
@@ -420,10 +456,17 @@ class LFUCache {
|
|
|
420
456
|
}
|
|
421
457
|
|
|
422
458
|
set(key, entry) {
|
|
459
|
+
// An entry larger than the whole cache can never fit: bail out BEFORE the
|
|
460
|
+
// eviction loop, otherwise it would flush every other entry for nothing.
|
|
461
|
+
// Warn (throttled) so the operator learns the cache is undersized for
|
|
462
|
+
// this file instead of it silently never being cached.
|
|
463
|
+
if (entry.buffer.length > this.maxSize) {
|
|
464
|
+
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.`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
423
467
|
while (this.currentSize + entry.buffer.length > this.maxSize && this._keyMap.size > 0) {
|
|
424
468
|
this._evictOne();
|
|
425
469
|
}
|
|
426
|
-
if (this.currentSize + entry.buffer.length > this.maxSize) return; // entry too large for cache
|
|
427
470
|
|
|
428
471
|
this._keyMap.set(key, { ...entry, freq: 1 });
|
|
429
472
|
this._addToFreqBucket(key, 1);
|
|
@@ -499,16 +542,37 @@ class LFUCache {
|
|
|
499
542
|
bucket.delete(evictKey);
|
|
500
543
|
if (bucket.size === 0) this._freqMap.delete(this._minFreq);
|
|
501
544
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
545
|
+
this._warnThrottled(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Emits a warning at most once per warnInterval ms (0 = always, false = never).
|
|
549
|
+
_warnThrottled(message) {
|
|
550
|
+
if (this.warnInterval === false) return;
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
if (now - this._lastWarnAt >= this.warnInterval) {
|
|
553
|
+
this.logger.warn(message);
|
|
554
|
+
this._lastWarnAt = now;
|
|
508
555
|
}
|
|
509
556
|
}
|
|
510
557
|
}
|
|
511
558
|
|
|
559
|
+
// Single-flight job map helper: joins the in-flight job for `key`, or starts
|
|
560
|
+
// `work()` as the leader. Concurrent callers share the same Promise — including
|
|
561
|
+
// a rejection, so on failure every waiter falls back together instead of
|
|
562
|
+
// re-running the work. The entry is removed as soon as the job settles
|
|
563
|
+
// (success or failure), so the next request after a failure retries from
|
|
564
|
+
// scratch and the map only ever holds jobs actually in progress.
|
|
565
|
+
function singleFlight(map, key, work) {
|
|
566
|
+
let job = map.get(key);
|
|
567
|
+
if (!job) {
|
|
568
|
+
job = work();
|
|
569
|
+
map.set(key, job);
|
|
570
|
+
const clean = () => map.delete(key);
|
|
571
|
+
job.then(clean, clean);
|
|
572
|
+
}
|
|
573
|
+
return job;
|
|
574
|
+
}
|
|
575
|
+
|
|
512
576
|
// Upserts a fresh entry into an LFUCache. When the previous entry was only
|
|
513
577
|
// stale-by-age (mtime + size unchanged), updates in place so the existing
|
|
514
578
|
// frequency counter survives — important for popular files refreshed by maxAge.
|
|
@@ -560,16 +624,36 @@ module.exports = function koaClassicServer(
|
|
|
560
624
|
// when visible entries > entriesPerPage. Page index via
|
|
561
625
|
// ?page=N (0-based); out-of-range values are clamped silently.
|
|
562
626
|
// Must be a finite integer >= 0; 0 = disabled (no pagination).
|
|
627
|
+
trailingSlash: true, // Canonical trailing-slash enforcement (V4). Default true:
|
|
628
|
+
// GET /dir (directory, no slash) → 301 redirect to /dir/
|
|
629
|
+
// GET /file/ (file, trailing slash) → 404
|
|
630
|
+
// so relative links in an index page resolve against the
|
|
631
|
+
// directory. Set false for the v3 behavior (serve directories
|
|
632
|
+
// and files regardless of the trailing slash).
|
|
563
633
|
},
|
|
564
|
-
index: [
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
634
|
+
index: [], // Index file name(s) - must be an ARRAY.
|
|
635
|
+
// Default: [] — no index file is looked up; directories always
|
|
636
|
+
// show the listing (when dirListing.enabled). Configure explicitly
|
|
637
|
+
// for the classic index-file behavior, e.g. ["index.html"].
|
|
638
|
+
// - Array of strings: ["index.html", "index.htm", "default.html"]
|
|
639
|
+
// - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
|
|
640
|
+
// - Mixed array: ["index.html", /index\.[eE][jJ][sS]/]
|
|
641
|
+
// Priority is determined by array order (first match wins)
|
|
642
|
+
urlPrefix: "", // URL path prefix. Should start with "/" and NOT end with "/"
|
|
643
|
+
// (e.g. "/static"); "" disables the prefix. A malformed value
|
|
644
|
+
// is tolerated with a deprecation warning for now (behavior
|
|
645
|
+
// unchanged) and WILL throw in the next major version.
|
|
646
|
+
urlsReserved: [], // Reserved first-level paths passed through to next().
|
|
647
|
+
// Each entry should be a single first-level path: a leading
|
|
648
|
+
// "/" plus one segment, no further "/" (e.g. "/admin").
|
|
649
|
+
// Malformed entries are tolerated with a deprecation warning
|
|
650
|
+
// for now and WILL throw in the next major version (a
|
|
651
|
+
// non-string entry is dropped to avoid a per-request 500).
|
|
571
652
|
template: {
|
|
572
653
|
render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
|
|
654
|
+
// rawBuffer (4th arg, may be null) is READ-ONLY: the same Buffer
|
|
655
|
+
// instance is shared with the server cache and with concurrent
|
|
656
|
+
// requests — mutating it corrupts other responses.
|
|
573
657
|
ext: [], // File extensions to process with template.render
|
|
574
658
|
renderTimeout: 30000, // Max ms allowed for template.render (number ≥ 0; 0 = disabled).
|
|
575
659
|
// On timeout responds 504 Gateway Timeout. The render receives an
|
|
@@ -628,6 +712,14 @@ module.exports = function koaClassicServer(
|
|
|
628
712
|
enabled: true, // master switch (false = disable all compression)
|
|
629
713
|
encodings: ['br', 'gzip'], // algorithms in priority order; [] = disable
|
|
630
714
|
minFileSize: 1024, // min file size in bytes to compress; false = no minimum
|
|
715
|
+
maxFileSize: 10485760, // max file size (bytes) for the buffered high-quality
|
|
716
|
+
// compression path (whole file in RAM → brotli Q11 →
|
|
717
|
+
// result cached). Default: 10 MB; false = no cap.
|
|
718
|
+
// Larger files are STILL compressed, but via the
|
|
719
|
+
// bounded-RAM streaming mode (brotli Q4 / gzip 6, no
|
|
720
|
+
// Content-Length, not cached). This is a SAFETY NET
|
|
721
|
+
// against unbounded RAM/CPU on huge compressible files
|
|
722
|
+
// (multi-GB logs/JSON/CSV), not a serving restriction.
|
|
631
723
|
mimeTypes: [], // compressible MIME types (replaces default list if provided)
|
|
632
724
|
},
|
|
633
725
|
// compression: false // shorthand to disable all compression
|
|
@@ -656,8 +748,31 @@ module.exports = function koaClassicServer(
|
|
|
656
748
|
|
|
657
749
|
const normalizedRootDir = path.resolve(rootDir);
|
|
658
750
|
|
|
659
|
-
|
|
660
|
-
|
|
751
|
+
// Options must be a plain object, or omitted entirely (the `opts = {}`
|
|
752
|
+
// default covers undefined). An explicit null — or any other non-object —
|
|
753
|
+
// is a configuration bug: surface it with a helpful error instead of a raw
|
|
754
|
+
// TypeError further down (or, worse, a silent fall-through to defaults).
|
|
755
|
+
if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
|
|
756
|
+
throw new Error(
|
|
757
|
+
'[koa-classic-server] options must be a plain object (or omitted entirely). Got: ' +
|
|
758
|
+
(opts === null ? 'null' : Array.isArray(opts) ? 'an array' : typeof opts)
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Work on a copy: the factory normalizes options in place and must never
|
|
763
|
+
// mutate the caller's configuration object (reusing one config for two
|
|
764
|
+
// instances, or inspecting it after startup, would otherwise observe the
|
|
765
|
+
// rewritten values). Only the two nested objects the normalization writes
|
|
766
|
+
// into need their own copy — template (render/ext/renderTimeout) and
|
|
767
|
+
// hideExtension (ext/redirect); every other namespace is only read and
|
|
768
|
+
// normalized into new internal structures.
|
|
769
|
+
const options = { ...opts };
|
|
770
|
+
options.template = (opts.template && typeof opts.template === 'object' && !Array.isArray(opts.template))
|
|
771
|
+
? { ...opts.template }
|
|
772
|
+
: {};
|
|
773
|
+
if (options.hideExtension && typeof options.hideExtension === 'object' && !Array.isArray(options.hideExtension)) {
|
|
774
|
+
options.hideExtension = { ...options.hideExtension };
|
|
775
|
+
}
|
|
661
776
|
|
|
662
777
|
const _logger = normalizeLogger(options.logger);
|
|
663
778
|
|
|
@@ -732,6 +847,15 @@ module.exports = function koaClassicServer(
|
|
|
732
847
|
'dirListing.entriesPerPage',
|
|
733
848
|
100
|
|
734
849
|
),
|
|
850
|
+
// Canonical trailing-slash enforcement (V4). Default ON:
|
|
851
|
+
// - GET /dir (directory, no slash) → 301 redirect to /dir/
|
|
852
|
+
// - GET /file/ (file, trailing slash) → 404
|
|
853
|
+
// so relative links in an index page resolve against the directory and
|
|
854
|
+
// a file is only reachable at its slash-less URL. Set false to keep the
|
|
855
|
+
// v3 behavior (serve directories and files regardless of trailing slash).
|
|
856
|
+
trailingSlash: userDirListing && userDirListing.trailingSlash !== undefined
|
|
857
|
+
? !!userDirListing.trailingSlash
|
|
858
|
+
: true,
|
|
735
859
|
};
|
|
736
860
|
|
|
737
861
|
// Normalize index option to array format
|
|
@@ -755,9 +879,68 @@ module.exports = function koaClassicServer(
|
|
|
755
879
|
options.index = [];
|
|
756
880
|
}
|
|
757
881
|
|
|
758
|
-
|
|
882
|
+
// ── urlPrefix / urlsReserved validation (V3.1, deprecation-warn) ──
|
|
883
|
+
// The request-time matcher depends on an implicit format for both options,
|
|
884
|
+
// and a malformed value fails SILENTLY: a urlPrefix with a stray
|
|
885
|
+
// leading/trailing slash makes the middleware serve nothing (it always falls
|
|
886
|
+
// through to next()), and a urlsReserved entry without a leading slash makes
|
|
887
|
+
// the reservation never match (the path is served instead of passed on).
|
|
888
|
+
// Both are v2-stable options, so instead of throwing (a breaking change on a
|
|
889
|
+
// minor upgrade — a mis-slashed value that "worked" only by falling through
|
|
890
|
+
// to a downstream handler would suddenly change behavior), we WARN and leave
|
|
891
|
+
// the runtime behavior exactly as it is today. The next major turns these
|
|
892
|
+
// warnings into throws. The one exception is a non-string urlsReserved entry:
|
|
893
|
+
// it would 500 on every request (value.substring is not a function), which is
|
|
894
|
+
// not working behavior, so it is dropped defensively (still warned).
|
|
895
|
+
if (options.urlPrefix === undefined) {
|
|
896
|
+
options.urlPrefix = "";
|
|
897
|
+
} else if (typeof options.urlPrefix !== 'string') {
|
|
898
|
+
// Unchanged behavior: pre-existing code already coerced non-string → "".
|
|
899
|
+
warnConfigDeprecation(_logger,
|
|
900
|
+
'urlPrefix should be a string like "/static" (or "" for no prefix); got ' +
|
|
901
|
+
(options.urlPrefix === null ? 'null' : typeof options.urlPrefix) + ' — treating it as "".');
|
|
902
|
+
options.urlPrefix = "";
|
|
903
|
+
} else if (options.urlPrefix !== "" && (!options.urlPrefix.startsWith('/') || options.urlPrefix.endsWith('/'))) {
|
|
904
|
+
// Left as-is: the matcher behaves exactly as today (falls through to
|
|
905
|
+
// next() under this prefix). Warn only — no behavior change.
|
|
906
|
+
warnConfigDeprecation(_logger,
|
|
907
|
+
'urlPrefix should start with "/" and not end with "/" (use "" to disable); got ' +
|
|
908
|
+
JSON.stringify(options.urlPrefix) + ' — it will not route correctly until corrected.');
|
|
909
|
+
}
|
|
759
910
|
const _urlPrefixParts = options.urlPrefix.split("/");
|
|
760
|
-
|
|
911
|
+
|
|
912
|
+
if (options.urlsReserved === undefined) {
|
|
913
|
+
options.urlsReserved = [];
|
|
914
|
+
} else if (!Array.isArray(options.urlsReserved)) {
|
|
915
|
+
// Unchanged behavior: pre-existing code already coerced non-array → [].
|
|
916
|
+
warnConfigDeprecation(_logger,
|
|
917
|
+
'urlsReserved should be an array of first-level paths like ["/admin"]; got ' +
|
|
918
|
+
(options.urlsReserved === null ? 'null' : typeof options.urlsReserved) + ' — treating it as [].');
|
|
919
|
+
options.urlsReserved = [];
|
|
920
|
+
} else {
|
|
921
|
+
const cleaned = [];
|
|
922
|
+
for (const value of options.urlsReserved) {
|
|
923
|
+
if (typeof value !== 'string') {
|
|
924
|
+
// Dropped defensively: a non-string entry would throw at match
|
|
925
|
+
// time (value.substring is not a function) → a 500 on every
|
|
926
|
+
// request. Dropping it can't break working code.
|
|
927
|
+
warnConfigDeprecation(_logger,
|
|
928
|
+
'urlsReserved entries must be strings like "/admin"; dropping a non-string (' +
|
|
929
|
+
(value === null ? 'null' : typeof value) + ') entry.');
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
// Malformed but non-crashing (missing leading slash, extra segment,
|
|
933
|
+
// trailing slash, empty): kept as-is so the matcher behaves exactly
|
|
934
|
+
// as today (it simply won't match). Warn only.
|
|
935
|
+
if (value === '' || !value.startsWith('/') || value.indexOf('/', 1) !== -1) {
|
|
936
|
+
warnConfigDeprecation(_logger,
|
|
937
|
+
'each urlsReserved entry should be a single first-level path — a leading "/" plus one ' +
|
|
938
|
+
'segment, e.g. "/admin"; got ' + JSON.stringify(value) + ' — it will not match until corrected.');
|
|
939
|
+
}
|
|
940
|
+
cleaned.push(value);
|
|
941
|
+
}
|
|
942
|
+
options.urlsReserved = cleaned;
|
|
943
|
+
}
|
|
761
944
|
options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
|
|
762
945
|
options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
|
|
763
946
|
|
|
@@ -788,7 +971,21 @@ module.exports = function koaClassicServer(
|
|
|
788
971
|
);
|
|
789
972
|
}
|
|
790
973
|
|
|
791
|
-
|
|
974
|
+
// browserCacheMaxAge: non-negative integer seconds. An invalid value (negative, NaN,
|
|
975
|
+
// non-integer, Infinity, or a string) previously fell back to 3600 SILENTLY (#12).
|
|
976
|
+
// Consistent with #11: warn now and keep the fallback; a future major will throw
|
|
977
|
+
// (validateNonNegativeInt semantics) — so what warns here is exactly what will throw then.
|
|
978
|
+
if (options.browserCacheMaxAge === undefined) {
|
|
979
|
+
options.browserCacheMaxAge = 3600;
|
|
980
|
+
} else if (!(typeof options.browserCacheMaxAge === 'number'
|
|
981
|
+
&& Number.isInteger(options.browserCacheMaxAge)
|
|
982
|
+
&& options.browserCacheMaxAge >= 0)) {
|
|
983
|
+
warnConfigDeprecation(_logger,
|
|
984
|
+
'browserCacheMaxAge must be a non-negative integer (seconds). Got: ' +
|
|
985
|
+
String(options.browserCacheMaxAge) + '. Falling back to the default 3600 for now.');
|
|
986
|
+
options.browserCacheMaxAge = 3600;
|
|
987
|
+
}
|
|
988
|
+
// else: valid — keep as-is
|
|
792
989
|
options.browserCacheEnabled = typeof options.browserCacheEnabled === 'boolean' ? options.browserCacheEnabled : false;
|
|
793
990
|
options.useOriginalUrl = typeof options.useOriginalUrl === 'boolean' ? options.useOriginalUrl : true;
|
|
794
991
|
|
|
@@ -1012,6 +1209,7 @@ module.exports = function koaClassicServer(
|
|
|
1012
1209
|
enabled: true,
|
|
1013
1210
|
encodings: ['br', 'gzip'], // priority order: brotli first, gzip as fallback
|
|
1014
1211
|
minFileSize: 1024, // bytes; skip compression for files smaller than this
|
|
1212
|
+
maxFileSize: 10485760, // bytes; above this the buffered+cached path is skipped (streaming instead)
|
|
1015
1213
|
mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
|
|
1016
1214
|
};
|
|
1017
1215
|
}
|
|
@@ -1034,11 +1232,17 @@ module.exports = function koaClassicServer(
|
|
|
1034
1232
|
const minFileSize = compression.minFileSize === false ? false
|
|
1035
1233
|
: (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0 ? compression.minFileSize : 1024);
|
|
1036
1234
|
|
|
1235
|
+
// Cap for the buffered (whole-file-in-RAM, max-quality, cached) compression
|
|
1236
|
+
// path. false = no cap. Files above the cap still get compressed via the
|
|
1237
|
+
// bounded-RAM streaming mode — safety net, not a serving restriction.
|
|
1238
|
+
const maxFileSize = compression.maxFileSize === false ? false
|
|
1239
|
+
: (typeof compression.maxFileSize === 'number' && compression.maxFileSize > 0 ? compression.maxFileSize : 10485760);
|
|
1240
|
+
|
|
1037
1241
|
const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
|
|
1038
1242
|
? compression.mimeTypes
|
|
1039
1243
|
: DEFAULT_COMPRESSIBLE_MIME_TYPES;
|
|
1040
1244
|
|
|
1041
|
-
return { enabled, encodings, minFileSize, mimeTypes: new Set(mimeTypes) };
|
|
1245
|
+
return { enabled, encodings, minFileSize, maxFileSize, mimeTypes: new Set(mimeTypes) };
|
|
1042
1246
|
}
|
|
1043
1247
|
|
|
1044
1248
|
// Normalize and validate the serverCache option into a clean internal structure.
|
|
@@ -1207,12 +1411,40 @@ module.exports = function koaClassicServer(
|
|
|
1207
1411
|
_logger
|
|
1208
1412
|
);
|
|
1209
1413
|
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1414
|
+
// Single-flight maps for cache population (thundering-herd protection):
|
|
1415
|
+
// N concurrent misses on the same key share ONE read (+ compression) instead
|
|
1416
|
+
// of N duplicated jobs. Entries live only while a job is in flight. Keys
|
|
1417
|
+
// include the stat'd mtime+size so requests that observed different versions
|
|
1418
|
+
// of the file never share a job (each response stays coherent with its own
|
|
1419
|
+
// ETag/Last-Modified).
|
|
1420
|
+
const _inflightRawReads = new Map(); // `${path}:${mtime}:${size}` → Promise<Buffer>
|
|
1421
|
+
const _inflightCompressions = new Map(); // `${path}:${encoding}:${mtime}:${size}` → Promise<Buffer>
|
|
1422
|
+
|
|
1423
|
+
// Returns the server-preferred enabled encoding the client is willing to accept.
|
|
1424
|
+
// Server preference order (compressionConfig.encodings) still wins; the
|
|
1425
|
+
// Accept-Encoding q-values are used only to EXCLUDE encodings the client refuses
|
|
1426
|
+
// (q=0), per RFC 9110 §12.5.3. Token match is exact, not substring ("x-gzip" is
|
|
1427
|
+
// NOT "gzip"). A "*" supplies the q-value for any encoding not explicitly listed.
|
|
1212
1428
|
function getClientEncoding(acceptEncoding) {
|
|
1213
1429
|
if (!acceptEncoding) return null;
|
|
1430
|
+
const qValues = new Map(); // token → q-value
|
|
1431
|
+
for (const part of acceptEncoding.split(',')) {
|
|
1432
|
+
const [tokenRaw, ...params] = part.split(';');
|
|
1433
|
+
const token = tokenRaw.trim().toLowerCase();
|
|
1434
|
+
if (!token) continue;
|
|
1435
|
+
let q = 1;
|
|
1436
|
+
for (const p of params) {
|
|
1437
|
+
const m = /^\s*q=(\d+(?:\.\d+)?)\s*$/i.exec(p);
|
|
1438
|
+
if (m) q = parseFloat(m[1]);
|
|
1439
|
+
}
|
|
1440
|
+
qValues.set(token, q);
|
|
1441
|
+
}
|
|
1442
|
+
const star = qValues.get('*');
|
|
1214
1443
|
for (const enc of compressionConfig.encodings) {
|
|
1215
|
-
|
|
1444
|
+
let q = qValues.get(enc);
|
|
1445
|
+
if (q === undefined) q = star; // not listed → fall back to "*" if present
|
|
1446
|
+
if (q === undefined) continue; // not listed and no "*" → not offered
|
|
1447
|
+
if (q > 0) return enc; // acceptable → pick it (server preference order)
|
|
1216
1448
|
}
|
|
1217
1449
|
return null;
|
|
1218
1450
|
}
|
|
@@ -1308,6 +1540,14 @@ module.exports = function koaClassicServer(
|
|
|
1308
1540
|
const urlToUse = options.useOriginalUrl ? ctx.originalUrl : ctx.url;
|
|
1309
1541
|
const _origin = ctx.protocol + '://' + ctx.host;
|
|
1310
1542
|
const fullUrl = _origin + urlToUse;
|
|
1543
|
+
|
|
1544
|
+
// Whether the client's request path ends with "/" — captured from the
|
|
1545
|
+
// raw originalUrl BEFORE the trailing slash is stripped for URL parsing
|
|
1546
|
+
// below. Drives the canonical trailing-slash redirect / 404 in the
|
|
1547
|
+
// directory / file branch. "/" (root) counts as ending with a slash, so
|
|
1548
|
+
// it is already canonical and never redirects.
|
|
1549
|
+
const _rawOriginalPath = ctx.originalUrl.split('?')[0];
|
|
1550
|
+
const _pathEndsWithSlash = _rawOriginalPath.endsWith('/');
|
|
1311
1551
|
// Parse the request URL. `new URL()` throws on an invalid Host header
|
|
1312
1552
|
// (e.g. "Host: bad host") — reject as 400 rather than letting it surface as 500.
|
|
1313
1553
|
let pageHref = '';
|
|
@@ -1357,194 +1597,291 @@ module.exports = function koaClassicServer(
|
|
|
1357
1597
|
}
|
|
1358
1598
|
}
|
|
1359
1599
|
|
|
1360
|
-
//
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1600
|
+
// From this point on the middleware OWNS the request: every early
|
|
1601
|
+
// pass-through above has already returned, and no next() is called below
|
|
1602
|
+
// (the template render's next goes through tryRenderTemplate's own catch).
|
|
1603
|
+
// Last-resort net: an unexpected failure on any unguarded path must not
|
|
1604
|
+
// leak to Koa's default handler (a plain-text 500 without the middleware's
|
|
1605
|
+
// security headers, logged outside the operator's `logger`). Errors from
|
|
1606
|
+
// downstream middleware are NOT masked — they never reach this try.
|
|
1607
|
+
try {
|
|
1608
|
+
// Path traversal protection: build and validate safe file path
|
|
1609
|
+
let requestedPath = "";
|
|
1610
|
+
if (pageHrefOutPrefix.pathname === "/") {
|
|
1611
|
+
requestedPath = "";
|
|
1612
|
+
} else {
|
|
1613
|
+
// decodeURIComponent() throws URIError on malformed percent-encoding
|
|
1614
|
+
// (e.g. "/%", "/%zz", a truncated UTF-8 sequence) — reject as 400
|
|
1615
|
+
// rather than letting it surface as an unhandled 500.
|
|
1616
|
+
try {
|
|
1617
|
+
requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
|
|
1618
|
+
} catch {
|
|
1619
|
+
sendBadRequest(ctx);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// Null byte guard: path.normalize() throws ERR_INVALID_ARG_VALUE for paths
|
|
1625
|
+
// containing \0. Reject early with 400 Bad Request before it reaches fs calls.
|
|
1626
|
+
if (requestedPath.includes('\0')) {
|
|
1371
1627
|
sendBadRequest(ctx);
|
|
1372
1628
|
return;
|
|
1373
1629
|
}
|
|
1374
|
-
}
|
|
1375
1630
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
// backslash sequences (path.normalize converts \ to / before the check).
|
|
1392
|
-
// Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
|
|
1393
|
-
// matching the symlink-escape and hidden-entry outcomes.
|
|
1394
|
-
if (!_isWithinRoot(fullPath, normalizedRootDir)) {
|
|
1395
|
-
sendNotFound(ctx);
|
|
1396
|
-
return;
|
|
1397
|
-
}
|
|
1631
|
+
const normalizedPath = path.normalize(requestedPath);
|
|
1632
|
+
const fullPath = path.join(normalizedRootDir, normalizedPath);
|
|
1633
|
+
|
|
1634
|
+
// Security check: ensure resolved path is within rootDir. Uses the shared
|
|
1635
|
+
// _isWithinRoot() helper, which is boundary-aware: it matches rootDir exactly
|
|
1636
|
+
// or rootDir + path.sep, never a sibling (e.g. /srv/wwwsecret for root /srv/www) —
|
|
1637
|
+
// hardened defense in depth against a future change to how fullPath is built.
|
|
1638
|
+
// Covers: ../ traversal, URL-encoded variants (%2e%2e%2f), and on Windows
|
|
1639
|
+
// backslash sequences (path.normalize converts \ to / before the check).
|
|
1640
|
+
// Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
|
|
1641
|
+
// matching the symlink-escape and hidden-entry outcomes.
|
|
1642
|
+
if (!_isWithinRoot(fullPath, normalizedRootDir)) {
|
|
1643
|
+
sendNotFound(ctx);
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1398
1646
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1647
|
+
// Hidden check: block requests that traverse a hidden directory.
|
|
1648
|
+
// Stops at length-1 because the leaf (the file or dir being served) is
|
|
1649
|
+
// checked separately by the file/listing path with its real stat.isDirectory().
|
|
1650
|
+
if (requestedPath !== '') {
|
|
1651
|
+
const segments = normalizedPath.split(path.sep).filter(Boolean);
|
|
1652
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
1653
|
+
const segName = segments[i];
|
|
1654
|
+
const segRelPath = segments.slice(0, i + 1).join('/');
|
|
1655
|
+
if (isHiddenEntry(segName, segRelPath, true)) {
|
|
1656
|
+
sendNotFound(ctx);
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1410
1659
|
}
|
|
1411
1660
|
}
|
|
1412
|
-
}
|
|
1413
1661
|
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
//
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1662
|
+
let toOpen = fullPath;
|
|
1663
|
+
|
|
1664
|
+
// hideExtension logic: redirect URLs with extension and resolve clean URLs
|
|
1665
|
+
if (options.hideExtension) {
|
|
1666
|
+
const hideExt = options.hideExtension.ext;
|
|
1667
|
+
const hideRedirect = options.hideExtension.redirect;
|
|
1668
|
+
|
|
1669
|
+
// Trailing slash check via string — avoids a full new URL() construction
|
|
1670
|
+
const rawPath = urlToUse.split('?')[0];
|
|
1671
|
+
const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
|
|
1672
|
+
|
|
1673
|
+
// Model B (V4): an extension URL is canonicalized to its clean form only when it
|
|
1674
|
+
// has NO trailing slash. A trailing slash means directory intent, so /foo.ejs/
|
|
1675
|
+
// falls through to the file/dir dispatch, where a file requested with a trailing
|
|
1676
|
+
// slash is a 404 (finding #3). We gate on _pathEndsWithSlash — the very flag that
|
|
1677
|
+
// #3's file-branch 404 uses — so "skip the redirect" and "404 downstream" are the
|
|
1678
|
+
// same condition. requestedPath is decoded (and already slash-stripped by the URL
|
|
1679
|
+
// parse), so a percent-encoded dot (/foo%2Eejs → /foo.ejs) matches consistently (#14),
|
|
1680
|
+
// while /foo%2Eejs/ is excluded here by _pathEndsWithSlash and 404s downstream.
|
|
1681
|
+
if (!_pathEndsWithSlash && requestedPath.endsWith(hideExt)) {
|
|
1682
|
+
// Build redirect target using ctx.originalUrl (always, regardless of
|
|
1683
|
+
// useOriginalUrl). With useOriginalUrl: false the URL-parsing prologue
|
|
1684
|
+
// validated ctx.url (the rewritten one), NOT originalUrl — a malformed
|
|
1685
|
+
// originalUrl (e.g. an absolute-form request target, legal in HTTP/1.1)
|
|
1686
|
+
// makes this constructor throw. Same treatment as the other malformed
|
|
1687
|
+
// client input: 400 Bad Request, not an unhandled error.
|
|
1688
|
+
let originalUrlObj;
|
|
1689
|
+
try {
|
|
1690
|
+
originalUrlObj = new URL(_origin + ctx.originalUrl);
|
|
1691
|
+
} catch {
|
|
1692
|
+
sendBadRequest(ctx);
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Build the clean target in DECODED space: the extension may be percent-encoded
|
|
1697
|
+
// in the raw URL (e.g. "%2Eejs"), so decoding first makes it literal and
|
|
1698
|
+
// sliceable, then we re-encode per segment (#14, #20). The URL constructor
|
|
1699
|
+
// already validated the encoding, so decodeURIComponent cannot throw — but
|
|
1700
|
+
// guard for symmetry with the other malformed-client-input guards.
|
|
1701
|
+
let decodedPath;
|
|
1702
|
+
try {
|
|
1703
|
+
decodedPath = decodeURIComponent(originalUrlObj.pathname);
|
|
1704
|
+
} catch {
|
|
1705
|
+
sendBadRequest(ctx);
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
let cleanPath = decodedPath.slice(0, decodedPath.length - hideExt.length);
|
|
1441
1710
|
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
redirectPath = redirectPath.slice(0, redirectPath.length - baseName.length);
|
|
1452
|
-
break;
|
|
1711
|
+
// Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
|
|
1712
|
+
const baseName = path.basename(cleanPath);
|
|
1713
|
+
if (options.index && options.index.length > 0) {
|
|
1714
|
+
for (const pattern of options.index) {
|
|
1715
|
+
if (typeof pattern === 'string' && (baseName + hideExt) === pattern) {
|
|
1716
|
+
// Redirect to the directory (with trailing slash)
|
|
1717
|
+
cleanPath = cleanPath.slice(0, cleanPath.length - baseName.length);
|
|
1718
|
+
break;
|
|
1719
|
+
}
|
|
1453
1720
|
}
|
|
1454
1721
|
}
|
|
1455
|
-
}
|
|
1456
1722
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1723
|
+
// Re-encode per path segment: round-trips spaces, "%", etc. back into a valid
|
|
1724
|
+
// URL. encodeURIComponent may over-encode sub-delims in exotic filenames, but
|
|
1725
|
+
// the result always resolves to the same path.
|
|
1726
|
+
let redirectPath = cleanPath.split('/').map(encodeURIComponent).join('/');
|
|
1727
|
+
|
|
1728
|
+
// Open-redirect guard LAST: a Location starting with "//" (or "/\") is a
|
|
1729
|
+
// protocol-relative URL that would navigate off-origin ("GET //evil.com/foo.ejs").
|
|
1730
|
+
// Placed after re-encoding because a decoded "%2F" can reintroduce a leading
|
|
1731
|
+
// "//"; collapse any run of leading slashes/backslashes to a single slash.
|
|
1732
|
+
if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
|
|
1733
|
+
redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// Preserve query string
|
|
1737
|
+
const redirectUrl = redirectPath + (originalUrlObj.search || '');
|
|
1459
1738
|
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1739
|
+
ctx.status = hideRedirect;
|
|
1740
|
+
ctx.redirect(redirectUrl);
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1464
1743
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1744
|
+
// Check if URL has no extension → try adding the configured extension
|
|
1745
|
+
// Skip if original URL had trailing slash (trailing slash = directory intent)
|
|
1746
|
+
const extOfRequested = path.extname(requestedPath);
|
|
1747
|
+
if (!extOfRequested && requestedPath !== '' && !requestedPath.endsWith('/') && !hadTrailingSlash) {
|
|
1748
|
+
const pathWithExt = fullPath + hideExt;
|
|
1470
1749
|
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1750
|
+
// Security check: ensure resolved path is still within rootDir (boundary-aware)
|
|
1751
|
+
if (_isWithinRoot(pathWithExt, normalizedRootDir)) {
|
|
1752
|
+
try {
|
|
1753
|
+
const statWithExt = await fs.promises.stat(pathWithExt);
|
|
1754
|
+
if (statWithExt.isFile()) {
|
|
1755
|
+
// File with extension exists, serve it
|
|
1756
|
+
toOpen = pathWithExt;
|
|
1757
|
+
}
|
|
1758
|
+
} catch {
|
|
1759
|
+
// File with extension doesn't exist, continue normal flow
|
|
1478
1760
|
}
|
|
1479
|
-
} catch {
|
|
1480
|
-
// File with extension doesn't exist, continue normal flow
|
|
1481
1761
|
}
|
|
1482
1762
|
}
|
|
1483
1763
|
}
|
|
1484
|
-
}
|
|
1485
1764
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1765
|
+
// Check if path exists
|
|
1766
|
+
let stat;
|
|
1767
|
+
try {
|
|
1768
|
+
stat = await fs.promises.stat(toOpen);
|
|
1769
|
+
} catch {
|
|
1770
|
+
// File/directory doesn't exist or can't be accessed
|
|
1771
|
+
sendNotFound(ctx);
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// Hidden check: block access to the requested file or directory itself
|
|
1776
|
+
if (requestedPath !== '') {
|
|
1777
|
+
const entryName = path.basename(toOpen);
|
|
1778
|
+
const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
|
|
1779
|
+
if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
|
|
1780
|
+
sendNotFound(ctx);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1495
1784
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
if (
|
|
1785
|
+
// Symlink boundary check (V-1): in protected modes reject any requested file
|
|
1786
|
+
// or directory whose realpath escapes rootDir (or, in 'deny' mode, any symlink
|
|
1787
|
+
// resolved below rootDir). The `_symlinkMode !== 'follow'` guard short-circuits
|
|
1788
|
+
// before any await so the default 'follow' mode stays truly zero-overhead.
|
|
1789
|
+
if (_symlinkMode !== 'follow' && !(await symlinkAllowed(toOpen))) {
|
|
1501
1790
|
sendNotFound(ctx);
|
|
1502
1791
|
return;
|
|
1503
1792
|
}
|
|
1504
|
-
}
|
|
1505
1793
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1794
|
+
if (stat.isDirectory()) {
|
|
1795
|
+
// Handle directory
|
|
1796
|
+
if (options.dirListing.enabled) {
|
|
1797
|
+
// Canonical trailing-slash redirect (V4): a directory URL
|
|
1798
|
+
// without a trailing slash serves an index/listing whose
|
|
1799
|
+
// relative links would resolve against the parent. Redirect
|
|
1800
|
+
// /dir → /dir/ (301) BEFORE serving so the browser's base is
|
|
1801
|
+
// the directory itself.
|
|
1802
|
+
if (options.dirListing.trailingSlash && !_pathEndsWithSlash) {
|
|
1803
|
+
// Build the Location from the parsed originalUrl (same
|
|
1804
|
+
// defense as the hideExtension redirect): re-parsing forces
|
|
1805
|
+
// the target to be origin-relative — an absolute-form
|
|
1806
|
+
// request target (`GET http://evil/x`, legal in HTTP/1.1
|
|
1807
|
+
// and reachable under useOriginalUrl:false + rewriting)
|
|
1808
|
+
// would otherwise become an off-origin `Location` (open
|
|
1809
|
+
// redirect). .pathname keeps urlPrefix and percent-encoding.
|
|
1810
|
+
let originalUrlObj;
|
|
1811
|
+
try {
|
|
1812
|
+
originalUrlObj = new URL(_origin + ctx.originalUrl);
|
|
1813
|
+
} catch {
|
|
1814
|
+
sendBadRequest(ctx);
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
// Collapse a leading "//" / "/\" (protocol-relative) to a
|
|
1818
|
+
// single slash before appending the canonical trailing one.
|
|
1819
|
+
let redirectPath = originalUrlObj.pathname;
|
|
1820
|
+
if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
|
|
1821
|
+
redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
|
|
1822
|
+
}
|
|
1823
|
+
ctx.status = 301;
|
|
1824
|
+
ctx.redirect(redirectPath + '/' + originalUrlObj.search);
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1514
1827
|
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1828
|
+
// Search for index file matching configured patterns
|
|
1829
|
+
if (options.index && options.index.length > 0) {
|
|
1830
|
+
const indexFile = await findIndexFile(toOpen, options.index);
|
|
1831
|
+
if (indexFile) {
|
|
1832
|
+
const indexRelPath = path.relative(normalizedRootDir, path.join(toOpen, indexFile.name)).split(path.sep).join('/');
|
|
1833
|
+
if (!isHiddenEntry(indexFile.name, indexRelPath, false)) {
|
|
1834
|
+
const indexPath = path.join(toOpen, indexFile.name);
|
|
1835
|
+
// Symlink boundary check (V-1): an index file may itself be a
|
|
1836
|
+
// symlink escaping rootDir — validate before serving it.
|
|
1837
|
+
// Guarded so the default 'follow' mode skips the await entirely.
|
|
1838
|
+
if (_symlinkMode !== 'follow' && !(await symlinkAllowed(indexPath))) {
|
|
1839
|
+
sendNotFound(ctx);
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
await loadFile(indexPath, indexFile.stat);
|
|
1530
1843
|
return;
|
|
1531
1844
|
}
|
|
1532
|
-
await loadFile(indexPath, indexFile.stat);
|
|
1533
|
-
return;
|
|
1534
1845
|
}
|
|
1535
1846
|
}
|
|
1536
|
-
}
|
|
1537
1847
|
|
|
1538
|
-
|
|
1539
|
-
|
|
1848
|
+
// No index file found, show directory listing
|
|
1849
|
+
ctx.body = await show_dir(toOpen, ctx);
|
|
1850
|
+
} else {
|
|
1851
|
+
// Directory listing disabled
|
|
1852
|
+
sendNotFound(ctx);
|
|
1853
|
+
}
|
|
1854
|
+
return;
|
|
1540
1855
|
} else {
|
|
1541
|
-
//
|
|
1542
|
-
|
|
1856
|
+
// Canonical trailing-slash 404 (V4): a trailing slash means
|
|
1857
|
+
// "directory", but this path resolved to a FILE — a file is only
|
|
1858
|
+
// reachable at its slash-less URL. Return 404 (indistinguishable
|
|
1859
|
+
// from not-found) rather than serving the file at a non-canonical
|
|
1860
|
+
// URL. Disabled by dirListing.trailingSlash: false (v3 behavior).
|
|
1861
|
+
if (options.dirListing.trailingSlash && _pathEndsWithSlash) {
|
|
1862
|
+
sendNotFound(ctx);
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
await loadFile(toOpen, stat);
|
|
1866
|
+
return;
|
|
1543
1867
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1868
|
+
} catch (err) {
|
|
1869
|
+
_logger.error('[koa-classic-server] Unexpected error while serving the request:', err);
|
|
1870
|
+
if (ctx.headerSent || ctx.res.writableEnded) {
|
|
1871
|
+
ctx.res.destroy(); // response already in flight — nothing sane left to send
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
// Scrub representation/caching headers a partially-built response may
|
|
1875
|
+
// have left behind: a stale Content-Encoding would corrupt the error
|
|
1876
|
+
// page, a public Cache-Control could get the 500 cached by proxies.
|
|
1877
|
+
for (const h of ['Content-Encoding', 'Content-Disposition', 'Content-Range', 'Vary', 'ETag', 'Last-Modified', 'Accept-Ranges']) {
|
|
1878
|
+
ctx.remove(h);
|
|
1879
|
+
}
|
|
1880
|
+
ctx.set('Cache-Control', 'no-store');
|
|
1881
|
+
ctx.set('Content-Type', 'text/html; charset=utf-8');
|
|
1882
|
+
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
1883
|
+
ctx.status = 500;
|
|
1884
|
+
ctx.body = _INTERNAL_ERROR_HTML;
|
|
1548
1885
|
}
|
|
1549
1886
|
|
|
1550
1887
|
// Internal functions
|
|
@@ -1578,13 +1915,23 @@ module.exports = function koaClassicServer(
|
|
|
1578
1915
|
rawBuffer = cached.buffer;
|
|
1579
1916
|
} else {
|
|
1580
1917
|
try {
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1918
|
+
// Single-flight: concurrent misses on the same path share one
|
|
1919
|
+
// readFile + cache insert; only the leader (the closure below)
|
|
1920
|
+
// runs, waiters await the same Promise. The key includes the
|
|
1921
|
+
// stat'd mtime+size so a request that observed a DIFFERENT
|
|
1922
|
+
// version of the file starts its own job instead of adopting
|
|
1923
|
+
// bytes that don't match the validators it will emit.
|
|
1924
|
+
const inflightKey = `${toOpen}:${fileStat.mtime.getTime()}:${fileStat.size}`;
|
|
1925
|
+
rawBuffer = await singleFlight(_inflightRawReads, inflightKey, async () => {
|
|
1926
|
+
const buf = await fs.promises.readFile(toOpen);
|
|
1927
|
+
refreshOrInsert(_rawFileCache, toOpen, {
|
|
1928
|
+
buffer: buf,
|
|
1929
|
+
mtime: fileStat.mtime.getTime(),
|
|
1930
|
+
size: fileStat.size,
|
|
1931
|
+
insertedAt: Date.now(),
|
|
1932
|
+
}, cached, staleByAge);
|
|
1933
|
+
return buf;
|
|
1934
|
+
});
|
|
1588
1935
|
} catch {
|
|
1589
1936
|
rawBuffer = null; // Fall through to disk reads later
|
|
1590
1937
|
}
|
|
@@ -1632,6 +1979,67 @@ module.exports = function koaClassicServer(
|
|
|
1632
1979
|
}
|
|
1633
1980
|
}
|
|
1634
1981
|
|
|
1982
|
+
// Determine MIME type and compression encoding for the full-file response
|
|
1983
|
+
const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
|
|
1984
|
+
const filename = path.basename(toOpen);
|
|
1985
|
+
|
|
1986
|
+
// Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
|
|
1987
|
+
let encoding = null; // 'br' | 'gzip' | null
|
|
1988
|
+
let potentiallyCompressible = false; // response content-negotiates on Accept-Encoding
|
|
1989
|
+
if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
|
|
1990
|
+
const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
|
|
1991
|
+
const meetsMinSize = compressionConfig.minFileSize === false
|
|
1992
|
+
|| fileStat.size >= compressionConfig.minFileSize;
|
|
1993
|
+
if (isCompressibleMime && meetsMinSize) {
|
|
1994
|
+
potentiallyCompressible = true; // even if this client gets identity
|
|
1995
|
+
encoding = getClientEncoding(ctx.get('Accept-Encoding'));
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// fullEtag is encoding-specific to avoid false 304 hits across representations.
|
|
2000
|
+
// Proxies use Vary: Accept-Encoding to cache separate versions per encoding.
|
|
2001
|
+
const etagSuffix = encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : '';
|
|
2002
|
+
const fullEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}${etagSuffix}"`;
|
|
2003
|
+
|
|
2004
|
+
// Vary: Accept-Encoding as soon as the resource is *potentially* compressible —
|
|
2005
|
+
// regardless of whether THIS client gets a compressed variant, and regardless
|
|
2006
|
+
// of browserCacheEnabled. RFC 9110 §15.4.5: the 304 below must carry the same
|
|
2007
|
+
// Vary the 200 would; and a shared proxy must not serve the identity variant to
|
|
2008
|
+
// a client that would have received the compressed one (#7).
|
|
2009
|
+
if (potentiallyCompressible) {
|
|
2010
|
+
ctx.set('Vary', 'Accept-Encoding');
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// Preconditions are evaluated BEFORE the Range branch: RFC 9110 §13.2.2 gives the
|
|
2014
|
+
// validators precedence over Range (steps 3/4 before step 5), so a conditional
|
|
2015
|
+
// request that matches returns 304 (Not Modified), not 206 (Partial Content).
|
|
2016
|
+
// Comparison uses fullEtag (the encoding-specific representation a full GET would
|
|
2017
|
+
// return); a 206 below re-tags itself with baseEtag (the identity partial it serves).
|
|
2018
|
+
if (options.browserCacheEnabled) {
|
|
2019
|
+
ctx.set('ETag', fullEtag);
|
|
2020
|
+
ctx.set('Last-Modified', fileStat.mtime.toUTCString());
|
|
2021
|
+
|
|
2022
|
+
// If-None-Match: "*" | comma-list, weak comparison (RFC 9110 §13.1.2).
|
|
2023
|
+
if (ifNoneMatchSatisfied(ctx.get('If-None-Match'), fullEtag)) {
|
|
2024
|
+
ctx.status = 304;
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// If-Modified-Since (date validation). The mtime is truncated to whole seconds
|
|
2029
|
+
// before comparing: Last-Modified is emitted via toUTCString() (second precision
|
|
2030
|
+
// — HTTP dates have no milliseconds), so a client echoing that header back would
|
|
2031
|
+
// otherwise never match a sub-second mtime (e.g. 22:13:20.500 <= 22:13:20.000).
|
|
2032
|
+
const clientModifiedSince = ctx.get('If-Modified-Since');
|
|
2033
|
+
if (clientModifiedSince) {
|
|
2034
|
+
const clientDate = new Date(clientModifiedSince);
|
|
2035
|
+
const mtimeSeconds = Math.floor(fileStat.mtime.getTime() / 1000) * 1000;
|
|
2036
|
+
if (mtimeSeconds <= clientDate.getTime()) {
|
|
2037
|
+
ctx.status = 304;
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1635
2043
|
// Range request handling (HTTP 206 Partial Content — compression skipped for ranges)
|
|
1636
2044
|
const rangeHeader = ctx.get('Range');
|
|
1637
2045
|
if (rangeHeader) {
|
|
@@ -1651,10 +2059,11 @@ module.exports = function koaClassicServer(
|
|
|
1651
2059
|
if (!ifRange || ifRange === baseEtag) {
|
|
1652
2060
|
const { start, end } = parsed;
|
|
1653
2061
|
const rangeLength = end - start + 1;
|
|
1654
|
-
const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
|
|
1655
|
-
const filename = path.basename(toOpen);
|
|
1656
2062
|
|
|
1657
2063
|
ctx.status = 206;
|
|
2064
|
+
// 206 returns identity bytes → tag with baseEtag, not the encoding-specific
|
|
2065
|
+
// fullEtag set above. Last-Modified is already set (browserCacheEnabled).
|
|
2066
|
+
if (options.browserCacheEnabled) ctx.set('ETag', baseEtag);
|
|
1658
2067
|
ctx.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
|
1659
2068
|
ctx.set('Content-Type', mimeType);
|
|
1660
2069
|
ctx.set('Content-Length', String(rangeLength));
|
|
@@ -1662,8 +2071,9 @@ module.exports = function koaClassicServer(
|
|
|
1662
2071
|
|
|
1663
2072
|
if (ctx.method !== 'HEAD') {
|
|
1664
2073
|
if (rawBuffer) {
|
|
1665
|
-
// Serve range slice from in-memory buffer — zero disk I/O
|
|
1666
|
-
|
|
2074
|
+
// Serve range slice from in-memory buffer — zero disk I/O.
|
|
2075
|
+
// subarray(): zero-copy view; Buffer.slice is deprecated (DEP0158).
|
|
2076
|
+
ctx.body = rawBuffer.subarray(start, end + 1);
|
|
1667
2077
|
} else {
|
|
1668
2078
|
const src = fs.createReadStream(toOpen, { start, end });
|
|
1669
2079
|
src.on('error', (err) => {
|
|
@@ -1688,59 +2098,23 @@ module.exports = function koaClassicServer(
|
|
|
1688
2098
|
// Invalid Range → fall through to full 200 response
|
|
1689
2099
|
}
|
|
1690
2100
|
|
|
1691
|
-
// Determine MIME type and compression encoding for the full-file response
|
|
1692
|
-
const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
|
|
1693
|
-
const filename = path.basename(toOpen);
|
|
1694
|
-
|
|
1695
|
-
// Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
|
|
1696
|
-
let encoding = null; // 'br' | 'gzip' | null
|
|
1697
|
-
if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
|
|
1698
|
-
const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
|
|
1699
|
-
const meetsMinSize = compressionConfig.minFileSize === false
|
|
1700
|
-
|| fileStat.size >= compressionConfig.minFileSize;
|
|
1701
|
-
if (isCompressibleMime && meetsMinSize) {
|
|
1702
|
-
encoding = getClientEncoding(ctx.get('Accept-Encoding'));
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
// fullEtag is encoding-specific to avoid false 304 hits across representations.
|
|
1707
|
-
// Proxies use Vary: Accept-Encoding to cache separate versions per encoding.
|
|
1708
|
-
const etagSuffix = encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : '';
|
|
1709
|
-
const fullEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}${etagSuffix}"`;
|
|
1710
|
-
|
|
1711
|
-
// ETag, Last-Modified, and 304 check — deferred until encoding is known
|
|
1712
|
-
if (options.browserCacheEnabled) {
|
|
1713
|
-
ctx.set('ETag', fullEtag);
|
|
1714
|
-
ctx.set('Last-Modified', fileStat.mtime.toUTCString());
|
|
1715
|
-
|
|
1716
|
-
// Check If-None-Match (ETag validation)
|
|
1717
|
-
const clientEtag = ctx.get('If-None-Match');
|
|
1718
|
-
if (clientEtag && clientEtag === fullEtag) {
|
|
1719
|
-
ctx.status = 304;
|
|
1720
|
-
return;
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
// Check If-Modified-Since (date validation)
|
|
1724
|
-
const clientModifiedSince = ctx.get('If-Modified-Since');
|
|
1725
|
-
if (clientModifiedSince) {
|
|
1726
|
-
const clientDate = new Date(clientModifiedSince);
|
|
1727
|
-
if (fileStat.mtime.getTime() <= clientDate.getTime()) {
|
|
1728
|
-
ctx.status = 304;
|
|
1729
|
-
return;
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
2101
|
// Common response headers
|
|
1735
2102
|
ctx.set('Content-Type', mimeType);
|
|
1736
2103
|
ctx.set('Content-Disposition', buildContentDisposition(filename));
|
|
1737
2104
|
|
|
1738
2105
|
if (encoding) {
|
|
1739
2106
|
// ── Compressed response ───────────────────────────────────────────────
|
|
2107
|
+
// Vary: Accept-Encoding is already set above (potentiallyCompressible).
|
|
1740
2108
|
ctx.set('Content-Encoding', encoding);
|
|
1741
|
-
ctx.set('Vary', 'Accept-Encoding'); // Required so proxies cache per-encoding
|
|
1742
2109
|
|
|
1743
|
-
|
|
2110
|
+
// Safety net (#4): the buffered path reads the WHOLE file into RAM and
|
|
2111
|
+
// compresses at max quality — fine for web assets, catastrophic for a
|
|
2112
|
+
// multi-GB log/CSV. Above compression.maxFileSize the bounded-RAM
|
|
2113
|
+
// streaming mode below is used instead (still compressed, not cached).
|
|
2114
|
+
const withinCompressCap = compressionConfig.maxFileSize === false
|
|
2115
|
+
|| fileStat.size <= compressionConfig.maxFileSize;
|
|
2116
|
+
|
|
2117
|
+
if (serverCacheConfig.compressedFile.enabled && withinCompressCap) {
|
|
1744
2118
|
// compressedFile cache mode: compress once → buffer in RAM → Content-Length known
|
|
1745
2119
|
const cacheKey = `${toOpen}:${encoding}`;
|
|
1746
2120
|
const cached = _compressedFileCache.peek(cacheKey);
|
|
@@ -1757,21 +2131,34 @@ module.exports = function koaClassicServer(
|
|
|
1757
2131
|
buf = cached.buffer; // Serve from cache
|
|
1758
2132
|
} else {
|
|
1759
2133
|
try {
|
|
1760
|
-
//
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
2134
|
+
// Single-flight: concurrent misses on the same path+encoding
|
|
2135
|
+
// share one read+compress+insert instead of N parallel brotli
|
|
2136
|
+
// jobs for identical content. A rejection is shared too: all
|
|
2137
|
+
// waiters land in this catch and use the uncompressed fallback.
|
|
2138
|
+
// The key includes the stat'd mtime+size so a request that
|
|
2139
|
+
// observed a DIFFERENT version of the file starts its own job
|
|
2140
|
+
// (its ETag/Last-Modified must describe the bytes it serves).
|
|
2141
|
+
const inflightKey = `${cacheKey}:${fileStat.mtime.getTime()}:${fileStat.size}`;
|
|
2142
|
+
buf = await singleFlight(_inflightCompressions, inflightKey, async () => {
|
|
2143
|
+
// Use rawFile buffer if available — avoids redundant disk read
|
|
2144
|
+
const rawData = rawBuffer || await fs.promises.readFile(toOpen);
|
|
2145
|
+
const compressed = await compressBuffer(rawData, encoding);
|
|
2146
|
+
refreshOrInsert(_compressedFileCache, cacheKey, {
|
|
2147
|
+
buffer: compressed,
|
|
2148
|
+
mtime: fileStat.mtime.getTime(),
|
|
2149
|
+
size: fileStat.size,
|
|
2150
|
+
insertedAt: Date.now(),
|
|
2151
|
+
}, cached, staleByAge);
|
|
2152
|
+
return compressed;
|
|
2153
|
+
});
|
|
1770
2154
|
} catch (err) {
|
|
1771
2155
|
_logger.error('Compression error:', err);
|
|
1772
|
-
// Fall back to uncompressed on any compression failure
|
|
2156
|
+
// Fall back to uncompressed on any compression failure. Vary STAYS
|
|
2157
|
+
// (the resource is still compressible), but the ETag must be reset to
|
|
2158
|
+
// the un-suffixed form: this identity body must not be cached by a
|
|
2159
|
+
// shared proxy under the -br/-gz validator (#7).
|
|
1773
2160
|
ctx.remove('Content-Encoding');
|
|
1774
|
-
ctx.
|
|
2161
|
+
if (options.browserCacheEnabled) ctx.set('ETag', baseEtag);
|
|
1775
2162
|
if (rawBuffer) {
|
|
1776
2163
|
ctx.set('Content-Length', String(rawBuffer.length));
|
|
1777
2164
|
if (ctx.method !== 'HEAD') {
|
|
@@ -1813,20 +2200,28 @@ module.exports = function koaClassicServer(
|
|
|
1813
2200
|
const compress = encoding === 'br'
|
|
1814
2201
|
? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })
|
|
1815
2202
|
: zlib.createGzip({ level: 6 });
|
|
1816
|
-
|
|
1817
|
-
//
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2203
|
+
const src = rawBuffer
|
|
2204
|
+
? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
|
|
2205
|
+
: fs.createReadStream(toOpen);
|
|
2206
|
+
// pipeline (NOT pipe): teardown propagates in BOTH directions.
|
|
2207
|
+
// When the client disconnects mid-transfer, Koa destroys the body
|
|
2208
|
+
// (`compress`) and pipeline destroys `src` too, closing its file
|
|
2209
|
+
// descriptor. A bare src.pipe(compress) leaves the ReadStream
|
|
2210
|
+
// paused with the fd open forever — fd leak under aborted
|
|
2211
|
+
// downloads. Client disconnects are a normal event and are not
|
|
2212
|
+
// logged (avoids client-driven log spam).
|
|
2213
|
+
ctx.body = pipeline(src, compress, (err) => {
|
|
2214
|
+
if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
|
|
2215
|
+
_logger.error('Stream error:', err);
|
|
2216
|
+
if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
|
|
2217
|
+
});
|
|
2218
|
+
} else {
|
|
2219
|
+
// HEAD: mirror the GET status and headers (RFC 9110 §9.3.2) — no
|
|
2220
|
+
// Content-Length, since the compressed size is unknown without
|
|
2221
|
+
// running the compression. The explicit status is required: with
|
|
2222
|
+
// no body ever assigned, Koa would otherwise respond its default 404.
|
|
2223
|
+
ctx.status = 200;
|
|
1828
2224
|
}
|
|
1829
|
-
// HEAD + streaming: no Content-Length available; Koa sends headers only via res.end()
|
|
1830
2225
|
}
|
|
1831
2226
|
|
|
1832
2227
|
} else {
|
|
@@ -1945,9 +2340,12 @@ module.exports = function koaClassicServer(
|
|
|
1945
2340
|
parts.push("</thead>");
|
|
1946
2341
|
parts.push("<tbody>");
|
|
1947
2342
|
|
|
1948
|
-
// Parent directory link
|
|
2343
|
+
// Parent directory link — omitted at the middleware's LOGICAL root
|
|
2344
|
+
// (pageHrefOutPrefix.pathname === '/'), not only at the absolute root: with
|
|
2345
|
+
// urlPrefix '/static', the listing of /static/ must not link to '/', which is
|
|
2346
|
+
// outside the served tree (#13).
|
|
1949
2347
|
const currentPath = pageHref.origin + pageHref.pathname;
|
|
1950
|
-
if (
|
|
2348
|
+
if (pageHrefOutPrefix.pathname !== "/") {
|
|
1951
2349
|
// Build parent directory URL without query parameters
|
|
1952
2350
|
const a_pD = currentPath.split("/");
|
|
1953
2351
|
a_pD.pop();
|
|
@@ -1956,8 +2354,9 @@ module.exports = function koaClassicServer(
|
|
|
1956
2354
|
parts.push(`<tr><td><a href="${escapeHtml(parentDirectory)}"><b>.. Parent Directory</b></a></td><td>DIR</td><td>-</td></tr>`);
|
|
1957
2355
|
}
|
|
1958
2356
|
|
|
2357
|
+
const emptyFolderRow = `<tr><td>empty folder</td><td></td><td></td></tr>`;
|
|
1959
2358
|
if (dir.length === 0) {
|
|
1960
|
-
parts.push(
|
|
2359
|
+
parts.push(emptyFolderRow);
|
|
1961
2360
|
} else {
|
|
1962
2361
|
const _listingBaseUrl = pageHref.origin + pageHref.pathname;
|
|
1963
2362
|
const _listingOriginPrefix = pageHref.origin + options.urlPrefix;
|
|
@@ -2051,6 +2450,13 @@ module.exports = function koaClassicServer(
|
|
|
2051
2450
|
}
|
|
2052
2451
|
const items = rawItems.filter(Boolean);
|
|
2053
2452
|
|
|
2453
|
+
// Every entry was filtered out as hidden (dotfiles / alwaysHide / blacklist):
|
|
2454
|
+
// show the same "empty folder" row as a physically empty directory (#16), not a
|
|
2455
|
+
// header-only empty table (which would also hint that hidden files exist).
|
|
2456
|
+
if (items.length === 0) {
|
|
2457
|
+
parts.push(emptyFolderRow);
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2054
2460
|
// Places directories before non-directories; falls back to `tieBreaker`
|
|
2055
2461
|
// when both items are in the same bucket. `effectiveType === 2` covers plain
|
|
2056
2462
|
// dirs and dir-resolved symlinks, matching the rest of the listing logic.
|