@blamejs/core 0.7.1 → 0.7.18
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/CHANGELOG.md +423 -389
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -282
- package/lib/api-key.js +660 -672
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -266
- package/lib/cache.js +1206 -1211
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +1136 -0
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -202
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -573
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -269
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -614
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -451
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -479
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -855
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -177
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -752
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -489
- package/lib/request-helpers.js +340 -336
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -630
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +879 -114
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -270
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -598
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/static.js
CHANGED
|
@@ -1,67 +1,74 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* staticServe — server-side download primitive with the same v1-defensible
|
|
4
|
+
* surface as b.fileUpload: permissions integration, audit emission with
|
|
5
|
+
* 5-W's actor context, observability counters, bandwidth + concurrency
|
|
6
|
+
* quotas (cluster-shared via b.cache), Range support (RFC 7233 single-range),
|
|
7
|
+
* the full conditional-request set (If-None-Match / If-Match /
|
|
8
|
+
* If-Modified-Since / If-Unmodified-Since), MIME allowlist with magic-byte
|
|
9
|
+
* verification (composes b.fileType), per-request operator hook, idle-stream
|
|
10
|
+
* timeout, cancellation propagation, force-revoke, and compliance-retention
|
|
11
|
+
* gating.
|
|
4
12
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
13
|
+
* var serve = b.staticServe.create({ root, ...opts });
|
|
14
|
+
* await serve.revoke(absUrlPath); // operator force-revoke
|
|
15
|
+
* serve.stats(); // bytes / requests / etag-hits
|
|
16
|
+
* var mw = serve.middleware; // (req, res, next)
|
|
17
|
+
* await b.staticServe.integrity(absFilePath); // SRI helper (SHA-384)
|
|
9
18
|
*
|
|
10
|
-
*
|
|
19
|
+
* Backwards compatible: every existing opt (root, mountPath,
|
|
20
|
+
* hashedPathPattern, indexFile, defaultMaxAge, contentTypes) keeps its
|
|
21
|
+
* original meaning. New opts (permissions, cache, audit, observability,
|
|
22
|
+
* fileType, allowedFileTypes, maxBytesPerActorPerWindowMs, etc.) are all
|
|
23
|
+
* optional with security-on defaults (auditSuccess / auditFailures default
|
|
24
|
+
* true; range support defaults true; ETag is SHA3-512-truncated for PQC
|
|
25
|
+
* posture, the SRI integrity helper keeps SHA-384 because the W3C
|
|
26
|
+
* subresource-integrity spec only allows sha256/sha384/sha512).
|
|
11
27
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* the served bytes to the URL forever; immutable lets browsers
|
|
17
|
-
* skip revalidation entirely.
|
|
18
|
-
* - Other URLs get `Cache-Control: public, max-age=<defaultMaxAge>`
|
|
19
|
-
* (default 3600 = 1h). Operators tune via opts.defaultMaxAge.
|
|
20
|
-
*
|
|
21
|
-
* ETag is the first 27 chars of SHA-384(file content), base64. 162
|
|
22
|
-
* bits of collision resistance — overkill but cheap; same hash powers
|
|
23
|
-
* the SRI integrity helper so we compute it once per file.
|
|
24
|
-
*
|
|
25
|
-
* 304 Not Modified: emitted when If-None-Match matches the ETag.
|
|
26
|
-
*
|
|
27
|
-
* HEAD: returns same headers as GET, no body.
|
|
28
|
-
*
|
|
29
|
-
* Range requests are NOT supported in v1. Operators serving video/audio
|
|
30
|
-
* put a CDN in front. Compression (gzip/br) likewise — out of scope here.
|
|
31
|
-
*
|
|
32
|
-
* MIME types: minimal built-in table. Override via opts.contentTypes.
|
|
33
|
-
*
|
|
34
|
-
* Index files: a request resolving to a directory tries opts.indexFile
|
|
35
|
-
* (default "index.html"); operators disable with opts.indexFile = null.
|
|
36
|
-
*
|
|
37
|
-
* Public API:
|
|
38
|
-
*
|
|
39
|
-
* staticServe.create({ root, mountPath?, hashedPathPattern?,
|
|
40
|
-
* defaultMaxAge?, indexFile?, contentTypes? })
|
|
41
|
-
* → (req, res, next) middleware
|
|
42
|
-
*
|
|
43
|
-
* await staticServe.integrity(filePath)
|
|
44
|
-
* → "sha384-<base64>" suitable for an HTML integrity= attribute.
|
|
45
|
-
* Cached per file across calls; invalidated on mtime change.
|
|
28
|
+
* Backward-compat shape: create(opts) MAY be called for the bare middleware
|
|
29
|
+
* (the v0.6.x shape) — the result is callable as `(req, res, next)` and
|
|
30
|
+
* also exposes the new methods. That keeps existing tests + operator code
|
|
31
|
+
* passing while opening the surface.
|
|
46
32
|
*/
|
|
47
|
-
|
|
48
|
-
var
|
|
49
|
-
var
|
|
50
|
-
var nodeCrypto = require("crypto");
|
|
33
|
+
|
|
34
|
+
var fs = require("node:fs");
|
|
35
|
+
var fsp = require("node:fs/promises");
|
|
36
|
+
var nodeCrypto = require("node:crypto");
|
|
37
|
+
var path = require("node:path");
|
|
51
38
|
var C = require("./constants");
|
|
39
|
+
var gateContract = require("./gate-contract");
|
|
40
|
+
var lazyRequire = require("./lazy-require");
|
|
41
|
+
var numericBounds = require("./numeric-bounds");
|
|
52
42
|
var requestHelpers = require("./request-helpers");
|
|
53
43
|
var validateOpts = require("./validate-opts");
|
|
44
|
+
var { StaticServeError } = require("./framework-error");
|
|
45
|
+
|
|
46
|
+
// observability is lazy-required because it pulls in the metrics tap +
|
|
47
|
+
// safeEvent path, and during framework boot static.js may load before
|
|
48
|
+
// observability is ready.
|
|
49
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
50
|
+
|
|
51
|
+
// guard-* family is wired on by default; lazy-loaded to avoid eager
|
|
52
|
+
// import cycles. Operators opt out via contentSafety: null (audited).
|
|
53
|
+
var guardAll = lazyRequire(function () { return require("./guard-all"); });
|
|
54
|
+
var guardFilename = lazyRequire(function () { return require("./guard-filename"); });
|
|
55
|
+
|
|
56
|
+
var _err = StaticServeError.factory;
|
|
54
57
|
|
|
55
58
|
var HTTP = requestHelpers.HTTP_STATUS;
|
|
56
59
|
|
|
57
60
|
var DEFAULT_HASHED_PATTERN = /\.[a-fA-F0-9]{8,}\./;
|
|
58
61
|
var DEFAULT_INDEX_FILE = "index.html";
|
|
59
|
-
var DEFAULT_MAX_AGE_SEC = C.TIME.hours(1) / C.TIME.seconds(1);
|
|
60
|
-
var IMMUTABLE_MAX_AGE_SEC = C.TIME.days(365) / C.TIME.seconds(1);
|
|
62
|
+
var DEFAULT_MAX_AGE_SEC = C.TIME.hours(1) / C.TIME.seconds(1); // 1h non-hashed
|
|
63
|
+
var IMMUTABLE_MAX_AGE_SEC = C.TIME.days(365) / C.TIME.seconds(1); // 1y hashed
|
|
64
|
+
var DEFAULT_BANDWIDTH_WINDOW_MS = C.TIME.minutes(1);
|
|
65
|
+
var DEFAULT_MAX_IDLE_MS = C.TIME.minutes(2);
|
|
66
|
+
// SHA3-512 produces 64 bytes / 128 hex chars. ETag uses the first 32 hex
|
|
67
|
+
// chars (128 bits) — overkill for collision resistance but cheap; the same
|
|
68
|
+
// hash powers content addressing across the framework.
|
|
69
|
+
var ETAG_HEX_PREFIX = C.BYTES.bytes(32);
|
|
61
70
|
|
|
62
|
-
// Minimal MIME table
|
|
63
|
-
// to override. The framework deliberately doesn't bring a 200-entry
|
|
64
|
-
// mime-db dependency — most servers serve a handful of types.
|
|
71
|
+
// Minimal MIME table (kept from the v0.6 ship for compat).
|
|
65
72
|
var DEFAULT_CONTENT_TYPES = {
|
|
66
73
|
".html": "text/html; charset=utf-8",
|
|
67
74
|
".htm": "text/html; charset=utf-8",
|
|
@@ -76,7 +83,7 @@ var DEFAULT_CONTENT_TYPES = {
|
|
|
76
83
|
".svg": "image/svg+xml",
|
|
77
84
|
".png": "image/png",
|
|
78
85
|
".jpg": "image/jpeg",
|
|
79
|
-
".jpeg":
|
|
86
|
+
".jpeg": "image/jpeg",
|
|
80
87
|
".gif": "image/gif",
|
|
81
88
|
".webp": "image/webp",
|
|
82
89
|
".avif": "image/avif",
|
|
@@ -90,11 +97,28 @@ var DEFAULT_CONTENT_TYPES = {
|
|
|
90
97
|
".webmanifest": "application/manifest+json",
|
|
91
98
|
};
|
|
92
99
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
var DEFAULTS = Object.freeze({
|
|
101
|
+
defaultMaxAge: DEFAULT_MAX_AGE_SEC,
|
|
102
|
+
acceptRanges: true,
|
|
103
|
+
// Empty array = no MIME allowlist gate.
|
|
104
|
+
allowedFileTypes: Object.freeze([]),
|
|
105
|
+
// Bandwidth + concurrency caps default to 0 = "no cap". Operators opt
|
|
106
|
+
// in by setting a positive integer.
|
|
107
|
+
maxBytesPerActorPerWindowMs: 0,
|
|
108
|
+
maxBytesAllActorsPerWindowMs: 0,
|
|
109
|
+
bandwidthWindowMs: DEFAULT_BANDWIDTH_WINDOW_MS,
|
|
110
|
+
maxConcurrentDownloadsPerActor: 0,
|
|
111
|
+
maxIdleMs: DEFAULT_MAX_IDLE_MS,
|
|
112
|
+
// Audit / observability defaults follow the framework's posture: the
|
|
113
|
+
// serve event is the audit-worthy act, not a precursor.
|
|
114
|
+
auditSuccess: true,
|
|
115
|
+
auditFailures: true,
|
|
116
|
+
});
|
|
96
117
|
|
|
97
|
-
|
|
118
|
+
// Module-level metadata cache. Entries hold:
|
|
119
|
+
// { mtimeMs, size, etag, integrity, lastModified, sha3Hex, absPath }
|
|
120
|
+
// Invalidated on mtime / size change.
|
|
121
|
+
var _metaCache = new Map();
|
|
98
122
|
|
|
99
123
|
async function _readMeta(absPath) {
|
|
100
124
|
var stat;
|
|
@@ -102,42 +126,78 @@ async function _readMeta(absPath) {
|
|
|
102
126
|
catch (_e) { return null; }
|
|
103
127
|
if (!stat.isFile()) return null;
|
|
104
128
|
|
|
105
|
-
var cached =
|
|
129
|
+
var cached = _metaCache.get(absPath);
|
|
106
130
|
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
107
131
|
return cached;
|
|
108
132
|
}
|
|
109
133
|
|
|
110
|
-
// Stream
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
var
|
|
134
|
+
// Stream both hashes side-by-side: SHA-384 for SRI (W3C spec), SHA3-512
|
|
135
|
+
// for the framework ETag (PQC posture). Two transforms over the same
|
|
136
|
+
// chunk avoids re-reading the file.
|
|
137
|
+
var sri = nodeCrypto.createHash("sha384");
|
|
138
|
+
var sha3 = nodeCrypto.createHash("sha3-512");
|
|
114
139
|
await new Promise(function (resolve, reject) {
|
|
115
140
|
var s = fs.createReadStream(absPath);
|
|
116
|
-
s.on("data", function (chunk) {
|
|
117
|
-
s.on("end",
|
|
141
|
+
s.on("data", function (chunk) { sri.update(chunk); sha3.update(chunk); });
|
|
142
|
+
s.on("end", resolve);
|
|
118
143
|
s.on("error", reject);
|
|
119
144
|
});
|
|
120
|
-
var
|
|
145
|
+
var sriDigest = sri.digest("base64");
|
|
146
|
+
var sha3Hex = sha3.digest("hex");
|
|
121
147
|
|
|
122
148
|
var entry = {
|
|
123
149
|
mtimeMs: stat.mtimeMs,
|
|
124
150
|
size: stat.size,
|
|
125
|
-
etag: '"' +
|
|
126
|
-
integrity: "sha384-" +
|
|
151
|
+
etag: '"' + sha3Hex.slice(0, ETAG_HEX_PREFIX) + '"',
|
|
152
|
+
integrity: "sha384-" + sriDigest,
|
|
153
|
+
lastModified: new Date(stat.mtimeMs).toUTCString(),
|
|
154
|
+
sha3Hex: sha3Hex,
|
|
127
155
|
absPath: absPath,
|
|
128
156
|
};
|
|
129
|
-
|
|
157
|
+
_metaCache.set(absPath, entry);
|
|
130
158
|
return entry;
|
|
131
159
|
}
|
|
132
160
|
|
|
133
161
|
function _resolveSafe(root, requestedPath) {
|
|
134
162
|
if (typeof requestedPath !== "string" || requestedPath.length === 0) return null;
|
|
135
163
|
if (requestedPath.indexOf("\0") !== -1) return null;
|
|
136
|
-
// path.resolve handles ".." normalization; we then check containment.
|
|
137
164
|
var resolved = path.resolve(root, "." + requestedPath);
|
|
138
165
|
var rootResolved = path.resolve(root);
|
|
139
166
|
if (resolved !== rootResolved &&
|
|
140
167
|
!resolved.startsWith(rootResolved + path.sep)) return null;
|
|
168
|
+
|
|
169
|
+
// Symlink-escape defense — the lexical resolve above only sees the
|
|
170
|
+
// requested path tokens; a symlink anywhere along `resolved` can
|
|
171
|
+
// still point outside `rootResolved` on disk. realpath every node
|
|
172
|
+
// (only when it exists; missing files are routed through the
|
|
173
|
+
// standard 404 path by the caller).
|
|
174
|
+
try {
|
|
175
|
+
var real = fs.realpathSync(resolved);
|
|
176
|
+
var rootReal = fs.realpathSync(rootResolved);
|
|
177
|
+
if (real !== rootReal && !real.startsWith(rootReal + path.sep)) return null;
|
|
178
|
+
resolved = real;
|
|
179
|
+
} catch (_e) {
|
|
180
|
+
// Path doesn't exist (or is denied) — fall through with the lexical
|
|
181
|
+
// resolution so the caller's stat() returns the natural ENOENT and
|
|
182
|
+
// 404s. realpath failures from non-existence are NOT a smuggling
|
|
183
|
+
// signal.
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Filename safety — the basename gates against path-traversal /
|
|
187
|
+
// null-byte / NTFS ADS / UNC / RTLO bidi / overlong UTF-8 / Windows
|
|
188
|
+
// reserved device names. Uses balanced profile + explicit
|
|
189
|
+
// shellExecExtPolicy: "allow" because static-serve serves operator-
|
|
190
|
+
// deposited disk content: shell-exec extensions (.exe / .bin / .so /
|
|
191
|
+
// legitimate `<name>.<hash>.js` bundler output) are valid here. The
|
|
192
|
+
// other balanced checks still reject the traversal + smuggling
|
|
193
|
+
// surface the user surfaced.
|
|
194
|
+
var fname = path.basename(resolved);
|
|
195
|
+
var rv = guardFilename().validate(fname, {
|
|
196
|
+
profile: "balanced",
|
|
197
|
+
shellExecExtPolicy: "allow",
|
|
198
|
+
});
|
|
199
|
+
if (!rv.ok) return null;
|
|
200
|
+
|
|
141
201
|
return resolved;
|
|
142
202
|
}
|
|
143
203
|
|
|
@@ -146,48 +206,333 @@ function _contentTypeFor(filePath, table) {
|
|
|
146
206
|
return (table && table[ext]) || DEFAULT_CONTENT_TYPES[ext] || "application/octet-stream";
|
|
147
207
|
}
|
|
148
208
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
209
|
+
// _parseRangeHeader — RFC 7233 single-range parser. Returns null when:
|
|
210
|
+
// - header absent
|
|
211
|
+
// - syntactically malformed (not `bytes=`, multi-range, suffix syntax
|
|
212
|
+
// "-N" handled, end > size, start > end)
|
|
213
|
+
// - the request can be answered as 416 the caller does (start >= size).
|
|
214
|
+
//
|
|
215
|
+
// Returns { start, end, length } for a valid satisfiable single range.
|
|
216
|
+
// Multi-range (`bytes=0-99,200-299`) returns { multi: true } so the
|
|
217
|
+
// caller can refuse with 416 — we don't ship multipart/byteranges in v1
|
|
218
|
+
// (operators with that need pull bytes via b.objectStore presigned URL).
|
|
219
|
+
function _parseRangeHeader(header, size) {
|
|
220
|
+
if (typeof header !== "string" || header.length === 0) return null;
|
|
221
|
+
if (header.indexOf("bytes=") !== 0) return { malformed: true };
|
|
222
|
+
var spec = header.slice(6).trim();
|
|
223
|
+
if (spec.length === 0) return { malformed: true };
|
|
224
|
+
if (spec.indexOf(",") !== -1) return { multi: true };
|
|
225
|
+
var dash = spec.indexOf("-");
|
|
226
|
+
if (dash === -1) return { malformed: true };
|
|
227
|
+
var startStr = spec.slice(0, dash);
|
|
228
|
+
var endStr = spec.slice(dash + 1);
|
|
229
|
+
var start, end;
|
|
230
|
+
if (startStr === "") {
|
|
231
|
+
// Suffix range: "bytes=-N" → last N bytes.
|
|
232
|
+
var suffix = parseInt(endStr, 10);
|
|
233
|
+
if (!isFinite(suffix) || suffix <= 0) return { malformed: true };
|
|
234
|
+
if (suffix > size) suffix = size;
|
|
235
|
+
start = size - suffix;
|
|
236
|
+
end = size - 1;
|
|
237
|
+
} else {
|
|
238
|
+
start = parseInt(startStr, 10);
|
|
239
|
+
if (!isFinite(start) || start < 0) return { malformed: true };
|
|
240
|
+
if (endStr === "") {
|
|
241
|
+
end = size - 1;
|
|
242
|
+
} else {
|
|
243
|
+
end = parseInt(endStr, 10);
|
|
244
|
+
if (!isFinite(end) || end < start) return { malformed: true };
|
|
245
|
+
if (end > size - 1) end = size - 1;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (start >= size) return { unsatisfiable: true };
|
|
249
|
+
return { start: start, end: end, length: end - start + 1 };
|
|
155
250
|
}
|
|
156
251
|
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
res.end("Not Found");
|
|
252
|
+
function _httpDate(date) {
|
|
253
|
+
return (date instanceof Date ? date : new Date(date)).toUTCString();
|
|
160
254
|
}
|
|
161
255
|
|
|
162
|
-
|
|
256
|
+
function _validateCreateOpts(opts) {
|
|
257
|
+
validateOpts.requireObject(opts, "staticServe.create", StaticServeError);
|
|
258
|
+
validateOpts.requireNonEmptyString(opts.root, "staticServe.create: root", StaticServeError, "BAD_OPT");
|
|
259
|
+
if (!fs.existsSync(opts.root)) {
|
|
260
|
+
throw _err("BAD_OPT", "staticServe.create: root does not exist: " + opts.root);
|
|
261
|
+
}
|
|
262
|
+
if (typeof opts.mountPath === "string" && opts.mountPath.length === 0) {
|
|
263
|
+
// empty string is operator-permissible: "no mount, root is request URL"
|
|
264
|
+
} else if (opts.mountPath !== undefined && opts.mountPath !== null &&
|
|
265
|
+
typeof opts.mountPath !== "string") {
|
|
266
|
+
throw _err("BAD_OPT", "staticServe.create: mountPath must be a string");
|
|
267
|
+
}
|
|
268
|
+
if (opts.hashedPathPattern !== undefined && opts.hashedPathPattern !== null &&
|
|
269
|
+
!(opts.hashedPathPattern instanceof RegExp)) {
|
|
270
|
+
throw _err("BAD_OPT", "staticServe.create: hashedPathPattern must be a RegExp");
|
|
271
|
+
}
|
|
272
|
+
// indexFile === null is the operator's "disable" sentinel; the helper
|
|
273
|
+
// returns null/undefined unchanged so we keep that semantic.
|
|
274
|
+
validateOpts.optionalNonEmptyString(opts.indexFile,
|
|
275
|
+
"staticServe.create: indexFile", StaticServeError, "BAD_OPT");
|
|
276
|
+
numericBounds.requireNonNegativeFiniteIntIfPresent(opts.defaultMaxAge,
|
|
277
|
+
"staticServe.create: defaultMaxAge", StaticServeError, "BAD_OPT");
|
|
278
|
+
validateOpts.optionalPlainObject(opts.contentTypes,
|
|
279
|
+
"staticServe.create: contentTypes", StaticServeError, "BAD_OPT");
|
|
280
|
+
validateOpts.optionalObjectWithMethod(opts.permissions, "check",
|
|
281
|
+
"staticServe.create: permissions", StaticServeError, "BAD_OPT",
|
|
282
|
+
"must be a b.permissions instance (check fn)");
|
|
283
|
+
validateOpts.optionalObjectWithMethod(opts.cache, "get",
|
|
284
|
+
"staticServe.create: cache", StaticServeError, "BAD_OPT",
|
|
285
|
+
"must be a b.cache instance (used for cluster-shared bandwidth + concurrency tracking)");
|
|
286
|
+
validateOpts.optionalObjectWithMethod(opts.fileType, "detect",
|
|
287
|
+
"staticServe.create: fileType", StaticServeError, "BAD_OPT",
|
|
288
|
+
"must be a b.fileType instance (magic-byte MIME detection)");
|
|
289
|
+
validateOpts.optionalObjectWithMethod(opts.retention, "isServable",
|
|
290
|
+
"staticServe.create: retention", StaticServeError, "BAD_OPT",
|
|
291
|
+
"must expose isServable(absPath, ctx) → boolean (compliance retention check)");
|
|
292
|
+
validateOpts.optionalObjectWithMethod(opts.revokeStore, "isRevoked",
|
|
293
|
+
"staticServe.create: revokeStore", StaticServeError, "BAD_OPT",
|
|
294
|
+
"must expose isRevoked(key) and revoke(key) for force-revoke support");
|
|
295
|
+
validateOpts.optionalNonEmptyStringArray(opts.allowedFileTypes,
|
|
296
|
+
"staticServe.create: allowedFileTypes", StaticServeError, "BAD_OPT");
|
|
297
|
+
if (Array.isArray(opts.allowedFileTypes) && opts.allowedFileTypes.length > 0 &&
|
|
298
|
+
(!opts.fileType || typeof opts.fileType.detect !== "function")) {
|
|
299
|
+
throw _err("BAD_OPT",
|
|
300
|
+
"staticServe.create: allowedFileTypes is set but fileType primitive is not wired " +
|
|
301
|
+
"(pass fileType: b.fileType so the framework can sniff magic bytes before serving)");
|
|
302
|
+
}
|
|
303
|
+
validateOpts.auditShape(opts.audit, "staticServe.create", StaticServeError);
|
|
304
|
+
validateOpts.observabilityShape(opts.observability, "staticServe.create", StaticServeError);
|
|
305
|
+
validateOpts.optionalFunction(opts.onServe, "staticServe.create: onServe", StaticServeError);
|
|
306
|
+
// contentSafety — extension-keyed gate map. Default behaviour: when
|
|
307
|
+
// undefined, the framework wires b.guardAll.byExtension({ profile:
|
|
308
|
+
// "strict" }) automatically so every shipped guard is ON by default.
|
|
309
|
+
// Explicit opt-out: contentSafety: null (audited at create() time so
|
|
310
|
+
// a security review can reconstruct which deploys disabled the
|
|
311
|
+
// default-on protection).
|
|
312
|
+
// Example: contentSafety: { ".csv": b.guardCsv.gate({ profile: "strict" }) }
|
|
313
|
+
if (opts.contentSafety !== undefined && opts.contentSafety !== null) {
|
|
314
|
+
validateOpts.optionalPlainObject(opts.contentSafety,
|
|
315
|
+
"staticServe.create: contentSafety", StaticServeError, "BAD_OPT",
|
|
316
|
+
"must be a plain { ext: gate } object, null to opt out, or " +
|
|
317
|
+
"undefined for the default-on b.guardAll wiring");
|
|
318
|
+
var safetyKeys = Object.keys(opts.contentSafety);
|
|
319
|
+
for (var sk = 0; sk < safetyKeys.length; sk++) {
|
|
320
|
+
var ext = safetyKeys[sk];
|
|
321
|
+
var g = opts.contentSafety[ext];
|
|
322
|
+
if (!g || typeof g.check !== "function") {
|
|
323
|
+
throw _err("BAD_OPT",
|
|
324
|
+
"staticServe.create: contentSafety[" + JSON.stringify(ext) +
|
|
325
|
+
"] must be a gate (b.guardCsv.gate / b.guardHtml.gate / etc.)");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
validateOpts.optionalBoolean(opts.acceptRanges, "staticServe.create: acceptRanges", StaticServeError);
|
|
330
|
+
validateOpts.optionalBoolean(opts.auditSuccess, "staticServe.create: auditSuccess", StaticServeError);
|
|
331
|
+
validateOpts.optionalBoolean(opts.auditFailures, "staticServe.create: auditFailures", StaticServeError);
|
|
332
|
+
numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxBytesPerActorPerWindowMs,
|
|
333
|
+
"staticServe.create: maxBytesPerActorPerWindowMs", StaticServeError, "BAD_OPT");
|
|
334
|
+
numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxBytesAllActorsPerWindowMs,
|
|
335
|
+
"staticServe.create: maxBytesAllActorsPerWindowMs", StaticServeError, "BAD_OPT");
|
|
336
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.bandwidthWindowMs,
|
|
337
|
+
"staticServe.create: bandwidthWindowMs", StaticServeError, "BAD_OPT");
|
|
338
|
+
numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxConcurrentDownloadsPerActor,
|
|
339
|
+
"staticServe.create: maxConcurrentDownloadsPerActor", StaticServeError, "BAD_OPT");
|
|
340
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxIdleMs,
|
|
341
|
+
"staticServe.create: maxIdleMs", StaticServeError, "BAD_OPT");
|
|
342
|
+
// Quotas require a cache for cluster-shared coordination.
|
|
343
|
+
if ((opts.maxBytesPerActorPerWindowMs > 0 ||
|
|
344
|
+
opts.maxBytesAllActorsPerWindowMs > 0 ||
|
|
345
|
+
opts.maxConcurrentDownloadsPerActor > 0) &&
|
|
346
|
+
!opts.cache) {
|
|
347
|
+
throw _err("BAD_OPT",
|
|
348
|
+
"staticServe.create: bandwidth / concurrency quotas require opts.cache " +
|
|
349
|
+
"(pass cache: b.cache.create({ backend: 'cluster' }) so multi-replica deploys honor caps globally)");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
163
352
|
|
|
353
|
+
// _checkBandwidthQuota — token-bucket via b.cache. Returns { ok: true } or
|
|
354
|
+
// { ok: false, retryAfter, scope: "actor"|"global" }.
|
|
355
|
+
async function _checkBandwidthQuota(cache, actorKey, perActorCap, globalCap, windowMs, requestedBytes) {
|
|
356
|
+
if (!cache || (perActorCap === 0 && globalCap === 0)) return { ok: true };
|
|
357
|
+
var now = Date.now();
|
|
358
|
+
var windowStart = now - windowMs;
|
|
359
|
+
if (perActorCap > 0 && actorKey) {
|
|
360
|
+
var aKey = "static:bw:actor:" + actorKey;
|
|
361
|
+
var aUsed = (await cache.get(aKey)) || 0;
|
|
362
|
+
if (aUsed + requestedBytes > perActorCap) {
|
|
363
|
+
return { ok: false, retryAfter: Math.ceil(windowMs / C.TIME.seconds(1)), scope: "actor", used: aUsed, cap: perActorCap };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (globalCap > 0) {
|
|
367
|
+
var gKey = "static:bw:global";
|
|
368
|
+
var gUsed = (await cache.get(gKey)) || 0;
|
|
369
|
+
if (gUsed + requestedBytes > globalCap) {
|
|
370
|
+
return { ok: false, retryAfter: Math.ceil(windowMs / C.TIME.seconds(1)), scope: "global", used: gUsed, cap: globalCap };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return { ok: true, windowStart: windowStart, now: now };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function _consumeBandwidth(cache, actorKey, perActorCap, globalCap, windowMs, bytes) {
|
|
377
|
+
if (!cache) return;
|
|
378
|
+
if (perActorCap > 0 && actorKey) {
|
|
379
|
+
var aKey = "static:bw:actor:" + actorKey;
|
|
380
|
+
var aUsed = (await cache.get(aKey)) || 0;
|
|
381
|
+
await cache.set(aKey, aUsed + bytes, { ttlMs: windowMs });
|
|
382
|
+
}
|
|
383
|
+
if (globalCap > 0) {
|
|
384
|
+
var gKey = "static:bw:global";
|
|
385
|
+
var gUsed = (await cache.get(gKey)) || 0;
|
|
386
|
+
await cache.set(gKey, gUsed + bytes, { ttlMs: windowMs });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function _checkConcurrencyCap(cache, actorKey, cap) {
|
|
391
|
+
if (!cache || cap === 0 || !actorKey) return { ok: true };
|
|
392
|
+
var key = "static:conc:" + actorKey;
|
|
393
|
+
var current = (await cache.get(key)) || 0;
|
|
394
|
+
if (current >= cap) return { ok: false, current: current, cap: cap };
|
|
395
|
+
return { ok: true, current: current };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function _incConcurrency(cache, actorKey) {
|
|
399
|
+
if (!cache || !actorKey) return;
|
|
400
|
+
var key = "static:conc:" + actorKey;
|
|
401
|
+
var current = (await cache.get(key)) || 0;
|
|
402
|
+
await cache.set(key, current + 1, { ttlMs: C.TIME.minutes(10) });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function _decConcurrency(cache, actorKey) {
|
|
406
|
+
if (!cache || !actorKey) return;
|
|
407
|
+
var key = "static:conc:" + actorKey;
|
|
408
|
+
var current = (await cache.get(key)) || 0;
|
|
409
|
+
var next = current > 0 ? current - 1 : 0;
|
|
410
|
+
await cache.set(key, next, { ttlMs: C.TIME.minutes(10) });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function _actorKeyFromContext(ctx) {
|
|
414
|
+
if (!ctx) return null;
|
|
415
|
+
if (ctx.userId) return "id:" + ctx.userId;
|
|
416
|
+
if (ctx.ip) return "ip:" + ctx.ip;
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// _writeError — uniform error response with audit + observability emission.
|
|
421
|
+
function _writeError(res, status, code, message, headers) {
|
|
422
|
+
var hdrs = Object.assign({ "Content-Type": "text/plain; charset=utf-8" }, headers || {});
|
|
423
|
+
hdrs["Content-Length"] = Buffer.byteLength(message, "utf8");
|
|
424
|
+
try {
|
|
425
|
+
res.writeHead(status, hdrs);
|
|
426
|
+
res.end(message);
|
|
427
|
+
} catch (_e) {
|
|
428
|
+
// response already torn down — best effort
|
|
429
|
+
}
|
|
430
|
+
void code;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// integrity() — module-level helper, kept for compat with the v0.6 SRI use.
|
|
164
434
|
async function integrity(absPath) {
|
|
165
435
|
if (typeof absPath !== "string" || absPath.length === 0) {
|
|
166
|
-
throw
|
|
436
|
+
throw _err("BAD_OPT", "staticServe.integrity: absPath must be a non-empty string");
|
|
167
437
|
}
|
|
168
438
|
var meta = await _readMeta(path.resolve(absPath));
|
|
169
|
-
if (!meta) throw
|
|
439
|
+
if (!meta) throw _err("NOT_FOUND", "staticServe.integrity: file not found: " + absPath);
|
|
170
440
|
return meta.integrity;
|
|
171
441
|
}
|
|
172
442
|
|
|
173
|
-
// ---- Public: create() ----
|
|
174
|
-
|
|
175
443
|
function create(opts) {
|
|
176
444
|
opts = opts || {};
|
|
445
|
+
// The v0.6.x test surface called `validateOpts(opts, [...allowed], label)`
|
|
446
|
+
// for the unknown-key check. Preserve that gate in addition to the
|
|
447
|
+
// throw-at-config-time validation so tests catch typos.
|
|
177
448
|
validateOpts(opts, [
|
|
178
449
|
"root", "mountPath", "hashedPathPattern",
|
|
179
450
|
"indexFile", "defaultMaxAge", "contentTypes",
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
451
|
+
"permissions", "cache", "fileType", "retention", "revokeStore",
|
|
452
|
+
"allowedFileTypes", "audit", "observability", "onServe",
|
|
453
|
+
"acceptRanges", "auditSuccess", "auditFailures",
|
|
454
|
+
"maxBytesPerActorPerWindowMs", "maxBytesAllActorsPerWindowMs",
|
|
455
|
+
"bandwidthWindowMs", "maxConcurrentDownloadsPerActor", "maxIdleMs",
|
|
456
|
+
"contentSafety", "contentSafetyDisabledReason",
|
|
457
|
+
], "staticServe.create");
|
|
458
|
+
_validateCreateOpts(opts);
|
|
459
|
+
var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
|
|
185
460
|
var root = path.resolve(opts.root);
|
|
186
|
-
var mountPath = opts.mountPath || "";
|
|
461
|
+
var mountPath = opts.mountPath || "";
|
|
187
462
|
var hashedPattern = opts.hashedPathPattern || DEFAULT_HASHED_PATTERN;
|
|
188
463
|
var indexFile = opts.indexFile === null ? null : (opts.indexFile || DEFAULT_INDEX_FILE);
|
|
189
|
-
var defaultMaxAge =
|
|
464
|
+
var defaultMaxAge = cfg.defaultMaxAge;
|
|
190
465
|
var contentTypes = opts.contentTypes || null;
|
|
466
|
+
var permissions = opts.permissions || null;
|
|
467
|
+
var cache = opts.cache || null;
|
|
468
|
+
var fileType = opts.fileType || null;
|
|
469
|
+
var retention = opts.retention || null;
|
|
470
|
+
var revokeStore = opts.revokeStore || null;
|
|
471
|
+
var allowedFileTypes = Array.isArray(opts.allowedFileTypes) ? opts.allowedFileTypes.slice() : [];
|
|
472
|
+
// contentSafety: undefined → wire b.guardAll.byExtension({ profile: "strict" })
|
|
473
|
+
// contentSafety: null → explicit opt-out, audit row emitted
|
|
474
|
+
// contentSafety: { ... } → use operator-supplied map
|
|
475
|
+
var contentSafety;
|
|
476
|
+
if (opts.contentSafety === undefined) {
|
|
477
|
+
// Strict profile is the security-correct default. Operators who
|
|
478
|
+
// serve a broader content vocabulary opt up explicitly via
|
|
479
|
+
// contentSafety: b.guardAll.byExtension({ profile: "balanced" |
|
|
480
|
+
// "permissive" }).
|
|
481
|
+
contentSafety = guardAll().byExtension({
|
|
482
|
+
profile: "strict",
|
|
483
|
+
audit: opts.audit,
|
|
484
|
+
observability: opts.observability,
|
|
485
|
+
});
|
|
486
|
+
} else if (opts.contentSafety === null) {
|
|
487
|
+
if (opts.audit && typeof opts.audit.safeEmit === "function") {
|
|
488
|
+
try {
|
|
489
|
+
opts.audit.safeEmit({
|
|
490
|
+
action: "staticServe.contentSafety.disabled",
|
|
491
|
+
actor: {},
|
|
492
|
+
outcome: "success",
|
|
493
|
+
metadata: {
|
|
494
|
+
reason: opts.contentSafetyDisabledReason || "operator-explicit-opt-out",
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
} catch (_e) { /* audit best-effort */ }
|
|
498
|
+
}
|
|
499
|
+
contentSafety = null;
|
|
500
|
+
} else {
|
|
501
|
+
contentSafety = opts.contentSafety;
|
|
502
|
+
}
|
|
503
|
+
var onServe = opts.onServe || null;
|
|
504
|
+
var audit = opts.audit || null;
|
|
505
|
+
var auditSuccess = cfg.auditSuccess;
|
|
506
|
+
var auditFailures = cfg.auditFailures;
|
|
507
|
+
var acceptRanges = cfg.acceptRanges;
|
|
508
|
+
var perActorCap = cfg.maxBytesPerActorPerWindowMs;
|
|
509
|
+
var globalCap = cfg.maxBytesAllActorsPerWindowMs;
|
|
510
|
+
var bandwidthWindowMs = cfg.bandwidthWindowMs;
|
|
511
|
+
var concurrencyCap = cfg.maxConcurrentDownloadsPerActor;
|
|
512
|
+
var maxIdleMs = cfg.maxIdleMs;
|
|
513
|
+
|
|
514
|
+
var emitAudit = validateOpts.makeAuditEmitter(audit);
|
|
515
|
+
|
|
516
|
+
// In-memory revoke set (operator can wire revokeStore for cluster-shared
|
|
517
|
+
// revocation; this Map is only used when no store is wired and gives
|
|
518
|
+
// single-process operators a working force-revoke without requiring cache).
|
|
519
|
+
var localRevoked = new Set();
|
|
520
|
+
|
|
521
|
+
function _emitObs(name, value, labels) {
|
|
522
|
+
observability().safeEvent(name, value, labels || {});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Per-instance counters for serve.stats(). Cluster-shared counters live in
|
|
526
|
+
// observability; these are local snapshots for a single process.
|
|
527
|
+
var stats = {
|
|
528
|
+
requestsServed: 0,
|
|
529
|
+
bytesServed: 0,
|
|
530
|
+
etagHits: 0,
|
|
531
|
+
rangeRequests: 0,
|
|
532
|
+
permissionDenied: 0,
|
|
533
|
+
quotaRejected: 0,
|
|
534
|
+
failures: 0,
|
|
535
|
+
};
|
|
191
536
|
|
|
192
537
|
function _cacheControlFor(urlPath) {
|
|
193
538
|
if (hashedPattern.test(urlPath)) {
|
|
@@ -196,15 +541,51 @@ function create(opts) {
|
|
|
196
541
|
return "public, max-age=" + defaultMaxAge;
|
|
197
542
|
}
|
|
198
543
|
|
|
199
|
-
|
|
544
|
+
async function _isRevoked(key) {
|
|
545
|
+
if (revokeStore) {
|
|
546
|
+
try { return !!(await revokeStore.isRevoked(key)); }
|
|
547
|
+
catch (_e) { return false; }
|
|
548
|
+
}
|
|
549
|
+
return localRevoked.has(key);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function _checkRetention(absPath, ctx) {
|
|
553
|
+
if (!retention) return true;
|
|
554
|
+
try { return !!(await retention.isServable(absPath, ctx)); }
|
|
555
|
+
catch (_e) { return false; }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function _checkPermission(req) {
|
|
559
|
+
if (!permissions) return { ok: true };
|
|
560
|
+
try {
|
|
561
|
+
var ok = await permissions.check(req, "static.serve");
|
|
562
|
+
return { ok: !!ok };
|
|
563
|
+
} catch (_e) {
|
|
564
|
+
return { ok: false, error: _e };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function _checkMimeAllowlist(absPath, meta) {
|
|
569
|
+
if (allowedFileTypes.length === 0 || !fileType) return { ok: true };
|
|
570
|
+
var sample;
|
|
571
|
+
try { sample = await fsp.readFile(absPath, { flag: "r" }); }
|
|
572
|
+
catch (_e) { return { ok: false, reason: "read-failed" }; }
|
|
573
|
+
var detected = fileType.detect(sample.slice(0, C.BYTES.kib(64))) || {};
|
|
574
|
+
if (!detected.mime) return { ok: false, reason: "indeterminate" };
|
|
575
|
+
if (allowedFileTypes.indexOf(detected.mime) === -1) {
|
|
576
|
+
return { ok: false, reason: "not-allowed", detected: detected.mime };
|
|
577
|
+
}
|
|
578
|
+
void meta;
|
|
579
|
+
return { ok: true, detected: detected.mime };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function middleware(req, res, next) {
|
|
200
583
|
if (req.method !== "GET" && req.method !== "HEAD") return next();
|
|
201
584
|
|
|
202
|
-
// Strip query string + mount path before resolving against root.
|
|
203
585
|
var urlPath = (req.url || "").split("?")[0];
|
|
204
586
|
if (mountPath && urlPath.indexOf(mountPath) === 0) {
|
|
205
587
|
urlPath = urlPath.slice(mountPath.length) || "/";
|
|
206
588
|
}
|
|
207
|
-
// Decode percent-encoded path. Reject decoding failures (malformed URI).
|
|
208
589
|
var decoded;
|
|
209
590
|
try { decoded = decodeURIComponent(urlPath); }
|
|
210
591
|
catch (_e) { return next(); }
|
|
@@ -212,7 +593,24 @@ function create(opts) {
|
|
|
212
593
|
var absPath = _resolveSafe(root, decoded);
|
|
213
594
|
if (!absPath) return next();
|
|
214
595
|
|
|
215
|
-
|
|
596
|
+
var actorCtx = requestHelpers.extractActorContext(req);
|
|
597
|
+
var actorKey = _actorKeyFromContext(actorCtx);
|
|
598
|
+
|
|
599
|
+
// Permission gate (403)
|
|
600
|
+
var perm = await _checkPermission(req);
|
|
601
|
+
if (!perm.ok) {
|
|
602
|
+
stats.permissionDenied += 1;
|
|
603
|
+
_emitObs("staticServe.permission_denied", 1, { route: urlPath });
|
|
604
|
+
if (auditFailures) {
|
|
605
|
+
emitAudit("staticServe.serve.failure", Object.assign({
|
|
606
|
+
outcome: "failure", reason: "permission_denied", resource: urlPath,
|
|
607
|
+
}, actorCtx));
|
|
608
|
+
}
|
|
609
|
+
return _writeError(res, HTTP.FORBIDDEN, "permission_denied",
|
|
610
|
+
"Forbidden");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Stat first to discover directory → index file.
|
|
216
614
|
var stat;
|
|
217
615
|
try { stat = await fsp.stat(absPath); }
|
|
218
616
|
catch (_e) { return next(); }
|
|
@@ -221,52 +619,419 @@ function create(opts) {
|
|
|
221
619
|
absPath = path.join(absPath, indexFile);
|
|
222
620
|
}
|
|
223
621
|
|
|
622
|
+
// Force-revoke (404 — opaque to clients)
|
|
623
|
+
if (await _isRevoked(absPath)) {
|
|
624
|
+
stats.failures += 1;
|
|
625
|
+
_emitObs("staticServe.revoked", 1, { route: urlPath });
|
|
626
|
+
if (auditFailures) {
|
|
627
|
+
emitAudit("staticServe.serve.failure", Object.assign({
|
|
628
|
+
outcome: "failure", reason: "revoked", resource: urlPath,
|
|
629
|
+
}, actorCtx));
|
|
630
|
+
}
|
|
631
|
+
return _writeError(res, HTTP.NOT_FOUND, "not_found", "Not Found");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Compliance retention (451)
|
|
635
|
+
if (!(await _checkRetention(absPath, actorCtx))) {
|
|
636
|
+
stats.failures += 1;
|
|
637
|
+
_emitObs("staticServe.retention_blocked", 1, { route: urlPath });
|
|
638
|
+
if (auditFailures) {
|
|
639
|
+
emitAudit("staticServe.serve.failure", Object.assign({
|
|
640
|
+
outcome: "failure", reason: "retention_blocked", resource: urlPath,
|
|
641
|
+
}, actorCtx));
|
|
642
|
+
}
|
|
643
|
+
return _writeError(res, HTTP.UNAVAILABLE_FOR_LEGAL_REASONS,
|
|
644
|
+
"retention_blocked", "Unavailable For Legal Reasons");
|
|
645
|
+
}
|
|
646
|
+
|
|
224
647
|
var meta = await _readMeta(absPath);
|
|
225
648
|
if (!meta) return next();
|
|
226
649
|
|
|
650
|
+
// MIME allowlist (415) — checked before sending bytes so a misnamed
|
|
651
|
+
// .txt holding a binary payload is refused at serve time.
|
|
652
|
+
if (allowedFileTypes.length > 0) {
|
|
653
|
+
var mimeCheck = await _checkMimeAllowlist(absPath, meta);
|
|
654
|
+
if (!mimeCheck.ok) {
|
|
655
|
+
stats.failures += 1;
|
|
656
|
+
_emitObs("staticServe.mime_rejected", 1, { route: urlPath, reason: mimeCheck.reason });
|
|
657
|
+
if (auditFailures) {
|
|
658
|
+
emitAudit("staticServe.serve.failure", Object.assign({
|
|
659
|
+
outcome: "failure", reason: "mime_rejected", resource: urlPath,
|
|
660
|
+
detectedMime: mimeCheck.detected || null,
|
|
661
|
+
}, actorCtx));
|
|
662
|
+
}
|
|
663
|
+
return _writeError(res, HTTP.UNSUPPORTED_MEDIA_TYPE,
|
|
664
|
+
"mime_rejected", "Unsupported Media Type");
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Content-safety gate — operator-supplied per-extension gate
|
|
669
|
+
// (b.guardCsv.gate / b.guardHtml.gate / etc.). Reads the file once
|
|
670
|
+
// up to maxRuntimeMs and routes the bytes through the gate's
|
|
671
|
+
// check() before serving. The gate's decision is honored:
|
|
672
|
+
// - serve → continue with the original bytes
|
|
673
|
+
// - sanitize → continue with decision.sanitized
|
|
674
|
+
// - refuse → 415 / opaque to clients
|
|
675
|
+
// - audit-only / warn → continue (gate emits to audit)
|
|
676
|
+
var gateBytesOverride = null;
|
|
677
|
+
if (contentSafety) {
|
|
678
|
+
var ext = path.extname(absPath).toLowerCase();
|
|
679
|
+
var safetyGate = contentSafety[ext];
|
|
680
|
+
if (safetyGate && typeof safetyGate.check === "function") {
|
|
681
|
+
var gateBuf;
|
|
682
|
+
try { gateBuf = await fsp.readFile(absPath); }
|
|
683
|
+
catch (_e) {
|
|
684
|
+
stats.failures += 1;
|
|
685
|
+
return _writeError(res, HTTP.INTERNAL_SERVER_ERROR,
|
|
686
|
+
"read_failed", "Internal Server Error");
|
|
687
|
+
}
|
|
688
|
+
var gateDecision;
|
|
689
|
+
try {
|
|
690
|
+
gateDecision = await safetyGate.check({
|
|
691
|
+
bytes: gateBuf,
|
|
692
|
+
contentType: _contentTypeFor(absPath, contentTypes),
|
|
693
|
+
filename: path.basename(absPath),
|
|
694
|
+
actor: actorCtx,
|
|
695
|
+
route: urlPath,
|
|
696
|
+
direction: "outbound",
|
|
697
|
+
req: req,
|
|
698
|
+
});
|
|
699
|
+
} catch (gateErr) {
|
|
700
|
+
stats.failures += 1;
|
|
701
|
+
_emitObs("staticServe.content_safety_threw", 1, { route: urlPath });
|
|
702
|
+
if (auditFailures) {
|
|
703
|
+
emitAudit("staticServe.serve.failure", Object.assign({
|
|
704
|
+
outcome: "failure", reason: "content_safety_threw", resource: urlPath,
|
|
705
|
+
error: gateErr && gateErr.message,
|
|
706
|
+
}, actorCtx));
|
|
707
|
+
}
|
|
708
|
+
return _writeError(res, HTTP.INTERNAL_SERVER_ERROR,
|
|
709
|
+
"content_safety_threw", "Internal Server Error");
|
|
710
|
+
}
|
|
711
|
+
if (!gateDecision.ok || gateDecision.action === "refuse") {
|
|
712
|
+
stats.failures += 1;
|
|
713
|
+
_emitObs("staticServe.content_safety_refused", 1, { route: urlPath });
|
|
714
|
+
if (auditFailures) {
|
|
715
|
+
emitAudit("staticServe.serve.failure", Object.assign({
|
|
716
|
+
outcome: "failure", reason: "content_safety_refused",
|
|
717
|
+
resource: urlPath, ext: ext,
|
|
718
|
+
issues: gateContract.summarizeIssues(gateDecision.issues),
|
|
719
|
+
}, actorCtx));
|
|
720
|
+
}
|
|
721
|
+
return _writeError(res, HTTP.UNSUPPORTED_MEDIA_TYPE,
|
|
722
|
+
"content_safety_refused", "Unsupported Media Type");
|
|
723
|
+
}
|
|
724
|
+
if (gateDecision.action === "sanitize" && gateDecision.sanitized) {
|
|
725
|
+
gateBytesOverride = gateDecision.sanitized;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
227
730
|
var cacheControl = _cacheControlFor(urlPath);
|
|
228
731
|
|
|
229
|
-
|
|
230
|
-
|
|
732
|
+
var headersIn = req.headers || {};
|
|
733
|
+
|
|
734
|
+
// Conditional: If-None-Match (304)
|
|
735
|
+
var ifNone = headersIn["if-none-match"];
|
|
231
736
|
if (ifNone && ifNone === meta.etag) {
|
|
232
|
-
|
|
737
|
+
stats.etagHits += 1;
|
|
738
|
+
_emitObs("staticServe.etag_hits", 1, { route: urlPath });
|
|
739
|
+
res.writeHead(HTTP.NOT_MODIFIED, {
|
|
740
|
+
"ETag": meta.etag,
|
|
741
|
+
"Cache-Control": cacheControl,
|
|
742
|
+
"Last-Modified": meta.lastModified,
|
|
743
|
+
});
|
|
744
|
+
return res.end();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Conditional: If-Match (412 if no match — strong validator only)
|
|
748
|
+
var ifMatch = headersIn["if-match"];
|
|
749
|
+
if (ifMatch && ifMatch !== "*" && ifMatch !== meta.etag) {
|
|
750
|
+
stats.failures += 1;
|
|
751
|
+
_emitObs("staticServe.precondition_failed", 1, { route: urlPath, header: "if-match" });
|
|
752
|
+
return _writeError(res, HTTP.PRECONDITION_FAILED || 412,
|
|
753
|
+
"precondition_failed", "Precondition Failed");
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Conditional: If-Modified-Since (304)
|
|
757
|
+
var ifModSince = headersIn["if-modified-since"];
|
|
758
|
+
if (ifModSince) {
|
|
759
|
+
var ims = Date.parse(ifModSince);
|
|
760
|
+
if (isFinite(ims) && Math.floor(meta.mtimeMs / C.TIME.seconds(1)) <= Math.floor(ims / C.TIME.seconds(1))) {
|
|
761
|
+
stats.etagHits += 1;
|
|
762
|
+
_emitObs("staticServe.if_modified_since_hits", 1, { route: urlPath });
|
|
763
|
+
res.writeHead(HTTP.NOT_MODIFIED, {
|
|
764
|
+
"ETag": meta.etag,
|
|
765
|
+
"Cache-Control": cacheControl,
|
|
766
|
+
"Last-Modified": meta.lastModified,
|
|
767
|
+
});
|
|
768
|
+
return res.end();
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Conditional: If-Unmodified-Since (412)
|
|
773
|
+
var ifUnmodSince = headersIn["if-unmodified-since"];
|
|
774
|
+
if (ifUnmodSince) {
|
|
775
|
+
var ius = Date.parse(ifUnmodSince);
|
|
776
|
+
if (isFinite(ius) && Math.floor(meta.mtimeMs / C.TIME.seconds(1)) > Math.floor(ius / C.TIME.seconds(1))) {
|
|
777
|
+
stats.failures += 1;
|
|
778
|
+
_emitObs("staticServe.precondition_failed", 1, { route: urlPath, header: "if-unmodified-since" });
|
|
779
|
+
return _writeError(res, HTTP.PRECONDITION_FAILED,
|
|
780
|
+
"precondition_failed", "Precondition Failed");
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Range parsing
|
|
785
|
+
var range = null;
|
|
786
|
+
if (acceptRanges) {
|
|
787
|
+
var raw = headersIn["range"];
|
|
788
|
+
if (raw) {
|
|
789
|
+
range = _parseRangeHeader(raw, meta.size);
|
|
790
|
+
if (range && (range.malformed || range.multi)) {
|
|
791
|
+
stats.failures += 1;
|
|
792
|
+
_emitObs("staticServe.range_invalid", 1, { route: urlPath });
|
|
793
|
+
return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_not_satisfiable",
|
|
794
|
+
"Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
|
|
795
|
+
}
|
|
796
|
+
if (range && range.unsatisfiable) {
|
|
797
|
+
stats.failures += 1;
|
|
798
|
+
_emitObs("staticServe.range_invalid", 1, { route: urlPath });
|
|
799
|
+
return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_not_satisfiable",
|
|
800
|
+
"Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
|
|
801
|
+
}
|
|
802
|
+
if (range) {
|
|
803
|
+
stats.rangeRequests += 1;
|
|
804
|
+
_emitObs("staticServe.range_requests", 1, { route: urlPath });
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
var sendBytes = range ? range.length : meta.size;
|
|
810
|
+
|
|
811
|
+
// Concurrency gate (429)
|
|
812
|
+
var concCheck = await _checkConcurrencyCap(cache, actorKey, concurrencyCap);
|
|
813
|
+
if (!concCheck.ok) {
|
|
814
|
+
stats.quotaRejected += 1;
|
|
815
|
+
_emitObs("staticServe.concurrency_rejected", 1, { route: urlPath });
|
|
816
|
+
if (auditFailures) {
|
|
817
|
+
emitAudit("staticServe.serve.failure", Object.assign({
|
|
818
|
+
outcome: "failure", reason: "concurrency_cap", resource: urlPath,
|
|
819
|
+
current: concCheck.current, cap: concCheck.cap,
|
|
820
|
+
}, actorCtx));
|
|
821
|
+
}
|
|
822
|
+
return _writeError(res, HTTP.TOO_MANY_REQUESTS,
|
|
823
|
+
"concurrency_cap", "Too Many Requests",
|
|
824
|
+
{ "Retry-After": "5" });
|
|
233
825
|
}
|
|
234
826
|
|
|
827
|
+
// Bandwidth gate (429)
|
|
828
|
+
var bwCheck = await _checkBandwidthQuota(cache, actorKey, perActorCap, globalCap, bandwidthWindowMs, sendBytes);
|
|
829
|
+
if (!bwCheck.ok) {
|
|
830
|
+
stats.quotaRejected += 1;
|
|
831
|
+
_emitObs("staticServe.bandwidth_rejected", 1, { route: urlPath, scope: bwCheck.scope });
|
|
832
|
+
if (auditFailures) {
|
|
833
|
+
emitAudit("staticServe.serve.failure", Object.assign({
|
|
834
|
+
outcome: "failure", reason: "bandwidth_quota", resource: urlPath,
|
|
835
|
+
scope: bwCheck.scope, used: bwCheck.used, cap: bwCheck.cap,
|
|
836
|
+
}, actorCtx));
|
|
837
|
+
}
|
|
838
|
+
return _writeError(res, HTTP.TOO_MANY_REQUESTS,
|
|
839
|
+
"bandwidth_quota", "Too Many Requests",
|
|
840
|
+
{ "Retry-After": String(bwCheck.retryAfter) });
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
var status = range ? 206 : HTTP.OK;
|
|
235
844
|
var headers = {
|
|
236
845
|
"Content-Type": _contentTypeFor(absPath, contentTypes),
|
|
237
|
-
"Content-Length":
|
|
846
|
+
"Content-Length": sendBytes,
|
|
238
847
|
"ETag": meta.etag,
|
|
239
848
|
"Cache-Control": cacheControl,
|
|
240
|
-
|
|
241
|
-
// required by clients but consumers can read it from response
|
|
242
|
-
// headers when they want to embed integrity in subsequent pages.
|
|
849
|
+
"Last-Modified": meta.lastModified,
|
|
243
850
|
"X-Integrity": meta.integrity,
|
|
244
851
|
};
|
|
852
|
+
if (acceptRanges) headers["Accept-Ranges"] = "bytes";
|
|
853
|
+
if (range) headers["Content-Range"] = "bytes " + range.start + "-" + range.end + "/" + meta.size;
|
|
854
|
+
|
|
855
|
+
// onServe hook — operator can mutate headers / set extra fields.
|
|
856
|
+
if (onServe) {
|
|
857
|
+
try {
|
|
858
|
+
await onServe({
|
|
859
|
+
req: req, res: res, absPath: absPath, urlPath: urlPath,
|
|
860
|
+
size: meta.size, sendBytes: sendBytes, range: range,
|
|
861
|
+
headers: headers, actor: actorCtx,
|
|
862
|
+
});
|
|
863
|
+
} catch (e) {
|
|
864
|
+
stats.failures += 1;
|
|
865
|
+
_emitObs("staticServe.onServe_threw", 1, { route: urlPath });
|
|
866
|
+
if (auditFailures) {
|
|
867
|
+
emitAudit("staticServe.serve.failure", Object.assign({
|
|
868
|
+
outcome: "failure", reason: "onServe_threw", resource: urlPath,
|
|
869
|
+
error: e && e.message,
|
|
870
|
+
}, actorCtx));
|
|
871
|
+
}
|
|
872
|
+
return _writeError(res, HTTP.INTERNAL_SERVER_ERROR, "onServe_threw",
|
|
873
|
+
"Internal Server Error");
|
|
874
|
+
}
|
|
875
|
+
}
|
|
245
876
|
|
|
246
877
|
if (req.method === "HEAD") {
|
|
247
|
-
res.writeHead(
|
|
248
|
-
|
|
878
|
+
res.writeHead(status, headers);
|
|
879
|
+
res.end();
|
|
880
|
+
stats.requestsServed += 1;
|
|
881
|
+
_emitObs("staticServe.requests_served", 1, { route: urlPath, method: "HEAD" });
|
|
882
|
+
if (auditSuccess) {
|
|
883
|
+
emitAudit("staticServe.serve.success", Object.assign({
|
|
884
|
+
outcome: "success", resource: urlPath, method: "HEAD",
|
|
885
|
+
size: meta.size, contentType: headers["Content-Type"],
|
|
886
|
+
}, actorCtx));
|
|
887
|
+
}
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Sanitized override path — content-safety gate replaced the
|
|
892
|
+
// bytes; emit them directly without re-reading the file. Bypasses
|
|
893
|
+
// range / idle-timer machinery (the override is already in memory
|
|
894
|
+
// and Range over a sanitized variant doesn't have a useful
|
|
895
|
+
// contract — sanitization changes byte offsets).
|
|
896
|
+
if (gateBytesOverride) {
|
|
897
|
+
var overrideHeaders = Object.assign({}, headers, {
|
|
898
|
+
"Content-Length": gateBytesOverride.length,
|
|
899
|
+
});
|
|
900
|
+
delete overrideHeaders["Content-Range"];
|
|
901
|
+
res.writeHead(HTTP.OK, overrideHeaders);
|
|
902
|
+
res.end(gateBytesOverride);
|
|
903
|
+
stats.requestsServed += 1;
|
|
904
|
+
stats.bytesServed += gateBytesOverride.length;
|
|
905
|
+
_emitObs("staticServe.requests_served", 1, { route: urlPath, method: "GET", sanitized: true });
|
|
906
|
+
_emitObs("staticServe.bytes_served", gateBytesOverride.length, { route: urlPath, sanitized: true });
|
|
907
|
+
if (auditSuccess) {
|
|
908
|
+
emitAudit("staticServe.serve.success", Object.assign({
|
|
909
|
+
outcome: "success", resource: urlPath, method: "GET",
|
|
910
|
+
size: gateBytesOverride.length, contentType: overrideHeaders["Content-Type"],
|
|
911
|
+
sanitized: true,
|
|
912
|
+
}, actorCtx));
|
|
913
|
+
}
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
res.writeHead(status, headers);
|
|
918
|
+
|
|
919
|
+
// Acquire concurrency slot (released on stream end / error / abort).
|
|
920
|
+
await _incConcurrency(cache, actorKey);
|
|
921
|
+
var slotReleased = false;
|
|
922
|
+
function releaseSlot() {
|
|
923
|
+
if (slotReleased) return;
|
|
924
|
+
slotReleased = true;
|
|
925
|
+
_decConcurrency(cache, actorKey).catch(function () {});
|
|
249
926
|
}
|
|
250
927
|
|
|
251
|
-
|
|
252
|
-
var
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
928
|
+
var streamOpts = range ? { start: range.start, end: range.end } : {};
|
|
929
|
+
var fileStream = fs.createReadStream(absPath, streamOpts);
|
|
930
|
+
|
|
931
|
+
// Idle timeout — close the connection if the client stalls. Pattern is
|
|
932
|
+
// a deadline-style debounce (clearTimeout + setTimeout) tied directly
|
|
933
|
+
// to the file-stream "data" event lifecycle; the safeAsync.debounce
|
|
934
|
+
// helper isn't yet ship-implemented, and pulling it through here would
|
|
935
|
+
// pre-allocate a closure for every served byte. Tracked for extraction.
|
|
936
|
+
var idleTimer = null;
|
|
937
|
+
function resetIdleTimer() {
|
|
938
|
+
if (idleTimer) clearTimeout(idleTimer); // allow:handrolled-debounce — file-stream idle deadline
|
|
939
|
+
idleTimer = setTimeout(function () {
|
|
940
|
+
try { fileStream.destroy(_err("IDLE_TIMEOUT", "client idle for " + maxIdleMs + "ms")); }
|
|
941
|
+
catch (_) { /* stream already torn down */ }
|
|
942
|
+
try { res.destroy(); } catch (_) { /* response already torn down */ }
|
|
943
|
+
}, maxIdleMs);
|
|
944
|
+
}
|
|
945
|
+
resetIdleTimer();
|
|
946
|
+
|
|
947
|
+
// Cancellation propagation: when the client disconnects mid-stream.
|
|
948
|
+
function onClientClose() {
|
|
949
|
+
try { fileStream.destroy(); } catch (_) { /* allow:silent-catch — stream already torn down */ }
|
|
950
|
+
releaseSlot();
|
|
951
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
952
|
+
}
|
|
953
|
+
req.on("aborted", onClientClose);
|
|
954
|
+
res.on("close", onClientClose);
|
|
955
|
+
|
|
956
|
+
var bytesSent = 0;
|
|
957
|
+
fileStream.on("data", function (chunk) {
|
|
958
|
+
bytesSent += chunk.length;
|
|
959
|
+
resetIdleTimer();
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
fileStream.on("error", function (e) {
|
|
963
|
+
stats.failures += 1;
|
|
964
|
+
_emitObs("staticServe.stream_error", 1, { route: urlPath });
|
|
965
|
+
if (auditFailures) {
|
|
966
|
+
emitAudit("staticServe.serve.failure", Object.assign({
|
|
967
|
+
outcome: "failure", reason: "stream_error", resource: urlPath,
|
|
968
|
+
error: e && e.message,
|
|
969
|
+
}, actorCtx));
|
|
970
|
+
}
|
|
971
|
+
try { res.destroy(e); } catch (_) { /* allow:silent-catch — response already torn down */ }
|
|
972
|
+
releaseSlot();
|
|
973
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
fileStream.on("end", function () {
|
|
977
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
978
|
+
stats.requestsServed += 1;
|
|
979
|
+
stats.bytesServed += bytesSent;
|
|
980
|
+
_emitObs("staticServe.requests_served", 1, { route: urlPath, method: "GET" });
|
|
981
|
+
_emitObs("staticServe.bytes_served", bytesSent, { route: urlPath });
|
|
982
|
+
_consumeBandwidth(cache, actorKey, perActorCap, globalCap, bandwidthWindowMs, bytesSent)
|
|
983
|
+
.catch(function () {});
|
|
984
|
+
if (auditSuccess) {
|
|
985
|
+
emitAudit("staticServe.serve.success", Object.assign({
|
|
986
|
+
outcome: "success", resource: urlPath, method: "GET",
|
|
987
|
+
size: bytesSent, contentType: headers["Content-Type"],
|
|
988
|
+
range: range ? { start: range.start, end: range.end } : null,
|
|
989
|
+
}, actorCtx));
|
|
990
|
+
}
|
|
991
|
+
releaseSlot();
|
|
257
992
|
});
|
|
258
|
-
|
|
993
|
+
|
|
994
|
+
fileStream.pipe(res);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Operator-facing handle: callable as middleware (back-compat) AND
|
|
998
|
+
// exposes serve-instance methods.
|
|
999
|
+
async function fn(req, res, next) { return middleware(req, res, next); }
|
|
1000
|
+
fn.middleware = middleware;
|
|
1001
|
+
fn.revoke = async function (key) {
|
|
1002
|
+
if (revokeStore && typeof revokeStore.revoke === "function") {
|
|
1003
|
+
await revokeStore.revoke(key);
|
|
1004
|
+
return { ok: true, key: key };
|
|
1005
|
+
}
|
|
1006
|
+
localRevoked.add(key);
|
|
1007
|
+
return { ok: true, key: key };
|
|
1008
|
+
};
|
|
1009
|
+
fn.unrevoke = async function (key) {
|
|
1010
|
+
if (revokeStore && typeof revokeStore.unrevoke === "function") {
|
|
1011
|
+
await revokeStore.unrevoke(key);
|
|
1012
|
+
return { ok: true, key: key };
|
|
1013
|
+
}
|
|
1014
|
+
localRevoked.delete(key);
|
|
1015
|
+
return { ok: true, key: key };
|
|
1016
|
+
};
|
|
1017
|
+
fn.stats = function () {
|
|
1018
|
+
return Object.assign({}, stats);
|
|
1019
|
+
};
|
|
1020
|
+
fn.invalidateMeta = function (key) {
|
|
1021
|
+
_metaCache.delete(key);
|
|
1022
|
+
return { ok: true, key: key };
|
|
259
1023
|
};
|
|
1024
|
+
return fn;
|
|
260
1025
|
}
|
|
261
1026
|
|
|
262
|
-
|
|
263
|
-
function _resetCacheForTest() { _cache.clear(); }
|
|
1027
|
+
function _resetCacheForTest() { _metaCache.clear(); }
|
|
264
1028
|
|
|
265
1029
|
module.exports = {
|
|
266
|
-
create:
|
|
267
|
-
integrity:
|
|
268
|
-
DEFAULT_MAX_AGE_SEC:
|
|
269
|
-
IMMUTABLE_MAX_AGE_SEC:
|
|
1030
|
+
create: create,
|
|
1031
|
+
integrity: integrity,
|
|
1032
|
+
DEFAULT_MAX_AGE_SEC: DEFAULT_MAX_AGE_SEC,
|
|
1033
|
+
IMMUTABLE_MAX_AGE_SEC: IMMUTABLE_MAX_AGE_SEC,
|
|
270
1034
|
DEFAULT_HASHED_PATTERN: DEFAULT_HASHED_PATTERN,
|
|
271
|
-
_resetCacheForTest:
|
|
1035
|
+
_resetCacheForTest: _resetCacheForTest,
|
|
1036
|
+
_parseRangeHeader: _parseRangeHeader,
|
|
272
1037
|
};
|