@blamejs/core 0.4.1
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 +230 -0
- package/LICENSE +201 -0
- package/LTS-CALENDAR.md +29 -0
- package/MIGRATING.md +7 -0
- package/NOTICE +59 -0
- package/README.md +100 -0
- package/bin/blamejs.js +13 -0
- package/index.js +253 -0
- package/lib/api-key.js +705 -0
- package/lib/api-snapshot.js +335 -0
- package/lib/app-shutdown.js +381 -0
- package/lib/app.js +364 -0
- package/lib/atomic-file.js +525 -0
- package/lib/audit-chain.js +168 -0
- package/lib/audit-sign.js +319 -0
- package/lib/audit-tools.js +682 -0
- package/lib/audit.js +753 -0
- package/lib/auth/jwt.js +280 -0
- package/lib/auth/oauth.js +691 -0
- package/lib/auth/passkey.js +185 -0
- package/lib/auth/password.js +139 -0
- package/lib/auth/totp.js +17 -0
- package/lib/auth-header.js +81 -0
- package/lib/backup/bundle.js +219 -0
- package/lib/backup/crypto.js +174 -0
- package/lib/backup/index.js +490 -0
- package/lib/backup/manifest.js +275 -0
- package/lib/bundler.js +295 -0
- package/lib/cache.js +819 -0
- package/lib/chain-writer.js +234 -0
- package/lib/cli-helpers.js +201 -0
- package/lib/cli.js +1377 -0
- package/lib/cluster-provider-db.js +245 -0
- package/lib/cluster-storage.js +166 -0
- package/lib/cluster.js +691 -0
- package/lib/consent.js +222 -0
- package/lib/constants.js +186 -0
- package/lib/cookies.js +293 -0
- package/lib/credential-hash.js +303 -0
- package/lib/crypto-field.js +159 -0
- package/lib/crypto.js +250 -0
- package/lib/db-query.js +297 -0
- package/lib/db-schema.js +250 -0
- package/lib/db.js +1054 -0
- package/lib/deprecate.js +226 -0
- package/lib/dev.js +324 -0
- package/lib/error-page.js +424 -0
- package/lib/events.js +135 -0
- package/lib/external-db.js +422 -0
- package/lib/forms.js +378 -0
- package/lib/framework-error.js +189 -0
- package/lib/framework-schema.js +604 -0
- package/lib/handlers.js +350 -0
- package/lib/html-balance.js +227 -0
- package/lib/http-client.js +615 -0
- package/lib/i18n.js +780 -0
- package/lib/jobs.js +181 -0
- package/lib/lazy-require.js +48 -0
- package/lib/log-stream-local.js +137 -0
- package/lib/log-stream-webhook.js +170 -0
- package/lib/log-stream.js +211 -0
- package/lib/log.js +355 -0
- package/lib/mail-bounce.js +507 -0
- package/lib/mail.js +701 -0
- package/lib/metrics.js +647 -0
- package/lib/middleware/api-encrypt.js +553 -0
- package/lib/middleware/attach-user.js +156 -0
- package/lib/middleware/body-parser.js +883 -0
- package/lib/middleware/bot-guard.js +148 -0
- package/lib/middleware/compression.js +436 -0
- package/lib/middleware/cors.js +236 -0
- package/lib/middleware/csp-nonce.js +332 -0
- package/lib/middleware/csrf-protect.js +275 -0
- package/lib/middleware/error-handler.js +46 -0
- package/lib/middleware/health.js +358 -0
- package/lib/middleware/index.js +52 -0
- package/lib/middleware/rate-limit.js +319 -0
- package/lib/middleware/request-id.js +53 -0
- package/lib/middleware/require-auth.js +95 -0
- package/lib/middleware/security-headers.js +91 -0
- package/lib/migrations.js +353 -0
- package/lib/mtls-ca.js +333 -0
- package/lib/mtls-engine-default.js +285 -0
- package/lib/nonce-store.js +177 -0
- package/lib/notify.js +643 -0
- package/lib/ntp-check.js +178 -0
- package/lib/object-store/azure-blob.js +467 -0
- package/lib/object-store/gcs.js +469 -0
- package/lib/object-store/http-put.js +153 -0
- package/lib/object-store/index.js +140 -0
- package/lib/object-store/local.js +163 -0
- package/lib/object-store/retry.js +15 -0
- package/lib/object-store/sigv4.js +535 -0
- package/lib/observability.js +114 -0
- package/lib/pagination.js +371 -0
- package/lib/parsers/index.js +64 -0
- package/lib/parsers/safe-csv.js +224 -0
- package/lib/parsers/safe-env.js +614 -0
- package/lib/parsers/safe-toml.js +745 -0
- package/lib/parsers/safe-xml.js +379 -0
- package/lib/parsers/safe-yaml.js +977 -0
- package/lib/permissions.js +430 -0
- package/lib/pqc-agent.js +85 -0
- package/lib/pqc-gate.js +266 -0
- package/lib/protocol-dispatcher.js +144 -0
- package/lib/queue-local.js +327 -0
- package/lib/queue.js +430 -0
- package/lib/redact.js +192 -0
- package/lib/render.js +193 -0
- package/lib/request-helpers.js +178 -0
- package/lib/restore-bundle.js +239 -0
- package/lib/restore-rollback.js +254 -0
- package/lib/restore.js +301 -0
- package/lib/retry.js +329 -0
- package/lib/router.js +437 -0
- package/lib/safe-async.js +520 -0
- package/lib/safe-buffer.js +162 -0
- package/lib/safe-json.js +532 -0
- package/lib/safe-schema.js +1176 -0
- package/lib/safe-sql.js +157 -0
- package/lib/safe-url.js +109 -0
- package/lib/scheduler.js +680 -0
- package/lib/seeders.js +622 -0
- package/lib/session.js +304 -0
- package/lib/slug.js +243 -0
- package/lib/static.js +268 -0
- package/lib/storage.js +470 -0
- package/lib/subject.js +281 -0
- package/lib/template.js +781 -0
- package/lib/testing.js +621 -0
- package/lib/totp.js +285 -0
- package/lib/tracing.js +484 -0
- package/lib/validate-opts.js +56 -0
- package/lib/vault/index.js +299 -0
- package/lib/vault/passphrase-ops.js +311 -0
- package/lib/vault/passphrase-source.js +198 -0
- package/lib/vault/rotate.js +761 -0
- package/lib/vault/wrap.js +289 -0
- package/lib/vendor/MANIFEST.json +84 -0
- package/lib/vendor/argon2/argon2.cjs +466 -0
- package/lib/vendor/argon2/argon2.d.cts +62 -0
- package/lib/vendor/argon2/package.json +1 -0
- package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
- package/lib/vendor/noble-ciphers.cjs +9 -0
- package/lib/vendor/pki.cjs +181 -0
- package/lib/vendor/simplewebauthn-server.cjs +328 -0
- package/lib/webhook.js +632 -0
- package/lib/websocket-channels.js +413 -0
- package/lib/websocket.js +833 -0
- package/package.json +39 -0
package/lib/ntp-check.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal SNTP client for boot-time clock-drift verification.
|
|
4
|
+
*
|
|
5
|
+
* Why: the audit chain's monotonicCounter orders events deterministically
|
|
6
|
+
* even if the wall clock jumps, but recordedAt is the human-readable
|
|
7
|
+
* timestamp auditors will rely on. A clock that's silently off by hours
|
|
8
|
+
* (container with no RTC sync, NTP daemon stopped) makes the audit trail
|
|
9
|
+
* misleading.
|
|
10
|
+
*
|
|
11
|
+
* What this does:
|
|
12
|
+
* - Sends a single SNTPv4 query to a configured server (default
|
|
13
|
+
* pool.ntp.org) over UDP port 123.
|
|
14
|
+
* - Computes drift = (server's transmit timestamp) - (local clock).
|
|
15
|
+
* - Returns the drift in milliseconds.
|
|
16
|
+
*
|
|
17
|
+
* What this does NOT do:
|
|
18
|
+
* - Continuous synchronization (use the OS NTP daemon for that).
|
|
19
|
+
* - Authenticated NTP (NTS, autokey).
|
|
20
|
+
* - Querying multiple servers and taking median (single-shot only).
|
|
21
|
+
*
|
|
22
|
+
* The framework's policy in db.init():
|
|
23
|
+
* - drift |x| < 5min → log info, continue
|
|
24
|
+
* - drift |x| in [5min,1hr) → log warning, continue
|
|
25
|
+
* - drift |x| >= 1hr → log fatal, exit (BLAMEJS_NTP_STRICT=1) or warn
|
|
26
|
+
* - NTP unreachable → log warning, continue (network may not allow UDP/123)
|
|
27
|
+
*/
|
|
28
|
+
var dgram = require("dgram");
|
|
29
|
+
var C = require("./constants");
|
|
30
|
+
|
|
31
|
+
// NTP epoch: 1900-01-01. Unix epoch: 1970-01-01. Offset: 70 years incl. 17
|
|
32
|
+
// leap days = 2,208,988,800 seconds.
|
|
33
|
+
var NTP_TO_UNIX_OFFSET_SECONDS = 2208988800;
|
|
34
|
+
|
|
35
|
+
var DEFAULT_SERVERS = ["pool.ntp.org", "time.cloudflare.com"];
|
|
36
|
+
var DEFAULT_PORT = 123;
|
|
37
|
+
var DEFAULT_TIMEOUT_MS = 3000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Query an NTP server once. Resolves with { driftMs, serverTimeMs } or
|
|
41
|
+
* rejects with { code, message } where code is one of:
|
|
42
|
+
* 'ntp/timeout' — server didn't reply within timeoutMs
|
|
43
|
+
* 'ntp/refused' — DNS/connection error
|
|
44
|
+
* 'ntp/bad-reply' — packet structure wrong
|
|
45
|
+
*/
|
|
46
|
+
function querySingle(server, opts) {
|
|
47
|
+
opts = opts || {};
|
|
48
|
+
var port = opts.port || DEFAULT_PORT;
|
|
49
|
+
var timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
50
|
+
|
|
51
|
+
return new Promise(function (resolve, reject) {
|
|
52
|
+
var socket = dgram.createSocket("udp4");
|
|
53
|
+
var settled = false;
|
|
54
|
+
|
|
55
|
+
function done(err, result) {
|
|
56
|
+
if (settled) return;
|
|
57
|
+
settled = true;
|
|
58
|
+
try { socket.close(); } catch (_e) { /* ignored */ }
|
|
59
|
+
if (err) reject(err); else resolve(result);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
var timer = setTimeout(function () {
|
|
63
|
+
done({ code: "ntp/timeout", message: "no reply from " + server + " within " + timeoutMs + "ms" });
|
|
64
|
+
}, timeoutMs);
|
|
65
|
+
timer.unref();
|
|
66
|
+
|
|
67
|
+
// SNTPv4 client request: 48-byte buffer, byte 0 = 0b00_100_011 = 0x23
|
|
68
|
+
// LI=0 (no warning), VN=4, Mode=3 (client). Other bytes zero.
|
|
69
|
+
var req = Buffer.alloc(48);
|
|
70
|
+
req[0] = 0x23;
|
|
71
|
+
var sendTimeMs = Date.now();
|
|
72
|
+
|
|
73
|
+
socket.on("error", function (e) {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
done({ code: "ntp/refused", message: server + ": " + e.message });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
socket.on("message", function (msg) {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
var receiveTimeMs = Date.now();
|
|
81
|
+
if (!Buffer.isBuffer(msg) || msg.length < 48) {
|
|
82
|
+
return done({ code: "ntp/bad-reply", message: "reply too short (" + (msg && msg.length) + " bytes)" });
|
|
83
|
+
}
|
|
84
|
+
// Bytes 40-47 = Transmit Timestamp (NTP epoch seconds.fraction)
|
|
85
|
+
var ntpSeconds = msg.readUInt32BE(40);
|
|
86
|
+
var ntpFraction = msg.readUInt32BE(44);
|
|
87
|
+
var serverUnixSeconds = ntpSeconds - NTP_TO_UNIX_OFFSET_SECONDS;
|
|
88
|
+
var fracMs = Math.round(ntpFraction / 0x100000000 * 1000);
|
|
89
|
+
var serverTimeMs = serverUnixSeconds * 1000 + fracMs;
|
|
90
|
+
|
|
91
|
+
// Round-trip-corrected drift: assume the server's reply transmit
|
|
92
|
+
// timestamp is approximately at the midpoint of our send/receive.
|
|
93
|
+
var midpointMs = sendTimeMs + (receiveTimeMs - sendTimeMs) / 2;
|
|
94
|
+
var driftMs = serverTimeMs - midpointMs;
|
|
95
|
+
|
|
96
|
+
done(null, { driftMs: driftMs, serverTimeMs: serverTimeMs, server: server });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
socket.send(req, 0, req.length, port, server, function (err) {
|
|
100
|
+
if (err) {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
done({ code: "ntp/refused", message: "send to " + server + ": " + err.message });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Try each server in turn; return the first successful drift measurement.
|
|
110
|
+
* Resolves null if all servers fail (caller decides whether that's fatal).
|
|
111
|
+
*/
|
|
112
|
+
async function checkDrift(opts) {
|
|
113
|
+
opts = opts || {};
|
|
114
|
+
var servers = opts.servers || DEFAULT_SERVERS;
|
|
115
|
+
var lastError = null;
|
|
116
|
+
for (var i = 0; i < servers.length; i++) {
|
|
117
|
+
try {
|
|
118
|
+
return await querySingle(servers[i], opts);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
lastError = e;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { driftMs: null, error: lastError };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Boot-time check that integrates with the framework's logging policy.
|
|
128
|
+
* Returns a result object with { ok, driftMs, severity, message }.
|
|
129
|
+
* Caller (db.init) decides whether to exit.
|
|
130
|
+
*/
|
|
131
|
+
async function bootCheck(opts) {
|
|
132
|
+
opts = opts || {};
|
|
133
|
+
var result = await checkDrift(opts);
|
|
134
|
+
if (result.driftMs === null) {
|
|
135
|
+
return {
|
|
136
|
+
ok: true, // unreachable NTP isn't a hard failure
|
|
137
|
+
severity: "warning",
|
|
138
|
+
driftMs: null,
|
|
139
|
+
message: "NTP unreachable: " + (result.error && result.error.message) +
|
|
140
|
+
" (continuing — set BLAMEJS_NTP_STRICT=1 to fail closed)",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
var absMs = Math.abs(result.driftMs);
|
|
144
|
+
var driftStr = (result.driftMs >= 0 ? "+" : "") + result.driftMs + "ms";
|
|
145
|
+
if (absMs >= C.TIME.hours(1)) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
severity: "fatal",
|
|
149
|
+
driftMs: result.driftMs,
|
|
150
|
+
server: result.server,
|
|
151
|
+
message: "clock drift " + driftStr + " from " + result.server + " (>= 1 hour) — refuse to boot",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (absMs >= C.TIME.minutes(5)) {
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
severity: "warning",
|
|
158
|
+
driftMs: result.driftMs,
|
|
159
|
+
server: result.server,
|
|
160
|
+
message: "clock drift " + driftStr + " from " + result.server + " (>= 5 minutes) — investigate",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
severity: "info",
|
|
166
|
+
driftMs: result.driftMs,
|
|
167
|
+
server: result.server,
|
|
168
|
+
message: "clock drift " + driftStr + " from " + result.server,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
querySingle: querySingle,
|
|
174
|
+
checkDrift: checkDrift,
|
|
175
|
+
bootCheck: bootCheck,
|
|
176
|
+
DEFAULT_SERVERS: DEFAULT_SERVERS,
|
|
177
|
+
NTP_TO_UNIX_OFFSET_SECONDS: NTP_TO_UNIX_OFFSET_SECONDS,
|
|
178
|
+
};
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Azure Blob Storage protocol adapter — Shared Key auth (account-level).
|
|
4
|
+
*
|
|
5
|
+
* Auth: HMAC-SHA256 over a canonicalized string-to-sign (different format
|
|
6
|
+
* from AWS SigV4 — Azure has its own signing scheme). Signature is
|
|
7
|
+
* base64-encoded; Authorization header: "SharedKey <account>:<sig>".
|
|
8
|
+
*
|
|
9
|
+
* Endpoint: https://<account>.blob.core.windows.net (override via
|
|
10
|
+
* config.endpoint for Azurite emulator / Azure Stack / private endpoints).
|
|
11
|
+
*
|
|
12
|
+
* Config:
|
|
13
|
+
* {
|
|
14
|
+
* accountName: 'mystorage' // required
|
|
15
|
+
* accountKey: '<base64 storage key>' // required (REST shared key)
|
|
16
|
+
* container: 'my-container' // required
|
|
17
|
+
* endpoint: 'https://...' // optional override
|
|
18
|
+
* apiVersion: '2024-08-04' // x-ms-version header
|
|
19
|
+
* timeoutMs: C.TIME.seconds(30)
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* Reference:
|
|
23
|
+
* https://learn.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key
|
|
24
|
+
* https://learn.microsoft.com/en-us/rest/api/storageservices/blob-service-rest-api
|
|
25
|
+
*
|
|
26
|
+
* Scope notes:
|
|
27
|
+
* - Auth: Shared Key only. SAS tokens (Shared Access Signatures) and
|
|
28
|
+
* Azure AD OAuth2 are not implemented; this covers the common
|
|
29
|
+
* server-to-storage case with rotated keys.
|
|
30
|
+
* - PutBlock + PutBlockList (multipart for >256MB blobs) is not
|
|
31
|
+
* implemented; uploads above that ceiling will fail at the API.
|
|
32
|
+
*/
|
|
33
|
+
var nodeCrypto = require("crypto");
|
|
34
|
+
var { URL } = require("url");
|
|
35
|
+
var { Readable } = require("stream");
|
|
36
|
+
var safeXml = require("../parsers/safe-xml");
|
|
37
|
+
var C = require("../constants");
|
|
38
|
+
var { ObjectStoreError } = require("../framework-error");
|
|
39
|
+
var httpClient = require("../http-client");
|
|
40
|
+
var safeUrl = require("../safe-url");
|
|
41
|
+
|
|
42
|
+
// Azure Blob list responses are commonly multi-thousand-key paginated
|
|
43
|
+
// payloads — well above the parser's default 1 MiB / 10K element ceilings.
|
|
44
|
+
var LIST_PARSE_OPTS = {
|
|
45
|
+
maxBytes: C.BYTES.mib(8),
|
|
46
|
+
maxElements: 50000,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function _arrayify(value) {
|
|
50
|
+
if (value == null) return [];
|
|
51
|
+
return Array.isArray(value) ? value : [value];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
var DEFAULT_API_VERSION = "2024-08-04";
|
|
55
|
+
|
|
56
|
+
// Service SAS expiry bounds. Azure doesn't enforce a hard max, but
|
|
57
|
+
// matching the SigV4 / V4 7-day cap keeps semantics uniform across
|
|
58
|
+
// backends and reflects best practice (long-lived SAS tokens are
|
|
59
|
+
// effectively credentials).
|
|
60
|
+
var SAS_DEFAULT_EXPIRES_SECONDS = 15 * 60;
|
|
61
|
+
var SAS_MAX_EXPIRES_SECONDS = 7 * 24 * 60 * 60;
|
|
62
|
+
var SAS_MIN_EXPIRES_SECONDS = 1;
|
|
63
|
+
|
|
64
|
+
var _err = ObjectStoreError.factory;
|
|
65
|
+
|
|
66
|
+
function _httpRequest(method, urlObj, headers, body, opts) {
|
|
67
|
+
return httpClient.request({
|
|
68
|
+
method: method,
|
|
69
|
+
url: urlObj,
|
|
70
|
+
headers: headers,
|
|
71
|
+
body: body,
|
|
72
|
+
idleTimeoutMs: opts && opts.timeoutMs,
|
|
73
|
+
maxResponseBytes: opts && opts.maxResponseBytes,
|
|
74
|
+
errorClass: ObjectStoreError,
|
|
75
|
+
allowedProtocols: opts && opts.allowedProtocols,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- Shared Key signing ----
|
|
80
|
+
//
|
|
81
|
+
// StringToSign for Blob (Shared Key):
|
|
82
|
+
// VERB + "\n" +
|
|
83
|
+
// Content-Encoding + "\n" +
|
|
84
|
+
// Content-Language + "\n" +
|
|
85
|
+
// Content-Length + "\n" +
|
|
86
|
+
// Content-MD5 + "\n" +
|
|
87
|
+
// Content-Type + "\n" +
|
|
88
|
+
// Date + "\n" +
|
|
89
|
+
// If-Modified-Since + "\n" +
|
|
90
|
+
// If-Match + "\n" +
|
|
91
|
+
// If-None-Match + "\n" +
|
|
92
|
+
// If-Unmodified-Since + "\n" +
|
|
93
|
+
// Range + "\n" +
|
|
94
|
+
// CanonicalizedHeaders +
|
|
95
|
+
// CanonicalizedResource
|
|
96
|
+
//
|
|
97
|
+
// CanonicalizedHeaders: x-ms-* headers, lowercased, sorted, "key:value\n"
|
|
98
|
+
// CanonicalizedResource: "/<account>/<container>/<blob>" + sorted query
|
|
99
|
+
// params each on their own line as "param:value\n"
|
|
100
|
+
|
|
101
|
+
function buildStringToSign(opts) {
|
|
102
|
+
var headers = opts.headers || {};
|
|
103
|
+
var url = opts.url instanceof URL ? opts.url : new URL(opts.url);
|
|
104
|
+
|
|
105
|
+
var canonicalHeaders = (function () {
|
|
106
|
+
var pairs = [];
|
|
107
|
+
Object.keys(headers).forEach(function (k) {
|
|
108
|
+
var lk = k.toLowerCase();
|
|
109
|
+
if (lk.indexOf("x-ms-") === 0) {
|
|
110
|
+
pairs.push([lk, String(headers[k]).trim().replace(/\s+/g, " ")]);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
pairs.sort(function (a, b) { return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; });
|
|
114
|
+
return pairs.map(function (p) { return p[0] + ":" + p[1]; }).join("\n");
|
|
115
|
+
})();
|
|
116
|
+
|
|
117
|
+
var canonicalResource = (function () {
|
|
118
|
+
// /<account>/<rest of path>
|
|
119
|
+
// Plus sorted query params, each "name:value\n"
|
|
120
|
+
var resourcePath = "/" + opts.accountName + url.pathname;
|
|
121
|
+
var paramPairs = [];
|
|
122
|
+
url.searchParams.forEach(function (v, k) {
|
|
123
|
+
paramPairs.push([k.toLowerCase(), v]);
|
|
124
|
+
});
|
|
125
|
+
paramPairs.sort(function (a, b) {
|
|
126
|
+
if (a[0] !== b[0]) return a[0] < b[0] ? -1 : 1;
|
|
127
|
+
return a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0;
|
|
128
|
+
});
|
|
129
|
+
// Group by name; multiple values comma-separated per Azure rules
|
|
130
|
+
var grouped = {};
|
|
131
|
+
paramPairs.forEach(function (p) {
|
|
132
|
+
if (!grouped[p[0]]) grouped[p[0]] = [];
|
|
133
|
+
grouped[p[0]].push(p[1]);
|
|
134
|
+
});
|
|
135
|
+
var queryLines = Object.keys(grouped).sort().map(function (name) {
|
|
136
|
+
return "\n" + name + ":" + grouped[name].join(",");
|
|
137
|
+
}).join("");
|
|
138
|
+
return resourcePath + queryLines;
|
|
139
|
+
})();
|
|
140
|
+
|
|
141
|
+
return [
|
|
142
|
+
opts.method.toUpperCase(),
|
|
143
|
+
headers["Content-Encoding"] || "",
|
|
144
|
+
headers["Content-Language"] || "",
|
|
145
|
+
headers["Content-Length"] && headers["Content-Length"] !== "0"
|
|
146
|
+
? headers["Content-Length"] : "", // "0" → empty (Azure quirk)
|
|
147
|
+
headers["Content-MD5"] || "",
|
|
148
|
+
headers["Content-Type"] || "",
|
|
149
|
+
"", // Date line — empty when x-ms-date is set
|
|
150
|
+
headers["If-Modified-Since"] || "",
|
|
151
|
+
headers["If-Match"] || "",
|
|
152
|
+
headers["If-None-Match"] || "",
|
|
153
|
+
headers["If-Unmodified-Since"] || "",
|
|
154
|
+
headers["Range"] || "",
|
|
155
|
+
canonicalHeaders,
|
|
156
|
+
canonicalResource,
|
|
157
|
+
].join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function signRequest(opts) {
|
|
161
|
+
var headers = Object.assign({}, opts.headers || {});
|
|
162
|
+
if (!headers["x-ms-version"]) headers["x-ms-version"] = opts.apiVersion || DEFAULT_API_VERSION;
|
|
163
|
+
if (!headers["x-ms-date"]) headers["x-ms-date"] = new Date().toUTCString();
|
|
164
|
+
|
|
165
|
+
var url = opts.url instanceof URL ? opts.url : new URL(opts.url);
|
|
166
|
+
if (!headers["host"]) headers["host"] = url.host;
|
|
167
|
+
|
|
168
|
+
var sts = buildStringToSign({
|
|
169
|
+
method: opts.method,
|
|
170
|
+
url: url,
|
|
171
|
+
headers: headers,
|
|
172
|
+
accountName: opts.accountName,
|
|
173
|
+
});
|
|
174
|
+
var keyBytes = Buffer.from(opts.accountKey, "base64");
|
|
175
|
+
var signature = nodeCrypto.createHmac("sha256", keyBytes).update(sts, "utf8").digest("base64");
|
|
176
|
+
headers["Authorization"] = "SharedKey " + opts.accountName + ":" + signature;
|
|
177
|
+
|
|
178
|
+
return { headers: headers, stringToSign: sts, signature: signature };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- Public adapter factory ----
|
|
182
|
+
|
|
183
|
+
function create(config) {
|
|
184
|
+
if (!config) throw new Error("azure-blob protocol requires config");
|
|
185
|
+
if (!config.accountName) throw new Error("azure-blob: accountName is required");
|
|
186
|
+
if (!config.accountKey) throw new Error("azure-blob: accountKey is required");
|
|
187
|
+
if (!config.container) throw new Error("azure-blob: container is required");
|
|
188
|
+
|
|
189
|
+
var endpoint = config.endpoint || ("https://" + config.accountName + ".blob.core.windows.net");
|
|
190
|
+
if (endpoint.endsWith("/")) endpoint = endpoint.slice(0, -1);
|
|
191
|
+
var apiVersion = config.apiVersion || DEFAULT_API_VERSION;
|
|
192
|
+
var timeoutMs = config.timeoutMs;
|
|
193
|
+
// HTTPS-only by default — real Azure is always HTTPS. Operators with
|
|
194
|
+
// an Azurite emulator endpoint opt in via config.allowedProtocols.
|
|
195
|
+
var allowedProtocols = config.allowedProtocols || safeUrl.ALLOW_HTTP_TLS;
|
|
196
|
+
safeUrl.parse(endpoint, { allowedProtocols: allowedProtocols, errorClass: ObjectStoreError });
|
|
197
|
+
var reqOpts = { timeoutMs: timeoutMs, allowedProtocols: allowedProtocols };
|
|
198
|
+
|
|
199
|
+
function _blobUrl(key, params) {
|
|
200
|
+
var u = new URL(endpoint + "/" + config.container + "/" + key);
|
|
201
|
+
if (params) {
|
|
202
|
+
Object.keys(params).forEach(function (k) { u.searchParams.set(k, params[k]); });
|
|
203
|
+
}
|
|
204
|
+
return u;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function _containerUrl(params) {
|
|
208
|
+
var u = new URL(endpoint + "/" + config.container);
|
|
209
|
+
if (params) {
|
|
210
|
+
Object.keys(params).forEach(function (k) { u.searchParams.set(k, params[k]); });
|
|
211
|
+
}
|
|
212
|
+
return u;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function _signed(method, url, headers) {
|
|
216
|
+
var s = signRequest({
|
|
217
|
+
method: method,
|
|
218
|
+
url: url,
|
|
219
|
+
headers: headers || {},
|
|
220
|
+
accountName: config.accountName,
|
|
221
|
+
accountKey: config.accountKey,
|
|
222
|
+
apiVersion: apiVersion,
|
|
223
|
+
});
|
|
224
|
+
return s.headers;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function put(key, body, opts) {
|
|
228
|
+
var url = _blobUrl(key);
|
|
229
|
+
var buf = Buffer.isBuffer(body) ? body : Buffer.from(typeof body === "string" ? body : "", "utf8");
|
|
230
|
+
var contentType = (opts && opts.contentType) || "application/octet-stream";
|
|
231
|
+
var headers = _signed("PUT", url, {
|
|
232
|
+
"Content-Type": contentType,
|
|
233
|
+
"Content-Length": String(buf.length),
|
|
234
|
+
"x-ms-blob-type": "BlockBlob",
|
|
235
|
+
});
|
|
236
|
+
return _httpRequest("PUT", url, headers, buf, reqOpts).then(function (res) {
|
|
237
|
+
return { size: buf.length, etag: res.headers.etag };
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function get(key) {
|
|
242
|
+
var url = _blobUrl(key);
|
|
243
|
+
var headers = _signed("GET", url, {});
|
|
244
|
+
return _httpRequest("GET", url, headers, null, reqOpts).then(function (res) {
|
|
245
|
+
return res.body;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getStream(key) { return Readable.from(get(key)); }
|
|
250
|
+
|
|
251
|
+
function head(key) {
|
|
252
|
+
var url = _blobUrl(key);
|
|
253
|
+
var headers = _signed("HEAD", url, {});
|
|
254
|
+
return _httpRequest("HEAD", url, headers, null, reqOpts).then(function (res) {
|
|
255
|
+
return {
|
|
256
|
+
size: res.headers["content-length"] ? parseInt(res.headers["content-length"], 10) : null,
|
|
257
|
+
etag: res.headers.etag,
|
|
258
|
+
lastModified: res.headers["last-modified"] ? Date.parse(res.headers["last-modified"]) : null,
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function deleteKey(key) {
|
|
264
|
+
var url = _blobUrl(key);
|
|
265
|
+
var headers = _signed("DELETE", url, {});
|
|
266
|
+
return _httpRequest("DELETE", url, headers, null, reqOpts).then(
|
|
267
|
+
function () { return true; },
|
|
268
|
+
function (e) { if (e.statusCode === 404) return false; throw e; }
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function list(prefix, opts) {
|
|
273
|
+
opts = opts || {};
|
|
274
|
+
var params = { restype: "container", comp: "list" };
|
|
275
|
+
if (prefix) params.prefix = prefix;
|
|
276
|
+
if (opts.maxResults) params.maxresults = String(opts.maxResults);
|
|
277
|
+
if (opts.continuationToken) params.marker = opts.continuationToken;
|
|
278
|
+
var url = _containerUrl(params);
|
|
279
|
+
var headers = _signed("GET", url, {});
|
|
280
|
+
return _httpRequest("GET", url, headers, null, reqOpts).then(function (res) {
|
|
281
|
+
var doc = safeXml.parse(res.body, LIST_PARSE_OPTS);
|
|
282
|
+
var result = doc.EnumerationResults || {};
|
|
283
|
+
var blobsContainer = result.Blobs || {};
|
|
284
|
+
var blobs = _arrayify(blobsContainer.Blob);
|
|
285
|
+
var items = blobs.map(function (b) {
|
|
286
|
+
var props = b.Properties || {};
|
|
287
|
+
var size = props["Content-Length"];
|
|
288
|
+
var lm = props["Last-Modified"];
|
|
289
|
+
return {
|
|
290
|
+
key: b.Name,
|
|
291
|
+
size: size != null ? parseInt(size, 10) : null,
|
|
292
|
+
lastModified: lm ? Date.parse(lm) : null,
|
|
293
|
+
};
|
|
294
|
+
}).filter(function (it) { return it.key; });
|
|
295
|
+
// <NextMarker/> (self-closing) parses to "" — falsy. A real token
|
|
296
|
+
// is a non-empty string, which is truthy.
|
|
297
|
+
var marker = (typeof result.NextMarker === "string") ? result.NextMarker : "";
|
|
298
|
+
return {
|
|
299
|
+
items: items,
|
|
300
|
+
truncated: marker.length > 0,
|
|
301
|
+
continuationToken: marker.length > 0 ? marker : null,
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Service SAS (Shared Access Signature) generator for blob endpoints.
|
|
307
|
+
// The string-to-sign layout is fixed by API version — see Azure docs
|
|
308
|
+
// "Create a service SAS". We sign with the account key (HMAC-SHA256
|
|
309
|
+
// over the canonical string) and emit the token as URL query params.
|
|
310
|
+
function _buildSasToken(permissions, opts) {
|
|
311
|
+
var expiresIn = opts.expiresIn != null ? opts.expiresIn : SAS_DEFAULT_EXPIRES_SECONDS;
|
|
312
|
+
if (typeof expiresIn !== "number" ||
|
|
313
|
+
expiresIn < SAS_MIN_EXPIRES_SECONDS ||
|
|
314
|
+
expiresIn > SAS_MAX_EXPIRES_SECONDS) {
|
|
315
|
+
throw _err("INVALID_EXPIRES",
|
|
316
|
+
"presigned URL: expiresIn must be a number of seconds between " +
|
|
317
|
+
SAS_MIN_EXPIRES_SECONDS + " and " + SAS_MAX_EXPIRES_SECONDS +
|
|
318
|
+
" (7 days)", true);
|
|
319
|
+
}
|
|
320
|
+
var nowDate = opts.date || new Date();
|
|
321
|
+
var expiry = new Date(nowDate.getTime() + C.TIME.seconds(expiresIn));
|
|
322
|
+
// Azure accepts ISO 8601 with second precision; strip ms.
|
|
323
|
+
var signedExpiry = expiry.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
324
|
+
var signedStart = ""; // omitted = SAS valid immediately
|
|
325
|
+
var signedVersion = apiVersion;
|
|
326
|
+
var signedResource = "b"; // blob
|
|
327
|
+
var signedProtocol = "https";
|
|
328
|
+
var canonicalizedResource = "/blob/" + config.accountName + "/" +
|
|
329
|
+
config.container + "/" + opts.key;
|
|
330
|
+
var signedContentType = opts.contentType || "";
|
|
331
|
+
|
|
332
|
+
// String-to-sign layout for Service SAS (blob), API version 2018-11-09+:
|
|
333
|
+
// signedPermissions \n signedStart \n signedExpiry \n
|
|
334
|
+
// canonicalizedResource \n signedIdentifier \n signedIP \n
|
|
335
|
+
// signedProtocol \n signedVersion \n signedResource \n
|
|
336
|
+
// signedSnapshotTime \n signedEncryptionScope \n
|
|
337
|
+
// rscc \n rscd \n rsce \n rscl \n rsct
|
|
338
|
+
var stringToSign = [
|
|
339
|
+
permissions,
|
|
340
|
+
signedStart,
|
|
341
|
+
signedExpiry,
|
|
342
|
+
canonicalizedResource,
|
|
343
|
+
"", // signedIdentifier
|
|
344
|
+
"", // signedIP
|
|
345
|
+
signedProtocol,
|
|
346
|
+
signedVersion,
|
|
347
|
+
signedResource,
|
|
348
|
+
"", // signedSnapshotTime
|
|
349
|
+
"", // signedEncryptionScope
|
|
350
|
+
"", // rscc — Cache-Control
|
|
351
|
+
"", // rscd — Content-Disposition
|
|
352
|
+
"", // rsce — Content-Encoding
|
|
353
|
+
"", // rscl — Content-Language
|
|
354
|
+
signedContentType, // rsct — Content-Type (signed when supplied)
|
|
355
|
+
].join("\n");
|
|
356
|
+
|
|
357
|
+
var keyBuf = Buffer.from(config.accountKey, "base64");
|
|
358
|
+
var signature = nodeCrypto.createHmac("sha256", keyBuf)
|
|
359
|
+
.update(stringToSign, "utf8").digest("base64");
|
|
360
|
+
|
|
361
|
+
var sas = new URLSearchParams();
|
|
362
|
+
sas.set("sv", signedVersion);
|
|
363
|
+
sas.set("sr", signedResource);
|
|
364
|
+
sas.set("sp", permissions);
|
|
365
|
+
sas.set("se", signedExpiry);
|
|
366
|
+
sas.set("spr", signedProtocol);
|
|
367
|
+
if (signedContentType) sas.set("rsct", signedContentType);
|
|
368
|
+
sas.set("sig", signature);
|
|
369
|
+
|
|
370
|
+
return { sas: sas.toString(), expiresAt: expiry.getTime() };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function _presign(method, permissions, opts) {
|
|
374
|
+
opts = opts || {};
|
|
375
|
+
if (!opts.key || typeof opts.key !== "string") {
|
|
376
|
+
throw _err("INVALID_KEY", "presigned URL: key is required", true);
|
|
377
|
+
}
|
|
378
|
+
if (opts.key.indexOf("\0") !== -1) {
|
|
379
|
+
throw _err("INVALID_KEY", "null byte in key", true);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
var token = _buildSasToken(permissions, opts);
|
|
383
|
+
var url = new URL(endpoint + "/" + config.container + "/" + opts.key + "?" + token.sas);
|
|
384
|
+
|
|
385
|
+
var clientHeaders = {};
|
|
386
|
+
if (opts.contentType) clientHeaders["Content-Type"] = opts.contentType;
|
|
387
|
+
if (method === "PUT") clientHeaders["x-ms-blob-type"] = "BlockBlob";
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
url: url.toString(),
|
|
391
|
+
method: method,
|
|
392
|
+
headers: clientHeaders,
|
|
393
|
+
expiresAt: token.expiresAt,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function presignedUploadUrl(opts) { return _presign("PUT", "cw", opts); }
|
|
398
|
+
function presignedDownloadUrl(opts) { return _presign("GET", "r", opts); }
|
|
399
|
+
|
|
400
|
+
// Azure SAS doesn't support an equivalent of S3 / GCS POST policy
|
|
401
|
+
// with a content-length-range constraint. The SAS spec carries
|
|
402
|
+
// permissions / start / expiry / IP / protocol / resource-content-
|
|
403
|
+
// headers but no body-size cap — Azure validates the upload at the
|
|
404
|
+
// service level only via the operator's optional Block Blob size
|
|
405
|
+
// limit applied at storage-account or container scope, not via the
|
|
406
|
+
// SAS token itself.
|
|
407
|
+
//
|
|
408
|
+
// Rather than throw NOT_SUPPORTED, this returns the same SAS PUT
|
|
409
|
+
// shape as presignedUploadUrl with an `enforcement: 'client-only'`
|
|
410
|
+
// marker so operators making cross-vendor decisions know the
|
|
411
|
+
// body-size guard is advisory on Azure and must be enforced on the
|
|
412
|
+
// client side or via a server-side post-upload check.
|
|
413
|
+
//
|
|
414
|
+
// For strict server-side body-size enforcement on Azure, the
|
|
415
|
+
// canonical pattern is: SAS-authorize the upload, then have the
|
|
416
|
+
// operator's app issue a HEAD on the resulting blob and delete +
|
|
417
|
+
// 4xx the requester if Content-Length > opts.maxBytes.
|
|
418
|
+
function presignedUploadPolicy(opts) {
|
|
419
|
+
opts = opts || {};
|
|
420
|
+
if (typeof opts.maxBytes !== "number" || !Number.isFinite(opts.maxBytes) ||
|
|
421
|
+
opts.maxBytes <= 0) {
|
|
422
|
+
throw _err("INVALID_MAX_BYTES",
|
|
423
|
+
"presignedUploadPolicy: maxBytes (positive number of bytes) is required " +
|
|
424
|
+
"(advisory on Azure — see docstring for server-side enforcement)", true);
|
|
425
|
+
}
|
|
426
|
+
var underlying = _presign("PUT", "cw", opts);
|
|
427
|
+
return {
|
|
428
|
+
url: underlying.url,
|
|
429
|
+
method: "PUT",
|
|
430
|
+
// SAS uploads are PUT, not multipart POST — there are no form
|
|
431
|
+
// fields. Operators uploading attach the body directly to the
|
|
432
|
+
// returned URL with the headers in `headers`.
|
|
433
|
+
fields: null,
|
|
434
|
+
headers: underlying.headers,
|
|
435
|
+
expiresAt: underlying.expiresAt,
|
|
436
|
+
maxBytes: opts.maxBytes,
|
|
437
|
+
enforcement: "client-only",
|
|
438
|
+
enforcementNote:
|
|
439
|
+
"Azure SAS does not natively cap upload size. Operators needing " +
|
|
440
|
+
"strict size enforcement must HEAD the blob post-upload and reject " +
|
|
441
|
+
"if Content-Length exceeds maxBytes.",
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
protocol: "azure-blob",
|
|
447
|
+
endpoint: endpoint,
|
|
448
|
+
container: config.container,
|
|
449
|
+
accountName: config.accountName,
|
|
450
|
+
put: put,
|
|
451
|
+
get: get,
|
|
452
|
+
getStream: getStream,
|
|
453
|
+
head: head,
|
|
454
|
+
delete: deleteKey,
|
|
455
|
+
list: list,
|
|
456
|
+
presignedUploadUrl: presignedUploadUrl,
|
|
457
|
+
presignedDownloadUrl: presignedDownloadUrl,
|
|
458
|
+
presignedUploadPolicy: presignedUploadPolicy,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
module.exports = {
|
|
463
|
+
create: create,
|
|
464
|
+
signRequest: signRequest,
|
|
465
|
+
buildStringToSign: buildStringToSign,
|
|
466
|
+
DEFAULT_API_VERSION: DEFAULT_API_VERSION,
|
|
467
|
+
};
|