create-monolayer-app 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/template/_gitignore +9 -4
- package/template/app/routes/files.ts +63 -0
- package/template/app/routes.ts +5 -2
- package/template/oxfmt.config.ts +6 -1
- package/template/oxlint.config.ts +3 -8
- package/template/package.json +2 -1
- package/template/vendor/file-bucket/client.ts +196 -0
- package/template/vendor/file-bucket/errors.ts +26 -0
- package/template/vendor/file-bucket/index.ts +7 -0
- package/template/vendor/file-bucket/multipart.ts +304 -0
- package/template/vendor/file-bucket/protocol.ts +222 -0
- package/template/vendor/file-bucket/react-router.ts +222 -0
- package/template/vendor/file-bucket/server.ts +544 -0
- package/template/vendor/file-bucket/transfers.ts +23 -0
- package/template/vendor/file-bucket/transport.ts +37 -0
- package/template/vendor/file-bucket/types.ts +153 -0
- package/template/vendor/file-bucket/vite.d.ts +7 -0
- package/template/vendor/file-bucket/vite.js +2482 -0
- package/template/vendor/file-bucket/xml.ts +325 -0
- package/template/vendor/package-app/package-app.mjs +116 -0
|
@@ -0,0 +1,2482 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import path, { dirname, join } from "node:path";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { crc32 } from "node:zlib";
|
|
6
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeSync } from "node:fs";
|
|
7
|
+
//#region src/server/context.ts
|
|
8
|
+
/**
|
|
9
|
+
* Execution context and multi-tenancy account scoping.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_ACCOUNT_ID = "000000000000";
|
|
12
|
+
const DEFAULT_REGION = process.env.AWS_REGION || "us-east-1";
|
|
13
|
+
const asyncLocalStorage = new AsyncLocalStorage();
|
|
14
|
+
function getAccountId() {
|
|
15
|
+
const store = asyncLocalStorage.getStore();
|
|
16
|
+
return store ? store.accountId : DEFAULT_ACCOUNT_ID;
|
|
17
|
+
}
|
|
18
|
+
function getRegion() {
|
|
19
|
+
const store = asyncLocalStorage.getStore();
|
|
20
|
+
return store ? store.region : DEFAULT_REGION;
|
|
21
|
+
}
|
|
22
|
+
function withAccountContext(context, fn) {
|
|
23
|
+
const current = asyncLocalStorage.getStore();
|
|
24
|
+
const next = {
|
|
25
|
+
accountId: context.accountId ?? current?.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
26
|
+
region: context.region ?? current?.region ?? DEFAULT_REGION
|
|
27
|
+
};
|
|
28
|
+
return asyncLocalStorage.run(next, fn);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Helper to serialize arbitrary key types (strings or tuples) to string keys.
|
|
32
|
+
*/
|
|
33
|
+
function serializeKey(key) {
|
|
34
|
+
if (Array.isArray(key)) return JSON.stringify(key);
|
|
35
|
+
return String(key);
|
|
36
|
+
}
|
|
37
|
+
function asKey(value) {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
function deserializeKey(rawKey) {
|
|
41
|
+
if (rawKey.startsWith("[") && rawKey.endsWith("]")) try {
|
|
42
|
+
return JSON.parse(rawKey);
|
|
43
|
+
} catch {
|
|
44
|
+
return asKey(rawKey);
|
|
45
|
+
}
|
|
46
|
+
return asKey(rawKey);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* AccountScopedMap stores values partitioned by account ID.
|
|
50
|
+
* When operations (get, set, has, delete, keys, etc.) are performed, they are
|
|
51
|
+
* implicitly scoped to the current account ID from getAccountId().
|
|
52
|
+
*/
|
|
53
|
+
var AccountScopedMap = class {
|
|
54
|
+
_data = /* @__PURE__ */ new Map();
|
|
55
|
+
makeCompositeKey(key, accountId = getAccountId()) {
|
|
56
|
+
return `${accountId}::${serializeKey(key)}`;
|
|
57
|
+
}
|
|
58
|
+
get(key, accountId) {
|
|
59
|
+
return this._data.get(this.makeCompositeKey(key, accountId));
|
|
60
|
+
}
|
|
61
|
+
set(key, value, accountId) {
|
|
62
|
+
this._data.set(this.makeCompositeKey(key, accountId), value);
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
has(key, accountId) {
|
|
66
|
+
return this._data.has(this.makeCompositeKey(key, accountId));
|
|
67
|
+
}
|
|
68
|
+
delete(key, accountId) {
|
|
69
|
+
return this._data.delete(this.makeCompositeKey(key, accountId));
|
|
70
|
+
}
|
|
71
|
+
keys(accountId = getAccountId()) {
|
|
72
|
+
const prefix = `${accountId}::`;
|
|
73
|
+
const result = [];
|
|
74
|
+
for (const k of this._data.keys()) if (k.startsWith(prefix)) result.push(deserializeKey(k.slice(prefix.length)));
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
entries(accountId = getAccountId()) {
|
|
78
|
+
const prefix = `${accountId}::`;
|
|
79
|
+
const result = [];
|
|
80
|
+
for (const [k, v] of this._data.entries()) if (k.startsWith(prefix)) result.push([deserializeKey(k.slice(prefix.length)), v]);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/server/utils.ts
|
|
86
|
+
/**
|
|
87
|
+
* General utilities: formatting, hashing, parsing, UUIDs, dates.
|
|
88
|
+
*/
|
|
89
|
+
function nowIso() {
|
|
90
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
91
|
+
}
|
|
92
|
+
function isoToRfc7231(iso) {
|
|
93
|
+
const date = iso instanceof Date ? iso : new Date(iso);
|
|
94
|
+
if (Number.isNaN(date.getTime())) return (/* @__PURE__ */ new Date()).toUTCString();
|
|
95
|
+
return date.toUTCString();
|
|
96
|
+
}
|
|
97
|
+
function parseHttpDate(value) {
|
|
98
|
+
if (!value) return null;
|
|
99
|
+
const parsed = Date.parse(value);
|
|
100
|
+
if (Number.isNaN(parsed)) return null;
|
|
101
|
+
return new Date(parsed);
|
|
102
|
+
}
|
|
103
|
+
function newUuid() {
|
|
104
|
+
return randomUUID();
|
|
105
|
+
}
|
|
106
|
+
function md5Hex(data) {
|
|
107
|
+
return createHash("md5").update(data).digest("hex");
|
|
108
|
+
}
|
|
109
|
+
function sha256Hex(data) {
|
|
110
|
+
return createHash("sha256").update(data).digest("hex");
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* S3 percent-encoding for `encoding-type=url`.
|
|
114
|
+
* Encodes characters per RFC 3986, but leaves "/" intact when safeSlash=true.
|
|
115
|
+
*/
|
|
116
|
+
function urlEncode(value, safeSlash = true) {
|
|
117
|
+
const encoded = encodeURIComponent(value).replaceAll(/[!'()*]/gu, (c) => {
|
|
118
|
+
return `%${(c.codePointAt(0) ?? 0).toString(16).toUpperCase()}`;
|
|
119
|
+
});
|
|
120
|
+
if (safeSlash) return encoded.replaceAll("%2F", "/");
|
|
121
|
+
return encoded;
|
|
122
|
+
}
|
|
123
|
+
function urlDecode(value) {
|
|
124
|
+
try {
|
|
125
|
+
return decodeURIComponent(value);
|
|
126
|
+
} catch {
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Parse query string into a Record<string, string | string[]>.
|
|
132
|
+
*/
|
|
133
|
+
function parseQueryParams(query) {
|
|
134
|
+
const result = Object.create(null);
|
|
135
|
+
if (!query) return result;
|
|
136
|
+
const queryString = query.startsWith("?") ? query.slice(1) : query;
|
|
137
|
+
if (!queryString) return result;
|
|
138
|
+
const pairs = queryString.split("&");
|
|
139
|
+
for (const pair of pairs) {
|
|
140
|
+
if (!pair) continue;
|
|
141
|
+
const [rawKey, ...rest] = pair.split("=");
|
|
142
|
+
const rawVal = rest.join("=");
|
|
143
|
+
const key = urlDecode(rawKey);
|
|
144
|
+
const val = urlDecode(rawVal);
|
|
145
|
+
if (key in result) {
|
|
146
|
+
const existing = result[key];
|
|
147
|
+
if (Array.isArray(existing)) existing.push(val);
|
|
148
|
+
else result[key] = [existing, val];
|
|
149
|
+
} else result[key] = val;
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Helper to retrieve first query param value, matching Python `_qp(params, key, default)`.
|
|
155
|
+
*/
|
|
156
|
+
function qp(params, key, defaultValue = "") {
|
|
157
|
+
if (!params) return defaultValue;
|
|
158
|
+
const val = params[key];
|
|
159
|
+
if (val === void 0) return defaultValue;
|
|
160
|
+
if (Array.isArray(val)) return val.length > 0 ? val[0] : defaultValue;
|
|
161
|
+
return val;
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/server/xml.ts
|
|
165
|
+
/**
|
|
166
|
+
* Zero-dependency XML parser, DOM builder, and serializer for S3 XML payloads.
|
|
167
|
+
*/
|
|
168
|
+
const S3_NS = "http://s3.amazonaws.com/doc/2006-03-01/";
|
|
169
|
+
const XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>";
|
|
170
|
+
var ParseError = class extends Error {
|
|
171
|
+
constructor(message) {
|
|
172
|
+
super(message);
|
|
173
|
+
this.name = "ParseError";
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
function escapeXml(str) {
|
|
177
|
+
if (str === null || str === void 0) return "";
|
|
178
|
+
return String(str).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
179
|
+
}
|
|
180
|
+
function unescapeXml(str) {
|
|
181
|
+
return str.replaceAll(""", "\"").replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&").replaceAll(/&#(\d+);/gu, (_, dec) => String.fromCodePoint(Math.trunc(Number(dec)))).replaceAll(/&#x([0-9a-fA-F]+);/gu, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Strips namespace prefix or Clark notation from a tag name.
|
|
185
|
+
* e.g. "{http://...}Rule" -> "Rule", "s3:Rule" -> "Rule", "Rule" -> "Rule"
|
|
186
|
+
*/
|
|
187
|
+
function localTagName(tag) {
|
|
188
|
+
if (tag.startsWith("{")) {
|
|
189
|
+
const idx = tag.indexOf("}");
|
|
190
|
+
if (idx !== -1) return tag.slice(idx + 1);
|
|
191
|
+
}
|
|
192
|
+
const colonIdx = tag.indexOf(":");
|
|
193
|
+
if (colonIdx !== -1) return tag.slice(colonIdx + 1);
|
|
194
|
+
return tag;
|
|
195
|
+
}
|
|
196
|
+
function isString(value) {
|
|
197
|
+
return typeof value === "string";
|
|
198
|
+
}
|
|
199
|
+
var XmlElement = class XmlElement {
|
|
200
|
+
tag;
|
|
201
|
+
attrs;
|
|
202
|
+
text;
|
|
203
|
+
children;
|
|
204
|
+
constructor(tag, attrs = {}, text) {
|
|
205
|
+
this.tag = tag;
|
|
206
|
+
this.attrs = { ...attrs };
|
|
207
|
+
this.text = text;
|
|
208
|
+
this.children = [];
|
|
209
|
+
}
|
|
210
|
+
localName() {
|
|
211
|
+
return localTagName(this.tag);
|
|
212
|
+
}
|
|
213
|
+
subElement(tag, textOrAttrs, text) {
|
|
214
|
+
const el = isString(textOrAttrs) ? new XmlElement(tag, {}, textOrAttrs) : new XmlElement(tag, textOrAttrs || {}, text);
|
|
215
|
+
this.children.push(el);
|
|
216
|
+
return el;
|
|
217
|
+
}
|
|
218
|
+
find(tag) {
|
|
219
|
+
const targetLocal = localTagName(tag);
|
|
220
|
+
return this.children.find((c) => c.tag === tag || c.localName() === targetLocal);
|
|
221
|
+
}
|
|
222
|
+
findAll(tag) {
|
|
223
|
+
const targetLocal = localTagName(tag);
|
|
224
|
+
return this.children.filter((c) => c.tag === tag || c.localName() === targetLocal);
|
|
225
|
+
}
|
|
226
|
+
findText(tag) {
|
|
227
|
+
return this.find(tag)?.text;
|
|
228
|
+
}
|
|
229
|
+
findDescendant(tag) {
|
|
230
|
+
const targetLocal = localTagName(tag);
|
|
231
|
+
for (const c of this.children) {
|
|
232
|
+
if (c.tag === tag || c.localName() === targetLocal) return c;
|
|
233
|
+
const deep = c.findDescendant(tag);
|
|
234
|
+
if (deep) return deep;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
findAllDescendants(tag) {
|
|
238
|
+
const targetLocal = localTagName(tag);
|
|
239
|
+
const result = [];
|
|
240
|
+
for (const c of this.children) {
|
|
241
|
+
if (c.tag === tag || c.localName() === targetLocal) result.push(c);
|
|
242
|
+
result.push(...c.findAllDescendants(tag));
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
iter() {
|
|
247
|
+
const result = [this];
|
|
248
|
+
for (const child of this.children) result.push(...child.iter());
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
toXml(includeDeclaration = false) {
|
|
252
|
+
let s = includeDeclaration ? `${XML_DECL}\n` : "";
|
|
253
|
+
s += `<${this.tag}`;
|
|
254
|
+
for (const [k, v] of Object.entries(this.attrs)) s += ` ${k}="${escapeXml(v)}"`;
|
|
255
|
+
if (this.children.length === 0 && this.text === void 0) {
|
|
256
|
+
s += "/>";
|
|
257
|
+
return s;
|
|
258
|
+
}
|
|
259
|
+
s += ">";
|
|
260
|
+
if (this.text !== void 0) s += escapeXml(this.text);
|
|
261
|
+
for (const child of this.children) s += child.toXml(false);
|
|
262
|
+
s += `</${this.tag}>`;
|
|
263
|
+
return s;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
function parseXml(xml) {
|
|
267
|
+
let str = (typeof xml === "string" ? xml : xml.toString("utf-8")).trim();
|
|
268
|
+
if (!str) throw new ParseError("Empty XML document");
|
|
269
|
+
if (str.startsWith("<?xml")) {
|
|
270
|
+
const end = str.indexOf("?>");
|
|
271
|
+
if (end !== -1) str = str.slice(end + 2).trim();
|
|
272
|
+
}
|
|
273
|
+
let pos = 0;
|
|
274
|
+
const len = str.length;
|
|
275
|
+
let root = null;
|
|
276
|
+
const stack = [];
|
|
277
|
+
while (pos < len) {
|
|
278
|
+
const lt = str.indexOf("<", pos);
|
|
279
|
+
if (lt === -1) {
|
|
280
|
+
if (stack.length > 0) {
|
|
281
|
+
const text = unescapeXml(str.slice(pos).trim());
|
|
282
|
+
const top = stack.at(-1);
|
|
283
|
+
if (text && top) top.text = (top.text || "") + text;
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
if (lt > pos && stack.length > 0) {
|
|
288
|
+
const text = unescapeXml(str.slice(pos, lt).trim());
|
|
289
|
+
const top = stack.at(-1);
|
|
290
|
+
if (text && top) top.text = (top.text || "") + text;
|
|
291
|
+
}
|
|
292
|
+
pos = lt + 1;
|
|
293
|
+
if (str.startsWith("!--", pos)) {
|
|
294
|
+
const end = str.indexOf("-->", pos + 3);
|
|
295
|
+
pos = end === -1 ? len : end + 3;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (str.startsWith("![CDATA[", pos)) {
|
|
299
|
+
const end = str.indexOf("]]>", pos + 8);
|
|
300
|
+
const cdata = end === -1 ? str.slice(pos + 8) : str.slice(pos + 8, end);
|
|
301
|
+
const top = stack.at(-1);
|
|
302
|
+
if (top) top.text = (top.text || "") + cdata;
|
|
303
|
+
pos = end === -1 ? len : end + 3;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (str.startsWith("?", pos) || str.startsWith("!", pos)) {
|
|
307
|
+
const gt = str.indexOf(">", pos);
|
|
308
|
+
pos = gt === -1 ? len : gt + 1;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (str[pos] === "/") {
|
|
312
|
+
const gt = str.indexOf(">", pos);
|
|
313
|
+
if (gt === -1) throw new ParseError("Unclosed closing tag");
|
|
314
|
+
const closeTag = str.slice(pos + 1, gt).trim();
|
|
315
|
+
const open = stack.pop();
|
|
316
|
+
if (!open) throw new ParseError(`Unexpected closing tag: </${closeTag}>`);
|
|
317
|
+
if (localTagName(open.tag) !== localTagName(closeTag) && open.tag !== closeTag) throw new ParseError(`Mismatched closing tag: expected </${open.tag}>, got </${closeTag}>`);
|
|
318
|
+
pos = gt + 1;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const gt = str.indexOf(">", pos);
|
|
322
|
+
if (gt === -1) throw new ParseError("Unclosed opening tag");
|
|
323
|
+
let tagBody = str.slice(pos, gt).trim();
|
|
324
|
+
const selfClosing = tagBody.endsWith("/");
|
|
325
|
+
if (selfClosing) tagBody = tagBody.slice(0, -1).trim();
|
|
326
|
+
const spaceIdx = tagBody.search(/\s/u);
|
|
327
|
+
const tagName = spaceIdx === -1 ? tagBody : tagBody.slice(0, spaceIdx);
|
|
328
|
+
const attrs = {};
|
|
329
|
+
if (spaceIdx !== -1) {
|
|
330
|
+
const attrStr = tagBody.slice(spaceIdx).trim();
|
|
331
|
+
const attrRegex = /([a-zA-Z0-9_:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gu;
|
|
332
|
+
let m;
|
|
333
|
+
while ((m = attrRegex.exec(attrStr)) !== null) attrs[m[1]] = unescapeXml(m[2] ?? m[3] ?? m[4] ?? "");
|
|
334
|
+
}
|
|
335
|
+
const el = new XmlElement(tagName, attrs);
|
|
336
|
+
const top = stack.at(-1);
|
|
337
|
+
if (top) top.children.push(el);
|
|
338
|
+
else {
|
|
339
|
+
if (root) throw new ParseError("Multiple root elements found in XML document");
|
|
340
|
+
root = el;
|
|
341
|
+
}
|
|
342
|
+
if (!selfClosing) stack.push(el);
|
|
343
|
+
pos = gt + 1;
|
|
344
|
+
}
|
|
345
|
+
const lastUnclosed = stack.at(-1);
|
|
346
|
+
if (lastUnclosed) throw new ParseError(`Unclosed element: <${lastUnclosed.tag}>`);
|
|
347
|
+
if (!root) throw new ParseError("Empty XML document");
|
|
348
|
+
return root;
|
|
349
|
+
}
|
|
350
|
+
function xmlBody(root) {
|
|
351
|
+
return Buffer.from(`${XML_DECL}\n${root.toXml(false)}`, "utf-8");
|
|
352
|
+
}
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region src/server/errors.ts
|
|
355
|
+
function s3Error(code, message, status, resource = "") {
|
|
356
|
+
const root = new XmlElement("Error");
|
|
357
|
+
root.subElement("Code", code);
|
|
358
|
+
root.subElement("Message", message);
|
|
359
|
+
root.subElement("Resource", resource);
|
|
360
|
+
root.subElement("RequestId", newUuid());
|
|
361
|
+
return [
|
|
362
|
+
status,
|
|
363
|
+
{ "Content-Type": "application/xml" },
|
|
364
|
+
xmlBody(root)
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
function noSuchBucket(bucketName) {
|
|
368
|
+
return s3Error("NoSuchBucket", "The specified bucket does not exist", 404, `/${bucketName}`);
|
|
369
|
+
}
|
|
370
|
+
function noSuchKey(bucketName, key, deleteMarkerHeaders) {
|
|
371
|
+
const [status, headers, body] = s3Error("NoSuchKey", "The specified key does not exist.", 404, `/${bucketName}/${key}`);
|
|
372
|
+
if (deleteMarkerHeaders) Object.assign(headers, deleteMarkerHeaders);
|
|
373
|
+
return [
|
|
374
|
+
status,
|
|
375
|
+
headers,
|
|
376
|
+
body
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
function noSuchVersion(bucketName, key) {
|
|
380
|
+
return s3Error("NoSuchVersion", "The specified version does not exist.", 404, `/${bucketName}/${key}`);
|
|
381
|
+
}
|
|
382
|
+
function preconditionFailed(message = "At least one of the pre-conditions you specified did not hold", resource = "") {
|
|
383
|
+
return s3Error("PreconditionFailed", message, 412, resource);
|
|
384
|
+
}
|
|
385
|
+
function invalidRange(bucketName, key, totalSize) {
|
|
386
|
+
const root = new XmlElement("Error");
|
|
387
|
+
root.subElement("Code", "InvalidRange");
|
|
388
|
+
root.subElement("Message", "The requested range is not satisfiable");
|
|
389
|
+
root.subElement("Resource", `/${bucketName}/${key}`);
|
|
390
|
+
root.subElement("RequestId", newUuid());
|
|
391
|
+
return [
|
|
392
|
+
416,
|
|
393
|
+
{
|
|
394
|
+
"Content-Type": "application/xml",
|
|
395
|
+
"Content-Range": `bytes */${totalSize}`
|
|
396
|
+
},
|
|
397
|
+
xmlBody(root)
|
|
398
|
+
];
|
|
399
|
+
}
|
|
400
|
+
function notModified(respHeaders) {
|
|
401
|
+
const payloadHeaders = /* @__PURE__ */ new Set([
|
|
402
|
+
"content-length",
|
|
403
|
+
"content-type",
|
|
404
|
+
"content-encoding",
|
|
405
|
+
"accept-ranges"
|
|
406
|
+
]);
|
|
407
|
+
const headers = {};
|
|
408
|
+
for (const [k, v] of Object.entries(respHeaders)) {
|
|
409
|
+
const lk = k.toLowerCase();
|
|
410
|
+
if (!payloadHeaders.has(lk) && !lk.startsWith("x-amz-checksum-")) headers[k] = v;
|
|
411
|
+
}
|
|
412
|
+
return [
|
|
413
|
+
304,
|
|
414
|
+
headers,
|
|
415
|
+
Buffer.alloc(0)
|
|
416
|
+
];
|
|
417
|
+
}
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/server/types.ts
|
|
420
|
+
const S3_CHECKSUM_ALGORITHMS = [
|
|
421
|
+
"CRC32",
|
|
422
|
+
"CRC32C",
|
|
423
|
+
"CRC64NVME",
|
|
424
|
+
"SHA1",
|
|
425
|
+
"SHA256"
|
|
426
|
+
];
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/server/checksums.ts
|
|
429
|
+
/**
|
|
430
|
+
* S3 Checksums implementation: CRC64NVME, CRC32C, CRC32, SHA1, SHA256,
|
|
431
|
+
* Content-MD5 validation, and composite multipart checksums.
|
|
432
|
+
*/
|
|
433
|
+
const CRC64NVME_POLY = 11127430586519243189n;
|
|
434
|
+
const CRC64NVME_INIT = 18446744073709551615n;
|
|
435
|
+
const CRC64NVME_TABLE = (() => {
|
|
436
|
+
const table = /* @__PURE__ */ new BigInt64Array(256);
|
|
437
|
+
for (let i = 0; i < 256; i += 1) {
|
|
438
|
+
let crc = BigInt(i);
|
|
439
|
+
for (let j = 0; j < 8; j += 1) crc = (crc & 1n) === 0n ? crc >> 1n : crc >> 1n ^ CRC64NVME_POLY;
|
|
440
|
+
table[i] = crc;
|
|
441
|
+
}
|
|
442
|
+
return table;
|
|
443
|
+
})();
|
|
444
|
+
function crc64nvme(body) {
|
|
445
|
+
let crc = CRC64NVME_INIT;
|
|
446
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
447
|
+
const idx = Number((crc ^ BigInt(body[i])) & 255n);
|
|
448
|
+
crc = (CRC64NVME_TABLE[idx] ^ crc >> 8n) & 18446744073709551615n;
|
|
449
|
+
}
|
|
450
|
+
return (crc ^ CRC64NVME_INIT) & 18446744073709551615n;
|
|
451
|
+
}
|
|
452
|
+
const CRC32C_POLY = 2197175160;
|
|
453
|
+
const CRC32C_INIT = 4294967295;
|
|
454
|
+
const CRC32C_TABLE = (() => {
|
|
455
|
+
const table = /* @__PURE__ */ new Uint32Array(256);
|
|
456
|
+
for (let i = 0; i < 256; i += 1) {
|
|
457
|
+
let crc = i >>> 0;
|
|
458
|
+
for (let j = 0; j < 8; j += 1) crc = (crc & 1) === 0 ? crc >>> 1 >>> 0 : (crc >>> 1 ^ CRC32C_POLY) >>> 0;
|
|
459
|
+
table[i] = crc;
|
|
460
|
+
}
|
|
461
|
+
return table;
|
|
462
|
+
})();
|
|
463
|
+
function crc32c(body) {
|
|
464
|
+
let crc = CRC32C_INIT;
|
|
465
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
466
|
+
const idx = (crc ^ body[i]) & 255;
|
|
467
|
+
crc = (CRC32C_TABLE[idx] ^ crc >>> 8) >>> 0;
|
|
468
|
+
}
|
|
469
|
+
return (crc ^ CRC32C_INIT) >>> 0;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Compute the base64-encoded checksum for the given algorithm name.
|
|
473
|
+
*/
|
|
474
|
+
function computeS3Checksum(algorithm, body) {
|
|
475
|
+
const algo = (algorithm || "").toUpperCase().replaceAll("_", "");
|
|
476
|
+
if (algo === "SHA256") return createHash("sha256").update(body).digest("base64");
|
|
477
|
+
if (algo === "SHA1") return createHash("sha1").update(body).digest("base64");
|
|
478
|
+
if (algo === "CRC32") {
|
|
479
|
+
const crc = crc32(body) >>> 0;
|
|
480
|
+
const buf = Buffer.alloc(4);
|
|
481
|
+
buf.writeUInt32BE(crc, 0);
|
|
482
|
+
return buf.toString("base64");
|
|
483
|
+
}
|
|
484
|
+
if (algo === "CRC32C") {
|
|
485
|
+
const crc = crc32c(body);
|
|
486
|
+
const buf = Buffer.alloc(4);
|
|
487
|
+
buf.writeUInt32BE(crc, 0);
|
|
488
|
+
return buf.toString("base64");
|
|
489
|
+
}
|
|
490
|
+
if (algo === "CRC64NVME") {
|
|
491
|
+
const crc = crc64nvme(body);
|
|
492
|
+
const buf = Buffer.alloc(8);
|
|
493
|
+
buf.writeBigUInt64BE(crc, 0);
|
|
494
|
+
return buf.toString("base64");
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Validate Content-MD5 header on write requests.
|
|
500
|
+
*/
|
|
501
|
+
function validateContentMd5(headers, body) {
|
|
502
|
+
const md5Header = headers["content-md5"] || "";
|
|
503
|
+
if (!md5Header) return null;
|
|
504
|
+
let expected;
|
|
505
|
+
try {
|
|
506
|
+
expected = Buffer.from(md5Header, "base64");
|
|
507
|
+
if (expected.toString("base64") !== md5Header.trim()) return s3Error("InvalidDigest", "The Content-MD5 you specified is not valid.", 400);
|
|
508
|
+
} catch {
|
|
509
|
+
return s3Error("InvalidDigest", "The Content-MD5 you specified is not valid.", 400);
|
|
510
|
+
}
|
|
511
|
+
if (expected.length !== 16) return s3Error("InvalidDigest", "The Content-MD5 you specified is not valid.", 400);
|
|
512
|
+
const actual = createHash("md5").update(body).digest();
|
|
513
|
+
if (!expected.equals(actual)) return s3Error("BadDigest", "The Content-MD5 you specified did not match what we received.", 400);
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
const CHECKSUM_HEADERS_LOWER = [
|
|
517
|
+
"crc32",
|
|
518
|
+
"crc32c",
|
|
519
|
+
"crc64nvme",
|
|
520
|
+
"sha1",
|
|
521
|
+
"sha256"
|
|
522
|
+
];
|
|
523
|
+
function resolveObjectChecksums(body, headers) {
|
|
524
|
+
const provided = {};
|
|
525
|
+
const unverifiable = [];
|
|
526
|
+
for (const alg of CHECKSUM_HEADERS_LOWER) {
|
|
527
|
+
const headerVal = headers[`x-amz-checksum-${alg}`];
|
|
528
|
+
if (headerVal) {
|
|
529
|
+
const uAlg = alg.toUpperCase();
|
|
530
|
+
provided[uAlg] = headerVal;
|
|
531
|
+
if (computeS3Checksum(uAlg, Buffer.alloc(0)) === null) unverifiable.push(uAlg);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const sdkAlgRaw = headers["x-amz-sdk-checksum-algorithm"];
|
|
535
|
+
let sdkKey = null;
|
|
536
|
+
if (sdkAlgRaw) {
|
|
537
|
+
sdkKey = sdkAlgRaw.toUpperCase().replaceAll("_", "");
|
|
538
|
+
if (computeS3Checksum(sdkAlgRaw, Buffer.alloc(0)) === null) unverifiable.push(sdkKey);
|
|
539
|
+
}
|
|
540
|
+
if (unverifiable.length > 0) return {
|
|
541
|
+
checksums: {},
|
|
542
|
+
error: s3Error("InvalidRequest", `Checksum algorithm not supported: ${unverifiable.sort().join(", ")}. Supported: SHA256, SHA1, CRC32, CRC32C, CRC64NVME.`, 400)
|
|
543
|
+
};
|
|
544
|
+
for (const [alg, supplied] of Object.entries(provided)) {
|
|
545
|
+
const computed = computeS3Checksum(alg, body);
|
|
546
|
+
if (computed !== null && supplied !== computed) return {
|
|
547
|
+
checksums: {},
|
|
548
|
+
error: s3Error("BadDigest", `The ${alg} you specified did not match the calculated checksum.`, 400)
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const checksums = { ...provided };
|
|
552
|
+
if (sdkAlgRaw && sdkKey) {
|
|
553
|
+
const computed = computeS3Checksum(sdkAlgRaw, body);
|
|
554
|
+
if (computed !== null) checksums[sdkKey] = computed;
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
checksums,
|
|
558
|
+
error: null
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function resolveMultipartChecksumAlgorithm(headers) {
|
|
562
|
+
const raw = headers["x-amz-checksum-algorithm"] || headers["x-amz-sdk-checksum-algorithm"];
|
|
563
|
+
if (!raw) return {
|
|
564
|
+
algorithm: null,
|
|
565
|
+
error: null
|
|
566
|
+
};
|
|
567
|
+
const algorithm = raw.toUpperCase().replaceAll("_", "");
|
|
568
|
+
if (!S3_CHECKSUM_ALGORITHMS.includes(algorithm)) return {
|
|
569
|
+
algorithm: null,
|
|
570
|
+
error: s3Error("InvalidRequest", `Checksum algorithm not supported: ${algorithm}. Supported: SHA256, SHA1, CRC32, CRC32C, CRC64NVME.`, 400)
|
|
571
|
+
};
|
|
572
|
+
return {
|
|
573
|
+
algorithm,
|
|
574
|
+
error: null
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
function resolvePartChecksums(body, headers, upload) {
|
|
578
|
+
const { checksums, error } = resolveObjectChecksums(body, headers);
|
|
579
|
+
if (error) return {
|
|
580
|
+
checksums: {},
|
|
581
|
+
error
|
|
582
|
+
};
|
|
583
|
+
const algorithm = upload.checksumAlgorithm;
|
|
584
|
+
if (algorithm && !(algorithm in checksums)) {
|
|
585
|
+
const computed = computeS3Checksum(algorithm, body);
|
|
586
|
+
if (computed !== null) checksums[algorithm] = computed;
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
checksums,
|
|
590
|
+
error: null
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
function compositeChecksum(algorithm, partChecksums) {
|
|
594
|
+
if (!algorithm || partChecksums.length === 0) return null;
|
|
595
|
+
const chunks = [];
|
|
596
|
+
for (const val of partChecksums) {
|
|
597
|
+
if (!val) return null;
|
|
598
|
+
try {
|
|
599
|
+
chunks.push(Buffer.from(val, "base64"));
|
|
600
|
+
} catch {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const digest = computeS3Checksum(algorithm, Buffer.concat(chunks));
|
|
605
|
+
if (!digest) return null;
|
|
606
|
+
return `${digest}-${partChecksums.length}`;
|
|
607
|
+
}
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/server/cors.ts
|
|
610
|
+
/** S3 permits a single wildcard in origins and header patterns. */
|
|
611
|
+
function matches(pattern, value) {
|
|
612
|
+
const wildcard = pattern.indexOf("*");
|
|
613
|
+
if (wildcard === -1) return pattern === value;
|
|
614
|
+
return value.length >= pattern.length - 1 && value.startsWith(pattern.slice(0, wildcard)) && value.endsWith(pattern.slice(wildcard + 1));
|
|
615
|
+
}
|
|
616
|
+
function corsResponseHeaders(configuration, method, headers) {
|
|
617
|
+
const origin = headers["origin"];
|
|
618
|
+
if (!origin || !configuration) return void 0;
|
|
619
|
+
const preflight = method === "OPTIONS";
|
|
620
|
+
const requestedMethod = preflight ? headers["access-control-request-method"] : method;
|
|
621
|
+
const requestedHeaders = preflight ? (headers["access-control-request-headers"] || "").split(",").map((header) => header.trim().toLowerCase()).filter(Boolean) : [];
|
|
622
|
+
try {
|
|
623
|
+
for (const rule of parseXml(configuration).findAll("CORSRule")) {
|
|
624
|
+
const origins = rule.findAll("AllowedOrigin").map((element) => element.text || "");
|
|
625
|
+
const methods = rule.findAll("AllowedMethod").map((element) => element.text || "");
|
|
626
|
+
const allowedHeaders = rule.findAll("AllowedHeader").map((element) => (element.text || "").toLowerCase());
|
|
627
|
+
if (!origins.some((pattern) => matches(pattern, origin)) || !methods.includes(requestedMethod) || !requestedHeaders.every((header) => allowedHeaders.some((pattern) => matches(pattern, header)))) continue;
|
|
628
|
+
const result = {
|
|
629
|
+
"Access-Control-Allow-Origin": origin,
|
|
630
|
+
Vary: "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
|
|
631
|
+
};
|
|
632
|
+
if (preflight) {
|
|
633
|
+
result["Access-Control-Allow-Methods"] = methods.join(", ");
|
|
634
|
+
if (requestedHeaders.length) result["Access-Control-Allow-Headers"] = requestedHeaders.join(", ");
|
|
635
|
+
const maxAge = rule.findText("MaxAgeSeconds");
|
|
636
|
+
if (maxAge !== void 0) result["Access-Control-Max-Age"] = maxAge;
|
|
637
|
+
}
|
|
638
|
+
const exposed = rule.findAll("ExposeHeader").map((element) => element.text || "");
|
|
639
|
+
if (exposed.length) result["Access-Control-Expose-Headers"] = exposed.join(", ");
|
|
640
|
+
return result;
|
|
641
|
+
}
|
|
642
|
+
} catch {}
|
|
643
|
+
}
|
|
644
|
+
//#endregion
|
|
645
|
+
//#region src/server/lifecycle.ts
|
|
646
|
+
function days(element, name) {
|
|
647
|
+
const value = Number(element.findText(name));
|
|
648
|
+
if (!Number.isInteger(value) || value < 1) throw new Error("Lifecycle days must be positive integers.");
|
|
649
|
+
return value;
|
|
650
|
+
}
|
|
651
|
+
function onlyChildren(element, names) {
|
|
652
|
+
if (element.children.some((child) => !names.includes(child.localName()))) throw new Error("Unsupported lifecycle configuration.");
|
|
653
|
+
}
|
|
654
|
+
function parseLifecycleXml(body) {
|
|
655
|
+
const root = parseXml(body);
|
|
656
|
+
if (root.localName() !== "LifecycleConfiguration") throw new Error("Invalid lifecycle root.");
|
|
657
|
+
onlyChildren(root, ["Rule"]);
|
|
658
|
+
return root.findAll("Rule").map((element) => {
|
|
659
|
+
onlyChildren(element, [
|
|
660
|
+
"ID",
|
|
661
|
+
"Status",
|
|
662
|
+
"Filter",
|
|
663
|
+
"Expiration",
|
|
664
|
+
"Transition",
|
|
665
|
+
"AbortIncompleteMultipartUpload"
|
|
666
|
+
]);
|
|
667
|
+
const status = element.findText("Status");
|
|
668
|
+
if (status !== "Enabled" && status !== "Disabled") throw new Error("Invalid lifecycle status.");
|
|
669
|
+
const rule = {
|
|
670
|
+
ID: element.findText("ID"),
|
|
671
|
+
Status: status
|
|
672
|
+
};
|
|
673
|
+
const filter = element.find("Filter");
|
|
674
|
+
if (filter) {
|
|
675
|
+
onlyChildren(filter, ["Prefix"]);
|
|
676
|
+
rule.Filter = { Prefix: filter.findText("Prefix") };
|
|
677
|
+
}
|
|
678
|
+
const expiration = element.find("Expiration");
|
|
679
|
+
if (expiration) {
|
|
680
|
+
onlyChildren(expiration, ["Days"]);
|
|
681
|
+
rule.Expiration = { Days: days(expiration, "Days") };
|
|
682
|
+
}
|
|
683
|
+
rule.Transitions = element.findAll("Transition").map((transition) => {
|
|
684
|
+
onlyChildren(transition, ["Days", "StorageClass"]);
|
|
685
|
+
if (transition.findText("StorageClass") !== "STANDARD_IA") throw new Error("Unsupported lifecycle transition.");
|
|
686
|
+
return {
|
|
687
|
+
Days: days(transition, "Days"),
|
|
688
|
+
StorageClass: "STANDARD_IA"
|
|
689
|
+
};
|
|
690
|
+
});
|
|
691
|
+
const abort = element.find("AbortIncompleteMultipartUpload");
|
|
692
|
+
if (abort) {
|
|
693
|
+
onlyChildren(abort, ["DaysAfterInitiation"]);
|
|
694
|
+
rule.AbortIncompleteMultipartUpload = { DaysAfterInitiation: days(abort, "DaysAfterInitiation") };
|
|
695
|
+
}
|
|
696
|
+
return rule;
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
//#endregion
|
|
700
|
+
//#region src/server/sigv4.ts
|
|
701
|
+
/**
|
|
702
|
+
* AWS Signature Version 4 (SigV4) presigned URL verification and header hoisting.
|
|
703
|
+
*/
|
|
704
|
+
const SIGV4_UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
|
|
705
|
+
const EXTRA_URI_CHARACTERS = /[!'()*]/gu;
|
|
706
|
+
const PRESIGN_SIGNING_PARAMS = /* @__PURE__ */ new Set([
|
|
707
|
+
"x-amz-algorithm",
|
|
708
|
+
"x-amz-credential",
|
|
709
|
+
"x-amz-date",
|
|
710
|
+
"x-amz-expires",
|
|
711
|
+
"x-amz-signedheaders",
|
|
712
|
+
"x-amz-signature",
|
|
713
|
+
"x-amz-security-token",
|
|
714
|
+
"x-amz-content-sha256"
|
|
715
|
+
]);
|
|
716
|
+
/**
|
|
717
|
+
* RFC 3986 URI encoding per SigV4 specification:
|
|
718
|
+
* Unreserved characters (A-Za-z0-9-_.~) remain literal; everything else is percent-encoded.
|
|
719
|
+
* If encodeSlash is false, "/" is preserved (used for path components).
|
|
720
|
+
*/
|
|
721
|
+
function uriEncode(value, encodeSlash = true) {
|
|
722
|
+
const encoded = encodeURIComponent(value).replaceAll(EXTRA_URI_CHARACTERS, (character) => `%${character.codePointAt(0)?.toString(16).toUpperCase()}`);
|
|
723
|
+
return encodeSlash ? encoded : encoded.replaceAll("%2F", "/");
|
|
724
|
+
}
|
|
725
|
+
function sigv4SigningKey(secret, dateStamp, region, service) {
|
|
726
|
+
const hmac = (key, msg) => {
|
|
727
|
+
return createHmac("sha256", key).update(msg, "utf-8").digest();
|
|
728
|
+
};
|
|
729
|
+
return hmac(hmac(hmac(hmac(`AWS4${secret}`, dateStamp), region), service), "aws4_request");
|
|
730
|
+
}
|
|
731
|
+
function resolvePresignSecret(_accessKeyId) {
|
|
732
|
+
return process.env.AWS_SECRET_ACCESS_KEY || "test";
|
|
733
|
+
}
|
|
734
|
+
function hasAnyPresignParam(queryParams) {
|
|
735
|
+
for (const key of Object.keys(queryParams)) if (PRESIGN_SIGNING_PARAMS.has(key.toLowerCase())) return true;
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
function hasDuplicatePresignParams(queryParams) {
|
|
739
|
+
const seen = /* @__PURE__ */ new Set();
|
|
740
|
+
for (const [key, val] of Object.entries(queryParams)) {
|
|
741
|
+
const lk = key.toLowerCase();
|
|
742
|
+
if (PRESIGN_SIGNING_PARAMS.has(lk)) {
|
|
743
|
+
if (seen.has(lk) || Array.isArray(val)) return true;
|
|
744
|
+
seen.add(lk);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
function verifyPresignedSigV4(method, path, headers, queryParams) {
|
|
750
|
+
if (!hasAnyPresignParam(queryParams)) return null;
|
|
751
|
+
const badSignature = () => {
|
|
752
|
+
return s3Error("SignatureDoesNotMatch", "The request signature we calculated does not match the signature you provided. Check your key and signing method.", 403, path);
|
|
753
|
+
};
|
|
754
|
+
if (hasDuplicatePresignParams(queryParams)) return s3Error("AuthorizationQueryParametersError", "Duplicate query parameter in request.", 400, path);
|
|
755
|
+
const signature = qp(queryParams, "X-Amz-Signature") || qp(queryParams, "x-amz-signature");
|
|
756
|
+
const algorithm = qp(queryParams, "X-Amz-Algorithm") || qp(queryParams, "x-amz-algorithm");
|
|
757
|
+
const credential = qp(queryParams, "X-Amz-Credential") || qp(queryParams, "x-amz-credential");
|
|
758
|
+
const amzDate = qp(queryParams, "X-Amz-Date") || qp(queryParams, "x-amz-date");
|
|
759
|
+
const signedHeaders = qp(queryParams, "X-Amz-SignedHeaders") || qp(queryParams, "x-amz-signedheaders");
|
|
760
|
+
if (!algorithm || algorithm !== "AWS4-HMAC-SHA256") return badSignature();
|
|
761
|
+
if (!signature || !credential || !amzDate || !signedHeaders) return badSignature();
|
|
762
|
+
const credParts = credential.split("/");
|
|
763
|
+
if (credParts.length !== 5 || credParts[4] !== "aws4_request" || credParts[3] !== "s3") return badSignature();
|
|
764
|
+
const [akid, dateStamp, region, service] = credParts;
|
|
765
|
+
const m = amzDate.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/u);
|
|
766
|
+
if (!m || amzDate.slice(0, 8) !== dateStamp) return badSignature();
|
|
767
|
+
const expires = qp(queryParams, "X-Amz-Expires") || qp(queryParams, "x-amz-expires");
|
|
768
|
+
if (!/^\d+$/u.test(expires) || Number(expires) < 1 || Number(expires) > 604800) return s3Error("AuthorizationQueryParametersError", "X-Amz-Expires must be a valid integer between 1 and 604800.", 400, path);
|
|
769
|
+
const signedAt = /* @__PURE__ */ new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`);
|
|
770
|
+
if (!Number.isFinite(signedAt.getTime()) || signedAt.toISOString().replaceAll(/[-:]/gu, "").replace(".000", "") !== amzDate) return badSignature();
|
|
771
|
+
if (!akid || !region) return badSignature();
|
|
772
|
+
const requiredHeaders = signedHeaders.split(";");
|
|
773
|
+
const presentHeaders = new Set(Object.keys(headers).map((header) => header.toLowerCase()));
|
|
774
|
+
if (!requiredHeaders.includes("host") || new Set(requiredHeaders).size !== requiredHeaders.length || requiredHeaders.toSorted().join(";") !== signedHeaders || requiredHeaders.some((header) => !/^[a-z0-9-]+$/u.test(header) || !presentHeaders.has(header))) return badSignature();
|
|
775
|
+
if (Date.now() >= signedAt.getTime() + Number(expires) * 1e3) return s3Error("AccessDenied", "Request has expired", 403, path);
|
|
776
|
+
if (signedAt.getTime() > Date.now() + 9e5) return badSignature();
|
|
777
|
+
const pairs = [];
|
|
778
|
+
for (const [name, values] of Object.entries(queryParams)) {
|
|
779
|
+
if (name.toLowerCase() === "x-amz-signature") continue;
|
|
780
|
+
const vlist = Array.isArray(values) ? values : [values];
|
|
781
|
+
for (const v of vlist) pairs.push([uriEncode(name), uriEncode(v ?? "")]);
|
|
782
|
+
}
|
|
783
|
+
pairs.sort((a, b) => {
|
|
784
|
+
const left = a[0] === b[0] ? a[1] : a[0];
|
|
785
|
+
const right = a[0] === b[0] ? b[1] : b[0];
|
|
786
|
+
if (left < right) return -1;
|
|
787
|
+
return left > right ? 1 : 0;
|
|
788
|
+
});
|
|
789
|
+
const canonicalQs = pairs.map(([k, v]) => `${k}=${v}`).join("&");
|
|
790
|
+
let canonicalHeaders = "";
|
|
791
|
+
const headerList = signedHeaders.split(";").filter(Boolean);
|
|
792
|
+
const lowerHeaders = {};
|
|
793
|
+
for (const [k, v] of Object.entries(headers)) lowerHeaders[k.toLowerCase()] = v;
|
|
794
|
+
for (const hname of headerList) {
|
|
795
|
+
const lh = hname.toLowerCase();
|
|
796
|
+
const collapsed = (lowerHeaders[lh] ?? "").toString().trim().replaceAll(/\s+/gu, " ");
|
|
797
|
+
canonicalHeaders += `${lh}:${collapsed}\n`;
|
|
798
|
+
}
|
|
799
|
+
const canonicalRequest = [
|
|
800
|
+
method,
|
|
801
|
+
path,
|
|
802
|
+
canonicalQs,
|
|
803
|
+
canonicalHeaders,
|
|
804
|
+
signedHeaders,
|
|
805
|
+
SIGV4_UNSIGNED_PAYLOAD
|
|
806
|
+
].join("\n");
|
|
807
|
+
const stringToSign = [
|
|
808
|
+
"AWS4-HMAC-SHA256",
|
|
809
|
+
amzDate,
|
|
810
|
+
`${dateStamp}/${region}/${service}/aws4_request`,
|
|
811
|
+
sha256Hex(canonicalRequest)
|
|
812
|
+
].join("\n");
|
|
813
|
+
const signingKey = sigv4SigningKey(resolvePresignSecret(akid), dateStamp, region, service);
|
|
814
|
+
const computed = createHmac("sha256", signingKey).update(stringToSign, "utf-8").digest("hex");
|
|
815
|
+
const computedBuf = Buffer.from(computed.toLowerCase(), "utf-8");
|
|
816
|
+
const providedBuf = Buffer.from(signature.toLowerCase(), "utf-8");
|
|
817
|
+
if (computedBuf.length !== providedBuf.length || !timingSafeEqual(computedBuf, providedBuf)) return badSignature();
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Merges hoisted `x-amz-*` query parameters into the request headers.
|
|
822
|
+
*/
|
|
823
|
+
function mergeHoistedAmzHeaders(headers, queryParams) {
|
|
824
|
+
let hoisted = null;
|
|
825
|
+
for (const [name, values] of Object.entries(queryParams)) {
|
|
826
|
+
const lname = name.toLowerCase();
|
|
827
|
+
if (!lname.startsWith("x-amz-") || PRESIGN_SIGNING_PARAMS.has(lname)) continue;
|
|
828
|
+
if (lname in headers) continue;
|
|
829
|
+
const val = Array.isArray(values) ? values[0] : values;
|
|
830
|
+
if (val === void 0 || val === null) continue;
|
|
831
|
+
if (!hoisted) hoisted = { ...headers };
|
|
832
|
+
hoisted[lname] = String(val);
|
|
833
|
+
}
|
|
834
|
+
return hoisted ?? headers;
|
|
835
|
+
}
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region src/server/storage-schema.ts
|
|
838
|
+
const BODY_REFERENCE = /^[a-f0-9]{64}$/u;
|
|
839
|
+
function record(value) {
|
|
840
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
841
|
+
}
|
|
842
|
+
function text(value) {
|
|
843
|
+
return typeof value === "string";
|
|
844
|
+
}
|
|
845
|
+
function timestamp(value) {
|
|
846
|
+
return text(value) && Number.isFinite(Date.parse(value));
|
|
847
|
+
}
|
|
848
|
+
function size(value) {
|
|
849
|
+
return Number.isSafeInteger(value) && Number(value) >= 0;
|
|
850
|
+
}
|
|
851
|
+
function strings(value) {
|
|
852
|
+
return record(value) && Object.values(value).every(text);
|
|
853
|
+
}
|
|
854
|
+
function optional(value, check) {
|
|
855
|
+
return value === void 0 || check(value);
|
|
856
|
+
}
|
|
857
|
+
function bodyFields(value, version) {
|
|
858
|
+
return text(value.etag) && timestamp(value.lastModified) && size(value.size) && optional(value.contentType, text) && optional(value.contentEncoding, text) && optional(value.metadata, strings) && optional(value.preservedHeaders, strings) && optional(value.checksums, strings) && optional(value.checksumType, (type) => type === "FULL_OBJECT" || type === "COMPOSITE") && optional(value.bodyRef, (ref) => text(ref) && BODY_REFERENCE.test(ref)) && (version < 2 || value.isDeleteMarker === true || text(value.bodyRef));
|
|
859
|
+
}
|
|
860
|
+
function object(value, version) {
|
|
861
|
+
return record(value) && value.body === null && (version < 2 || text(value.bodyRef)) && text(value.contentType) && strings(value.metadata) && strings(value.preservedHeaders) && strings(value.checksums) && optional(value.versionId, (id) => id === null || text(id)) && bodyFields(value, version);
|
|
862
|
+
}
|
|
863
|
+
function versions(value, version) {
|
|
864
|
+
return Array.isArray(value) && value.every((item) => record(item) && text(item.versionId) && typeof item.isLatest === "boolean" && optional(item.isDeleteMarker, (flag) => typeof flag === "boolean") && (item.data === void 0 || item.data === null) && bodyFields(item, version));
|
|
865
|
+
}
|
|
866
|
+
function positive(value) {
|
|
867
|
+
return size(value) && Number(value) > 0;
|
|
868
|
+
}
|
|
869
|
+
function lifecycle(value) {
|
|
870
|
+
return Array.isArray(value) && value.every((rule) => record(rule) && (rule.Status === "Enabled" || rule.Status === "Disabled") && optional(rule.ID, text) && optional(rule.Filter, (filter) => record(filter) && optional(filter.Prefix, text)) && optional(rule.Expiration, (expiry) => record(expiry) && positive(expiry.Days)) && optional(rule.AbortIncompleteMultipartUpload, (abort) => record(abort) && positive(abort.DaysAfterInitiation)) && optional(rule.Transitions, (transitions) => Array.isArray(transitions) && transitions.every((entry) => record(entry) && positive(entry.Days) && entry.StorageClass === "STANDARD_IA")));
|
|
871
|
+
}
|
|
872
|
+
function isValidState(value) {
|
|
873
|
+
if (!record(value)) throw new Error("Corrupted persistence state manifest: expected object.");
|
|
874
|
+
if (value.version !== void 0 && value.version !== 1 && value.version !== 2) throw new Error(`Unsupported persistence state manifest version: ${String(value.version)}`);
|
|
875
|
+
const version = Number(value.version ?? 1);
|
|
876
|
+
const map = (input, check) => record(input) && Object.entries(input).every(([key, entry]) => key.includes("::") && check(entry));
|
|
877
|
+
const optionalMap = (input, check) => version < 2 && input === void 0 || map(input, check);
|
|
878
|
+
if (!map(value.buckets_meta, (bucket) => record(bucket) && timestamp(bucket.created) && text(bucket.region) && record(bucket.objects) && Object.values(bucket.objects).every((entry) => object(entry, version))) || !optionalMap(value.bucket_versioning, (entry) => entry === "Enabled" || entry === "Suspended") || !optionalMap(value.bucket_cors, text) || !optionalMap(value.bucket_lifecycle, lifecycle) || !optionalMap(value.object_versions, (entry) => versions(entry, version))) throw new Error("Corrupted persistence state manifest: invalid state schema.");
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
function validateState(value) {
|
|
882
|
+
if (!isValidState(value)) throw new Error("Invalid persistence state.");
|
|
883
|
+
return value;
|
|
884
|
+
}
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/server/storage.ts
|
|
887
|
+
/**
|
|
888
|
+
* In-memory storage engine and filesystem persistence for S3 emulator.
|
|
889
|
+
*/
|
|
890
|
+
function writeAll(fd, bytes) {
|
|
891
|
+
let offset = 0;
|
|
892
|
+
while (offset < bytes.length) {
|
|
893
|
+
const written = writeSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
894
|
+
if (written === 0) throw new Error("Persistence write made no progress.");
|
|
895
|
+
offset += written;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
var S3Storage = class {
|
|
899
|
+
dataDir;
|
|
900
|
+
persist;
|
|
901
|
+
committedState;
|
|
902
|
+
buckets = new AccountScopedMap();
|
|
903
|
+
bucketVersioning = new AccountScopedMap();
|
|
904
|
+
bucketLifecycle = new AccountScopedMap();
|
|
905
|
+
bucketCors = new AccountScopedMap();
|
|
906
|
+
objectVersions = new AccountScopedMap();
|
|
907
|
+
multipartUploads = new AccountScopedMap();
|
|
908
|
+
completedMultipartUploads = new AccountScopedMap();
|
|
909
|
+
constructor(options = {}) {
|
|
910
|
+
this.dataDir = options.dataDir || process.env.S3_DATA_DIR || "/tmp/emulator/s3";
|
|
911
|
+
this.persist = options.persist ?? process.env.S3_PERSIST === "1";
|
|
912
|
+
if (this.persist) this.loadPersistedData();
|
|
913
|
+
}
|
|
914
|
+
objectDiskPath(bucket, key, accountId) {
|
|
915
|
+
return join(this.bucketDiskPath(bucket, accountId), createHash("sha256").update(key).digest("hex"), "body");
|
|
916
|
+
}
|
|
917
|
+
bucketDiskPath(bucket, accountId = getAccountId()) {
|
|
918
|
+
const digest = createHash("sha256").update(JSON.stringify([accountId, bucket])).digest("hex");
|
|
919
|
+
return join(this.dataDir, "buckets", digest);
|
|
920
|
+
}
|
|
921
|
+
versionDiskPath(bucket, key, versionId, accountId) {
|
|
922
|
+
const basePath = this.objectDiskPath(bucket, key, accountId);
|
|
923
|
+
return join(dirname(basePath), "versions", createHash("sha256").update(versionId).digest("hex"));
|
|
924
|
+
}
|
|
925
|
+
syncDirectory(directory) {
|
|
926
|
+
if (process.platform === "win32") return;
|
|
927
|
+
const fd = openSync(directory, "r");
|
|
928
|
+
try {
|
|
929
|
+
fsyncSync(fd);
|
|
930
|
+
} finally {
|
|
931
|
+
closeSync(fd);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
atomicWrite(filePath, data) {
|
|
935
|
+
const tmp = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
936
|
+
const dir = dirname(filePath);
|
|
937
|
+
const missing = [];
|
|
938
|
+
for (let current = dir; !existsSync(current); current = dirname(current)) missing.push(current);
|
|
939
|
+
mkdirSync(dir, {
|
|
940
|
+
recursive: true,
|
|
941
|
+
mode: 448
|
|
942
|
+
});
|
|
943
|
+
for (const created of missing.toReversed()) this.syncDirectory(dirname(created));
|
|
944
|
+
try {
|
|
945
|
+
const fd = openSync(tmp, "wx", 384);
|
|
946
|
+
try {
|
|
947
|
+
writeAll(fd, Buffer.isBuffer(data) ? data : Buffer.from(data, "utf-8"));
|
|
948
|
+
fsyncSync(fd);
|
|
949
|
+
} finally {
|
|
950
|
+
closeSync(fd);
|
|
951
|
+
}
|
|
952
|
+
renameSync(tmp, filePath);
|
|
953
|
+
this.syncDirectory(dir);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
956
|
+
throw error;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
blobPath(reference) {
|
|
960
|
+
return join(this.dataDir, "blobs", reference);
|
|
961
|
+
}
|
|
962
|
+
persistBody(body) {
|
|
963
|
+
const reference = createHash("sha256").update(body).digest("hex");
|
|
964
|
+
const path = this.blobPath(reference);
|
|
965
|
+
if (existsSync(path)) this.syncDirectory(dirname(path));
|
|
966
|
+
else this.atomicWrite(path, body);
|
|
967
|
+
return reference;
|
|
968
|
+
}
|
|
969
|
+
readBody(bucketName, key, obj) {
|
|
970
|
+
if (obj?.body !== null && obj?.body !== void 0) return obj.body;
|
|
971
|
+
if (!this.persist) return Buffer.alloc(0);
|
|
972
|
+
if (obj?.bodyRef) {
|
|
973
|
+
if (!existsSync(this.blobPath(obj.bodyRef))) throw new Error(`Persisted object body not found for key: ${key}`);
|
|
974
|
+
return readFileSync(this.blobPath(obj.bodyRef));
|
|
975
|
+
}
|
|
976
|
+
if (obj?.versionId) {
|
|
977
|
+
const vpath = this.versionDiskPath(bucketName, key, obj.versionId);
|
|
978
|
+
return readFileSync(vpath);
|
|
979
|
+
}
|
|
980
|
+
const fpath = this.objectDiskPath(bucketName, key);
|
|
981
|
+
if (!existsSync(fpath)) throw new Error(`Persisted object body not found for key: ${key}`);
|
|
982
|
+
return readFileSync(fpath);
|
|
983
|
+
}
|
|
984
|
+
readVersionBody(bucketName, key, v) {
|
|
985
|
+
if (v.data !== null && v.data !== void 0) return v.data;
|
|
986
|
+
if (!this.persist) return Buffer.alloc(0);
|
|
987
|
+
if (v.bodyRef) return readFileSync(this.blobPath(v.bodyRef));
|
|
988
|
+
const vpath = this.versionDiskPath(bucketName, key, v.versionId);
|
|
989
|
+
if (existsSync(vpath)) return readFileSync(vpath);
|
|
990
|
+
throw new Error(`Persisted version body not found for key: ${key}, version: ${v.versionId}`);
|
|
991
|
+
}
|
|
992
|
+
deletePersistedObject(_bucketName, _key) {}
|
|
993
|
+
/** Commit immutable body references before releasing any in-memory content. */
|
|
994
|
+
persistState() {
|
|
995
|
+
if (!this.persist) return;
|
|
996
|
+
try {
|
|
997
|
+
const state = this.getState();
|
|
998
|
+
for (const [scopedKey, bucket] of this.buckets._data) {
|
|
999
|
+
const separator = scopedKey.indexOf("::");
|
|
1000
|
+
const accountId = scopedKey.slice(0, separator);
|
|
1001
|
+
const bucketName = scopedKey.slice(separator + 2);
|
|
1002
|
+
withAccountContext({ accountId }, () => {
|
|
1003
|
+
for (const [key, obj] of Object.entries(bucket.objects)) state.buckets_meta[scopedKey].objects[key].bodyRef = obj.bodyRef && obj.body === null ? obj.bodyRef : this.persistBody(this.readBody(bucketName, key, obj));
|
|
1004
|
+
for (const [[name, key], versions] of this.objectVersions.entries()) {
|
|
1005
|
+
if (name !== bucketName) continue;
|
|
1006
|
+
const stored = state.object_versions?.[`${accountId}::${JSON.stringify([name, key])}`];
|
|
1007
|
+
if (!stored) throw new Error("Missing version snapshot.");
|
|
1008
|
+
for (const [index, version] of versions.entries()) {
|
|
1009
|
+
if (version.isDeleteMarker) continue;
|
|
1010
|
+
stored[index].bodyRef = version.bodyRef && (version.data === null || version.data === void 0) ? version.bodyRef : this.persistBody(this.readVersionBody(name, key, version));
|
|
1011
|
+
stored[index].data = null;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
validateState(state);
|
|
1017
|
+
this.atomicWrite(join(this.dataDir, ".config", "state.json"), JSON.stringify(state));
|
|
1018
|
+
this.committedState = state;
|
|
1019
|
+
this.replaceState(state);
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
this.clearState();
|
|
1022
|
+
this.completedMultipartUploads._data.clear();
|
|
1023
|
+
this.loadPersistedData(false);
|
|
1024
|
+
throw error;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
clearState() {
|
|
1028
|
+
this.buckets._data.clear();
|
|
1029
|
+
this.bucketVersioning._data.clear();
|
|
1030
|
+
this.bucketLifecycle._data.clear();
|
|
1031
|
+
this.bucketCors._data.clear();
|
|
1032
|
+
this.objectVersions._data.clear();
|
|
1033
|
+
}
|
|
1034
|
+
replaceState(state) {
|
|
1035
|
+
this.clearState();
|
|
1036
|
+
this.restoreState(structuredClone(state));
|
|
1037
|
+
}
|
|
1038
|
+
loadPersistedData(reclaim = true) {
|
|
1039
|
+
const manifest = join(this.dataDir, ".config", "state.json");
|
|
1040
|
+
if (!existsSync(manifest)) return;
|
|
1041
|
+
let parsed;
|
|
1042
|
+
try {
|
|
1043
|
+
parsed = JSON.parse(readFileSync(manifest, "utf-8"));
|
|
1044
|
+
} catch {
|
|
1045
|
+
throw new Error("Corrupted persistence state manifest: invalid JSON.");
|
|
1046
|
+
}
|
|
1047
|
+
const state = validateState(parsed);
|
|
1048
|
+
this.committedState = state;
|
|
1049
|
+
this.replaceState(state);
|
|
1050
|
+
if (reclaim) this.reclaimBlobs();
|
|
1051
|
+
}
|
|
1052
|
+
reclaimBlobs() {
|
|
1053
|
+
const directory = join(this.dataDir, "blobs");
|
|
1054
|
+
if (!existsSync(directory) || !this.committedState) return;
|
|
1055
|
+
const references = /* @__PURE__ */ new Set();
|
|
1056
|
+
for (const bucket of Object.values(this.committedState.buckets_meta)) for (const object of Object.values(bucket.objects)) if (object.bodyRef) references.add(object.bodyRef);
|
|
1057
|
+
for (const versions of Object.values(this.committedState.object_versions ?? {})) for (const version of versions) if (version.bodyRef) references.add(version.bodyRef);
|
|
1058
|
+
for (const reference of references) if (!existsSync(this.blobPath(reference))) throw new Error("Persisted object body not found.");
|
|
1059
|
+
for (const name of readdirSync(directory)) if (!references.has(name)) unlinkSync(join(directory, name));
|
|
1060
|
+
}
|
|
1061
|
+
getState() {
|
|
1062
|
+
const bucketsMeta = {};
|
|
1063
|
+
for (const [scopedKey, bkt] of this.buckets._data.entries()) {
|
|
1064
|
+
const objectsMeta = Object.create(null);
|
|
1065
|
+
for (const [k, obj] of Object.entries(bkt.objects)) objectsMeta[k] = {
|
|
1066
|
+
...obj,
|
|
1067
|
+
body: null
|
|
1068
|
+
};
|
|
1069
|
+
const { objects: _objects, ...meta } = bkt;
|
|
1070
|
+
bucketsMeta[scopedKey] = {
|
|
1071
|
+
...meta,
|
|
1072
|
+
objects: objectsMeta
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
return {
|
|
1076
|
+
version: 2,
|
|
1077
|
+
buckets_meta: bucketsMeta,
|
|
1078
|
+
bucket_versioning: Object.fromEntries(this.bucketVersioning._data),
|
|
1079
|
+
bucket_lifecycle: Object.fromEntries(this.bucketLifecycle._data),
|
|
1080
|
+
bucket_cors: Object.fromEntries(this.bucketCors._data),
|
|
1081
|
+
object_versions: structuredClone(Object.fromEntries(this.objectVersions._data))
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
restoreState(state) {
|
|
1085
|
+
if (!state) return;
|
|
1086
|
+
if (state.buckets_meta) {
|
|
1087
|
+
if (typeof state.buckets_meta !== "object" || state.buckets_meta === null) throw new Error("Corrupted persistence state manifest: invalid buckets_meta.");
|
|
1088
|
+
for (const [scopedKey, meta] of Object.entries(state.buckets_meta)) if (!this.buckets._data.has(scopedKey)) {
|
|
1089
|
+
const objects = meta.objects || {};
|
|
1090
|
+
this.buckets._data.set(scopedKey, {
|
|
1091
|
+
...meta,
|
|
1092
|
+
objects: Object.assign(Object.create(null), objects)
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const importMap = (map, data) => {
|
|
1097
|
+
if (!data) return;
|
|
1098
|
+
for (const [k, v] of Object.entries(data)) map._data.set(k, v);
|
|
1099
|
+
};
|
|
1100
|
+
importMap(this.bucketVersioning, state.bucket_versioning);
|
|
1101
|
+
importMap(this.bucketLifecycle, state.bucket_lifecycle);
|
|
1102
|
+
importMap(this.bucketCors, state.bucket_cors);
|
|
1103
|
+
importMap(this.objectVersions, state.object_versions);
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/server/service.ts
|
|
1108
|
+
/**
|
|
1109
|
+
* Core S3 Service Emulator implementation.
|
|
1110
|
+
*/
|
|
1111
|
+
const PRESERVED_HEADERS = [
|
|
1112
|
+
"cache-control",
|
|
1113
|
+
"content-disposition",
|
|
1114
|
+
"content-language",
|
|
1115
|
+
"expires"
|
|
1116
|
+
];
|
|
1117
|
+
const RESPONSE_OVERRIDE_PARAMS = [
|
|
1118
|
+
"response-cache-control",
|
|
1119
|
+
"response-content-disposition",
|
|
1120
|
+
"response-content-encoding",
|
|
1121
|
+
"response-content-language",
|
|
1122
|
+
"response-content-type",
|
|
1123
|
+
"response-expires"
|
|
1124
|
+
];
|
|
1125
|
+
var S3Service = class {
|
|
1126
|
+
storage;
|
|
1127
|
+
constructor(options = {}) {
|
|
1128
|
+
this.storage = new S3Storage(options);
|
|
1129
|
+
}
|
|
1130
|
+
canonicalOwnerId() {
|
|
1131
|
+
return createHash("sha256").update(getAccountId()).digest("hex");
|
|
1132
|
+
}
|
|
1133
|
+
extractUserMetadata(headers) {
|
|
1134
|
+
const meta = {};
|
|
1135
|
+
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase().startsWith("x-amz-meta-")) meta[k] = v;
|
|
1136
|
+
return meta;
|
|
1137
|
+
}
|
|
1138
|
+
buildObjectRecord(body, headers, etag, checksums) {
|
|
1139
|
+
const contentType = headers["content-type"] || "application/octet-stream";
|
|
1140
|
+
const contentEncoding = headers["content-encoding"];
|
|
1141
|
+
const preserved = {};
|
|
1142
|
+
for (const h of PRESERVED_HEADERS) if (headers[h] !== void 0) preserved[h] = headers[h];
|
|
1143
|
+
return {
|
|
1144
|
+
body,
|
|
1145
|
+
contentType,
|
|
1146
|
+
contentEncoding,
|
|
1147
|
+
etag: etag || `"${md5Hex(body)}"`,
|
|
1148
|
+
lastModified: nowIso(),
|
|
1149
|
+
size: body.length,
|
|
1150
|
+
metadata: this.extractUserMetadata(headers),
|
|
1151
|
+
preservedHeaders: preserved,
|
|
1152
|
+
checksums: checksums || {}
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
parseRange(rangeHeader, total) {
|
|
1156
|
+
const m = rangeHeader.match(/^bytes=(\d*)-(\d*)$/u);
|
|
1157
|
+
if (!m) return null;
|
|
1158
|
+
const s = m[1];
|
|
1159
|
+
const e = m[2];
|
|
1160
|
+
if (s === "" && e === "") return null;
|
|
1161
|
+
if (s === "") {
|
|
1162
|
+
const suffix = Math.trunc(Number(e));
|
|
1163
|
+
if (Number.isNaN(suffix) || suffix === 0) return null;
|
|
1164
|
+
return [Math.max(0, total - suffix), total - 1];
|
|
1165
|
+
}
|
|
1166
|
+
const start = Math.trunc(Number(s));
|
|
1167
|
+
if (Number.isNaN(start) || start >= total) return null;
|
|
1168
|
+
const end = e === "" ? total - 1 : Math.min(Math.trunc(Number(e)), total - 1);
|
|
1169
|
+
if (Number.isNaN(end) || start > end) return null;
|
|
1170
|
+
return [start, end];
|
|
1171
|
+
}
|
|
1172
|
+
applyResponseOverrides(respHeaders, queryParams) {
|
|
1173
|
+
for (const [qkey, hkey] of [
|
|
1174
|
+
["response-cache-control", "Cache-Control"],
|
|
1175
|
+
["response-content-disposition", "Content-Disposition"],
|
|
1176
|
+
["response-content-encoding", "Content-Encoding"],
|
|
1177
|
+
["response-content-language", "Content-Language"],
|
|
1178
|
+
["response-content-type", "Content-Type"],
|
|
1179
|
+
["response-expires", "Expires"]
|
|
1180
|
+
]) {
|
|
1181
|
+
const val = qp(queryParams, qkey);
|
|
1182
|
+
if (!val) continue;
|
|
1183
|
+
const targetLc = hkey.toLowerCase();
|
|
1184
|
+
for (const existing of Object.keys(respHeaders)) if (existing.toLowerCase() === targetLc) delete respHeaders[existing];
|
|
1185
|
+
respHeaders[hkey] = val;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
rejectResponseOverridesIfUnsigned(headers, queryParams, bucketName, key) {
|
|
1189
|
+
if (!RESPONSE_OVERRIDE_PARAMS.some((p) => Boolean(qp(queryParams, p)))) return null;
|
|
1190
|
+
if (headers["authorization"]) return null;
|
|
1191
|
+
if (qp(queryParams, "X-Amz-Signature") || qp(queryParams, "x-amz-signature") || qp(queryParams, "X-Amz-Algorithm") || qp(queryParams, "x-amz-algorithm")) return null;
|
|
1192
|
+
return s3Error("InvalidRequest", "Request specific response headers cannot be used for anonymous GET requests.", 400, `/${bucketName}/${key}`);
|
|
1193
|
+
}
|
|
1194
|
+
checkPutPreconditions(headers, existingObj) {
|
|
1195
|
+
const ifNoneMatch = (headers["if-none-match"] || "").trim();
|
|
1196
|
+
const ifMatch = (headers["if-match"] || "").trim();
|
|
1197
|
+
if (!ifNoneMatch && !ifMatch) return null;
|
|
1198
|
+
const existingEtag = existingObj ? existingObj.etag.replaceAll(/^"|"$/gu, "") : null;
|
|
1199
|
+
if (ifNoneMatch) {
|
|
1200
|
+
if (ifNoneMatch === "*") {
|
|
1201
|
+
if (existingObj !== void 0) return preconditionFailed();
|
|
1202
|
+
} else if (existingObj !== void 0 && ifNoneMatch.replaceAll(/^"|"$/gu, "") === existingEtag) return preconditionFailed();
|
|
1203
|
+
}
|
|
1204
|
+
if (ifMatch) {
|
|
1205
|
+
if (existingObj === void 0) return s3Error("NoSuchKey", "The specified key does not exist.", 404);
|
|
1206
|
+
if (ifMatch !== "*" && ifMatch.replaceAll(/^"|"$/gu, "") !== existingEtag) return preconditionFailed();
|
|
1207
|
+
}
|
|
1208
|
+
return null;
|
|
1209
|
+
}
|
|
1210
|
+
etagConditionMatches(fieldValue, etag) {
|
|
1211
|
+
if (fieldValue === "*") return true;
|
|
1212
|
+
for (let candidate of fieldValue.split(",")) {
|
|
1213
|
+
candidate = candidate.trim();
|
|
1214
|
+
if (candidate.toUpperCase().startsWith("W/")) candidate = candidate.slice(2);
|
|
1215
|
+
if (candidate.replaceAll(/^"|"$/gu, "") === etag) return true;
|
|
1216
|
+
}
|
|
1217
|
+
return false;
|
|
1218
|
+
}
|
|
1219
|
+
checkReadPreconditions(headers, obj, respHeaders) {
|
|
1220
|
+
const etag = (obj.etag || "").replaceAll(/^"|"$/gu, "");
|
|
1221
|
+
const mtime = parseHttpDate(obj.lastModified);
|
|
1222
|
+
const ifMatch = (headers["if-match"] || "").trim();
|
|
1223
|
+
if (ifMatch) {
|
|
1224
|
+
if (!this.etagConditionMatches(ifMatch, etag)) return preconditionFailed();
|
|
1225
|
+
} else {
|
|
1226
|
+
const unmod = parseHttpDate(headers["if-unmodified-since"]);
|
|
1227
|
+
if (unmod && mtime && mtime.getTime() > unmod.getTime()) return preconditionFailed();
|
|
1228
|
+
}
|
|
1229
|
+
const ifNoneMatch = (headers["if-none-match"] || "").trim();
|
|
1230
|
+
if (ifNoneMatch) {
|
|
1231
|
+
if (this.etagConditionMatches(ifNoneMatch, etag)) return notModified(respHeaders);
|
|
1232
|
+
} else {
|
|
1233
|
+
const mod = parseHttpDate(headers["if-modified-since"]);
|
|
1234
|
+
if (mod && mtime && mtime.getTime() <= mod.getTime()) return notModified(respHeaders);
|
|
1235
|
+
}
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
objectResponseHeaders(obj, includeChecksums = false) {
|
|
1239
|
+
const h = {};
|
|
1240
|
+
h["Content-Type"] = obj.contentType;
|
|
1241
|
+
h["ETag"] = obj.etag;
|
|
1242
|
+
h["Last-Modified"] = isoToRfc7231(obj.lastModified);
|
|
1243
|
+
h["Content-Length"] = String(obj.size);
|
|
1244
|
+
h["Accept-Ranges"] = "bytes";
|
|
1245
|
+
if (obj.contentEncoding) h["Content-Encoding"] = obj.contentEncoding;
|
|
1246
|
+
for (const [k, val] of Object.entries(obj.preservedHeaders || {})) h[k] = val;
|
|
1247
|
+
Object.assign(h, obj.metadata || {});
|
|
1248
|
+
if (obj.versionId) h["x-amz-version-id"] = obj.versionId;
|
|
1249
|
+
if (includeChecksums) {
|
|
1250
|
+
const stored = obj.checksums || {};
|
|
1251
|
+
for (const [alg, val] of Object.entries(stored)) h[`x-amz-checksum-${alg.toLowerCase()}`] = val;
|
|
1252
|
+
if (Object.keys(stored).length > 0) h["x-amz-checksum-type"] = obj.checksumType || "FULL_OBJECT";
|
|
1253
|
+
}
|
|
1254
|
+
return h;
|
|
1255
|
+
}
|
|
1256
|
+
recordObjectVersion(bucketName, key, priorObj, obj, data) {
|
|
1257
|
+
const versioning = this.storage.bucketVersioning.get(bucketName);
|
|
1258
|
+
if (versioning !== "Enabled" && versioning !== "Suspended") return null;
|
|
1259
|
+
const vkey = [bucketName, key];
|
|
1260
|
+
let versions = this.storage.objectVersions.get(vkey);
|
|
1261
|
+
if (!versions) {
|
|
1262
|
+
versions = [];
|
|
1263
|
+
this.storage.objectVersions.set(vkey, versions);
|
|
1264
|
+
}
|
|
1265
|
+
let versionId;
|
|
1266
|
+
if (versioning === "Enabled") {
|
|
1267
|
+
if (priorObj && !priorObj.versionId && !versions.some((v) => v.versionId === "null")) versions.push({
|
|
1268
|
+
versionId: "null",
|
|
1269
|
+
lastModified: priorObj.lastModified,
|
|
1270
|
+
etag: priorObj.etag,
|
|
1271
|
+
size: priorObj.size,
|
|
1272
|
+
isLatest: false,
|
|
1273
|
+
data: this.storage.readBody(bucketName, key, priorObj),
|
|
1274
|
+
contentType: priorObj.contentType,
|
|
1275
|
+
contentEncoding: priorObj.contentEncoding,
|
|
1276
|
+
metadata: priorObj.metadata,
|
|
1277
|
+
preservedHeaders: priorObj.preservedHeaders,
|
|
1278
|
+
checksums: priorObj.checksums,
|
|
1279
|
+
checksumType: priorObj.checksumType
|
|
1280
|
+
});
|
|
1281
|
+
versionId = newUuid();
|
|
1282
|
+
obj.versionId = versionId;
|
|
1283
|
+
} else {
|
|
1284
|
+
versionId = "null";
|
|
1285
|
+
const filtered = versions.filter((v) => v.versionId !== "null");
|
|
1286
|
+
versions.length = 0;
|
|
1287
|
+
versions.push(...filtered);
|
|
1288
|
+
}
|
|
1289
|
+
for (const v of versions) v.isLatest = false;
|
|
1290
|
+
versions.push({
|
|
1291
|
+
versionId,
|
|
1292
|
+
lastModified: obj.lastModified,
|
|
1293
|
+
etag: obj.etag,
|
|
1294
|
+
size: obj.size,
|
|
1295
|
+
isLatest: true,
|
|
1296
|
+
data,
|
|
1297
|
+
contentType: obj.contentType,
|
|
1298
|
+
contentEncoding: obj.contentEncoding,
|
|
1299
|
+
metadata: obj.metadata,
|
|
1300
|
+
preservedHeaders: obj.preservedHeaders,
|
|
1301
|
+
checksums: obj.checksums,
|
|
1302
|
+
checksumType: obj.checksumType
|
|
1303
|
+
});
|
|
1304
|
+
return versioning === "Enabled" ? versionId : null;
|
|
1305
|
+
}
|
|
1306
|
+
recordDeleteMarker(bucketName, key, priorObj) {
|
|
1307
|
+
const vkey = [bucketName, key];
|
|
1308
|
+
let versions = this.storage.objectVersions.get(vkey);
|
|
1309
|
+
if (!versions) {
|
|
1310
|
+
versions = [];
|
|
1311
|
+
this.storage.objectVersions.set(vkey, versions);
|
|
1312
|
+
}
|
|
1313
|
+
const versioning = this.storage.bucketVersioning.get(bucketName);
|
|
1314
|
+
let markerId;
|
|
1315
|
+
if (versioning === "Enabled") {
|
|
1316
|
+
if (priorObj && !priorObj.versionId && !versions.some((v) => v.versionId === "null")) versions.push({
|
|
1317
|
+
versionId: "null",
|
|
1318
|
+
lastModified: priorObj.lastModified,
|
|
1319
|
+
etag: priorObj.etag,
|
|
1320
|
+
size: priorObj.size,
|
|
1321
|
+
isLatest: false,
|
|
1322
|
+
data: this.storage.readBody(bucketName, key, priorObj),
|
|
1323
|
+
contentType: priorObj.contentType,
|
|
1324
|
+
contentEncoding: priorObj.contentEncoding,
|
|
1325
|
+
metadata: priorObj.metadata,
|
|
1326
|
+
preservedHeaders: priorObj.preservedHeaders,
|
|
1327
|
+
checksums: priorObj.checksums,
|
|
1328
|
+
checksumType: priorObj.checksumType
|
|
1329
|
+
});
|
|
1330
|
+
markerId = newUuid();
|
|
1331
|
+
} else {
|
|
1332
|
+
markerId = "null";
|
|
1333
|
+
const filtered = versions.filter((v) => v.versionId !== "null");
|
|
1334
|
+
versions.length = 0;
|
|
1335
|
+
versions.push(...filtered);
|
|
1336
|
+
}
|
|
1337
|
+
for (const v of versions) v.isLatest = false;
|
|
1338
|
+
versions.push({
|
|
1339
|
+
versionId: markerId,
|
|
1340
|
+
lastModified: nowIso(),
|
|
1341
|
+
etag: "",
|
|
1342
|
+
size: 0,
|
|
1343
|
+
isLatest: true,
|
|
1344
|
+
isDeleteMarker: true
|
|
1345
|
+
});
|
|
1346
|
+
return markerId;
|
|
1347
|
+
}
|
|
1348
|
+
objectRecordFromVersion(v) {
|
|
1349
|
+
return {
|
|
1350
|
+
body: v.data ?? null,
|
|
1351
|
+
bodyRef: v.bodyRef,
|
|
1352
|
+
contentType: v.contentType || "application/octet-stream",
|
|
1353
|
+
contentEncoding: v.contentEncoding,
|
|
1354
|
+
etag: v.etag,
|
|
1355
|
+
lastModified: v.lastModified,
|
|
1356
|
+
size: v.size,
|
|
1357
|
+
metadata: v.metadata || {},
|
|
1358
|
+
preservedHeaders: v.preservedHeaders || {},
|
|
1359
|
+
checksums: v.checksums || {},
|
|
1360
|
+
checksumType: v.checksumType,
|
|
1361
|
+
versionId: v.versionId
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
deleteMarkerReadRefused(version) {
|
|
1365
|
+
const [status, headers, body] = s3Error("MethodNotAllowed", "The specified method is not allowed against this resource.", 405);
|
|
1366
|
+
headers["x-amz-delete-marker"] = "true";
|
|
1367
|
+
headers["x-amz-version-id"] = version.versionId;
|
|
1368
|
+
if (version.lastModified) headers["Last-Modified"] = isoToRfc7231(version.lastModified);
|
|
1369
|
+
return [
|
|
1370
|
+
status,
|
|
1371
|
+
headers,
|
|
1372
|
+
body
|
|
1373
|
+
];
|
|
1374
|
+
}
|
|
1375
|
+
deleteMarker404Headers(bucketName, key) {
|
|
1376
|
+
const versions = this.storage.objectVersions.get([bucketName, key]);
|
|
1377
|
+
if (!versions || versions.length === 0) return {};
|
|
1378
|
+
const latest = versions.at(-1);
|
|
1379
|
+
if (latest && latest.isDeleteMarker && latest.isLatest) return {
|
|
1380
|
+
"x-amz-delete-marker": "true",
|
|
1381
|
+
"x-amz-version-id": latest.versionId
|
|
1382
|
+
};
|
|
1383
|
+
return {};
|
|
1384
|
+
}
|
|
1385
|
+
deleteObjectVersion(bucket, bucketName, key, versionId) {
|
|
1386
|
+
const vkey = [bucketName, key];
|
|
1387
|
+
const versions = this.storage.objectVersions.get(vkey);
|
|
1388
|
+
if (!versions || versions.length === 0) {
|
|
1389
|
+
if (versionId === "null" && bucket.objects[key] && !bucket.objects[key].versionId) {
|
|
1390
|
+
this.purgeCurrentObject(bucketName, key, bucket);
|
|
1391
|
+
return [true, false];
|
|
1392
|
+
}
|
|
1393
|
+
return [false, false];
|
|
1394
|
+
}
|
|
1395
|
+
const idx = versions.findIndex((v) => v.versionId === versionId);
|
|
1396
|
+
if (idx === -1) {
|
|
1397
|
+
if (versionId === "null" && bucket.objects[key] && !bucket.objects[key].versionId) {
|
|
1398
|
+
this.purgeCurrentObject(bucketName, key, bucket);
|
|
1399
|
+
return [true, false];
|
|
1400
|
+
}
|
|
1401
|
+
return [false, false];
|
|
1402
|
+
}
|
|
1403
|
+
const removed = versions.splice(idx, 1)[0];
|
|
1404
|
+
const wasMarker = Boolean(removed.isDeleteMarker);
|
|
1405
|
+
if (versions.length === 0) {
|
|
1406
|
+
this.storage.objectVersions.delete(vkey);
|
|
1407
|
+
this.purgeCurrentObject(bucketName, key, bucket);
|
|
1408
|
+
return [true, wasMarker];
|
|
1409
|
+
}
|
|
1410
|
+
for (const v of versions) v.isLatest = false;
|
|
1411
|
+
const latest = versions.at(-1);
|
|
1412
|
+
if (latest) {
|
|
1413
|
+
latest.isLatest = true;
|
|
1414
|
+
if (latest.isDeleteMarker) delete bucket.objects[key];
|
|
1415
|
+
else bucket.objects[key] = this.objectRecordFromVersion(latest);
|
|
1416
|
+
}
|
|
1417
|
+
return [true, wasMarker];
|
|
1418
|
+
}
|
|
1419
|
+
purgeCurrentObject(bucketName, key, bucket) {
|
|
1420
|
+
delete bucket.objects[key];
|
|
1421
|
+
this.storage.deletePersistedObject(bucketName, key);
|
|
1422
|
+
}
|
|
1423
|
+
parseBucketKey(path) {
|
|
1424
|
+
const trimmed = path.replace(/^\/+/u, "");
|
|
1425
|
+
const slashIdx = trimmed.indexOf("/");
|
|
1426
|
+
if (slashIdx === -1) return [trimmed, ""];
|
|
1427
|
+
return [trimmed.slice(0, slashIdx), trimmed.slice(slashIdx + 1)];
|
|
1428
|
+
}
|
|
1429
|
+
handleRequest(method, path, headers, body, queryParams, signedPath) {
|
|
1430
|
+
const [bucket, key] = this.parseBucketKey(path);
|
|
1431
|
+
if (method === "OPTIONS") {
|
|
1432
|
+
this.ensureBucket(bucket);
|
|
1433
|
+
const corsHeaders = corsResponseHeaders(this.storage.bucketCors.get(bucket), method, headers);
|
|
1434
|
+
if (!this.storage.buckets.has(bucket)) return noSuchBucket(bucket);
|
|
1435
|
+
if (!corsHeaders) return s3Error("AccessDenied", "CORS request is not allowed.", 403);
|
|
1436
|
+
return [
|
|
1437
|
+
200,
|
|
1438
|
+
corsHeaders,
|
|
1439
|
+
Buffer.alloc(0)
|
|
1440
|
+
];
|
|
1441
|
+
}
|
|
1442
|
+
const sigError = verifyPresignedSigV4(method, signedPath === void 0 ? path : signedPath, headers, queryParams);
|
|
1443
|
+
if (sigError) {
|
|
1444
|
+
const [status, respHeaders, respBody] = sigError;
|
|
1445
|
+
const corsHeaders = corsResponseHeaders(this.storage.bucketCors.get(bucket) ?? `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod><AllowedMethod>PUT</AllowedMethod><AllowedMethod>POST</AllowedMethod><AllowedMethod>DELETE</AllowedMethod><AllowedMethod>HEAD</AllowedMethod><AllowedHeader>*</AllowedHeader><ExposeHeader>ETag</ExposeHeader><MaxAgeSeconds>3000</MaxAgeSeconds></CORSRule></CORSConfiguration>`, method, headers);
|
|
1446
|
+
Object.assign(respHeaders, corsHeaders);
|
|
1447
|
+
respHeaders["x-amz-request-id"] = respHeaders["x-amz-request-id"] || newUuid();
|
|
1448
|
+
respHeaders["x-amz-id-2"] = respHeaders["x-amz-id-2"] || createHash("sha256").update(newUuid()).digest("base64");
|
|
1449
|
+
return [
|
|
1450
|
+
status,
|
|
1451
|
+
respHeaders,
|
|
1452
|
+
respBody
|
|
1453
|
+
];
|
|
1454
|
+
}
|
|
1455
|
+
this.ensureBucket(bucket);
|
|
1456
|
+
const corsHeaders = corsResponseHeaders(this.storage.bucketCors.get(bucket), method, headers);
|
|
1457
|
+
const mergedHeaders = mergeHoistedAmzHeaders(headers, queryParams);
|
|
1458
|
+
this.expireMultipartUploads(bucket);
|
|
1459
|
+
const [status, respHeaders, respBody] = this.dispatch(method, bucket, key, mergedHeaders, body, queryParams);
|
|
1460
|
+
Object.assign(respHeaders, corsHeaders);
|
|
1461
|
+
if (method !== "GET" && method !== "HEAD" && status >= 200 && status < 300) this.storage.persistState();
|
|
1462
|
+
respHeaders["x-amz-request-id"] = respHeaders["x-amz-request-id"] || newUuid();
|
|
1463
|
+
respHeaders["x-amz-id-2"] = respHeaders["x-amz-id-2"] || createHash("sha256").update(newUuid()).digest("base64");
|
|
1464
|
+
if (method === "HEAD") return [
|
|
1465
|
+
status,
|
|
1466
|
+
respHeaders,
|
|
1467
|
+
Buffer.alloc(0)
|
|
1468
|
+
];
|
|
1469
|
+
return [
|
|
1470
|
+
status,
|
|
1471
|
+
respHeaders,
|
|
1472
|
+
respBody
|
|
1473
|
+
];
|
|
1474
|
+
}
|
|
1475
|
+
dispatch(method, bucket, key, headers, body, queryParams) {
|
|
1476
|
+
const unsupported = () => s3Error("NotImplemented", "This operation is not supported by the FileBucket emulator.", 501);
|
|
1477
|
+
const selectors = Object.keys(queryParams).filter((name) => !name.toLowerCase().startsWith("x-amz-") && ![
|
|
1478
|
+
"x-id",
|
|
1479
|
+
"accountId",
|
|
1480
|
+
"region"
|
|
1481
|
+
].includes(name));
|
|
1482
|
+
const accepts = (...names) => selectors.every((name) => names.includes(name));
|
|
1483
|
+
if (Object.keys(headers).some((name) => name === "x-amz-acl" || name.startsWith("x-amz-grant-") || name.startsWith("x-amz-server-side-encryption") || name.startsWith("x-amz-copy-source-server-side-encryption") || name.startsWith("x-amz-object-lock-") || name === "x-amz-bucket-object-lock-enabled" || name.startsWith("x-amz-tagging") || name === "x-amz-website-redirect-location" || name === "x-amz-storage-class")) return unsupported();
|
|
1484
|
+
if (!bucket) return unsupported();
|
|
1485
|
+
if (key) {
|
|
1486
|
+
if (method === "GET" && accepts("versionId", ...RESPONSE_OVERRIDE_PARAMS)) return this.getObject(bucket, key, headers, queryParams);
|
|
1487
|
+
if (method === "PUT") {
|
|
1488
|
+
if ("uploadId" in queryParams && "partNumber" in queryParams && accepts("uploadId", "partNumber") && !("x-amz-copy-source" in headers)) return this.uploadPart(bucket, key, body, queryParams, headers);
|
|
1489
|
+
if (accepts()) return "x-amz-copy-source" in headers ? this.copyObject(bucket, key, headers) : this.putObject(bucket, key, body, headers);
|
|
1490
|
+
}
|
|
1491
|
+
if (method === "POST") {
|
|
1492
|
+
if ("uploads" in queryParams && accepts("uploads")) return this.createMultipartUpload(bucket, key, headers);
|
|
1493
|
+
if ("uploadId" in queryParams && accepts("uploadId")) return this.completeMultipartUpload(bucket, key, body, queryParams, headers);
|
|
1494
|
+
}
|
|
1495
|
+
if (method === "DELETE") {
|
|
1496
|
+
if ("uploadId" in queryParams && accepts("uploadId")) return this.abortMultipartUpload(bucket, key, queryParams);
|
|
1497
|
+
if (accepts("versionId")) return this.deleteObject(bucket, key, headers, queryParams);
|
|
1498
|
+
}
|
|
1499
|
+
} else {
|
|
1500
|
+
if (method === "HEAD" && accepts()) return [
|
|
1501
|
+
200,
|
|
1502
|
+
{},
|
|
1503
|
+
Buffer.alloc(0)
|
|
1504
|
+
];
|
|
1505
|
+
if (method === "PUT") {
|
|
1506
|
+
if ("versioning" in queryParams && accepts("versioning")) return this.putBucketVersioning(bucket, body);
|
|
1507
|
+
if ("cors" in queryParams && accepts("cors")) return this.putBucketCors(bucket, body);
|
|
1508
|
+
if ("lifecycle" in queryParams && accepts("lifecycle")) return this.putBucketLifecycle(bucket, body);
|
|
1509
|
+
}
|
|
1510
|
+
if (method === "GET") {
|
|
1511
|
+
if (qp(queryParams, "list-type") === "2" && accepts("list-type", "prefix", "delimiter", "max-keys", "continuation-token", "start-after", "encoding-type", "fetch-owner")) return this.listObjectsV2(bucket, queryParams);
|
|
1512
|
+
if ("versions" in queryParams && accepts("versions", "prefix", "delimiter", "max-keys", "key-marker", "version-id-marker", "encoding-type")) return this.listObjectVersions(bucket, queryParams);
|
|
1513
|
+
}
|
|
1514
|
+
if (method === "POST" && "delete" in queryParams && accepts("delete")) return this.deleteObjects(bucket, body);
|
|
1515
|
+
}
|
|
1516
|
+
return unsupported();
|
|
1517
|
+
}
|
|
1518
|
+
ensureBucket(name) {
|
|
1519
|
+
if (!name) return;
|
|
1520
|
+
let changed = false;
|
|
1521
|
+
if (!this.storage.buckets.has(name)) {
|
|
1522
|
+
this.storage.buckets.set(name, {
|
|
1523
|
+
created: nowIso(),
|
|
1524
|
+
objects: Object.create(null),
|
|
1525
|
+
region: getRegion()
|
|
1526
|
+
});
|
|
1527
|
+
changed = true;
|
|
1528
|
+
}
|
|
1529
|
+
if (!this.storage.bucketCors.has(name)) {
|
|
1530
|
+
this.storage.bucketCors.set(name, `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod><AllowedMethod>PUT</AllowedMethod><AllowedMethod>POST</AllowedMethod><AllowedMethod>DELETE</AllowedMethod><AllowedMethod>HEAD</AllowedMethod><AllowedHeader>*</AllowedHeader><ExposeHeader>ETag</ExposeHeader><MaxAgeSeconds>3000</MaxAgeSeconds></CORSRule></CORSConfiguration>`);
|
|
1531
|
+
changed = true;
|
|
1532
|
+
}
|
|
1533
|
+
if (changed) this.storage.persistState();
|
|
1534
|
+
}
|
|
1535
|
+
putBucketVersioning(name, body) {
|
|
1536
|
+
if (!this.storage.buckets.has(name)) return noSuchBucket(name);
|
|
1537
|
+
try {
|
|
1538
|
+
const statusEl = parseXml(body).find("Status");
|
|
1539
|
+
if (statusEl?.text) this.storage.bucketVersioning.set(name, statusEl.text.trim());
|
|
1540
|
+
} catch {}
|
|
1541
|
+
return [
|
|
1542
|
+
200,
|
|
1543
|
+
{},
|
|
1544
|
+
Buffer.alloc(0)
|
|
1545
|
+
];
|
|
1546
|
+
}
|
|
1547
|
+
putBucketLifecycle(name, body) {
|
|
1548
|
+
if (!this.storage.buckets.has(name)) return noSuchBucket(name);
|
|
1549
|
+
try {
|
|
1550
|
+
const rules = parseLifecycleXml(body);
|
|
1551
|
+
this.storage.bucketLifecycle.set(name, rules);
|
|
1552
|
+
} catch {
|
|
1553
|
+
return s3Error("MalformedXML", "Invalid lifecycle configuration.", 400);
|
|
1554
|
+
}
|
|
1555
|
+
return [
|
|
1556
|
+
200,
|
|
1557
|
+
{ "x-amz-transition-default-minimum-object-size": "all_storage_classes_128K" },
|
|
1558
|
+
Buffer.alloc(0)
|
|
1559
|
+
];
|
|
1560
|
+
}
|
|
1561
|
+
putBucketCors(name, body) {
|
|
1562
|
+
if (!this.storage.buckets.has(name)) return noSuchBucket(name);
|
|
1563
|
+
this.storage.bucketCors.set(name, body.toString("utf-8"));
|
|
1564
|
+
return [
|
|
1565
|
+
200,
|
|
1566
|
+
{},
|
|
1567
|
+
Buffer.alloc(0)
|
|
1568
|
+
];
|
|
1569
|
+
}
|
|
1570
|
+
putObject(bucketName, key, body, headers) {
|
|
1571
|
+
const bucket = this.storage.buckets.get(bucketName);
|
|
1572
|
+
if (!bucket) return noSuchBucket(bucketName);
|
|
1573
|
+
const md5Err = validateContentMd5(headers, body);
|
|
1574
|
+
if (md5Err) return md5Err;
|
|
1575
|
+
const existingObj = bucket.objects[key];
|
|
1576
|
+
const precondErr = this.checkPutPreconditions(headers, existingObj);
|
|
1577
|
+
if (precondErr) return precondErr;
|
|
1578
|
+
const { checksums, error: csumErr } = resolveObjectChecksums(body, headers);
|
|
1579
|
+
if (csumErr) return csumErr;
|
|
1580
|
+
const etag = `"${md5Hex(body)}"`;
|
|
1581
|
+
const obj = this.buildObjectRecord(body, headers, etag, checksums);
|
|
1582
|
+
const priorObj = bucket.objects[key];
|
|
1583
|
+
bucket.objects[key] = obj;
|
|
1584
|
+
const respHeaders = {};
|
|
1585
|
+
respHeaders["ETag"] = obj.etag;
|
|
1586
|
+
respHeaders["Content-Length"] = "0";
|
|
1587
|
+
const versionId = this.recordObjectVersion(bucketName, key, priorObj, obj, body);
|
|
1588
|
+
if (versionId) respHeaders["x-amz-version-id"] = versionId;
|
|
1589
|
+
return [
|
|
1590
|
+
200,
|
|
1591
|
+
respHeaders,
|
|
1592
|
+
Buffer.alloc(0)
|
|
1593
|
+
];
|
|
1594
|
+
}
|
|
1595
|
+
getObject(bucketName, key, headers, queryParams) {
|
|
1596
|
+
const bucket = this.storage.buckets.get(bucketName);
|
|
1597
|
+
if (!bucket) return noSuchBucket(bucketName);
|
|
1598
|
+
const unsignedErr = this.rejectResponseOverridesIfUnsigned(headers, queryParams, bucketName, key);
|
|
1599
|
+
if (unsignedErr) return unsignedErr;
|
|
1600
|
+
const versionId = qp(queryParams, "versionId");
|
|
1601
|
+
let obj = bucket.objects[key];
|
|
1602
|
+
let version;
|
|
1603
|
+
if (versionId) {
|
|
1604
|
+
version = this.storage.objectVersions.get([bucketName, key])?.find((entry) => entry.versionId === versionId);
|
|
1605
|
+
if (version?.isDeleteMarker) return this.deleteMarkerReadRefused(version);
|
|
1606
|
+
if (version) obj = this.objectRecordFromVersion(version);
|
|
1607
|
+
else if (versionId !== "null" || !obj || obj.versionId) return noSuchVersion(bucketName, key);
|
|
1608
|
+
}
|
|
1609
|
+
if (!obj) return noSuchKey(bucketName, key, this.deleteMarker404Headers(bucketName, key));
|
|
1610
|
+
const rangeHeader = headers["range"] || "";
|
|
1611
|
+
const respHeaders = this.objectResponseHeaders(obj, headers["x-amz-checksum-mode"]?.toUpperCase() === "ENABLED" && !rangeHeader);
|
|
1612
|
+
const precond = this.checkReadPreconditions(headers, obj, respHeaders);
|
|
1613
|
+
if (precond) return precond;
|
|
1614
|
+
const body = version ? this.storage.readVersionBody(bucketName, key, version) : this.storage.readBody(bucketName, key, obj);
|
|
1615
|
+
this.applyResponseOverrides(respHeaders, queryParams);
|
|
1616
|
+
if (rangeHeader) {
|
|
1617
|
+
const range = obj.size > 0 ? this.parseRange(rangeHeader, obj.size) : null;
|
|
1618
|
+
if (!range) return invalidRange(bucketName, key, obj.size);
|
|
1619
|
+
const [start, end] = range;
|
|
1620
|
+
const slice = body.subarray(start, end + 1);
|
|
1621
|
+
respHeaders["Content-Length"] = String(slice.length);
|
|
1622
|
+
respHeaders["Content-Range"] = `bytes ${start}-${end}/${obj.size}`;
|
|
1623
|
+
return [
|
|
1624
|
+
206,
|
|
1625
|
+
respHeaders,
|
|
1626
|
+
slice
|
|
1627
|
+
];
|
|
1628
|
+
}
|
|
1629
|
+
return [
|
|
1630
|
+
200,
|
|
1631
|
+
respHeaders,
|
|
1632
|
+
body
|
|
1633
|
+
];
|
|
1634
|
+
}
|
|
1635
|
+
deleteObject(bucketName, key, headers = {}, queryParams = {}) {
|
|
1636
|
+
const bucket = this.storage.buckets.get(bucketName);
|
|
1637
|
+
if (!bucket) return noSuchBucket(bucketName);
|
|
1638
|
+
if (headers["x-amz-if-match-size"] || headers["x-amz-if-match-last-modified-time"]) return s3Error("NotImplemented", "A header you provided implies functionality that is not implemented", 501, `/${bucketName}/${key}`);
|
|
1639
|
+
const ifMatch = (headers["if-match"] || "").trim();
|
|
1640
|
+
if (ifMatch) {
|
|
1641
|
+
const cur = bucket.objects[key];
|
|
1642
|
+
if (!cur) return s3Error("NoSuchKey", "The specified key does not exist.", 404, `/${bucketName}/${key}`);
|
|
1643
|
+
if (ifMatch !== "*" && ifMatch.replaceAll(/^"|"$/gu, "") !== cur.etag.replaceAll(/^"|"$/gu, "")) return preconditionFailed(void 0, `/${bucketName}/${key}`);
|
|
1644
|
+
}
|
|
1645
|
+
const versionId = qp(queryParams, "versionId");
|
|
1646
|
+
if (versionId) {
|
|
1647
|
+
const [, wasDeleteMarker] = this.deleteObjectVersion(bucket, bucketName, key, versionId);
|
|
1648
|
+
const respHeaders = {};
|
|
1649
|
+
respHeaders["x-amz-version-id"] = versionId;
|
|
1650
|
+
if (wasDeleteMarker) respHeaders["x-amz-delete-marker"] = "true";
|
|
1651
|
+
return [
|
|
1652
|
+
204,
|
|
1653
|
+
respHeaders,
|
|
1654
|
+
Buffer.alloc(0)
|
|
1655
|
+
];
|
|
1656
|
+
}
|
|
1657
|
+
const versioning = this.storage.bucketVersioning.get(bucketName);
|
|
1658
|
+
if (versioning === "Enabled" || versioning === "Suspended") {
|
|
1659
|
+
const deleteMarkerId = this.recordDeleteMarker(bucketName, key, bucket.objects[key]);
|
|
1660
|
+
delete bucket.objects[key];
|
|
1661
|
+
return [
|
|
1662
|
+
204,
|
|
1663
|
+
{
|
|
1664
|
+
"x-amz-delete-marker": "true",
|
|
1665
|
+
"x-amz-version-id": deleteMarkerId
|
|
1666
|
+
},
|
|
1667
|
+
Buffer.alloc(0)
|
|
1668
|
+
];
|
|
1669
|
+
}
|
|
1670
|
+
delete bucket.objects[key];
|
|
1671
|
+
this.storage.deletePersistedObject(bucketName, key);
|
|
1672
|
+
return [
|
|
1673
|
+
204,
|
|
1674
|
+
{},
|
|
1675
|
+
Buffer.alloc(0)
|
|
1676
|
+
];
|
|
1677
|
+
}
|
|
1678
|
+
deleteObjects(bucketName, body) {
|
|
1679
|
+
const bucket = this.storage.buckets.get(bucketName);
|
|
1680
|
+
if (!bucket) return noSuchBucket(bucketName);
|
|
1681
|
+
let root;
|
|
1682
|
+
try {
|
|
1683
|
+
root = parseXml(body);
|
|
1684
|
+
} catch {
|
|
1685
|
+
return s3Error("MalformedXML", "The XML you provided was not well-formed", 400);
|
|
1686
|
+
}
|
|
1687
|
+
const quiet = (root.findText("Quiet") || "").toLowerCase() === "true";
|
|
1688
|
+
const deleted = [];
|
|
1689
|
+
const errors = [];
|
|
1690
|
+
for (const objEl of root.findAll("Object")) {
|
|
1691
|
+
const k = objEl.findText("Key");
|
|
1692
|
+
if (!k) continue;
|
|
1693
|
+
const versionId = objEl.findText("VersionId") || "";
|
|
1694
|
+
if (objEl.findText("Size") || objEl.findText("LastModifiedTime")) {
|
|
1695
|
+
errors.push({
|
|
1696
|
+
key: k,
|
|
1697
|
+
versionId,
|
|
1698
|
+
code: "NotImplemented",
|
|
1699
|
+
msg: "A header you provided implies functionality that is not implemented"
|
|
1700
|
+
});
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
const condEtag = objEl.findText("ETag");
|
|
1704
|
+
if (condEtag) {
|
|
1705
|
+
const cur = bucket.objects[k];
|
|
1706
|
+
if (!cur) {
|
|
1707
|
+
errors.push({
|
|
1708
|
+
key: k,
|
|
1709
|
+
versionId,
|
|
1710
|
+
code: "NoSuchKey",
|
|
1711
|
+
msg: "The specified key does not exist."
|
|
1712
|
+
});
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
if (condEtag !== "*" && condEtag.replaceAll(/^"|"$/gu, "") !== cur.etag.replaceAll(/^"|"$/gu, "")) {
|
|
1716
|
+
errors.push({
|
|
1717
|
+
key: k,
|
|
1718
|
+
versionId,
|
|
1719
|
+
code: "PreconditionFailed",
|
|
1720
|
+
msg: "At least one of the preconditions you specified did not hold."
|
|
1721
|
+
});
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
if (versionId) {
|
|
1726
|
+
const [, wasMarker] = this.deleteObjectVersion(bucket, bucketName, k, versionId);
|
|
1727
|
+
deleted.push({
|
|
1728
|
+
key: k,
|
|
1729
|
+
versionId,
|
|
1730
|
+
wasMarker
|
|
1731
|
+
});
|
|
1732
|
+
} else if (this.storage.bucketVersioning.get(bucketName) === "Enabled" || this.storage.bucketVersioning.get(bucketName) === "Suspended") {
|
|
1733
|
+
const markerId = this.recordDeleteMarker(bucketName, k, bucket.objects[k]);
|
|
1734
|
+
delete bucket.objects[k];
|
|
1735
|
+
deleted.push({
|
|
1736
|
+
key: k,
|
|
1737
|
+
versionId: "",
|
|
1738
|
+
wasMarker: false,
|
|
1739
|
+
markerCreated: markerId
|
|
1740
|
+
});
|
|
1741
|
+
} else {
|
|
1742
|
+
delete bucket.objects[k];
|
|
1743
|
+
this.storage.deletePersistedObject(bucketName, k);
|
|
1744
|
+
deleted.push({
|
|
1745
|
+
key: k,
|
|
1746
|
+
versionId: "",
|
|
1747
|
+
wasMarker: false
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const resp = new XmlElement("DeleteResult", { xmlns: S3_NS });
|
|
1752
|
+
if (!quiet) for (const d of deleted) {
|
|
1753
|
+
const el = resp.subElement("Deleted");
|
|
1754
|
+
el.subElement("Key", d.key);
|
|
1755
|
+
if (d.versionId) {
|
|
1756
|
+
el.subElement("VersionId", d.versionId);
|
|
1757
|
+
if (d.wasMarker) {
|
|
1758
|
+
el.subElement("DeleteMarker", "true");
|
|
1759
|
+
el.subElement("DeleteMarkerVersionId", d.versionId);
|
|
1760
|
+
}
|
|
1761
|
+
} else if (d.markerCreated) {
|
|
1762
|
+
el.subElement("DeleteMarker", "true");
|
|
1763
|
+
el.subElement("DeleteMarkerVersionId", d.markerCreated);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
for (const e of errors) {
|
|
1767
|
+
const el = resp.subElement("Error");
|
|
1768
|
+
el.subElement("Key", e.key);
|
|
1769
|
+
if (e.versionId) el.subElement("VersionId", e.versionId);
|
|
1770
|
+
el.subElement("Code", e.code);
|
|
1771
|
+
el.subElement("Message", e.msg);
|
|
1772
|
+
}
|
|
1773
|
+
return [
|
|
1774
|
+
200,
|
|
1775
|
+
{ "Content-Type": "application/xml" },
|
|
1776
|
+
xmlBody(resp)
|
|
1777
|
+
];
|
|
1778
|
+
}
|
|
1779
|
+
copyObject(destBucketName, destKey, headers) {
|
|
1780
|
+
const [rawPath, srcQuery] = (headers["x-amz-copy-source"] || "").replace(/^\/+/u, "").split("?");
|
|
1781
|
+
const srcParts = urlDecode(rawPath).split("/");
|
|
1782
|
+
if (srcParts.length < 2) return s3Error("InvalidArgument", "Copy Source must mention the source bucket and key: /sourcebucket/sourcekey", 400);
|
|
1783
|
+
const srcBucketName = srcParts[0];
|
|
1784
|
+
const srcKey = srcParts.slice(1).join("/");
|
|
1785
|
+
const srcBucket = this.storage.buckets.get(srcBucketName);
|
|
1786
|
+
if (!srcBucket) return noSuchBucket(srcBucketName);
|
|
1787
|
+
const destBucket = this.storage.buckets.get(destBucketName);
|
|
1788
|
+
if (!destBucket) return noSuchBucket(destBucketName);
|
|
1789
|
+
let srcVersionId;
|
|
1790
|
+
if (srcQuery) srcVersionId = new URLSearchParams(srcQuery).get("versionId") ?? void 0;
|
|
1791
|
+
let srcObj;
|
|
1792
|
+
if (srcVersionId) {
|
|
1793
|
+
const ventry = (this.storage.objectVersions.get([srcBucketName, srcKey]) || []).find((v) => v.versionId === srcVersionId);
|
|
1794
|
+
if (!ventry || ventry.isDeleteMarker) return s3Error("NoSuchVersion", "The specified version does not exist.", 404, `/${srcBucketName}/${srcKey}`);
|
|
1795
|
+
srcObj = this.objectRecordFromVersion(ventry);
|
|
1796
|
+
} else {
|
|
1797
|
+
if (!(srcKey in srcBucket.objects)) return s3Error("NoSuchKey", "The specified key does not exist.", 404, `/${srcBucketName}/${srcKey}`);
|
|
1798
|
+
srcObj = srcBucket.objects[srcKey];
|
|
1799
|
+
}
|
|
1800
|
+
const copySrcVid = srcVersionId || srcObj.versionId;
|
|
1801
|
+
const srcEtag = srcObj.etag.replaceAll(/^"|"$/gu, "");
|
|
1802
|
+
const srcMtime = parseHttpDate(srcObj.lastModified);
|
|
1803
|
+
const ifMatch = headers["x-amz-copy-source-if-match"];
|
|
1804
|
+
if (ifMatch) {
|
|
1805
|
+
if (ifMatch.replaceAll(/^"|"$/gu, "") !== srcEtag) return preconditionFailed();
|
|
1806
|
+
} else {
|
|
1807
|
+
const unmod = parseHttpDate(headers["x-amz-copy-source-if-unmodified-since"]);
|
|
1808
|
+
if (unmod && srcMtime && srcMtime.getTime() > unmod.getTime()) return preconditionFailed();
|
|
1809
|
+
}
|
|
1810
|
+
const ifNoneMatch = headers["x-amz-copy-source-if-none-match"];
|
|
1811
|
+
if (ifNoneMatch) {
|
|
1812
|
+
if (ifNoneMatch.replaceAll(/^"|"$/gu, "") === srcEtag) return preconditionFailed();
|
|
1813
|
+
} else {
|
|
1814
|
+
const mod = parseHttpDate(headers["x-amz-copy-source-if-modified-since"]);
|
|
1815
|
+
if (mod && srcMtime && srcMtime.getTime() <= mod.getTime()) return preconditionFailed();
|
|
1816
|
+
}
|
|
1817
|
+
const directive = (headers["x-amz-metadata-directive"] || "COPY").toUpperCase();
|
|
1818
|
+
let metadata;
|
|
1819
|
+
let contentType = srcObj.contentType;
|
|
1820
|
+
let contentEncoding = srcObj.contentEncoding;
|
|
1821
|
+
let preserved = {};
|
|
1822
|
+
if (directive === "REPLACE") {
|
|
1823
|
+
metadata = this.extractUserMetadata(headers);
|
|
1824
|
+
contentType = headers["content-type"] || srcObj.contentType;
|
|
1825
|
+
contentEncoding = headers["content-encoding"] || srcObj.contentEncoding;
|
|
1826
|
+
for (const h of PRESERVED_HEADERS) if (headers[h] !== void 0) preserved[h] = headers[h];
|
|
1827
|
+
} else {
|
|
1828
|
+
metadata = {};
|
|
1829
|
+
Object.assign(metadata, srcObj.metadata);
|
|
1830
|
+
preserved = {};
|
|
1831
|
+
Object.assign(preserved, srcObj.preservedHeaders);
|
|
1832
|
+
}
|
|
1833
|
+
const srcBody = this.storage.readBody(srcBucketName, srcKey, srcObj);
|
|
1834
|
+
const destChecksums = { ...srcObj.checksums };
|
|
1835
|
+
if (headers["x-amz-sdk-checksum-algorithm"] || headers["x-amz-checksum-crc32"] || headers["x-amz-checksum-crc32c"] || headers["x-amz-checksum-crc64nvme"] || headers["x-amz-checksum-sha1"] || headers["x-amz-checksum-sha256"]) {
|
|
1836
|
+
const { checksums: resolved, error: csumErr } = resolveObjectChecksums(srcBody, headers);
|
|
1837
|
+
if (csumErr) return csumErr;
|
|
1838
|
+
Object.assign(destChecksums, resolved);
|
|
1839
|
+
}
|
|
1840
|
+
const newEtag = srcObj.etag;
|
|
1841
|
+
const lastModified = nowIso();
|
|
1842
|
+
const destObj = {
|
|
1843
|
+
body: srcBody,
|
|
1844
|
+
contentType,
|
|
1845
|
+
contentEncoding,
|
|
1846
|
+
etag: newEtag,
|
|
1847
|
+
lastModified,
|
|
1848
|
+
size: srcObj.size,
|
|
1849
|
+
metadata,
|
|
1850
|
+
preservedHeaders: preserved,
|
|
1851
|
+
checksums: destChecksums,
|
|
1852
|
+
checksumType: srcObj.checksumType
|
|
1853
|
+
};
|
|
1854
|
+
const destPriorObj = destBucket.objects[destKey];
|
|
1855
|
+
destBucket.objects[destKey] = destObj;
|
|
1856
|
+
const respHeaders = {};
|
|
1857
|
+
respHeaders["Content-Type"] = "application/xml";
|
|
1858
|
+
if (copySrcVid) respHeaders["x-amz-copy-source-version-id"] = copySrcVid;
|
|
1859
|
+
const versionId = this.recordObjectVersion(destBucketName, destKey, destPriorObj, destObj, srcBody);
|
|
1860
|
+
if (versionId) respHeaders["x-amz-version-id"] = versionId;
|
|
1861
|
+
const root = new XmlElement("CopyObjectResult", { xmlns: S3_NS });
|
|
1862
|
+
root.subElement("LastModified", lastModified);
|
|
1863
|
+
root.subElement("ETag", newEtag);
|
|
1864
|
+
return [
|
|
1865
|
+
200,
|
|
1866
|
+
respHeaders,
|
|
1867
|
+
xmlBody(root)
|
|
1868
|
+
];
|
|
1869
|
+
}
|
|
1870
|
+
collectListEntries(bucketObjects, prefix, delimiter, maxKeys, startAfter) {
|
|
1871
|
+
const rows = [];
|
|
1872
|
+
const seenPrefixes = /* @__PURE__ */ new Set();
|
|
1873
|
+
const sortedKeys = Object.keys(bucketObjects).filter((k) => k.startsWith(prefix)).toSorted();
|
|
1874
|
+
for (const k of sortedKeys) {
|
|
1875
|
+
if (delimiter) {
|
|
1876
|
+
const suffix = k.slice(prefix.length);
|
|
1877
|
+
const delimIdx = suffix.indexOf(delimiter);
|
|
1878
|
+
if (delimIdx !== -1) {
|
|
1879
|
+
const cp = prefix + suffix.slice(0, delimIdx + delimiter.length);
|
|
1880
|
+
if (!seenPrefixes.has(cp)) {
|
|
1881
|
+
seenPrefixes.add(cp);
|
|
1882
|
+
rows.push({
|
|
1883
|
+
value: cp,
|
|
1884
|
+
isPrefix: true
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
rows.push({
|
|
1891
|
+
value: k,
|
|
1892
|
+
isPrefix: false
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
let filteredRows = rows;
|
|
1896
|
+
if (startAfter) filteredRows = rows.filter((r) => r.value > startAfter);
|
|
1897
|
+
const contents = [];
|
|
1898
|
+
const commonPrefixes = [];
|
|
1899
|
+
let isTruncated = false;
|
|
1900
|
+
let nextMarker = "";
|
|
1901
|
+
for (let i = 0; i < filteredRows.length; i += 1) {
|
|
1902
|
+
if (i >= maxKeys) {
|
|
1903
|
+
isTruncated = true;
|
|
1904
|
+
break;
|
|
1905
|
+
}
|
|
1906
|
+
const r = filteredRows[i];
|
|
1907
|
+
if (r.isPrefix) commonPrefixes.push(r.value);
|
|
1908
|
+
else contents.push(r.value);
|
|
1909
|
+
nextMarker = r.value;
|
|
1910
|
+
}
|
|
1911
|
+
return {
|
|
1912
|
+
contents,
|
|
1913
|
+
commonPrefixes,
|
|
1914
|
+
isTruncated,
|
|
1915
|
+
nextMarker
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
listObjectsV2(bucketName, queryParams) {
|
|
1919
|
+
const bucket = this.storage.buckets.get(bucketName);
|
|
1920
|
+
if (!bucket) return noSuchBucket(bucketName);
|
|
1921
|
+
const prefix = qp(queryParams, "prefix");
|
|
1922
|
+
const delimiter = qp(queryParams, "delimiter");
|
|
1923
|
+
const maxKeys = Math.trunc(Number(qp(queryParams, "max-keys", "1000"))) || 1e3;
|
|
1924
|
+
const continuation = qp(queryParams, "continuation-token");
|
|
1925
|
+
const startAfter = qp(queryParams, "start-after");
|
|
1926
|
+
const encodingType = qp(queryParams, "encoding-type");
|
|
1927
|
+
const encode = encodingType === "url";
|
|
1928
|
+
let effectiveStart = startAfter;
|
|
1929
|
+
if (continuation) try {
|
|
1930
|
+
effectiveStart = Buffer.from(continuation, "base64").toString("utf-8");
|
|
1931
|
+
} catch {
|
|
1932
|
+
effectiveStart = continuation;
|
|
1933
|
+
}
|
|
1934
|
+
const { contents, commonPrefixes, isTruncated, nextMarker } = this.collectListEntries(bucket.objects, prefix, delimiter, maxKeys, effectiveStart);
|
|
1935
|
+
const root = new XmlElement("ListBucketResult", { xmlns: S3_NS });
|
|
1936
|
+
root.subElement("Name", bucketName);
|
|
1937
|
+
root.subElement("Prefix", encode && prefix ? urlEncode(prefix, true) : prefix);
|
|
1938
|
+
if (delimiter) root.subElement("Delimiter", encode ? urlEncode(delimiter, true) : delimiter);
|
|
1939
|
+
if (encodingType) root.subElement("EncodingType", encodingType);
|
|
1940
|
+
root.subElement("MaxKeys", String(maxKeys));
|
|
1941
|
+
root.subElement("KeyCount", String(contents.length + commonPrefixes.length));
|
|
1942
|
+
root.subElement("IsTruncated", isTruncated ? "true" : "false");
|
|
1943
|
+
if (continuation) root.subElement("ContinuationToken", continuation);
|
|
1944
|
+
if (startAfter) root.subElement("StartAfter", encode ? urlEncode(startAfter, true) : startAfter);
|
|
1945
|
+
if (isTruncated && nextMarker) {
|
|
1946
|
+
const token = Buffer.from(nextMarker, "utf-8").toString("base64");
|
|
1947
|
+
root.subElement("NextContinuationToken", token);
|
|
1948
|
+
}
|
|
1949
|
+
for (const k of contents) {
|
|
1950
|
+
const obj = bucket.objects[k];
|
|
1951
|
+
const c = root.subElement("Contents");
|
|
1952
|
+
c.subElement("Key", encode ? urlEncode(k, true) : k);
|
|
1953
|
+
c.subElement("LastModified", obj.lastModified);
|
|
1954
|
+
c.subElement("ETag", obj.etag);
|
|
1955
|
+
c.subElement("Size", String(obj.size));
|
|
1956
|
+
c.subElement("StorageClass", "STANDARD");
|
|
1957
|
+
}
|
|
1958
|
+
for (const cp of commonPrefixes.toSorted()) root.subElement("CommonPrefixes").subElement("Prefix", encode ? urlEncode(cp, true) : cp);
|
|
1959
|
+
return [
|
|
1960
|
+
200,
|
|
1961
|
+
{ "Content-Type": "application/xml" },
|
|
1962
|
+
xmlBody(root)
|
|
1963
|
+
];
|
|
1964
|
+
}
|
|
1965
|
+
listObjectVersions(bucketName, queryParams) {
|
|
1966
|
+
const bucket = this.storage.buckets.get(bucketName);
|
|
1967
|
+
if (!bucket) return noSuchBucket(bucketName);
|
|
1968
|
+
const prefix = qp(queryParams, "prefix");
|
|
1969
|
+
const delimiter = qp(queryParams, "delimiter");
|
|
1970
|
+
const keyMarker = qp(queryParams, "key-marker");
|
|
1971
|
+
const versionIdMarker = qp(queryParams, "version-id-marker");
|
|
1972
|
+
const maxKeys = Math.trunc(Number(qp(queryParams, "max-keys", "1000"))) || 1e3;
|
|
1973
|
+
const root = new XmlElement("ListVersionsResult", { xmlns: S3_NS });
|
|
1974
|
+
root.subElement("Name", bucketName);
|
|
1975
|
+
root.subElement("Prefix", prefix);
|
|
1976
|
+
if (delimiter) root.subElement("Delimiter", delimiter);
|
|
1977
|
+
root.subElement("KeyMarker", keyMarker);
|
|
1978
|
+
root.subElement("VersionIdMarker", versionIdMarker);
|
|
1979
|
+
root.subElement("MaxKeys", String(maxKeys));
|
|
1980
|
+
const inPage = (k) => {
|
|
1981
|
+
if (!k.startsWith(prefix)) return false;
|
|
1982
|
+
if (versionIdMarker) return k >= keyMarker;
|
|
1983
|
+
return k > keyMarker;
|
|
1984
|
+
};
|
|
1985
|
+
const allKeys = /* @__PURE__ */ new Set();
|
|
1986
|
+
for (const k of Object.keys(bucket.objects)) if (inPage(k)) allKeys.add(k);
|
|
1987
|
+
for (const [bn, k] of this.storage.objectVersions.keys()) if (bn === bucketName && inPage(k)) allKeys.add(k);
|
|
1988
|
+
const sortedKeys = [...allKeys].toSorted();
|
|
1989
|
+
const commonPrefixes = /* @__PURE__ */ new Set();
|
|
1990
|
+
const remainingKeys = [];
|
|
1991
|
+
if (delimiter) for (const k of sortedKeys) {
|
|
1992
|
+
const suffix = k.slice(prefix.length);
|
|
1993
|
+
const cut = suffix.indexOf(delimiter);
|
|
1994
|
+
if (cut === -1) remainingKeys.push(k);
|
|
1995
|
+
else commonPrefixes.add(prefix + suffix.slice(0, cut + delimiter.length));
|
|
1996
|
+
}
|
|
1997
|
+
else remainingKeys.push(...sortedKeys);
|
|
1998
|
+
const stream = [];
|
|
1999
|
+
for (const cp of commonPrefixes) stream.push({
|
|
2000
|
+
type: "prefix",
|
|
2001
|
+
value: cp
|
|
2002
|
+
});
|
|
2003
|
+
for (const k of remainingKeys) stream.push({
|
|
2004
|
+
type: "key",
|
|
2005
|
+
value: k
|
|
2006
|
+
});
|
|
2007
|
+
stream.sort((a, b) => a.value.localeCompare(b.value));
|
|
2008
|
+
let count = 0;
|
|
2009
|
+
let isTruncated = false;
|
|
2010
|
+
let nextKeyMarker = null;
|
|
2011
|
+
let nextVersionIdMarker = null;
|
|
2012
|
+
const emitVersion = (k, v) => {
|
|
2013
|
+
if (v.isDeleteMarker) {
|
|
2014
|
+
const dm = root.subElement("DeleteMarker");
|
|
2015
|
+
dm.subElement("Key", k);
|
|
2016
|
+
dm.subElement("VersionId", v.versionId);
|
|
2017
|
+
dm.subElement("IsLatest", v.isLatest ? "true" : "false");
|
|
2018
|
+
dm.subElement("LastModified", v.lastModified);
|
|
2019
|
+
} else {
|
|
2020
|
+
const ver = root.subElement("Version");
|
|
2021
|
+
ver.subElement("Key", k);
|
|
2022
|
+
ver.subElement("VersionId", v.versionId);
|
|
2023
|
+
ver.subElement("IsLatest", v.isLatest ? "true" : "false");
|
|
2024
|
+
ver.subElement("LastModified", v.lastModified);
|
|
2025
|
+
ver.subElement("ETag", v.etag);
|
|
2026
|
+
ver.subElement("Size", String(v.size));
|
|
2027
|
+
ver.subElement("StorageClass", "STANDARD");
|
|
2028
|
+
}
|
|
2029
|
+
};
|
|
2030
|
+
for (const item of stream) {
|
|
2031
|
+
if (count >= maxKeys) {
|
|
2032
|
+
isTruncated = true;
|
|
2033
|
+
break;
|
|
2034
|
+
}
|
|
2035
|
+
if (item.type === "prefix") {
|
|
2036
|
+
root.subElement("CommonPrefixes").subElement("Prefix", item.value);
|
|
2037
|
+
nextKeyMarker = item.value;
|
|
2038
|
+
nextVersionIdMarker = null;
|
|
2039
|
+
count += 1;
|
|
2040
|
+
continue;
|
|
2041
|
+
}
|
|
2042
|
+
const k = item.value;
|
|
2043
|
+
const versions = this.storage.objectVersions.get([bucketName, k]);
|
|
2044
|
+
if (versions && versions.length > 0) {
|
|
2045
|
+
let skipping = Boolean(versionIdMarker && k === keyMarker);
|
|
2046
|
+
for (let i = versions.length - 1; i >= 0; i -= 1) {
|
|
2047
|
+
const v = versions[i];
|
|
2048
|
+
if (skipping) {
|
|
2049
|
+
if (v.versionId === versionIdMarker) skipping = false;
|
|
2050
|
+
continue;
|
|
2051
|
+
}
|
|
2052
|
+
if (count >= maxKeys) {
|
|
2053
|
+
isTruncated = true;
|
|
2054
|
+
break;
|
|
2055
|
+
}
|
|
2056
|
+
emitVersion(k, v);
|
|
2057
|
+
nextKeyMarker = k;
|
|
2058
|
+
nextVersionIdMarker = v.versionId;
|
|
2059
|
+
count += 1;
|
|
2060
|
+
}
|
|
2061
|
+
} else {
|
|
2062
|
+
const obj = bucket.objects[k];
|
|
2063
|
+
if (!obj) continue;
|
|
2064
|
+
const ver = root.subElement("Version");
|
|
2065
|
+
ver.subElement("Key", k);
|
|
2066
|
+
ver.subElement("VersionId", obj.versionId || "null");
|
|
2067
|
+
ver.subElement("IsLatest", "true");
|
|
2068
|
+
ver.subElement("LastModified", obj.lastModified);
|
|
2069
|
+
ver.subElement("ETag", obj.etag);
|
|
2070
|
+
ver.subElement("Size", String(obj.size));
|
|
2071
|
+
ver.subElement("StorageClass", "STANDARD");
|
|
2072
|
+
nextKeyMarker = k;
|
|
2073
|
+
nextVersionIdMarker = obj.versionId || "null";
|
|
2074
|
+
count += 1;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
root.subElement("IsTruncated", isTruncated ? "true" : "false");
|
|
2078
|
+
if (isTruncated && nextKeyMarker !== null) {
|
|
2079
|
+
root.subElement("NextKeyMarker", nextKeyMarker);
|
|
2080
|
+
root.subElement("NextVersionIdMarker", nextVersionIdMarker || "null");
|
|
2081
|
+
}
|
|
2082
|
+
return [
|
|
2083
|
+
200,
|
|
2084
|
+
{ "Content-Type": "application/xml" },
|
|
2085
|
+
xmlBody(root)
|
|
2086
|
+
];
|
|
2087
|
+
}
|
|
2088
|
+
expireMultipartUploads(bucketName) {
|
|
2089
|
+
const rules = this.storage.bucketLifecycle.get(bucketName);
|
|
2090
|
+
if (!Array.isArray(rules)) return;
|
|
2091
|
+
for (const [uploadId, upload] of this.storage.multipartUploads.entries()) {
|
|
2092
|
+
if (upload.bucket !== bucketName) continue;
|
|
2093
|
+
for (const rule of rules) {
|
|
2094
|
+
const days = rule.AbortIncompleteMultipartUpload?.DaysAfterInitiation;
|
|
2095
|
+
if (rule.Status !== "Enabled" || !days) continue;
|
|
2096
|
+
if (upload.key.startsWith(rule.Filter?.Prefix ?? "") && Date.now() - Date.parse(upload.created) >= days * 864e5) {
|
|
2097
|
+
this.storage.multipartUploads.delete(uploadId);
|
|
2098
|
+
break;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
createMultipartUpload(bucketName, key, headers) {
|
|
2104
|
+
if (!this.storage.buckets.get(bucketName)) return noSuchBucket(bucketName);
|
|
2105
|
+
const { algorithm: checksumAlgorithm, error: csumErr } = resolveMultipartChecksumAlgorithm(headers);
|
|
2106
|
+
if (csumErr) return csumErr;
|
|
2107
|
+
const uploadId = newUuid();
|
|
2108
|
+
const contentType = headers["content-type"] || "application/octet-stream";
|
|
2109
|
+
const contentEncoding = headers["content-encoding"];
|
|
2110
|
+
const metadata = this.extractUserMetadata(headers);
|
|
2111
|
+
const preserved = {};
|
|
2112
|
+
for (const h of PRESERVED_HEADERS) if (headers[h] !== void 0) preserved[h] = headers[h];
|
|
2113
|
+
this.storage.multipartUploads.set(uploadId, {
|
|
2114
|
+
bucket: bucketName,
|
|
2115
|
+
key,
|
|
2116
|
+
parts: {},
|
|
2117
|
+
metadata,
|
|
2118
|
+
contentType,
|
|
2119
|
+
contentEncoding,
|
|
2120
|
+
preservedHeaders: preserved,
|
|
2121
|
+
checksumAlgorithm,
|
|
2122
|
+
created: nowIso()
|
|
2123
|
+
});
|
|
2124
|
+
const root = new XmlElement("InitiateMultipartUploadResult", { xmlns: S3_NS });
|
|
2125
|
+
root.subElement("Bucket", bucketName);
|
|
2126
|
+
root.subElement("Key", key);
|
|
2127
|
+
root.subElement("UploadId", uploadId);
|
|
2128
|
+
const csumHeaders = {};
|
|
2129
|
+
if (checksumAlgorithm) {
|
|
2130
|
+
csumHeaders["x-amz-checksum-algorithm"] = checksumAlgorithm;
|
|
2131
|
+
csumHeaders["x-amz-checksum-type"] = "COMPOSITE";
|
|
2132
|
+
}
|
|
2133
|
+
return [
|
|
2134
|
+
200,
|
|
2135
|
+
{
|
|
2136
|
+
"Content-Type": "application/xml",
|
|
2137
|
+
...csumHeaders
|
|
2138
|
+
},
|
|
2139
|
+
xmlBody(root)
|
|
2140
|
+
];
|
|
2141
|
+
}
|
|
2142
|
+
uploadPart(bucketName, key, body, queryParams, headers) {
|
|
2143
|
+
if (!this.storage.buckets.get(bucketName)) return noSuchBucket(bucketName);
|
|
2144
|
+
const uploadId = qp(queryParams, "uploadId");
|
|
2145
|
+
const partNumberStr = qp(queryParams, "partNumber");
|
|
2146
|
+
const upload = this.storage.multipartUploads.get(uploadId);
|
|
2147
|
+
if (!upload || upload.bucket !== bucketName || upload.key !== key) return s3Error("NoSuchUpload", "The specified multipart upload does not exist.", 404, `/${bucketName}/${key}`);
|
|
2148
|
+
const pn = Number(partNumberStr);
|
|
2149
|
+
if (!Number.isInteger(pn) || pn < 1 || pn > 1e4) return s3Error("InvalidArgument", "Part number must be an integer between 1 and 10000, inclusive.", 400);
|
|
2150
|
+
const md5Err = validateContentMd5(headers, body);
|
|
2151
|
+
if (md5Err) return md5Err;
|
|
2152
|
+
const { checksums: partChecksums, error: csumErr } = resolvePartChecksums(body, headers, upload);
|
|
2153
|
+
if (csumErr) return csumErr;
|
|
2154
|
+
const etag = `"${md5Hex(body)}"`;
|
|
2155
|
+
upload.parts[pn] = {
|
|
2156
|
+
body,
|
|
2157
|
+
etag,
|
|
2158
|
+
size: body.length,
|
|
2159
|
+
lastModified: nowIso(),
|
|
2160
|
+
checksums: partChecksums
|
|
2161
|
+
};
|
|
2162
|
+
const csumHeaders = {};
|
|
2163
|
+
for (const [alg, val] of Object.entries(partChecksums)) csumHeaders[`x-amz-checksum-${alg.toLowerCase()}`] = val;
|
|
2164
|
+
return [
|
|
2165
|
+
200,
|
|
2166
|
+
{
|
|
2167
|
+
ETag: etag,
|
|
2168
|
+
...csumHeaders
|
|
2169
|
+
},
|
|
2170
|
+
Buffer.alloc(0)
|
|
2171
|
+
];
|
|
2172
|
+
}
|
|
2173
|
+
completeMultipartUpload(bucketName, key, body, queryParams, headers = {}) {
|
|
2174
|
+
const bucket = this.storage.buckets.get(bucketName);
|
|
2175
|
+
if (!bucket) return noSuchBucket(bucketName);
|
|
2176
|
+
const uploadId = qp(queryParams, "uploadId");
|
|
2177
|
+
const cached = this.storage.completedMultipartUploads.get(uploadId);
|
|
2178
|
+
if (cached && cached.bucket === bucketName && cached.key === key) return cached.response;
|
|
2179
|
+
const upload = this.storage.multipartUploads.get(uploadId);
|
|
2180
|
+
if (!upload || upload.bucket !== bucketName || upload.key !== key) return s3Error("NoSuchUpload", "The specified multipart upload does not exist.", 404, `/${bucketName}/${key}`);
|
|
2181
|
+
const precondErr = this.checkPutPreconditions(headers, bucket.objects[key]);
|
|
2182
|
+
if (precondErr) return precondErr;
|
|
2183
|
+
let root;
|
|
2184
|
+
try {
|
|
2185
|
+
root = parseXml(body);
|
|
2186
|
+
} catch {
|
|
2187
|
+
return s3Error("MalformedXML", "The XML you provided was not well-formed or did not validate against our published schema.", 400, `/${bucketName}/${key}`);
|
|
2188
|
+
}
|
|
2189
|
+
const orderedParts = [];
|
|
2190
|
+
for (const partEl of root.children) if (partEl.localName() === "Part") {
|
|
2191
|
+
let pnText;
|
|
2192
|
+
let etagText;
|
|
2193
|
+
const partChecksums = {};
|
|
2194
|
+
for (const child of partEl.children) {
|
|
2195
|
+
const local = child.localName();
|
|
2196
|
+
if (local === "PartNumber") pnText = child.text;
|
|
2197
|
+
else if (local === "ETag") etagText = child.text;
|
|
2198
|
+
else if (local.startsWith("Checksum")) partChecksums[local.slice(8).toUpperCase()] = child.text || "";
|
|
2199
|
+
}
|
|
2200
|
+
if (pnText === void 0) return s3Error("InvalidPart", "Each part requires a part number.", 400);
|
|
2201
|
+
if (pnText !== void 0) orderedParts.push({
|
|
2202
|
+
pn: Number(pnText),
|
|
2203
|
+
reqEtag: etagText,
|
|
2204
|
+
reqChecksums: partChecksums
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
if (root.localName() !== "CompleteMultipartUpload" || orderedParts.length === 0 || orderedParts.length > 1e4) return s3Error("MalformedXML", "A completion request must contain between 1 and 10000 parts.", 400);
|
|
2208
|
+
let previousPartNumber = 0;
|
|
2209
|
+
for (const part of orderedParts) {
|
|
2210
|
+
if (!Number.isInteger(part.pn) || part.pn < 1 || part.pn > 1e4 || !part.reqEtag) return s3Error("InvalidPart", "Each part requires a valid part number and ETag.", 400);
|
|
2211
|
+
if (part.pn <= previousPartNumber) return s3Error("InvalidPartOrder", "Parts must be in ascending order without duplicates.", 400);
|
|
2212
|
+
previousPartNumber = part.pn;
|
|
2213
|
+
}
|
|
2214
|
+
const md5Digests = [];
|
|
2215
|
+
const combinedParts = [];
|
|
2216
|
+
const partDigests = [];
|
|
2217
|
+
const checksumAlgorithm = upload.checksumAlgorithm;
|
|
2218
|
+
for (const { pn, reqEtag, reqChecksums } of orderedParts) {
|
|
2219
|
+
const stored = upload.parts[pn];
|
|
2220
|
+
if (!stored) return s3Error("InvalidPart", "One or more of the specified parts could not be found.", 400);
|
|
2221
|
+
if (reqEtag && reqEtag.replaceAll(/^"|"$/gu, "") !== stored.etag.replaceAll(/^"|"$/gu, "")) return s3Error("InvalidPart", `One or more of the specified parts could not be found. The following part numbers are invalid: ${pn}`, 400);
|
|
2222
|
+
if (pn !== orderedParts.at(-1)?.pn && stored.size < 5242880) return s3Error("EntityTooSmall", "Each nonfinal part must contain at least 5 MiB.", 400);
|
|
2223
|
+
const storedChecksums = stored.checksums || {};
|
|
2224
|
+
for (const [alg, val] of Object.entries(reqChecksums)) if (val && storedChecksums[alg] !== val) return s3Error("InvalidPart", `One or more of the specified parts could not be found. The following part numbers are invalid: ${pn}`, 400);
|
|
2225
|
+
if (checksumAlgorithm) partDigests.push(storedChecksums[checksumAlgorithm]);
|
|
2226
|
+
md5Digests.push(createHash("md5").update(stored.body).digest());
|
|
2227
|
+
combinedParts.push(stored.body);
|
|
2228
|
+
}
|
|
2229
|
+
const composite = compositeChecksum(checksumAlgorithm, partDigests);
|
|
2230
|
+
if (checksumAlgorithm) {
|
|
2231
|
+
const requested = headers[`x-amz-checksum-${checksumAlgorithm.toLowerCase()}`];
|
|
2232
|
+
if (requested && requested !== composite) return s3Error("BadDigest", `The ${checksumAlgorithm} you specified did not match the calculated checksum.`, 400, `/${bucketName}/${key}`);
|
|
2233
|
+
}
|
|
2234
|
+
const finalEtag = `"${createHash("md5").update(Buffer.concat(md5Digests)).digest("hex")}-${orderedParts.length}"`;
|
|
2235
|
+
const combined = Buffer.concat(combinedParts);
|
|
2236
|
+
const obj = {
|
|
2237
|
+
body: combined,
|
|
2238
|
+
contentType: upload.contentType,
|
|
2239
|
+
contentEncoding: upload.contentEncoding,
|
|
2240
|
+
etag: finalEtag,
|
|
2241
|
+
lastModified: nowIso(),
|
|
2242
|
+
size: combined.length,
|
|
2243
|
+
metadata: upload.metadata,
|
|
2244
|
+
preservedHeaders: upload.preservedHeaders,
|
|
2245
|
+
checksums: composite && checksumAlgorithm ? { [checksumAlgorithm]: composite } : {},
|
|
2246
|
+
checksumType: composite ? "COMPOSITE" : void 0
|
|
2247
|
+
};
|
|
2248
|
+
const priorObj = bucket.objects[key];
|
|
2249
|
+
bucket.objects[key] = obj;
|
|
2250
|
+
this.storage.multipartUploads.delete(uploadId);
|
|
2251
|
+
const respHeaders = {};
|
|
2252
|
+
respHeaders["Content-Type"] = "application/xml";
|
|
2253
|
+
const versionId = this.recordObjectVersion(bucketName, key, priorObj, obj, combined);
|
|
2254
|
+
if (versionId) respHeaders["x-amz-version-id"] = versionId;
|
|
2255
|
+
const rootResult = new XmlElement("CompleteMultipartUploadResult", { xmlns: S3_NS });
|
|
2256
|
+
const requestHost = headers["host"];
|
|
2257
|
+
let s3Host = "http://localhost:4566";
|
|
2258
|
+
if (requestHost) s3Host = `${headers["x-forwarded-proto"] || "http"}://${requestHost}`;
|
|
2259
|
+
rootResult.subElement("Location", `${s3Host}/${bucketName}/${key}`);
|
|
2260
|
+
rootResult.subElement("Bucket", bucketName);
|
|
2261
|
+
rootResult.subElement("Key", key);
|
|
2262
|
+
rootResult.subElement("ETag", finalEtag);
|
|
2263
|
+
if (composite && checksumAlgorithm) {
|
|
2264
|
+
rootResult.subElement(`Checksum${checksumAlgorithm}`, composite);
|
|
2265
|
+
rootResult.subElement("ChecksumType", "COMPOSITE");
|
|
2266
|
+
}
|
|
2267
|
+
const response = [
|
|
2268
|
+
200,
|
|
2269
|
+
respHeaders,
|
|
2270
|
+
xmlBody(rootResult)
|
|
2271
|
+
];
|
|
2272
|
+
this.storage.completedMultipartUploads.set(uploadId, {
|
|
2273
|
+
bucket: bucketName,
|
|
2274
|
+
key,
|
|
2275
|
+
response
|
|
2276
|
+
});
|
|
2277
|
+
const completed = this.storage.completedMultipartUploads._data;
|
|
2278
|
+
if (completed.size > 1e3) {
|
|
2279
|
+
const oldest = completed.keys().next().value;
|
|
2280
|
+
if (oldest !== void 0) completed.delete(oldest);
|
|
2281
|
+
}
|
|
2282
|
+
return response;
|
|
2283
|
+
}
|
|
2284
|
+
abortMultipartUpload(bucketName, key, queryParams) {
|
|
2285
|
+
if (!this.storage.buckets.get(bucketName)) return noSuchBucket(bucketName);
|
|
2286
|
+
const uploadId = qp(queryParams, "uploadId");
|
|
2287
|
+
const upload = this.storage.multipartUploads.get(uploadId);
|
|
2288
|
+
if (!upload || upload.bucket !== bucketName || upload.key !== key) return s3Error("NoSuchUpload", "The specified multipart upload does not exist.", 404, `/${bucketName}/${key}`);
|
|
2289
|
+
this.storage.multipartUploads.delete(uploadId);
|
|
2290
|
+
return [
|
|
2291
|
+
204,
|
|
2292
|
+
{},
|
|
2293
|
+
Buffer.alloc(0)
|
|
2294
|
+
];
|
|
2295
|
+
}
|
|
2296
|
+
};
|
|
2297
|
+
//#endregion
|
|
2298
|
+
//#region src/server/server.ts
|
|
2299
|
+
function collectRequestBody(req) {
|
|
2300
|
+
return new Promise((resolve, reject) => {
|
|
2301
|
+
const chunks = [];
|
|
2302
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
2303
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
2304
|
+
req.on("error", reject);
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Creates a Node.js `http.Server` request listener that serves the local S3 emulator API.
|
|
2309
|
+
*
|
|
2310
|
+
* @param options - Server configuration options (region, persistence, data directory).
|
|
2311
|
+
* @returns A Node.js `RequestListener` function `(req, res) => Promise<void>`.
|
|
2312
|
+
*
|
|
2313
|
+
* @example
|
|
2314
|
+
* ```ts
|
|
2315
|
+
* import { createServer } from "node:http";
|
|
2316
|
+
* import { createS3NodeHandler } from "local-s3/server";
|
|
2317
|
+
*
|
|
2318
|
+
* const server = createServer(createS3NodeHandler({ persist: true }));
|
|
2319
|
+
* server.listen(4566, "127.0.0.1");
|
|
2320
|
+
* ```
|
|
2321
|
+
*/
|
|
2322
|
+
function createS3NodeHandler(options = {}) {
|
|
2323
|
+
const s3 = new S3Service(options);
|
|
2324
|
+
return async (req, res) => {
|
|
2325
|
+
const method = (req.method || "GET").toUpperCase();
|
|
2326
|
+
const [pathPart, queryPart] = (req.url || "/").split("?");
|
|
2327
|
+
const queryParams = parseQueryParams(queryPart || "");
|
|
2328
|
+
const headers = {};
|
|
2329
|
+
for (const [k, v] of Object.entries(req.headers)) if (v !== void 0) headers[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : v;
|
|
2330
|
+
const accountId = headers["x-amz-account-id"] || qp(queryParams, "accountId") || void 0;
|
|
2331
|
+
const region = headers["x-amz-region"] || qp(queryParams, "region") || options.region || "us-east-1";
|
|
2332
|
+
let path = pathPart;
|
|
2333
|
+
const signedPath = path;
|
|
2334
|
+
try {
|
|
2335
|
+
path = decodeURIComponent(path);
|
|
2336
|
+
} catch {
|
|
2337
|
+
res.writeHead(400);
|
|
2338
|
+
res.end("Invalid URI encoding");
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
let body;
|
|
2342
|
+
try {
|
|
2343
|
+
body = await collectRequestBody(req);
|
|
2344
|
+
} catch {
|
|
2345
|
+
res.destroy();
|
|
2346
|
+
return;
|
|
2347
|
+
}
|
|
2348
|
+
try {
|
|
2349
|
+
const [status, respHeaders, respBody] = withAccountContext({
|
|
2350
|
+
accountId,
|
|
2351
|
+
region
|
|
2352
|
+
}, () => s3.handleRequest(method, path, headers, body, queryParams, signedPath));
|
|
2353
|
+
res.writeHead(status, respHeaders);
|
|
2354
|
+
res.end(respBody);
|
|
2355
|
+
} catch {
|
|
2356
|
+
if (!res.headersSent) {
|
|
2357
|
+
res.writeHead(500, { "Content-Type": "application/xml" });
|
|
2358
|
+
res.end("<Error><Code>InternalError</Code><Message>We encountered an internal error. Please try again.</Message></Error>");
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
//#endregion
|
|
2364
|
+
//#region src/vite.ts
|
|
2365
|
+
const isColorSupported = !("NO_COLOR" in process.env) && (Boolean(process.env.FORCE_COLOR) || process.stdout.isTTY && process.env.TERM !== "dumb");
|
|
2366
|
+
function dim(text) {
|
|
2367
|
+
return isColorSupported ? `\u001B[2m${text}\u001B[22m` : text;
|
|
2368
|
+
}
|
|
2369
|
+
function boldGreen(text) {
|
|
2370
|
+
return isColorSupported ? `\u001B[1m\u001B[32m${text}\u001B[39m\u001B[22m` : text;
|
|
2371
|
+
}
|
|
2372
|
+
function boldRed(text) {
|
|
2373
|
+
return isColorSupported ? `\u001B[1m\u001B[31m${text}\u001B[39m\u001B[22m` : text;
|
|
2374
|
+
}
|
|
2375
|
+
function formatTime() {
|
|
2376
|
+
return new Intl.DateTimeFormat(void 0, {
|
|
2377
|
+
hour: "numeric",
|
|
2378
|
+
minute: "numeric",
|
|
2379
|
+
second: "numeric"
|
|
2380
|
+
}).format(/* @__PURE__ */ new Date());
|
|
2381
|
+
}
|
|
2382
|
+
const runningServersKey = Symbol.for("local-s3.vite.runningServers");
|
|
2383
|
+
const runtime = globalThis;
|
|
2384
|
+
runtime[runningServersKey] ??= /* @__PURE__ */ new Map();
|
|
2385
|
+
const runningServers = runtime[runningServersKey];
|
|
2386
|
+
/** Start the embedded S3 emulator for Vite development. */
|
|
2387
|
+
function s3Emulator(options = {}) {
|
|
2388
|
+
const cleanups = /* @__PURE__ */ new WeakMap();
|
|
2389
|
+
return {
|
|
2390
|
+
name: "vite-plugin-local-s3",
|
|
2391
|
+
apply: "serve",
|
|
2392
|
+
async configureServer(vite) {
|
|
2393
|
+
const host = "127.0.0.1";
|
|
2394
|
+
const port = options.port ?? 4566;
|
|
2395
|
+
const targetKey = `${host}:${port}`;
|
|
2396
|
+
const rootKey = `root:${vite.config.root}`;
|
|
2397
|
+
await Promise.all([
|
|
2398
|
+
runningServers.get(targetKey)?.(),
|
|
2399
|
+
runningServers.get(rootKey)?.(),
|
|
2400
|
+
vite.config.inlineConfig ? runningServers.get(vite.config.inlineConfig)?.() : void 0
|
|
2401
|
+
]);
|
|
2402
|
+
const handler = createS3NodeHandler({
|
|
2403
|
+
region: "us-east-1",
|
|
2404
|
+
persist: true,
|
|
2405
|
+
dataDir: path.resolve(vite.config.root, ".emulator/s3")
|
|
2406
|
+
});
|
|
2407
|
+
const server = createServer(async (request, response) => {
|
|
2408
|
+
try {
|
|
2409
|
+
await handler(request, response);
|
|
2410
|
+
} catch (error) {
|
|
2411
|
+
vite.config.logger.error(`${dim(formatTime())} ${boldRed("[s3-emulator]")} Request failed: ${String(error)}`);
|
|
2412
|
+
if (response.headersSent) response.destroy();
|
|
2413
|
+
else {
|
|
2414
|
+
response.writeHead(500);
|
|
2415
|
+
response.end("Internal Server Error");
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
});
|
|
2419
|
+
let closing;
|
|
2420
|
+
let boundKey;
|
|
2421
|
+
function stop() {
|
|
2422
|
+
closing ??= (async () => {
|
|
2423
|
+
try {
|
|
2424
|
+
await new Promise((resolve, reject) => {
|
|
2425
|
+
server.close((error) => {
|
|
2426
|
+
if (error && !("code" in error && error.code === "ERR_SERVER_NOT_RUNNING")) reject(error);
|
|
2427
|
+
else resolve();
|
|
2428
|
+
});
|
|
2429
|
+
server.closeAllConnections();
|
|
2430
|
+
});
|
|
2431
|
+
} finally {
|
|
2432
|
+
if (boundKey && runningServers.get(boundKey) === stop) runningServers.delete(boundKey);
|
|
2433
|
+
if (runningServers.get(targetKey) === stop) runningServers.delete(targetKey);
|
|
2434
|
+
if (runningServers.get(rootKey) === stop) runningServers.delete(rootKey);
|
|
2435
|
+
if (vite.config.inlineConfig && runningServers.get(vite.config.inlineConfig) === stop) runningServers.delete(vite.config.inlineConfig);
|
|
2436
|
+
}
|
|
2437
|
+
})();
|
|
2438
|
+
return closing;
|
|
2439
|
+
}
|
|
2440
|
+
runningServers.set(targetKey, stop);
|
|
2441
|
+
runningServers.set(rootKey, stop);
|
|
2442
|
+
if (vite.config.inlineConfig) runningServers.set(vite.config.inlineConfig, stop);
|
|
2443
|
+
for (const environment of Object.values(vite.environments)) cleanups.set(environment, stop);
|
|
2444
|
+
vite.httpServer?.on("close", () => {
|
|
2445
|
+
stop();
|
|
2446
|
+
});
|
|
2447
|
+
const originalClose = vite.close.bind(vite);
|
|
2448
|
+
vite.close = async () => {
|
|
2449
|
+
await stop();
|
|
2450
|
+
return originalClose();
|
|
2451
|
+
};
|
|
2452
|
+
try {
|
|
2453
|
+
await new Promise((resolve, reject) => {
|
|
2454
|
+
server.once("error", reject);
|
|
2455
|
+
server.listen(port, host, () => {
|
|
2456
|
+
server.off("error", reject);
|
|
2457
|
+
resolve();
|
|
2458
|
+
});
|
|
2459
|
+
});
|
|
2460
|
+
} catch (error) {
|
|
2461
|
+
await stop();
|
|
2462
|
+
await vite.close();
|
|
2463
|
+
throw new Error(`[s3-emulator] Cannot listen on ${host}:${port}. Check that the address is available and no other server is using the port.`, { cause: error });
|
|
2464
|
+
}
|
|
2465
|
+
server.on("error", (error) => {
|
|
2466
|
+
vite.config.logger.error(`${dim(formatTime())} ${boldRed("[s3-emulator]")} Server error: ${error.message}`);
|
|
2467
|
+
});
|
|
2468
|
+
const address = server.address();
|
|
2469
|
+
if (address && typeof address !== "string") {
|
|
2470
|
+
boundKey = `${host}:${address.port}`;
|
|
2471
|
+
runningServers.set(boundKey, stop);
|
|
2472
|
+
const endpoint = `http://${host}:${address.port}`;
|
|
2473
|
+
vite.config.logger.info(`${dim(formatTime())} ${boldGreen("[s3-emulator]")} Listening at ${endpoint}`);
|
|
2474
|
+
}
|
|
2475
|
+
},
|
|
2476
|
+
async closeBundle() {
|
|
2477
|
+
await cleanups.get(this.environment)?.();
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
//#endregion
|
|
2482
|
+
export { s3Emulator };
|