@blamejs/core 0.4.24 → 0.4.26
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 +2 -0
- package/lib/auth/oauth.js +17 -8
- package/lib/forms.js +12 -2
- package/lib/middleware/bot-guard.js +12 -8
- package/lib/middleware/csrf-protect.js +2 -1
- package/lib/middleware/rate-limit.js +5 -1
- package/lib/middleware/require-auth.js +2 -1
- package/lib/object-store/index.js +6 -0
- package/lib/object-store/sigv4-bucket-ops.js +491 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- **0.4.25** (2026-04-30) — b.objectStore.bucketOps: bucket-level operations (SigV4)
|
|
12
|
+
- **0.4.24** (2026-04-30) — b.objectStore: multipart upload + server-side encryption
|
|
11
13
|
- **0.4.23** (2026-04-30) — b.mail.dkim signing + calendar invites
|
|
12
14
|
- **0.4.22** (2026-04-30) — b.mail attachments + inline images + plain/HTML alternatives
|
|
13
15
|
- **0.4.21** (2026-04-30) — b.queue: repeat-in-queue (cron) + parent-child Flows
|
package/lib/auth/oauth.js
CHANGED
|
@@ -216,15 +216,24 @@ function _validateUrl(url, allowHttp, label) {
|
|
|
216
216
|
if (typeof url !== "string" || url.length === 0) {
|
|
217
217
|
throw new OAuthError("auth-oauth/bad-url", label + ": URL is required");
|
|
218
218
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
219
|
+
// Operator-supplied OAuth issuer / endpoint URL — route through
|
|
220
|
+
// safeUrl so the scheme allowlist is consistent with the rest of the
|
|
221
|
+
// framework's outbound gates. Map safe-url's error codes to the
|
|
222
|
+
// domain-specific oauth codes operators already key alerts on.
|
|
223
|
+
try {
|
|
224
|
+
safeUrl.parse(url, {
|
|
225
|
+
allowedProtocols: allowHttp ? safeUrl.ALLOW_HTTP_ALL : safeUrl.ALLOW_HTTP_TLS,
|
|
226
|
+
});
|
|
227
|
+
} catch (e) {
|
|
228
|
+
if (e && e.code === "safe-url/protocol-disallowed") {
|
|
229
|
+
throw new OAuthError("auth-oauth/insecure-url",
|
|
230
|
+
label + ": must be https" + (allowHttp ? " or http" : "") +
|
|
231
|
+
" (got '" + url + "')");
|
|
232
|
+
}
|
|
233
|
+
throw new OAuthError("auth-oauth/bad-url",
|
|
234
|
+
label + ": invalid URL '" + url + "'");
|
|
223
235
|
}
|
|
224
|
-
|
|
225
|
-
if (parsed.protocol === "http:" && allowHttp) return url;
|
|
226
|
-
throw new OAuthError("auth-oauth/insecure-url",
|
|
227
|
-
label + ": must be https (got '" + parsed.protocol + "//" + parsed.host + "')");
|
|
236
|
+
return url;
|
|
228
237
|
}
|
|
229
238
|
|
|
230
239
|
// ---- JOSE alg → node:crypto verify parameters ----
|
package/lib/forms.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* forms.escapeHtml = template.escapeHtml (re-export for convenience)
|
|
37
37
|
*/
|
|
38
38
|
var nodeCrypto = require("crypto");
|
|
39
|
+
var safeUrl = require("./safe-url");
|
|
39
40
|
var template = require("./template");
|
|
40
41
|
|
|
41
42
|
// ============================================================
|
|
@@ -327,8 +328,17 @@ function validate(spec, body) {
|
|
|
327
328
|
}
|
|
328
329
|
}
|
|
329
330
|
if (f.type === "url" && typeof coerced === "string") {
|
|
330
|
-
|
|
331
|
-
|
|
331
|
+
// Form `url` fields come from the request body — operator/external
|
|
332
|
+
// input. Route through safeUrl so the scheme allowlist is honored
|
|
333
|
+
// (https-only by default; operator opts in to http via field meta).
|
|
334
|
+
try {
|
|
335
|
+
safeUrl.parse(coerced, {
|
|
336
|
+
allowedProtocols: f.allowHttp ? safeUrl.ALLOW_HTTP_ALL : safeUrl.ALLOW_HTTP_TLS,
|
|
337
|
+
});
|
|
338
|
+
} catch (_e) {
|
|
339
|
+
errors[f.name] = (f.label || f.name) + " must be a valid URL";
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
332
342
|
}
|
|
333
343
|
if (typeof coerced === "string") {
|
|
334
344
|
if (f.minlength !== undefined && coerced.length < Number(f.minlength)) {
|
|
@@ -45,9 +45,19 @@ var DEFAULT_BLOCKED_AGENTS = [
|
|
|
45
45
|
];
|
|
46
46
|
|
|
47
47
|
var lazyRequire = require("../lazy-require");
|
|
48
|
+
var requestHelpers = require("../request-helpers");
|
|
48
49
|
var validateOpts = require("../validate-opts");
|
|
49
50
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
50
51
|
|
|
52
|
+
// Bot-guard's "trust the proxy header" semantics for actor.ip — the
|
|
53
|
+
// audit event records the apparent source even when behind a CDN.
|
|
54
|
+
// extractActorContext defaults to socket.remoteAddress; we override.
|
|
55
|
+
function _xffIp(req) {
|
|
56
|
+
var xff = req.headers && req.headers["x-forwarded-for"];
|
|
57
|
+
if (xff) return String(xff).split(",")[0].trim();
|
|
58
|
+
return (req.socket && req.socket.remoteAddress) || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
function create(opts) {
|
|
52
62
|
opts = opts || {};
|
|
53
63
|
validateOpts(opts, [
|
|
@@ -105,10 +115,7 @@ function create(opts) {
|
|
|
105
115
|
req.suspectedBot = hit;
|
|
106
116
|
try {
|
|
107
117
|
audit().emit({
|
|
108
|
-
actor: {
|
|
109
|
-
ip: (req.headers && req.headers["x-forwarded-for"]) || (req.socket && req.socket.remoteAddress),
|
|
110
|
-
userAgent: req.headers && req.headers["user-agent"],
|
|
111
|
-
},
|
|
118
|
+
actor: requestHelpers.extractActorContext(req, { ip: _xffIp(req) }),
|
|
112
119
|
action: "system.botguard.tag",
|
|
113
120
|
outcome: "denied",
|
|
114
121
|
reason: hit,
|
|
@@ -121,10 +128,7 @@ function create(opts) {
|
|
|
121
128
|
|
|
122
129
|
try {
|
|
123
130
|
audit().emit({
|
|
124
|
-
actor: {
|
|
125
|
-
ip: (req.headers && req.headers["x-forwarded-for"]) || (req.socket && req.socket.remoteAddress),
|
|
126
|
-
userAgent: req.headers && req.headers["user-agent"],
|
|
127
|
-
},
|
|
131
|
+
actor: requestHelpers.extractActorContext(req, { ip: _xffIp(req) }),
|
|
128
132
|
action: "system.botguard.block",
|
|
129
133
|
outcome: "denied",
|
|
130
134
|
reason: hit,
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
*/
|
|
67
67
|
var lazyRequire = require("../lazy-require");
|
|
68
68
|
var forms = require("../forms");
|
|
69
|
+
var requestHelpers = require("../request-helpers");
|
|
69
70
|
var validateOpts = require("../validate-opts");
|
|
70
71
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
71
72
|
|
|
@@ -203,7 +204,7 @@ function create(opts) {
|
|
|
203
204
|
audit().safeEmit({
|
|
204
205
|
action: "auth.csrf.denied",
|
|
205
206
|
outcome: "denied",
|
|
206
|
-
actor:
|
|
207
|
+
actor: requestHelpers.extractActorContext(req),
|
|
207
208
|
reason: reason,
|
|
208
209
|
metadata: { method: req.method, path: (req.url || "").split("?")[0] },
|
|
209
210
|
});
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
*/
|
|
46
46
|
var C = require("../constants");
|
|
47
47
|
var lazyRequire = require("../lazy-require");
|
|
48
|
+
var requestHelpers = require("../request-helpers");
|
|
48
49
|
var validateOpts = require("../validate-opts");
|
|
49
50
|
var clusterStorage = require("../cluster-storage");
|
|
50
51
|
|
|
@@ -252,8 +253,11 @@ function create(opts) {
|
|
|
252
253
|
if (verdict.retryAfter > 0) res.setHeader("Retry-After", String(verdict.retryAfter));
|
|
253
254
|
}
|
|
254
255
|
try {
|
|
256
|
+
// Override `ip` with the x-forwarded-for-aware client IP so the
|
|
257
|
+
// audit event carries the proxied origin even when extractActorContext
|
|
258
|
+
// would have read the socket address.
|
|
255
259
|
audit().emit({
|
|
256
|
-
actor: { ip: _clientIp(req)
|
|
260
|
+
actor: requestHelpers.extractActorContext(req, { ip: _clientIp(req) }),
|
|
257
261
|
action: "system.ratelimit.block",
|
|
258
262
|
outcome: "denied",
|
|
259
263
|
reason: "rate limit exceeded",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* }
|
|
30
30
|
*/
|
|
31
31
|
var lazyRequire = require("../lazy-require");
|
|
32
|
+
var requestHelpers = require("../request-helpers");
|
|
32
33
|
var validateOpts = require("../validate-opts");
|
|
33
34
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
34
35
|
|
|
@@ -61,7 +62,7 @@ function create(opts) {
|
|
|
61
62
|
audit().emit({
|
|
62
63
|
action: "auth.required.denied",
|
|
63
64
|
outcome: "denied",
|
|
64
|
-
actor:
|
|
65
|
+
actor: requestHelpers.extractActorContext(req),
|
|
65
66
|
reason: "no authenticated user on request",
|
|
66
67
|
metadata: { method: req.method, path: req.pathname || (req.url || "").split("?")[0] },
|
|
67
68
|
});
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
var localProto = require("./local");
|
|
31
31
|
var httpPutProto = require("./http-put");
|
|
32
32
|
var sigv4Proto = require("./sigv4");
|
|
33
|
+
var sigv4BucketOps = require("./sigv4-bucket-ops");
|
|
33
34
|
var gcsProto = require("./gcs");
|
|
34
35
|
var azureBlobProto = require("./azure-blob");
|
|
35
36
|
var retryHelper = require("./retry");
|
|
@@ -137,4 +138,9 @@ module.exports = {
|
|
|
137
138
|
buildBackend: buildBackend,
|
|
138
139
|
PROTOCOLS: dispatcher.protocols,
|
|
139
140
|
DEFERRED_PROTOCOLS: dispatcher.deferred,
|
|
141
|
+
// Bucket-level (lifecycle / CORS / create / delete / list) ops are
|
|
142
|
+
// service-scoped, not bucket-scoped — they get their own factory.
|
|
143
|
+
// SigV4 only; GCS / Azure bucket lifecycle differs substantially per
|
|
144
|
+
// cloud and is operator-managed (Terraform / CDK / Pulumi).
|
|
145
|
+
bucketOps: sigv4BucketOps,
|
|
140
146
|
};
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* sigv4-bucket-ops — bucket-level operations for SigV4 backends.
|
|
4
|
+
*
|
|
5
|
+
* Per-object ops (put / get / list / delete / multipart) live in
|
|
6
|
+
* lib/object-store/sigv4.js and are bound to a single bucket at
|
|
7
|
+
* create() time. Bucket lifecycle ops are at a different level —
|
|
8
|
+
* they need a service-scoped client that addresses arbitrary
|
|
9
|
+
* buckets — so they get their own factory.
|
|
10
|
+
*
|
|
11
|
+
* Operators with multi-cloud ambitions reach for Terraform / CDK /
|
|
12
|
+
* Pulumi. The framework's bucket-ops surface is the operator-from-app
|
|
13
|
+
* path: create the bucket your app needs at boot, attach a lifecycle
|
|
14
|
+
* rule that aborts incomplete multiparts after a week, etc. Niche ops
|
|
15
|
+
* (Object Lock, Replication, Inventory, Notification) are deferred —
|
|
16
|
+
* they're well into Terraform territory.
|
|
17
|
+
*
|
|
18
|
+
* Public API:
|
|
19
|
+
*
|
|
20
|
+
* var ops = b.objectStore.bucketOps.create({
|
|
21
|
+
* protocol: "sigv4",
|
|
22
|
+
* region: "us-east-1",
|
|
23
|
+
* accessKeyId: env("AWS_ACCESS_KEY_ID"),
|
|
24
|
+
* secretAccessKey: env("AWS_SECRET_ACCESS_KEY"),
|
|
25
|
+
* endpoint: "https://s3.us-east-1.amazonaws.com", // optional
|
|
26
|
+
* pathStyle: false,
|
|
27
|
+
* timeoutMs: 30000,
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* await ops.create("my-bucket", { region: "eu-west-1" });
|
|
31
|
+
* await ops.delete("my-bucket");
|
|
32
|
+
* var buckets = await ops.list(); // [{ name, creationDate }]
|
|
33
|
+
* await ops.setLifecycle("my-bucket", [{
|
|
34
|
+
* id: "abort-stale-multiparts",
|
|
35
|
+
* status: "Enabled",
|
|
36
|
+
* prefix: "",
|
|
37
|
+
* abortIncompleteMultipartUpload: { daysAfterInitiation: 7 },
|
|
38
|
+
* }]);
|
|
39
|
+
* await ops.setCorsRules("my-bucket", [{
|
|
40
|
+
* allowedOrigins: ["https://app.example.com"],
|
|
41
|
+
* allowedMethods: ["GET", "PUT", "POST"],
|
|
42
|
+
* allowedHeaders: ["*"],
|
|
43
|
+
* exposeHeaders: ["ETag"],
|
|
44
|
+
* maxAgeSeconds: 3600,
|
|
45
|
+
* }]);
|
|
46
|
+
*
|
|
47
|
+
* Validation is Tier-A: every input shape is rejected at the call
|
|
48
|
+
* site rather than producing a server-side 400. Errors surface as
|
|
49
|
+
* ObjectStoreError with codes (BUCKET_INVALID_NAME, INVALID_LIFECYCLE,
|
|
50
|
+
* INVALID_CORS_RULE, BUCKET_ALREADY_OWNED, BUCKET_NOT_EMPTY, etc.).
|
|
51
|
+
*/
|
|
52
|
+
var { URL } = require("url");
|
|
53
|
+
var nodeCrypto = require("crypto");
|
|
54
|
+
var sigv4 = require("./sigv4");
|
|
55
|
+
var safeXml = require("../parsers/safe-xml");
|
|
56
|
+
var safeUrl = require("../safe-url");
|
|
57
|
+
var httpClient = require("../http-client");
|
|
58
|
+
var { ObjectStoreError } = require("../framework-error");
|
|
59
|
+
|
|
60
|
+
var _err = ObjectStoreError.factory;
|
|
61
|
+
|
|
62
|
+
// S3 bucket-name rules (general purpose). Source: AWS docs
|
|
63
|
+
// "Bucket naming rules". Lowercase letters, digits, hyphens; 3..63
|
|
64
|
+
// chars; no consecutive dots; cannot end in -s3alias / -ol-s3 etc.
|
|
65
|
+
// We catch the common-mistake cases at config time; AWS catches the
|
|
66
|
+
// rest at request time.
|
|
67
|
+
var BUCKET_NAME_RE = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
|
|
68
|
+
|
|
69
|
+
function _validateBucketName(name) {
|
|
70
|
+
if (typeof name !== "string" || name.length < 3 || name.length > 63) {
|
|
71
|
+
throw _err("BUCKET_INVALID_NAME",
|
|
72
|
+
"bucket name must be a string of length 3..63, got " +
|
|
73
|
+
(typeof name === "string" ? "length " + name.length : typeof name), true);
|
|
74
|
+
}
|
|
75
|
+
if (!BUCKET_NAME_RE.test(name)) {
|
|
76
|
+
throw _err("BUCKET_INVALID_NAME",
|
|
77
|
+
"bucket name '" + name + "' violates S3 naming rules " +
|
|
78
|
+
"(lowercase, digits, hyphens, dots — no leading/trailing punct)", true);
|
|
79
|
+
}
|
|
80
|
+
if (name.indexOf("..") !== -1) {
|
|
81
|
+
throw _err("BUCKET_INVALID_NAME",
|
|
82
|
+
"bucket name '" + name + "' contains consecutive dots", true);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function _xmlEscape(s) {
|
|
87
|
+
return String(s)
|
|
88
|
+
.replace(/&/g, "&")
|
|
89
|
+
.replace(/</g, "<")
|
|
90
|
+
.replace(/>/g, ">")
|
|
91
|
+
.replace(/"/g, """)
|
|
92
|
+
.replace(/'/g, "'");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function _md5Base64(buf) {
|
|
96
|
+
return nodeCrypto.createHash("md5").update(buf).digest("base64");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- Lifecycle XML ----
|
|
100
|
+
|
|
101
|
+
var ALLOWED_STORAGE_CLASSES = [
|
|
102
|
+
"STANDARD", "REDUCED_REDUNDANCY", "STANDARD_IA", "ONEZONE_IA",
|
|
103
|
+
"INTELLIGENT_TIERING", "GLACIER", "DEEP_ARCHIVE", "GLACIER_IR",
|
|
104
|
+
"EXPRESS_ONEZONE",
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
function _buildLifecycleXml(rules) {
|
|
108
|
+
if (!Array.isArray(rules) || rules.length === 0) {
|
|
109
|
+
throw _err("INVALID_LIFECYCLE",
|
|
110
|
+
"setLifecycle: rules must be a non-empty array", true);
|
|
111
|
+
}
|
|
112
|
+
if (rules.length > 1000) {
|
|
113
|
+
throw _err("INVALID_LIFECYCLE",
|
|
114
|
+
"setLifecycle: maximum 1000 rules per bucket (S3 spec)", true);
|
|
115
|
+
}
|
|
116
|
+
var body = '<?xml version="1.0" encoding="UTF-8"?>';
|
|
117
|
+
body += '<LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">';
|
|
118
|
+
for (var i = 0; i < rules.length; i++) {
|
|
119
|
+
var rule = rules[i];
|
|
120
|
+
if (!rule || typeof rule !== "object") {
|
|
121
|
+
throw _err("INVALID_LIFECYCLE",
|
|
122
|
+
"rules[" + i + "] must be an object", true);
|
|
123
|
+
}
|
|
124
|
+
var status = rule.status || "Enabled";
|
|
125
|
+
if (status !== "Enabled" && status !== "Disabled") {
|
|
126
|
+
throw _err("INVALID_LIFECYCLE",
|
|
127
|
+
"rules[" + i + "].status must be 'Enabled' or 'Disabled'", true);
|
|
128
|
+
}
|
|
129
|
+
if (!rule.expiration && !rule.transition && !rule.abortIncompleteMultipartUpload) {
|
|
130
|
+
throw _err("INVALID_LIFECYCLE",
|
|
131
|
+
"rules[" + i + "] must specify at least one of " +
|
|
132
|
+
"expiration / transition / abortIncompleteMultipartUpload", true);
|
|
133
|
+
}
|
|
134
|
+
body += "<Rule>";
|
|
135
|
+
if (rule.id !== undefined) {
|
|
136
|
+
if (typeof rule.id !== "string" || rule.id.length === 0) {
|
|
137
|
+
throw _err("INVALID_LIFECYCLE",
|
|
138
|
+
"rules[" + i + "].id must be a non-empty string when set", true);
|
|
139
|
+
}
|
|
140
|
+
body += "<ID>" + _xmlEscape(rule.id) + "</ID>";
|
|
141
|
+
}
|
|
142
|
+
body += "<Filter><Prefix>" + _xmlEscape(rule.prefix || "") + "</Prefix></Filter>";
|
|
143
|
+
body += "<Status>" + status + "</Status>";
|
|
144
|
+
if (rule.expiration) {
|
|
145
|
+
body += "<Expiration>";
|
|
146
|
+
if (rule.expiration.days !== undefined) {
|
|
147
|
+
if (typeof rule.expiration.days !== "number" || rule.expiration.days < 1) {
|
|
148
|
+
throw _err("INVALID_LIFECYCLE",
|
|
149
|
+
"rules[" + i + "].expiration.days must be a positive integer", true);
|
|
150
|
+
}
|
|
151
|
+
body += "<Days>" + rule.expiration.days + "</Days>";
|
|
152
|
+
}
|
|
153
|
+
if (rule.expiration.date !== undefined) {
|
|
154
|
+
body += "<Date>" + _xmlEscape(rule.expiration.date) + "</Date>";
|
|
155
|
+
}
|
|
156
|
+
if (rule.expiration.expiredObjectDeleteMarker !== undefined) {
|
|
157
|
+
body += "<ExpiredObjectDeleteMarker>" +
|
|
158
|
+
(rule.expiration.expiredObjectDeleteMarker ? "true" : "false") +
|
|
159
|
+
"</ExpiredObjectDeleteMarker>";
|
|
160
|
+
}
|
|
161
|
+
body += "</Expiration>";
|
|
162
|
+
}
|
|
163
|
+
if (rule.transition) {
|
|
164
|
+
if (ALLOWED_STORAGE_CLASSES.indexOf(rule.transition.storageClass) === -1) {
|
|
165
|
+
throw _err("INVALID_LIFECYCLE",
|
|
166
|
+
"rules[" + i + "].transition.storageClass must be one of: " +
|
|
167
|
+
ALLOWED_STORAGE_CLASSES.join(", "), true);
|
|
168
|
+
}
|
|
169
|
+
body += "<Transition>";
|
|
170
|
+
if (rule.transition.days !== undefined) {
|
|
171
|
+
body += "<Days>" + rule.transition.days + "</Days>";
|
|
172
|
+
}
|
|
173
|
+
if (rule.transition.date !== undefined) {
|
|
174
|
+
body += "<Date>" + _xmlEscape(rule.transition.date) + "</Date>";
|
|
175
|
+
}
|
|
176
|
+
body += "<StorageClass>" + rule.transition.storageClass + "</StorageClass>";
|
|
177
|
+
body += "</Transition>";
|
|
178
|
+
}
|
|
179
|
+
if (rule.abortIncompleteMultipartUpload) {
|
|
180
|
+
var dai = rule.abortIncompleteMultipartUpload.daysAfterInitiation;
|
|
181
|
+
if (typeof dai !== "number" || dai < 1) {
|
|
182
|
+
throw _err("INVALID_LIFECYCLE",
|
|
183
|
+
"rules[" + i + "].abortIncompleteMultipartUpload.daysAfterInitiation " +
|
|
184
|
+
"must be a positive integer", true);
|
|
185
|
+
}
|
|
186
|
+
body += "<AbortIncompleteMultipartUpload>";
|
|
187
|
+
body += "<DaysAfterInitiation>" + dai + "</DaysAfterInitiation>";
|
|
188
|
+
body += "</AbortIncompleteMultipartUpload>";
|
|
189
|
+
}
|
|
190
|
+
body += "</Rule>";
|
|
191
|
+
}
|
|
192
|
+
body += "</LifecycleConfiguration>";
|
|
193
|
+
return body;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- CORS XML ----
|
|
197
|
+
|
|
198
|
+
var ALLOWED_CORS_METHODS = ["GET", "PUT", "POST", "DELETE", "HEAD"];
|
|
199
|
+
|
|
200
|
+
function _buildCorsXml(rules) {
|
|
201
|
+
if (!Array.isArray(rules) || rules.length === 0) {
|
|
202
|
+
throw _err("INVALID_CORS_RULE",
|
|
203
|
+
"setCorsRules: rules must be a non-empty array", true);
|
|
204
|
+
}
|
|
205
|
+
if (rules.length > 100) {
|
|
206
|
+
throw _err("INVALID_CORS_RULE",
|
|
207
|
+
"setCorsRules: maximum 100 rules per bucket (S3 spec)", true);
|
|
208
|
+
}
|
|
209
|
+
var body = '<?xml version="1.0" encoding="UTF-8"?>';
|
|
210
|
+
body += '<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">';
|
|
211
|
+
for (var i = 0; i < rules.length; i++) {
|
|
212
|
+
var rule = rules[i];
|
|
213
|
+
if (!rule || typeof rule !== "object") {
|
|
214
|
+
throw _err("INVALID_CORS_RULE",
|
|
215
|
+
"rules[" + i + "] must be an object", true);
|
|
216
|
+
}
|
|
217
|
+
if (!Array.isArray(rule.allowedOrigins) || rule.allowedOrigins.length === 0) {
|
|
218
|
+
throw _err("INVALID_CORS_RULE",
|
|
219
|
+
"rules[" + i + "].allowedOrigins must be a non-empty array", true);
|
|
220
|
+
}
|
|
221
|
+
if (!Array.isArray(rule.allowedMethods) || rule.allowedMethods.length === 0) {
|
|
222
|
+
throw _err("INVALID_CORS_RULE",
|
|
223
|
+
"rules[" + i + "].allowedMethods must be a non-empty array", true);
|
|
224
|
+
}
|
|
225
|
+
for (var m = 0; m < rule.allowedMethods.length; m++) {
|
|
226
|
+
if (ALLOWED_CORS_METHODS.indexOf(rule.allowedMethods[m]) === -1) {
|
|
227
|
+
throw _err("INVALID_CORS_RULE",
|
|
228
|
+
"rules[" + i + "].allowedMethods[" + m + "] must be one of: " +
|
|
229
|
+
ALLOWED_CORS_METHODS.join(", "), true);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
body += "<CORSRule>";
|
|
233
|
+
if (rule.id !== undefined) body += "<ID>" + _xmlEscape(rule.id) + "</ID>";
|
|
234
|
+
rule.allowedOrigins.forEach(function (o) {
|
|
235
|
+
body += "<AllowedOrigin>" + _xmlEscape(o) + "</AllowedOrigin>";
|
|
236
|
+
});
|
|
237
|
+
rule.allowedMethods.forEach(function (m) {
|
|
238
|
+
body += "<AllowedMethod>" + _xmlEscape(m) + "</AllowedMethod>";
|
|
239
|
+
});
|
|
240
|
+
if (Array.isArray(rule.allowedHeaders)) {
|
|
241
|
+
rule.allowedHeaders.forEach(function (h) {
|
|
242
|
+
body += "<AllowedHeader>" + _xmlEscape(h) + "</AllowedHeader>";
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (Array.isArray(rule.exposeHeaders)) {
|
|
246
|
+
rule.exposeHeaders.forEach(function (h) {
|
|
247
|
+
body += "<ExposeHeader>" + _xmlEscape(h) + "</ExposeHeader>";
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (rule.maxAgeSeconds !== undefined) {
|
|
251
|
+
if (typeof rule.maxAgeSeconds !== "number" || rule.maxAgeSeconds < 0) {
|
|
252
|
+
throw _err("INVALID_CORS_RULE",
|
|
253
|
+
"rules[" + i + "].maxAgeSeconds must be a non-negative number", true);
|
|
254
|
+
}
|
|
255
|
+
body += "<MaxAgeSeconds>" + rule.maxAgeSeconds + "</MaxAgeSeconds>";
|
|
256
|
+
}
|
|
257
|
+
body += "</CORSRule>";
|
|
258
|
+
}
|
|
259
|
+
body += "</CORSConfiguration>";
|
|
260
|
+
if (Buffer.byteLength(body, "utf8") > 64 * 1024) {
|
|
261
|
+
throw _err("INVALID_CORS_RULE",
|
|
262
|
+
"CORS configuration exceeds 64 KB (S3 spec)", true);
|
|
263
|
+
}
|
|
264
|
+
return body;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- Public factory ----
|
|
268
|
+
|
|
269
|
+
function create(config) {
|
|
270
|
+
if (!config || typeof config !== "object") {
|
|
271
|
+
throw _err("INVALID_CONFIG", "bucketOps.create requires a config object", true);
|
|
272
|
+
}
|
|
273
|
+
if (config.protocol && config.protocol !== "sigv4") {
|
|
274
|
+
throw _err("INVALID_CONFIG",
|
|
275
|
+
"bucketOps currently only supports protocol 'sigv4'; got '" +
|
|
276
|
+
config.protocol + "'. GCS and Azure bucket lifecycle differs " +
|
|
277
|
+
"substantially per cloud and is operator-managed (Terraform / " +
|
|
278
|
+
"CDK / Pulumi).", true);
|
|
279
|
+
}
|
|
280
|
+
if (!config.region) throw _err("INVALID_CONFIG", "bucketOps: region is required", true);
|
|
281
|
+
if (!config.accessKeyId) throw _err("INVALID_CONFIG", "bucketOps: accessKeyId is required", true);
|
|
282
|
+
if (!config.secretAccessKey) throw _err("INVALID_CONFIG", "bucketOps: secretAccessKey is required", true);
|
|
283
|
+
|
|
284
|
+
var endpoint = config.endpoint || ("https://s3." + config.region + ".amazonaws.com");
|
|
285
|
+
if (endpoint.endsWith("/")) endpoint = endpoint.slice(0, -1);
|
|
286
|
+
var pathStyle = !!(config.pathStyle || config.forcePathStyle);
|
|
287
|
+
var allowedProtocols = config.allowedProtocols || safeUrl.ALLOW_HTTP_TLS;
|
|
288
|
+
var allowInternal = config.allowInternal != null ? config.allowInternal : null;
|
|
289
|
+
safeUrl.parse(endpoint, {
|
|
290
|
+
allowedProtocols: allowedProtocols,
|
|
291
|
+
errorClass: ObjectStoreError,
|
|
292
|
+
});
|
|
293
|
+
var reqOpts = { timeoutMs: config.timeoutMs, allowedProtocols: allowedProtocols };
|
|
294
|
+
if (allowInternal !== null) reqOpts.allowInternal = allowInternal;
|
|
295
|
+
|
|
296
|
+
function _bucketUrl(name, query) {
|
|
297
|
+
var u;
|
|
298
|
+
if (pathStyle) {
|
|
299
|
+
u = new URL(endpoint + "/" + name + "/");
|
|
300
|
+
} else {
|
|
301
|
+
u = new URL(endpoint);
|
|
302
|
+
u.hostname = name + "." + u.hostname;
|
|
303
|
+
u.pathname = "/";
|
|
304
|
+
}
|
|
305
|
+
if (query) {
|
|
306
|
+
Object.keys(query).forEach(function (k) {
|
|
307
|
+
u.searchParams.set(k, query[k] != null ? query[k] : "");
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return u;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function _serviceUrl(query) {
|
|
314
|
+
// ListBuckets — service-level, no bucket prefix.
|
|
315
|
+
var u = new URL(endpoint);
|
|
316
|
+
u.pathname = "/";
|
|
317
|
+
if (query) {
|
|
318
|
+
Object.keys(query).forEach(function (k) { u.searchParams.set(k, query[k]); });
|
|
319
|
+
}
|
|
320
|
+
return u;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function _signed(method, url, payloadHash, extraHeaders) {
|
|
324
|
+
var signed = sigv4.signRequest({
|
|
325
|
+
method: method,
|
|
326
|
+
url: url,
|
|
327
|
+
headers: extraHeaders || {},
|
|
328
|
+
payloadHash: payloadHash,
|
|
329
|
+
region: config.region,
|
|
330
|
+
accessKeyId: config.accessKeyId,
|
|
331
|
+
secretAccessKey: config.secretAccessKey,
|
|
332
|
+
sessionToken: config.sessionToken,
|
|
333
|
+
});
|
|
334
|
+
return signed.headers;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function _request(method, url, headers, body) {
|
|
338
|
+
return httpClient.request({
|
|
339
|
+
method: method,
|
|
340
|
+
url: url,
|
|
341
|
+
headers: headers,
|
|
342
|
+
body: body,
|
|
343
|
+
idleTimeoutMs: reqOpts.timeoutMs,
|
|
344
|
+
errorClass: ObjectStoreError,
|
|
345
|
+
allowedProtocols: reqOpts.allowedProtocols,
|
|
346
|
+
...((reqOpts.allowInternal !== undefined) ? { allowInternal: reqOpts.allowInternal } : {}),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- create ----
|
|
351
|
+
|
|
352
|
+
function createBucket(name, opts) {
|
|
353
|
+
_validateBucketName(name);
|
|
354
|
+
opts = opts || {};
|
|
355
|
+
var targetRegion = opts.region || config.region;
|
|
356
|
+
var url = _bucketUrl(name);
|
|
357
|
+
var bodyBuf;
|
|
358
|
+
var extra = {};
|
|
359
|
+
// us-east-1 takes an empty body. Other regions need
|
|
360
|
+
// CreateBucketConfiguration with a LocationConstraint.
|
|
361
|
+
if (targetRegion && targetRegion !== "us-east-1") {
|
|
362
|
+
bodyBuf = Buffer.from(
|
|
363
|
+
'<?xml version="1.0" encoding="UTF-8"?>' +
|
|
364
|
+
'<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' +
|
|
365
|
+
'<LocationConstraint>' + _xmlEscape(targetRegion) + '</LocationConstraint>' +
|
|
366
|
+
'</CreateBucketConfiguration>',
|
|
367
|
+
"utf8"
|
|
368
|
+
);
|
|
369
|
+
extra["Content-Type"] = "application/xml";
|
|
370
|
+
extra["Content-Length"] = String(bodyBuf.length);
|
|
371
|
+
} else {
|
|
372
|
+
bodyBuf = Buffer.alloc(0);
|
|
373
|
+
extra["Content-Length"] = "0";
|
|
374
|
+
}
|
|
375
|
+
var payloadHash = sigv4.sha256Hex(bodyBuf);
|
|
376
|
+
var headers = _signed("PUT", url, payloadHash, extra);
|
|
377
|
+
return _request("PUT", url, headers, bodyBuf).then(
|
|
378
|
+
function () { return { created: true, name: name, region: targetRegion }; },
|
|
379
|
+
function (e) {
|
|
380
|
+
// Map S3 conflict response codes into stable framework codes.
|
|
381
|
+
if (e.statusCode === 409 && /BucketAlreadyOwnedByYou/.test(e.message || "")) {
|
|
382
|
+
throw _err("BUCKET_ALREADY_OWNED",
|
|
383
|
+
"bucket '" + name + "' already exists and is owned by this account", true);
|
|
384
|
+
}
|
|
385
|
+
if (e.statusCode === 409) {
|
|
386
|
+
throw _err("BUCKET_NAME_TAKEN",
|
|
387
|
+
"bucket name '" + name + "' is taken in S3's global namespace", true);
|
|
388
|
+
}
|
|
389
|
+
throw e;
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---- delete ----
|
|
395
|
+
|
|
396
|
+
function deleteBucket(name) {
|
|
397
|
+
_validateBucketName(name);
|
|
398
|
+
var url = _bucketUrl(name);
|
|
399
|
+
var payloadHash = sigv4.sha256Hex(Buffer.alloc(0));
|
|
400
|
+
var headers = _signed("DELETE", url, payloadHash);
|
|
401
|
+
return _request("DELETE", url, headers, null).then(
|
|
402
|
+
function () { return true; },
|
|
403
|
+
function (e) {
|
|
404
|
+
if (e.statusCode === 404) return false;
|
|
405
|
+
if (e.statusCode === 409 && /BucketNotEmpty/.test(e.message || "")) {
|
|
406
|
+
throw _err("BUCKET_NOT_EMPTY",
|
|
407
|
+
"bucket '" + name + "' is not empty; delete all objects + " +
|
|
408
|
+
"noncurrent versions + delete-markers first", true);
|
|
409
|
+
}
|
|
410
|
+
throw e;
|
|
411
|
+
}
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---- list ----
|
|
416
|
+
|
|
417
|
+
function listBuckets() {
|
|
418
|
+
var url = _serviceUrl();
|
|
419
|
+
var payloadHash = sigv4.sha256Hex(Buffer.alloc(0));
|
|
420
|
+
var headers = _signed("GET", url, payloadHash);
|
|
421
|
+
return _request("GET", url, headers, null).then(function (res) {
|
|
422
|
+
var doc = safeXml.parse(res.body);
|
|
423
|
+
var result = doc.ListAllMyBucketsResult || {};
|
|
424
|
+
var bucketsContainer = result.Buckets || {};
|
|
425
|
+
var raw = bucketsContainer.Bucket;
|
|
426
|
+
if (!raw) return [];
|
|
427
|
+
var arr = Array.isArray(raw) ? raw : [raw];
|
|
428
|
+
return arr.map(function (b) {
|
|
429
|
+
return {
|
|
430
|
+
name: b.Name,
|
|
431
|
+
creationDate: b.CreationDate ? Date.parse(b.CreationDate) : null,
|
|
432
|
+
region: b.BucketRegion || null,
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---- setLifecycle ----
|
|
439
|
+
|
|
440
|
+
function setLifecycle(name, rules) {
|
|
441
|
+
_validateBucketName(name);
|
|
442
|
+
var bodyXml = _buildLifecycleXml(rules);
|
|
443
|
+
var bodyBuf = Buffer.from(bodyXml, "utf8");
|
|
444
|
+
var url = _bucketUrl(name, { lifecycle: "" });
|
|
445
|
+
var payloadHash = sigv4.sha256Hex(bodyBuf);
|
|
446
|
+
var headers = _signed("PUT", url, payloadHash, {
|
|
447
|
+
"Content-Type": "application/xml",
|
|
448
|
+
"Content-Length": String(bodyBuf.length),
|
|
449
|
+
"Content-MD5": _md5Base64(bodyBuf),
|
|
450
|
+
});
|
|
451
|
+
return _request("PUT", url, headers, bodyBuf).then(function () {
|
|
452
|
+
return { applied: true, name: name, ruleCount: rules.length };
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---- setCorsRules ----
|
|
457
|
+
|
|
458
|
+
function setCorsRules(name, rules) {
|
|
459
|
+
_validateBucketName(name);
|
|
460
|
+
var bodyXml = _buildCorsXml(rules);
|
|
461
|
+
var bodyBuf = Buffer.from(bodyXml, "utf8");
|
|
462
|
+
var url = _bucketUrl(name, { cors: "" });
|
|
463
|
+
var payloadHash = sigv4.sha256Hex(bodyBuf);
|
|
464
|
+
var headers = _signed("PUT", url, payloadHash, {
|
|
465
|
+
"Content-Type": "application/xml",
|
|
466
|
+
"Content-Length": String(bodyBuf.length),
|
|
467
|
+
"Content-MD5": _md5Base64(bodyBuf),
|
|
468
|
+
});
|
|
469
|
+
return _request("PUT", url, headers, bodyBuf).then(function () {
|
|
470
|
+
return { applied: true, name: name, ruleCount: rules.length };
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
protocol: "sigv4",
|
|
476
|
+
create: createBucket,
|
|
477
|
+
delete: deleteBucket,
|
|
478
|
+
list: listBuckets,
|
|
479
|
+
setLifecycle: setLifecycle,
|
|
480
|
+
setCorsRules: setCorsRules,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
module.exports = {
|
|
485
|
+
create: create,
|
|
486
|
+
// Test-only exports for unit-testing the XML builders without
|
|
487
|
+
// standing up a fake S3 server.
|
|
488
|
+
_buildLifecycleXmlForTest: _buildLifecycleXml,
|
|
489
|
+
_buildCorsXmlForTest: _buildCorsXml,
|
|
490
|
+
_validateBucketNameForTest: _validateBucketName,
|
|
491
|
+
};
|