@midsummer-ai/sdk 0.0.0-stage → 0.1.0-next.1305
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/LICENSE +177 -0
- package/README.md +4 -2
- package/agents/contract.d.mts +9 -0
- package/agents/contract.mjs +221 -0
- package/agents/index.d.mts +128 -0
- package/agents/index.mjs +203 -0
- package/agents/stand-in.d.mts +35 -0
- package/agents/stand-in.mjs +149 -0
- package/ai/contract.d.mts +10 -0
- package/ai/contract.mjs +77 -0
- package/ai/index.d.mts +94 -0
- package/ai/index.mjs +193 -0
- package/ai/stand-in.d.mts +7 -0
- package/ai/stand-in.mjs +41 -0
- package/build-SQ44jwSa.d.mts +41 -0
- package/client.d.mts +45 -0
- package/client.mjs +106 -0
- package/connection-CxD_Nry5.mjs +1911 -0
- package/contract.d.mts +10 -0
- package/contract.mjs +469 -0
- package/database/build.d.mts +2 -0
- package/database/build.mjs +579 -0
- package/database/client.d.mts +60 -0
- package/database/client.mjs +133 -0
- package/database/contract.d.mts +11 -0
- package/database/contract.mjs +164 -0
- package/database/errors.d.mts +23 -0
- package/database/errors.mjs +13 -0
- package/database/server.d.mts +3 -0
- package/database/server.mjs +320 -0
- package/database/stand-in.d.mts +99 -0
- package/database/stand-in.mjs +430 -0
- package/email/client.d.mts +34 -0
- package/email/client.mjs +131 -0
- package/email/contract.d.mts +11 -0
- package/email/contract.mjs +99 -0
- package/email/index.d.mts +74 -0
- package/email/index.mjs +59 -0
- package/email/smtp.d.mts +22 -0
- package/email/smtp.mjs +296 -0
- package/email/stand-in.d.mts +13 -0
- package/email/stand-in.mjs +51 -0
- package/index-DdoK0y7D.d.mts +192 -0
- package/index-sgdyoA2P.d.mts +212 -0
- package/index.d.mts +2 -0
- package/index.mjs +137 -0
- package/names-DfCBKupJ.mjs +115 -0
- package/package.json +152 -4
- package/predict-Drfm_8pO.mjs +179 -0
- package/queues/contract.d.mts +13 -0
- package/queues/contract.mjs +278 -0
- package/queues/index.d.mts +184 -0
- package/queues/index.mjs +126 -0
- package/queues/stand-in.d.mts +7 -0
- package/queues/stand-in.mjs +110 -0
- package/queues/wire.d.mts +9 -0
- package/queues/wire.mjs +199 -0
- package/s3.d.mts +43 -0
- package/s3.mjs +1003 -0
- package/schema-BF-Y1EVu.mjs +142 -0
- package/schema-DS8x1Dn8.mjs +115 -0
- package/schema-f7E9uzUn.d.mts +74 -0
- package/server-C7O7CICU.d.mts +182 -0
- package/socket.d.mts +67 -0
- package/socket.mjs +263 -0
- package/stand-in.d.mts +7 -0
- package/stand-in.mjs +185 -0
- package/store-DDuOr-_F.mjs +237 -0
- package/tabs-4FLTfg1c.mjs +457 -0
- package/vector/contract.d.mts +10 -0
- package/vector/contract.mjs +635 -0
- package/vector/index.d.mts +2 -0
- package/vector/index.mjs +108 -0
- package/vector/stand-in.d.mts +11 -0
- package/vector/stand-in.mjs +169 -0
- package/vector/wire.d.mts +25 -0
- package/vector/wire.mjs +175 -0
- package/wire.d.mts +54 -0
- package/wire.mjs +731 -0
package/s3.mjs
ADDED
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
import { a as checkMetadata, h as StorageError, l as checkUploadId, m as sha256, n as checkBucketName, o as checkPartNumber, r as checkKey, s as checkParts } from "./names-DfCBKupJ.mjs";
|
|
2
|
+
import { streamsOf } from "./index.mjs";
|
|
3
|
+
//#region src/s3.ts
|
|
4
|
+
/**
|
|
5
|
+
* Storage over the S3 protocol, so the tools and SDKs that speak it reach a Project's Buckets
|
|
6
|
+
* unchanged. `s3(engine, options)` serves one HTTP request from any engine: the Stand-in under
|
|
7
|
+
* `midsummer dev`, or the real Capability's engine on its server. S3 here is the protocol, not a
|
|
8
|
+
* provider.
|
|
9
|
+
*
|
|
10
|
+
* Bun.serve({ fetch: s3(standIn({ clock: Date.now }), { credentials, clock: Date.now }) })
|
|
11
|
+
* // aws --endpoint-url http://127.0.0.1:<port> s3 cp photo.png s3://avatars/ada.png
|
|
12
|
+
*
|
|
13
|
+
* Requests are signed with Signature Version 4, in the Authorization header or as a presigned URL;
|
|
14
|
+
* `credentials` turns an access key id into its secret and what it may do. A Bucket is addressed in
|
|
15
|
+
* the path (`/<bucket>/<key>`), or as the first label of one of `hosts` (`<bucket>.<host>/<key>`).
|
|
16
|
+
*
|
|
17
|
+
* Supported: ListBuckets (the key's own Buckets), HeadBucket, CreateBucket (confirms a Bucket the
|
|
18
|
+
* key may use; Buckets are made by the platform), ListObjectsV2 and ListObjects, PutObject (plain
|
|
19
|
+
* or aws-chunked bodies), GetObject and HeadObject with a Range and the conditional headers
|
|
20
|
+
* (If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since), DeleteObject, DeleteObjects,
|
|
21
|
+
* CopyObject and UploadPartCopy with their `x-amz-copy-source-if-*` conditions, multipart uploads
|
|
22
|
+
* (Create, UploadPart, UploadPartCopy, Complete, Abort), presigned URLs for any of these, and a
|
|
23
|
+
* Bucket's settings as they stand: GetBucketLocation, GetBucketVersioning (never enabled),
|
|
24
|
+
* GetBucketCors, GetObjectTagging (an empty tag set), and DeleteObjectTagging,
|
|
25
|
+
* DeleteBucketLifecycle and DeleteBucketTagging (there is nothing to delete). A Bucket whose writes
|
|
26
|
+
* are paused gets `QuotaExceeded` for its writes.
|
|
27
|
+
*
|
|
28
|
+
* CORS is open to every origin, for every Bucket, and cannot be changed: a request is allowed by
|
|
29
|
+
* its signature, never by a cookie, so a browser on any site holding a presigned URL, or signing
|
|
30
|
+
* with a key, may use it, and a site with neither can do nothing. A preflight gets an unsigned reply,
|
|
31
|
+
* as browsers send it.
|
|
32
|
+
*
|
|
33
|
+
* Refused with `NotImplemented` (501), as S3 refuses what a deployment does not offer, and as its
|
|
34
|
+
* clients expect: anything that would keep a setting Storage does not keep. Tags (PutObjectTagging,
|
|
35
|
+
* PutBucketTagging, `x-amz-tagging` on a put), lifecycle rules (PutBucketLifecycleConfiguration:
|
|
36
|
+
* Objects are kept until deleted), CORS rules (PutBucketCors, DeleteBucketCors: always open, above),
|
|
37
|
+
* versioning, ACLs and policies (a Storage Key's role is what grants), and every other setting.
|
|
38
|
+
* Also ListParts and ListMultipartUploads, which the engine cannot list (a client keeps its own
|
|
39
|
+
* parts, and an unfinished upload is removed by Abort), If-None-Match with an etag on a put (S3
|
|
40
|
+
* takes only `*` there), and DeleteBucket (Buckets are made and deleted by the platform, with
|
|
41
|
+
* `midsummer storage bucket`). The settings' GETs reply as S3 does for a Bucket that has none:
|
|
42
|
+
* `NoSuchLifecycleConfiguration`, `NoSuchTagSet`, `NoSuchBucketPolicy`.
|
|
43
|
+
*
|
|
44
|
+
* An aws-chunked body is taken signed chunk by chunk (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD`, with or
|
|
45
|
+
* without a signed trailer) or unsigned with a trailing checksum (`STREAMING-UNSIGNED-PAYLOAD-TRAILER`):
|
|
46
|
+
* every chunk's signature, the trailer's, and a CRC32 or CRC32C trailing checksum are checked before
|
|
47
|
+
* the Object is kept. Any other streaming mode, or another trailing checksum, is `NotImplemented`.
|
|
48
|
+
*
|
|
49
|
+
* Not yet: Content-MD5.
|
|
50
|
+
*/
|
|
51
|
+
const XMLNS = "http://s3.amazonaws.com/doc/2006-03-01/";
|
|
52
|
+
const SKEW = 9e5;
|
|
53
|
+
function s3(engine, options) {
|
|
54
|
+
const answer = async (request) => {
|
|
55
|
+
const url = new URL(request.url);
|
|
56
|
+
const host = (request.headers.get("host") ?? url.host).toLowerCase();
|
|
57
|
+
const base = options.base ?? "";
|
|
58
|
+
if (base && url.pathname !== base && !url.pathname.startsWith(`${base}/`)) return errorResponse(request, 404, "NoSuchBucket", "Nothing is served at this path", url.pathname);
|
|
59
|
+
const { bucket, key } = address(url.pathname.slice(base.length) || "/", host, options.hosts ?? []);
|
|
60
|
+
const resource = `/${bucket ?? ""}${key ? `/${key}` : ""}`;
|
|
61
|
+
let credential;
|
|
62
|
+
let response;
|
|
63
|
+
try {
|
|
64
|
+
const signing = await authenticate(request, url, host, options, bucket ? namedBy(request, bucket) : void 0);
|
|
65
|
+
credential = signing.credential;
|
|
66
|
+
const chunking = chunkingOf(request, signing);
|
|
67
|
+
const streamed = streamedBody(request, engine, chunking);
|
|
68
|
+
response = await route({
|
|
69
|
+
request,
|
|
70
|
+
url,
|
|
71
|
+
base,
|
|
72
|
+
body: streamed ? /* @__PURE__ */ new Uint8Array(0) : await payloadOf(request, new Uint8Array(await request.arrayBuffer()), chunking),
|
|
73
|
+
...streamed ? { streamed } : {},
|
|
74
|
+
credential,
|
|
75
|
+
engine: scoped(engine, credential),
|
|
76
|
+
clock: options.clock,
|
|
77
|
+
...bucket ? { bucket } : {},
|
|
78
|
+
...key ? { key } : {}
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof S3Error) response = errorResponse(request, error.status, error.code, error.message, resource);
|
|
82
|
+
else if (error instanceof StorageError) {
|
|
83
|
+
const [status, code] = statusOf(error.code, error.reason);
|
|
84
|
+
response = errorResponse(request, status, code, error.message, resource);
|
|
85
|
+
} else throw error;
|
|
86
|
+
}
|
|
87
|
+
const kind = classOf(request.method, url.searchParams, key);
|
|
88
|
+
if (options.counted && credential && bucket && kind && response.status !== 403 && response.status !== 501) {
|
|
89
|
+
const id = credential.buckets ? Object.hasOwn(credential.buckets, bucket) ? credential.buckets[bucket] : void 0 : bucket;
|
|
90
|
+
if (id) options.counted(id, kind);
|
|
91
|
+
}
|
|
92
|
+
return response;
|
|
93
|
+
};
|
|
94
|
+
return async (request) => {
|
|
95
|
+
if (request.method === "OPTIONS") return preflight(request);
|
|
96
|
+
const response = await answer(request);
|
|
97
|
+
if (request.headers.has("origin")) for (const [name, value] of Object.entries(OPEN)) response.headers.set(name, value);
|
|
98
|
+
return response;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const CORS_METHODS = [
|
|
102
|
+
"GET",
|
|
103
|
+
"PUT",
|
|
104
|
+
"POST",
|
|
105
|
+
"DELETE",
|
|
106
|
+
"HEAD"
|
|
107
|
+
];
|
|
108
|
+
/** What every answer to a browser carries: any site may read it, and every header, ETag included. */
|
|
109
|
+
const OPEN = {
|
|
110
|
+
"access-control-allow-origin": "*",
|
|
111
|
+
"access-control-expose-headers": "*"
|
|
112
|
+
};
|
|
113
|
+
function preflight(request) {
|
|
114
|
+
return new Response(null, {
|
|
115
|
+
status: 200,
|
|
116
|
+
headers: {
|
|
117
|
+
...OPEN,
|
|
118
|
+
"access-control-allow-methods": CORS_METHODS.join(", "),
|
|
119
|
+
"access-control-allow-headers": request.headers.get("access-control-request-headers") ?? "*",
|
|
120
|
+
"access-control-max-age": "3000"
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** The class S3 bills a request in, or `undefined` for a delete, which is free. */
|
|
125
|
+
function classOf(method, q, key) {
|
|
126
|
+
if (method === "DELETE" || method === "POST" && q.has("delete")) return void 0;
|
|
127
|
+
if (method !== "GET" && method !== "HEAD") return "a";
|
|
128
|
+
return !key && method === "GET" && !BUCKET_SETTINGS.some((name) => q.has(name)) ? "a" : "b";
|
|
129
|
+
}
|
|
130
|
+
var S3Error = class extends Error {
|
|
131
|
+
status;
|
|
132
|
+
code;
|
|
133
|
+
constructor(status, code, message) {
|
|
134
|
+
super(message);
|
|
135
|
+
this.status = status;
|
|
136
|
+
this.code = code;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const byReason = {
|
|
140
|
+
range: [416, "InvalidRange"],
|
|
141
|
+
upload: [404, "NoSuchUpload"],
|
|
142
|
+
part: [400, "InvalidPart"],
|
|
143
|
+
"part-order": [400, "InvalidPartOrder"],
|
|
144
|
+
"part-too-small": [400, "EntityTooSmall"]
|
|
145
|
+
};
|
|
146
|
+
function statusOf(code, reason) {
|
|
147
|
+
if (reason) return byReason[reason];
|
|
148
|
+
switch (code) {
|
|
149
|
+
case "invalid": return [400, "InvalidArgument"];
|
|
150
|
+
case "not_found": return [404, "NoSuchKey"];
|
|
151
|
+
case "conflict": return [412, "PreconditionFailed"];
|
|
152
|
+
case "forbidden": return [403, "AccessDenied"];
|
|
153
|
+
case "quota": return [403, "QuotaExceeded"];
|
|
154
|
+
case "unavailable": return [503, "ServiceUnavailable"];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function address(path, host, hosts) {
|
|
158
|
+
const segments = path.split("/").slice(1);
|
|
159
|
+
const decoded = (parts) => parts.map((part) => decodeURIComponent(part)).join("/");
|
|
160
|
+
const hostname = host.replace(/:\d+$/, "");
|
|
161
|
+
for (const suffix of hosts) if (hostname.endsWith(`.${suffix}`)) {
|
|
162
|
+
const key = decoded(segments);
|
|
163
|
+
return {
|
|
164
|
+
bucket: hostname.slice(0, -suffix.length - 1),
|
|
165
|
+
...key ? { key } : {}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const [first = "", ...rest] = segments;
|
|
169
|
+
const key = decoded(rest);
|
|
170
|
+
return {
|
|
171
|
+
...first ? { bucket: decodeURIComponent(first) } : {},
|
|
172
|
+
...key ? { key } : {}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** Every byte except the unreserved ones, as Signature Version 4 encodes; `/` too unless it is a path. */
|
|
176
|
+
function awsEncode(value, slash) {
|
|
177
|
+
return [...new TextEncoder().encode(value)].map((b) => {
|
|
178
|
+
const c = String.fromCharCode(b);
|
|
179
|
+
if (/[A-Za-z0-9\-._~]/.test(c) || !slash && c === "/") return c;
|
|
180
|
+
return `%${b.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
181
|
+
}).join("");
|
|
182
|
+
}
|
|
183
|
+
async function hmac(key, data) {
|
|
184
|
+
const raw = typeof key === "string" ? new TextEncoder().encode(key) : key;
|
|
185
|
+
const imported = await crypto.subtle.importKey("raw", raw, {
|
|
186
|
+
name: "HMAC",
|
|
187
|
+
hash: "SHA-256"
|
|
188
|
+
}, false, ["sign"]);
|
|
189
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", imported, new TextEncoder().encode(data)));
|
|
190
|
+
}
|
|
191
|
+
const hex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
192
|
+
function sameHex(a, b) {
|
|
193
|
+
if (a.length !== b.length) return false;
|
|
194
|
+
let difference = 0;
|
|
195
|
+
for (let i = 0; i < a.length; i += 1) difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
196
|
+
return difference === 0;
|
|
197
|
+
}
|
|
198
|
+
function signedPart(request, url) {
|
|
199
|
+
const authorization = request.headers.get("authorization");
|
|
200
|
+
if (authorization) {
|
|
201
|
+
const match = /^AWS4-HMAC-SHA256 Credential=([^/,\s]+)\/(\d{8})\/([^/,\s]+)\/s3\/aws4_request,\s*SignedHeaders=([^,\s]+),\s*Signature=([0-9a-f]{64})$/.exec(authorization.trim());
|
|
202
|
+
if (!match) throw new S3Error(400, "AuthorizationHeaderMalformed", "The Authorization header is not a Signature Version 4 header");
|
|
203
|
+
return {
|
|
204
|
+
accessKeyId: match[1],
|
|
205
|
+
date: match[2],
|
|
206
|
+
region: match[3],
|
|
207
|
+
signedHeaders: match[4].split(";"),
|
|
208
|
+
signature: match[5],
|
|
209
|
+
amzDate: request.headers.get("x-amz-date") ?? "",
|
|
210
|
+
payloadHash: request.headers.get("x-amz-content-sha256") ?? ""
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const q = url.searchParams;
|
|
214
|
+
if (q.get("X-Amz-Algorithm") === "AWS4-HMAC-SHA256") {
|
|
215
|
+
const credential = /^([^/]+)\/(\d{8})\/([^/]+)\/s3\/aws4_request$/.exec(q.get("X-Amz-Credential") ?? "");
|
|
216
|
+
const signature = q.get("X-Amz-Signature") ?? "";
|
|
217
|
+
const expires = Number(q.get("X-Amz-Expires"));
|
|
218
|
+
if (!credential || !/^[0-9a-f]{64}$/.test(signature) || !Number.isSafeInteger(expires) || expires < 1 || expires > 604800) throw new S3Error(400, "AuthorizationQueryParametersError", "The presigned URL's query is not Signature Version 4");
|
|
219
|
+
return {
|
|
220
|
+
accessKeyId: credential[1],
|
|
221
|
+
date: credential[2],
|
|
222
|
+
region: credential[3],
|
|
223
|
+
signedHeaders: (q.get("X-Amz-SignedHeaders") ?? "").split(";"),
|
|
224
|
+
signature,
|
|
225
|
+
amzDate: q.get("X-Amz-Date") ?? "",
|
|
226
|
+
payloadHash: "UNSIGNED-PAYLOAD",
|
|
227
|
+
presigned: { expires }
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
throw new S3Error(403, "AccessDenied", "The request is not signed");
|
|
231
|
+
}
|
|
232
|
+
function timeOf(amzDate) {
|
|
233
|
+
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(amzDate);
|
|
234
|
+
if (!m) throw new S3Error(403, "AccessDenied", "The request's date is not in Signature Version 4 form");
|
|
235
|
+
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]));
|
|
236
|
+
}
|
|
237
|
+
/** The Buckets a request names: its own, and the source of a copy when that is another. */
|
|
238
|
+
function namedBy(request, bucket) {
|
|
239
|
+
const source = request.headers.get("x-amz-copy-source");
|
|
240
|
+
if (!source) return [bucket];
|
|
241
|
+
let from;
|
|
242
|
+
try {
|
|
243
|
+
from = decodeURIComponent(source.replace(/^\//, "").split("?")[0]).split("/", 1)[0];
|
|
244
|
+
} catch {
|
|
245
|
+
return [bucket];
|
|
246
|
+
}
|
|
247
|
+
return from && from !== bucket ? [bucket, from] : [bucket];
|
|
248
|
+
}
|
|
249
|
+
async function authenticate(request, url, host, options, named) {
|
|
250
|
+
const signed = signedPart(request, url);
|
|
251
|
+
const at = timeOf(signed.amzDate), now = options.clock();
|
|
252
|
+
if (!signed.amzDate.startsWith(signed.date)) throw new S3Error(403, "AccessDenied", "The credential's date is not the request's date");
|
|
253
|
+
if (signed.presigned) {
|
|
254
|
+
if (now > at + signed.presigned.expires * 1e3) throw new S3Error(403, "AccessDenied", "Request has expired");
|
|
255
|
+
if (at > now + SKEW) throw new S3Error(403, "AccessDenied", "The presigned URL is dated in the future");
|
|
256
|
+
} else if (Math.abs(now - at) > SKEW) throw new S3Error(403, "RequestTimeTooSkewed", "The request's time is more than 15 minutes from the server's");
|
|
257
|
+
if (!signed.signedHeaders.includes("host")) throw new S3Error(403, "AccessDenied", "The host header is not signed");
|
|
258
|
+
const credential = await options.credentials(signed.accessKeyId, named);
|
|
259
|
+
if (!credential) throw new S3Error(403, "InvalidAccessKeyId", "The access key id is not known");
|
|
260
|
+
const query = [...url.searchParams].filter(([name]) => !(signed.presigned && name === "X-Amz-Signature")).map(([name, value]) => [awsEncode(name, true), awsEncode(value, true)]).sort(([a, x], [b, y]) => a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0).map(([name, value]) => `${name}=${value}`).join("&");
|
|
261
|
+
const path = url.pathname.split("/").map((part) => awsEncode(decodeURIComponent(part), true)).join("/");
|
|
262
|
+
const headers = signed.signedHeaders.map((name) => `${name}:${(name === "host" ? host : request.headers.get(name) ?? "").trim().replace(/\s+/g, " ")}\n`).join("");
|
|
263
|
+
const canonical = [
|
|
264
|
+
request.method,
|
|
265
|
+
path,
|
|
266
|
+
query,
|
|
267
|
+
headers,
|
|
268
|
+
signed.signedHeaders.join(";"),
|
|
269
|
+
signed.payloadHash
|
|
270
|
+
].join("\n");
|
|
271
|
+
const scope = `${signed.date}/${signed.region}/s3/aws4_request`;
|
|
272
|
+
const toSign = [
|
|
273
|
+
"AWS4-HMAC-SHA256",
|
|
274
|
+
signed.amzDate,
|
|
275
|
+
scope,
|
|
276
|
+
await sha256(new TextEncoder().encode(canonical))
|
|
277
|
+
].join("\n");
|
|
278
|
+
let key = await hmac(`AWS4${credential.secret}`, signed.date);
|
|
279
|
+
for (const part of [
|
|
280
|
+
signed.region,
|
|
281
|
+
"s3",
|
|
282
|
+
"aws4_request"
|
|
283
|
+
]) key = await hmac(key, part);
|
|
284
|
+
if (!sameHex(hex(await hmac(key, toSign)), signed.signature)) throw new S3Error(403, "SignatureDoesNotMatch", "The request signature does not match");
|
|
285
|
+
return {
|
|
286
|
+
credential,
|
|
287
|
+
key,
|
|
288
|
+
amzDate: signed.amzDate,
|
|
289
|
+
scope,
|
|
290
|
+
seed: signed.signature
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/** The bytes a request stores: its body, or the data of an aws-chunked body, its signatures and checksum checked. A signed hash is checked. */
|
|
294
|
+
async function payloadOf(request, body, chunking) {
|
|
295
|
+
const declared = request.headers.get("x-amz-content-sha256") ?? "UNSIGNED-PAYLOAD";
|
|
296
|
+
if (chunking) {
|
|
297
|
+
const data = new Uint8Array(await new Response(new Blob([body]).stream().pipeThrough(unchunkStream(chunking))).arrayBuffer());
|
|
298
|
+
const length = request.headers.get("x-amz-decoded-content-length");
|
|
299
|
+
if (length !== null && Number(length) !== data.length) throw new S3Error(400, "IncompleteBody", "The decoded body is not x-amz-decoded-content-length bytes");
|
|
300
|
+
return data;
|
|
301
|
+
}
|
|
302
|
+
if (/^[0-9a-f]{64}$/.test(declared) && !sameHex(await sha256(body), declared)) throw new S3Error(400, "XAmzContentSHA256Mismatch", "The body's SHA-256 is not x-amz-content-sha256");
|
|
303
|
+
return body;
|
|
304
|
+
}
|
|
305
|
+
const STREAM_ABOVE = 1048576;
|
|
306
|
+
/** A PUT's body as a stream of the bytes it stores, with their size and signed hash, when an engine can take it so. */
|
|
307
|
+
function streamedBody(request, engine, chunking) {
|
|
308
|
+
if (request.method !== "PUT" || !request.body || !(engine.putStream || engine.uploadPartStream)) return void 0;
|
|
309
|
+
const declared = request.headers.get("x-amz-content-sha256") ?? "UNSIGNED-PAYLOAD";
|
|
310
|
+
const size = Number(request.headers.get(chunking ? "x-amz-decoded-content-length" : "content-length") ?? NaN);
|
|
311
|
+
if (!Number.isSafeInteger(size) || size <= STREAM_ABOVE) return void 0;
|
|
312
|
+
return {
|
|
313
|
+
body: chunking ? request.body.pipeThrough(unchunkStream(chunking)) : request.body,
|
|
314
|
+
size,
|
|
315
|
+
.../^[0-9a-f]{64}$/.test(declared) ? { sha256: declared } : {}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
319
|
+
/** A signed chunk is held whole until its signature is checked: at most this many bytes. */
|
|
320
|
+
const MAX_CHUNK = 16777216;
|
|
321
|
+
/** How a request's aws-chunked body is checked, from its x-amz-content-sha256 and x-amz-trailer; undefined for a body that is not aws-chunked. */
|
|
322
|
+
function chunkingOf(request, signing) {
|
|
323
|
+
const declared = request.headers.get("x-amz-content-sha256") ?? "UNSIGNED-PAYLOAD";
|
|
324
|
+
if (!declared.startsWith("STREAMING-")) return void 0;
|
|
325
|
+
const mode = {
|
|
326
|
+
"STREAMING-AWS4-HMAC-SHA256-PAYLOAD": {
|
|
327
|
+
signed: true,
|
|
328
|
+
trailer: false
|
|
329
|
+
},
|
|
330
|
+
"STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER": {
|
|
331
|
+
signed: true,
|
|
332
|
+
trailer: true
|
|
333
|
+
},
|
|
334
|
+
"STREAMING-UNSIGNED-PAYLOAD-TRAILER": {
|
|
335
|
+
signed: false,
|
|
336
|
+
trailer: true
|
|
337
|
+
}
|
|
338
|
+
}[declared];
|
|
339
|
+
if (!mode) throw new S3Error(501, "NotImplemented", `${declared} bodies are not taken: sign each chunk with STREAMING-AWS4-HMAC-SHA256-PAYLOAD, or send STREAMING-UNSIGNED-PAYLOAD-TRAILER with a CRC32 or CRC32C checksum`);
|
|
340
|
+
if (!mode.trailer) return mode.signed ? { signing } : {};
|
|
341
|
+
const trailer = (request.headers.get("x-amz-trailer") ?? "").trim().toLowerCase();
|
|
342
|
+
if (!trailer) throw new S3Error(400, "InvalidRequest", `${declared} needs x-amz-trailer to name its checksum`);
|
|
343
|
+
if (trailer !== "x-amz-checksum-crc32" && trailer !== "x-amz-checksum-crc32c") throw new S3Error(501, "NotImplemented", `A ${trailer} trailer is not checked: send x-amz-checksum-crc32 or x-amz-checksum-crc32c`);
|
|
344
|
+
return {
|
|
345
|
+
...mode.signed ? { signing } : {},
|
|
346
|
+
trailer: trailer === "x-amz-checksum-crc32" ? "crc32" : "crc32c"
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const crcTables = /* @__PURE__ */ new Map();
|
|
350
|
+
/** A reflected CRC-32 under `polynomial` (CRC32's 0xEDB88320, CRC32C's 0x82F63B78), carried on from `crc`. */
|
|
351
|
+
function crc32(polynomial, crc, bytes) {
|
|
352
|
+
let table = crcTables.get(polynomial);
|
|
353
|
+
if (!table) {
|
|
354
|
+
table = /* @__PURE__ */ new Uint32Array(256);
|
|
355
|
+
for (let n = 0; n < 256; n += 1) {
|
|
356
|
+
let c = n;
|
|
357
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? polynomial ^ c >>> 1 : c >>> 1;
|
|
358
|
+
table[n] = c >>> 0;
|
|
359
|
+
}
|
|
360
|
+
crcTables.set(polynomial, table);
|
|
361
|
+
}
|
|
362
|
+
let c = ~crc >>> 0;
|
|
363
|
+
for (const byte of bytes) c = table[(c ^ byte) & 255] ^ c >>> 8;
|
|
364
|
+
return ~c >>> 0;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The data of an aws-chunked body as it arrives: `<hex size>[;chunk-signature=…]\r\n<data>\r\n` until a 0
|
|
368
|
+
* chunk, then trailer lines and an empty line. Signed, each chunk is held until its signature, chained from
|
|
369
|
+
* the one before, is checked, so nothing unsigned is passed on; the trailer's signature and checksum are
|
|
370
|
+
* checked at the end, where the stream fails, and the engine keeps nothing, when either does not match.
|
|
371
|
+
*/
|
|
372
|
+
function unchunkStream(chunking) {
|
|
373
|
+
const signing = chunking.signing;
|
|
374
|
+
const polynomial = chunking.trailer === "crc32" ? 3988292384 : 2197175160;
|
|
375
|
+
let state = "size", header = [], remaining = 0;
|
|
376
|
+
let previous = signing?.seed ?? "", signature = "", held = [], crc = 0;
|
|
377
|
+
const trailers = [];
|
|
378
|
+
const signedOver = async (kind, hash) => hex(await hmac(signing.key, [
|
|
379
|
+
`AWS4-HMAC-SHA256-${kind}`,
|
|
380
|
+
signing.amzDate,
|
|
381
|
+
signing.scope,
|
|
382
|
+
previous,
|
|
383
|
+
...kind === "PAYLOAD" ? [EMPTY_SHA256] : [],
|
|
384
|
+
hash
|
|
385
|
+
].join("\n")));
|
|
386
|
+
/** Checks the chunk just read against its signature, then passes its data on. */
|
|
387
|
+
const settleChunk = async (controller) => {
|
|
388
|
+
if (!signing) return;
|
|
389
|
+
const data = new Uint8Array(held.reduce((n, part) => n + part.length, 0));
|
|
390
|
+
held.reduce((at, part) => (data.set(part, at), at + part.length), 0);
|
|
391
|
+
held = [];
|
|
392
|
+
const expected = await signedOver("PAYLOAD", await sha256(data));
|
|
393
|
+
if (!sameHex(expected, signature)) throw new S3Error(403, "SignatureDoesNotMatch", "An aws-chunked chunk's signature does not match");
|
|
394
|
+
previous = expected;
|
|
395
|
+
if (data.length) controller.enqueue(data);
|
|
396
|
+
};
|
|
397
|
+
return new TransformStream({
|
|
398
|
+
async transform(bytes, controller) {
|
|
399
|
+
for (let at = 0; at < bytes.length;) {
|
|
400
|
+
if (state === "done") return;
|
|
401
|
+
if (state === "data") {
|
|
402
|
+
const n = Math.min(remaining, bytes.length - at);
|
|
403
|
+
const piece = bytes.slice(at, at + n);
|
|
404
|
+
if (chunking.trailer) crc = crc32(polynomial, crc, piece);
|
|
405
|
+
if (signing) held.push(piece);
|
|
406
|
+
else controller.enqueue(piece);
|
|
407
|
+
remaining -= n, at += n;
|
|
408
|
+
if (remaining === 0) state = "crlf", header = [];
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
header.push(bytes[at++]);
|
|
412
|
+
if (header.length > 4096) throw new S3Error(400, "InvalidRequest", "An aws-chunked chunk header is too long");
|
|
413
|
+
if (header.length < 2 || header.at(-2) !== 13 || header.at(-1) !== 10) continue;
|
|
414
|
+
const line = new TextDecoder().decode(new Uint8Array(header.slice(0, -2)));
|
|
415
|
+
header = [];
|
|
416
|
+
if (state === "crlf") {
|
|
417
|
+
if (line !== "") throw new S3Error(400, "IncompleteBody", "An aws-chunked chunk does not end where its size says");
|
|
418
|
+
await settleChunk(controller);
|
|
419
|
+
state = "size";
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (state === "trailer") {
|
|
423
|
+
if (line === "") state = "done";
|
|
424
|
+
else trailers.push(line);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const [sizeText, ...extensions] = line.split(";");
|
|
428
|
+
const size = parseInt(sizeText, 16);
|
|
429
|
+
if (!/^[0-9a-fA-F]+$/.test(sizeText) || !Number.isSafeInteger(size)) throw new S3Error(400, "InvalidRequest", "An aws-chunked chunk size is not hexadecimal");
|
|
430
|
+
if (signing) {
|
|
431
|
+
signature = extensions.find((extension) => extension.startsWith("chunk-signature="))?.slice(16) ?? "";
|
|
432
|
+
if (!/^[0-9a-f]{64}$/.test(signature)) throw new S3Error(403, "SignatureDoesNotMatch", "An aws-chunked chunk is not signed");
|
|
433
|
+
if (size > MAX_CHUNK) throw new S3Error(400, "InvalidRequest", "An aws-chunked chunk is over 16 MiB");
|
|
434
|
+
}
|
|
435
|
+
if (size === 0) {
|
|
436
|
+
await settleChunk(controller);
|
|
437
|
+
state = "trailer";
|
|
438
|
+
} else state = "data", remaining = size;
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
async flush() {
|
|
442
|
+
if (state !== "trailer" && state !== "done") throw new S3Error(400, "IncompleteBody", "The aws-chunked body ends before its last chunk");
|
|
443
|
+
if (!chunking.trailer) return;
|
|
444
|
+
const name = `x-amz-checksum-${chunking.trailer}`;
|
|
445
|
+
const fields = trailers.map((line) => [line.slice(0, line.indexOf(":")).trim().toLowerCase(), line.slice(line.indexOf(":") + 1).trim()]);
|
|
446
|
+
const checksum = fields.find(([field]) => field === name)?.[1];
|
|
447
|
+
if (checksum === void 0) throw new S3Error(400, "InvalidRequest", `The aws-chunked body's trailer has no ${name}`);
|
|
448
|
+
if (signing) {
|
|
449
|
+
const given = fields.find(([field]) => field === "x-amz-trailer-signature")?.[1] ?? "";
|
|
450
|
+
if (!sameHex(await signedOver("TRAILER", await sha256(new TextEncoder().encode(`${name}:${checksum}\n`))), given)) throw new S3Error(403, "SignatureDoesNotMatch", "The aws-chunked body's trailer signature does not match");
|
|
451
|
+
}
|
|
452
|
+
if (checksum !== btoa(String.fromCharCode(crc >>> 24, crc >>> 16 & 255, crc >>> 8 & 255, crc & 255))) throw new S3Error(400, "BadDigest", `The body's ${chunking.trailer.toUpperCase()} is not its ${name}`);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
/** `engine` with each Bucket name a credential may use turned into the name the engine knows it by. */
|
|
457
|
+
function scoped(engine, credential) {
|
|
458
|
+
const map = credential.buckets;
|
|
459
|
+
if (!map) return engine;
|
|
460
|
+
const id = (name) => {
|
|
461
|
+
if (!Object.hasOwn(map, name)) throw new S3Error(403, "AccessDenied", `The key may not use the Bucket ${name}`);
|
|
462
|
+
return map[name];
|
|
463
|
+
};
|
|
464
|
+
return {
|
|
465
|
+
put: (bucket, ...rest) => engine.put(id(bucket), ...rest),
|
|
466
|
+
get: (bucket, ...rest) => engine.get(id(bucket), ...rest),
|
|
467
|
+
head: (bucket, ...rest) => engine.head(id(bucket), ...rest),
|
|
468
|
+
delete: (bucket, ...rest) => engine.delete(id(bucket), ...rest),
|
|
469
|
+
list: (bucket, ...rest) => engine.list(id(bucket), ...rest),
|
|
470
|
+
copy: (bucket, ...rest) => engine.copy(id(bucket), ...rest),
|
|
471
|
+
createUpload: (bucket, ...rest) => engine.createUpload(id(bucket), ...rest),
|
|
472
|
+
uploadPart: (bucket, ...rest) => engine.uploadPart(id(bucket), ...rest),
|
|
473
|
+
completeUpload: (bucket, ...rest) => engine.completeUpload(id(bucket), ...rest),
|
|
474
|
+
abortUpload: (bucket, ...rest) => engine.abortUpload(id(bucket), ...rest),
|
|
475
|
+
usage: (bucket) => engine.usage(id(bucket)),
|
|
476
|
+
operations: (bucket, ...rest) => engine.operations(id(bucket), ...rest),
|
|
477
|
+
...streamsOf(engine, id, id)
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function may(call, bucket, write) {
|
|
481
|
+
checkBucketName(bucket);
|
|
482
|
+
if (call.credential.buckets && !Object.hasOwn(call.credential.buckets, bucket)) throw new S3Error(403, "AccessDenied", `The key may not use the Bucket ${bucket}`);
|
|
483
|
+
if (write && call.credential.access !== "write") throw new S3Error(403, "AccessDenied", "The key may only read");
|
|
484
|
+
if (write && call.credential.paused?.includes(bucket)) throw new S3Error(403, "QuotaExceeded", `Writes to the Bucket ${bucket} are paused: its Team reached its budget`);
|
|
485
|
+
}
|
|
486
|
+
async function route(call) {
|
|
487
|
+
const { request, url, bucket, key } = call;
|
|
488
|
+
const q = url.searchParams, method = request.method;
|
|
489
|
+
if (!bucket) {
|
|
490
|
+
if (method === "GET") return listBuckets(call);
|
|
491
|
+
throw new S3Error(405, "MethodNotAllowed", "Only ListBuckets is answered at the root");
|
|
492
|
+
}
|
|
493
|
+
if (!key) {
|
|
494
|
+
const setting = BUCKET_SETTINGS.find((name) => q.has(name));
|
|
495
|
+
if (setting) return bucketSetting(call, bucket, setting);
|
|
496
|
+
if (method === "HEAD" || method === "PUT") return may(call, bucket, false), new Response(null, { status: 200 });
|
|
497
|
+
if (method === "GET") return may(call, bucket, false), listObjects(call, bucket);
|
|
498
|
+
if (method === "POST" && q.has("delete")) return may(call, bucket, true), deleteObjects(call, bucket);
|
|
499
|
+
if (method === "DELETE") throw new S3Error(501, "NotImplemented", "Buckets are deleted by the platform (midsummer storage bucket delete), not over S3");
|
|
500
|
+
throw new S3Error(501, "NotImplemented", "That Bucket operation is not answered");
|
|
501
|
+
}
|
|
502
|
+
checkKey(key);
|
|
503
|
+
if (q.has("uploads") || q.has("uploadId")) return multipart(call, bucket, key);
|
|
504
|
+
if (q.has("tagging")) return tagging(call, bucket, key);
|
|
505
|
+
const other = OBJECT_SETTINGS.find((name) => q.has(name));
|
|
506
|
+
if (other) throw new S3Error(501, "NotImplemented", other === "acl" ? "Access is granted by a Storage Key's role, not by ACLs" : `The Object operation "${other}" is not answered`);
|
|
507
|
+
switch (method) {
|
|
508
|
+
case "GET":
|
|
509
|
+
case "HEAD":
|
|
510
|
+
may(call, bucket, false);
|
|
511
|
+
return getObject(call, bucket, key, method === "HEAD");
|
|
512
|
+
case "PUT":
|
|
513
|
+
may(call, bucket, true);
|
|
514
|
+
return request.headers.has("x-amz-copy-source") ? copyObject(call, bucket, key) : putObject(call, bucket, key);
|
|
515
|
+
case "DELETE":
|
|
516
|
+
may(call, bucket, true);
|
|
517
|
+
await call.engine.delete(bucket, key);
|
|
518
|
+
return new Response(null, { status: 204 });
|
|
519
|
+
default: throw new S3Error(405, "MethodNotAllowed", `${method} is not answered on an Object`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/** A Bucket's settings, as S3 calls them in the query: returned as they stand, never changed. */
|
|
523
|
+
const BUCKET_SETTINGS = [
|
|
524
|
+
"accelerate",
|
|
525
|
+
"acl",
|
|
526
|
+
"analytics",
|
|
527
|
+
"cors",
|
|
528
|
+
"encryption",
|
|
529
|
+
"intelligent-tiering",
|
|
530
|
+
"inventory",
|
|
531
|
+
"lifecycle",
|
|
532
|
+
"location",
|
|
533
|
+
"logging",
|
|
534
|
+
"metrics",
|
|
535
|
+
"notification",
|
|
536
|
+
"object-lock",
|
|
537
|
+
"ownershipControls",
|
|
538
|
+
"policy",
|
|
539
|
+
"policyStatus",
|
|
540
|
+
"publicAccessBlock",
|
|
541
|
+
"replication",
|
|
542
|
+
"requestPayment",
|
|
543
|
+
"tagging",
|
|
544
|
+
"uploads",
|
|
545
|
+
"versioning",
|
|
546
|
+
"versions",
|
|
547
|
+
"website"
|
|
548
|
+
];
|
|
549
|
+
/** An Object's subresources other than tags and uploads, none of which is served. */
|
|
550
|
+
const OBJECT_SETTINGS = [
|
|
551
|
+
"acl",
|
|
552
|
+
"attributes",
|
|
553
|
+
"legal-hold",
|
|
554
|
+
"retention",
|
|
555
|
+
"restore",
|
|
556
|
+
"select",
|
|
557
|
+
"torrent"
|
|
558
|
+
];
|
|
559
|
+
/** Why a Bucket setting cannot be changed, or listed, over S3. */
|
|
560
|
+
const UNCHANGED = {
|
|
561
|
+
cors: "CORS is open to every origin and cannot be changed: a request is allowed by its signature",
|
|
562
|
+
lifecycle: "Lifecycle rules are not kept: Objects are kept until they are deleted",
|
|
563
|
+
tagging: "Tags are not kept",
|
|
564
|
+
uploads: "ListMultipartUploads is not answered: an unfinished upload is ended by AbortMultipartUpload from the client that started it",
|
|
565
|
+
versioning: "Versioning is not offered: a put replaces what its key holds",
|
|
566
|
+
versions: "Versioning is not offered: a put replaces what its key holds",
|
|
567
|
+
acl: "Access is granted by a Storage Key's role, not by ACLs",
|
|
568
|
+
policy: "Access is granted by a Storage Key's role, not by policies"
|
|
569
|
+
};
|
|
570
|
+
/** GetBucketLocation, GetBucketVersioning, GetBucketCors and the settings S3 returns "none" for; nothing is changed. */
|
|
571
|
+
async function bucketSetting(call, bucket, setting) {
|
|
572
|
+
const method = call.request.method;
|
|
573
|
+
may(call, bucket, method !== "GET" && method !== "HEAD");
|
|
574
|
+
if (method === "GET") switch (setting) {
|
|
575
|
+
case "location": return xml(200, `<LocationConstraint xmlns="${XMLNS}"></LocationConstraint>`);
|
|
576
|
+
case "versioning": return xml(200, `<VersioningConfiguration xmlns="${XMLNS}"></VersioningConfiguration>`);
|
|
577
|
+
case "cors": return xml(200, `<CORSConfiguration xmlns="${XMLNS}"><CORSRule><AllowedOrigin>*</AllowedOrigin>${CORS_METHODS.map((m) => `<AllowedMethod>${m}</AllowedMethod>`).join("")}<AllowedHeader>*</AllowedHeader><ExposeHeader>ETag</ExposeHeader><MaxAgeSeconds>3000</MaxAgeSeconds></CORSRule></CORSConfiguration>`);
|
|
578
|
+
case "lifecycle": throw new S3Error(404, "NoSuchLifecycleConfiguration", UNCHANGED.lifecycle);
|
|
579
|
+
case "tagging": throw new S3Error(404, "NoSuchTagSet", "Buckets have no tags");
|
|
580
|
+
case "policy": throw new S3Error(404, "NoSuchBucketPolicy", UNCHANGED.policy);
|
|
581
|
+
}
|
|
582
|
+
if (method === "DELETE" && (setting === "lifecycle" || setting === "tagging")) return new Response(null, { status: 204 });
|
|
583
|
+
throw new S3Error(501, "NotImplemented", UNCHANGED[setting] ?? `The Bucket setting "${setting}" is not kept`);
|
|
584
|
+
}
|
|
585
|
+
/** Refuses a put that asks for tags: they are not kept, and dropping them would lose what was asked. */
|
|
586
|
+
function noTags(headers) {
|
|
587
|
+
if (headers.get("x-amz-tagging")) throw new S3Error(501, "NotImplemented", UNCHANGED.tagging);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Whether an If-Match or If-None-Match list names `etag` (or is `*`). If-Match compares strongly, so
|
|
591
|
+
* a weak validator (`W/"…"`) never matches; If-None-Match compares weakly, ignoring `W/` (RFC 9110, 13.1).
|
|
592
|
+
*/
|
|
593
|
+
function names(list, etag, weak) {
|
|
594
|
+
return list.split(",").some((tag) => {
|
|
595
|
+
const trimmed = tag.trim();
|
|
596
|
+
if (trimmed === "*") return true;
|
|
597
|
+
if (trimmed.startsWith("W/") && !weak) return false;
|
|
598
|
+
return trimmed.replace(/^W\//, "").replace(/^"|"$/g, "") === etag;
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
/** An HTTP date in milliseconds; one that is not a date is ignored, as HTTP says. */
|
|
602
|
+
function dateOf(value) {
|
|
603
|
+
if (value === null) return void 0;
|
|
604
|
+
const at = Date.parse(value);
|
|
605
|
+
return Number.isNaN(at) ? void 0 : at;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* How `object` meets a request's conditions, as S3 and HTTP check them (each `prefix`ed, such as
|
|
609
|
+
* `x-amz-copy-source-` for the source of a copy): `failed` when If-Match, or without it
|
|
610
|
+
* If-Unmodified-Since, does not hold; `unchanged` when If-None-Match, or without it
|
|
611
|
+
* If-Modified-Since, says the caller has it; otherwise `undefined`. Dates count whole seconds, as
|
|
612
|
+
* Last-Modified carries them.
|
|
613
|
+
*/
|
|
614
|
+
function conditionOf(headers, object, prefix = "") {
|
|
615
|
+
const modified = Math.floor(object.uploadedAt / 1e3) * 1e3;
|
|
616
|
+
const match = headers.get(`${prefix}if-match`), none = headers.get(`${prefix}if-none-match`);
|
|
617
|
+
const unmodifiedSince = dateOf(headers.get(`${prefix}if-unmodified-since`)), modifiedSince = dateOf(headers.get(`${prefix}if-modified-since`));
|
|
618
|
+
if (match !== null ? !names(match, object.etag, false) : unmodifiedSince !== void 0 && modified > unmodifiedSince) return "failed";
|
|
619
|
+
if (none !== null ? names(none, object.etag, true) : modifiedSince !== void 0 && modified <= modifiedSince) return "unchanged";
|
|
620
|
+
}
|
|
621
|
+
const COPY_CONDITIONS = [
|
|
622
|
+
"x-amz-copy-source-if-match",
|
|
623
|
+
"x-amz-copy-source-if-none-match",
|
|
624
|
+
"x-amz-copy-source-if-modified-since",
|
|
625
|
+
"x-amz-copy-source-if-unmodified-since"
|
|
626
|
+
];
|
|
627
|
+
/** Refuses a copy whose source does not meet its `x-amz-copy-source-if-*` conditions: every failure is 412 for a copy. */
|
|
628
|
+
async function checkSource(headers, source) {
|
|
629
|
+
if (!conditionOf(headers, source, "x-amz-copy-source-")) return;
|
|
630
|
+
if (source.body instanceof ReadableStream) await source.body.cancel();
|
|
631
|
+
throw new S3Error(412, "PreconditionFailed", "The copy source does not meet the conditions given");
|
|
632
|
+
}
|
|
633
|
+
function metadataOf(headers) {
|
|
634
|
+
const metadata = {};
|
|
635
|
+
for (const [name, value] of headers) if (name.startsWith("x-amz-meta-")) metadata[name.slice(11)] = value;
|
|
636
|
+
checkMetadata(metadata);
|
|
637
|
+
return metadata;
|
|
638
|
+
}
|
|
639
|
+
function conditionsOf(headers) {
|
|
640
|
+
const none = headers.get("if-none-match"), match = headers.get("if-match");
|
|
641
|
+
if (none !== null && none !== "*") throw new S3Error(501, "NotImplemented", "If-None-Match on a put takes only *");
|
|
642
|
+
return {
|
|
643
|
+
...none === "*" ? { ifNoneMatch: "*" } : {},
|
|
644
|
+
...match !== null ? { ifMatch: match.replace(/^"|"$/g, "") } : {}
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
async function putObject(call, bucket, key) {
|
|
648
|
+
noTags(call.request.headers);
|
|
649
|
+
const options = {
|
|
650
|
+
contentType: call.request.headers.get("content-type") ?? "application/octet-stream",
|
|
651
|
+
metadata: metadataOf(call.request.headers),
|
|
652
|
+
...conditionsOf(call.request.headers)
|
|
653
|
+
};
|
|
654
|
+
const { streamed, engine } = call;
|
|
655
|
+
const object = streamed && engine.putStream ? await engine.putStream(bucket, key, streamed.body, streamed.size, {
|
|
656
|
+
...options,
|
|
657
|
+
...streamed.sha256 ? { sha256: streamed.sha256 } : {}
|
|
658
|
+
}) : await engine.put(bucket, key, call.body, options);
|
|
659
|
+
return new Response(null, {
|
|
660
|
+
status: 200,
|
|
661
|
+
headers: { etag: `"${object.etag}"` }
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
/** The Object `x-amz-copy-source` names, which the key must be able to read. */
|
|
665
|
+
function copySource(call) {
|
|
666
|
+
const source = decodeURIComponent(call.request.headers.get("x-amz-copy-source").replace(/^\//, "").split("?")[0]);
|
|
667
|
+
const cut = source.indexOf("/");
|
|
668
|
+
const from = {
|
|
669
|
+
bucket: source.slice(0, cut),
|
|
670
|
+
key: source.slice(cut + 1)
|
|
671
|
+
};
|
|
672
|
+
if (cut < 1 || !from.key) throw new S3Error(400, "InvalidArgument", "x-amz-copy-source is not <bucket>/<key>");
|
|
673
|
+
may(call, from.bucket, false);
|
|
674
|
+
checkKey(from.key);
|
|
675
|
+
return from;
|
|
676
|
+
}
|
|
677
|
+
/** Object tags: none are kept, so an Object's tag set is empty, deleting it leaves it so, and setting tags is refused. */
|
|
678
|
+
async function tagging(call, bucket, key) {
|
|
679
|
+
const method = call.request.method;
|
|
680
|
+
if (method !== "GET" && method !== "DELETE") throw new S3Error(501, "NotImplemented", UNCHANGED.tagging);
|
|
681
|
+
may(call, bucket, method === "DELETE");
|
|
682
|
+
if (!await call.engine.head(bucket, key)) throw new StorageError("not_found", `"${key}" holds nothing`);
|
|
683
|
+
return method === "DELETE" ? new Response(null, { status: 204 }) : xml(200, `<Tagging xmlns="${XMLNS}"><TagSet></TagSet></Tagging>`);
|
|
684
|
+
}
|
|
685
|
+
async function copyObject(call, bucket, key) {
|
|
686
|
+
const from = copySource(call);
|
|
687
|
+
const headers = call.request.headers;
|
|
688
|
+
if (headers.get("x-amz-tagging-directive") === "REPLACE") noTags(headers);
|
|
689
|
+
const replace = headers.get("x-amz-metadata-directive") === "REPLACE";
|
|
690
|
+
const conditions = conditionsOf(headers);
|
|
691
|
+
const checked = COPY_CONDITIONS.some((name) => headers.has(name));
|
|
692
|
+
let object;
|
|
693
|
+
if (from.bucket === bucket && !replace && conditions.ifMatch === void 0 && !checked) object = await call.engine.copy(bucket, from.key, key, conditions.ifNoneMatch ? { ifNoneMatch: "*" } : {});
|
|
694
|
+
else {
|
|
695
|
+
const { engine } = call;
|
|
696
|
+
const replacement = replace ? {
|
|
697
|
+
contentType: headers.get("content-type") ?? "application/octet-stream",
|
|
698
|
+
metadata: metadataOf(headers)
|
|
699
|
+
} : void 0;
|
|
700
|
+
const read = engine.stream && engine.putStream ? await engine.stream(from.bucket, from.key) : await engine.get(from.bucket, from.key);
|
|
701
|
+
if (!read) throw new StorageError("not_found", `"${from.key}" holds nothing`);
|
|
702
|
+
await checkSource(headers, read);
|
|
703
|
+
const options = {
|
|
704
|
+
...replacement ?? {
|
|
705
|
+
contentType: read.contentType,
|
|
706
|
+
metadata: read.metadata
|
|
707
|
+
},
|
|
708
|
+
...conditions
|
|
709
|
+
};
|
|
710
|
+
object = read.body instanceof ReadableStream ? await engine.putStream(bucket, key, read.body, read.size, options) : await engine.put(bucket, key, read.body, options);
|
|
711
|
+
}
|
|
712
|
+
return xml(200, `<CopyObjectResult xmlns="${XMLNS}"><LastModified>${iso(object.uploadedAt)}</LastModified><ETag>${escape(`"${object.etag}"`)}</ETag></CopyObjectResult>`);
|
|
713
|
+
}
|
|
714
|
+
function rangeOf(header) {
|
|
715
|
+
if (!header) return void 0;
|
|
716
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
717
|
+
if (!m || m[1] === "" && m[2] === "") return void 0;
|
|
718
|
+
if (m[1] === "") return { suffix: Number(m[2]) };
|
|
719
|
+
const offset = Number(m[1]);
|
|
720
|
+
if (m[2] === "") return { offset };
|
|
721
|
+
const last = Number(m[2]);
|
|
722
|
+
if (last < offset) return void 0;
|
|
723
|
+
return {
|
|
724
|
+
offset,
|
|
725
|
+
length: last - offset + 1
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
function objectHeaders(object) {
|
|
729
|
+
const headers = {
|
|
730
|
+
etag: `"${object.etag}"`,
|
|
731
|
+
"content-type": object.contentType,
|
|
732
|
+
"last-modified": new Date(object.uploadedAt).toUTCString(),
|
|
733
|
+
"accept-ranges": "bytes"
|
|
734
|
+
};
|
|
735
|
+
for (const [name, value] of Object.entries(object.metadata)) headers[`x-amz-meta-${name}`] = value;
|
|
736
|
+
return headers;
|
|
737
|
+
}
|
|
738
|
+
async function getObject(call, bucket, key, head) {
|
|
739
|
+
const range = rangeOf(call.request.headers.get("range"));
|
|
740
|
+
if (head && !range) {
|
|
741
|
+
const object = await call.engine.head(bucket, key);
|
|
742
|
+
if (!object) throw new S3Error(404, "NoSuchKey", "The key holds nothing");
|
|
743
|
+
return await unmet(call.request.headers, object) ?? new Response(null, {
|
|
744
|
+
status: 200,
|
|
745
|
+
headers: {
|
|
746
|
+
...objectHeaders(object),
|
|
747
|
+
"content-length": String(object.size)
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
let read;
|
|
752
|
+
try {
|
|
753
|
+
read = !head && call.engine.stream ? await call.engine.stream(bucket, key, range) : await call.engine.get(bucket, key, range);
|
|
754
|
+
} catch (error) {
|
|
755
|
+
if (error instanceof StorageError && error.reason === "range") {
|
|
756
|
+
const object = await call.engine.head(bucket, key);
|
|
757
|
+
const refused = object && await unmet(call.request.headers, object);
|
|
758
|
+
if (refused) return refused;
|
|
759
|
+
}
|
|
760
|
+
throw error;
|
|
761
|
+
}
|
|
762
|
+
if (!read) throw new S3Error(404, "NoSuchKey", "The key holds nothing");
|
|
763
|
+
const refused = await unmet(call.request.headers, read);
|
|
764
|
+
if (refused) return refused;
|
|
765
|
+
const headers = {
|
|
766
|
+
...objectHeaders(read),
|
|
767
|
+
"content-length": String(read.range?.length ?? read.size)
|
|
768
|
+
};
|
|
769
|
+
if (read.range) {
|
|
770
|
+
const last = read.range.offset + read.range.length - 1;
|
|
771
|
+
return new Response(head ? null : read.body, {
|
|
772
|
+
status: 206,
|
|
773
|
+
headers: {
|
|
774
|
+
...headers,
|
|
775
|
+
"content-range": `bytes ${read.range.offset}-${last}/${read.size}`
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
return new Response(head ? null : read.body, {
|
|
780
|
+
status: 200,
|
|
781
|
+
headers
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* The answer to a read whose conditions `object` does not meet: 304 with its validators, or 412.
|
|
786
|
+
* Either way a body already opened is cancelled first, so no stream is left open.
|
|
787
|
+
*/
|
|
788
|
+
async function unmet(headers, object) {
|
|
789
|
+
const condition = conditionOf(headers, object);
|
|
790
|
+
if (!condition) return void 0;
|
|
791
|
+
if ("body" in object && object.body instanceof ReadableStream) await object.body.cancel();
|
|
792
|
+
if (condition === "failed") throw new S3Error(412, "PreconditionFailed", "At least one of the preconditions given did not hold");
|
|
793
|
+
if (condition === "unchanged") return new Response(null, {
|
|
794
|
+
status: 304,
|
|
795
|
+
headers: {
|
|
796
|
+
etag: `"${object.etag}"`,
|
|
797
|
+
"last-modified": new Date(object.uploadedAt).toUTCString()
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
/** CreateMultipartUpload, UploadPart, CompleteMultipartUpload and AbortMultipartUpload. */
|
|
802
|
+
async function multipart(call, bucket, key) {
|
|
803
|
+
const { request, url, engine } = call;
|
|
804
|
+
const q = url.searchParams, uploadId = q.get("uploadId");
|
|
805
|
+
may(call, bucket, request.method !== "GET");
|
|
806
|
+
if (request.method === "POST" && q.has("uploads")) {
|
|
807
|
+
noTags(request.headers);
|
|
808
|
+
const none = conditionsOf(request.headers).ifNoneMatch;
|
|
809
|
+
const { uploadId: id } = await engine.createUpload(bucket, key, {
|
|
810
|
+
contentType: request.headers.get("content-type") ?? "application/octet-stream",
|
|
811
|
+
metadata: metadataOf(request.headers),
|
|
812
|
+
...none ? { ifNoneMatch: none } : {}
|
|
813
|
+
});
|
|
814
|
+
return xml(200, `<InitiateMultipartUploadResult xmlns="${XMLNS}"><Bucket>${escape(bucket)}</Bucket><Key>${escape(key)}</Key><UploadId>${escape(id)}</UploadId></InitiateMultipartUploadResult>`);
|
|
815
|
+
}
|
|
816
|
+
if (uploadId === null) throw new S3Error(400, "InvalidRequest", "The request names no uploadId");
|
|
817
|
+
checkUploadId(uploadId);
|
|
818
|
+
if (request.method === "PUT") {
|
|
819
|
+
const partNumber = Number(q.get("partNumber"));
|
|
820
|
+
checkPartNumber(partNumber);
|
|
821
|
+
if (request.headers.has("x-amz-copy-source")) {
|
|
822
|
+
const from = copySource(call);
|
|
823
|
+
const range = /^bytes=(\d+)-(\d+)$/.exec(request.headers.get("x-amz-copy-source-range") ?? "");
|
|
824
|
+
const span = range ? {
|
|
825
|
+
offset: Number(range[1]),
|
|
826
|
+
length: Number(range[2]) - Number(range[1]) + 1
|
|
827
|
+
} : void 0;
|
|
828
|
+
if (engine.stream && engine.uploadPartStream) {
|
|
829
|
+
const source = await engine.stream(from.bucket, from.key, span);
|
|
830
|
+
if (!source) throw new StorageError("not_found", `"${from.key}" holds nothing`);
|
|
831
|
+
await checkSource(request.headers, source);
|
|
832
|
+
const part = await engine.uploadPartStream(bucket, key, uploadId, partNumber, source.body, source.range?.length ?? source.size);
|
|
833
|
+
return xml(200, `<CopyPartResult xmlns="${XMLNS}"><LastModified>${iso(source.uploadedAt)}</LastModified><ETag>${escape(`"${part.etag}"`)}</ETag></CopyPartResult>`);
|
|
834
|
+
}
|
|
835
|
+
const read = await engine.get(from.bucket, from.key, span);
|
|
836
|
+
if (!read) throw new StorageError("not_found", `"${from.key}" holds nothing`);
|
|
837
|
+
await checkSource(request.headers, read);
|
|
838
|
+
const part = await engine.uploadPart(bucket, key, uploadId, partNumber, read.body);
|
|
839
|
+
return xml(200, `<CopyPartResult xmlns="${XMLNS}"><LastModified>${iso(read.uploadedAt)}</LastModified><ETag>${escape(`"${part.etag}"`)}</ETag></CopyPartResult>`);
|
|
840
|
+
}
|
|
841
|
+
const { streamed } = call;
|
|
842
|
+
const part = streamed && engine.uploadPartStream ? await engine.uploadPartStream(bucket, key, uploadId, partNumber, streamed.body, streamed.size, streamed.sha256) : await engine.uploadPart(bucket, key, uploadId, partNumber, call.body);
|
|
843
|
+
return new Response(null, {
|
|
844
|
+
status: 200,
|
|
845
|
+
headers: { etag: `"${part.etag}"` }
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
if (request.method === "POST") {
|
|
849
|
+
const parts = [...new TextDecoder().decode(call.body).matchAll(/<Part>([\s\S]*?)<\/Part>/g)].map((m) => ({
|
|
850
|
+
partNumber: Number(/<PartNumber>\s*(\d+)\s*<\/PartNumber>/.exec(m[1])?.[1]),
|
|
851
|
+
etag: unescape(/<ETag>([\s\S]*?)<\/ETag>/.exec(m[1])?.[1] ?? "").trim().replace(/^"|"$/g, "")
|
|
852
|
+
}));
|
|
853
|
+
if (parts.length === 0) throw new S3Error(400, "MalformedXML", "CompleteMultipartUpload names no parts");
|
|
854
|
+
checkParts(parts);
|
|
855
|
+
const location = `${url.origin}${call.base}/${awsEncode(bucket, true)}/${key.split("/").map((p) => awsEncode(p, true)).join("/")}`;
|
|
856
|
+
return keptAlive(engine.completeUpload(bucket, key, uploadId, parts).then((object) => `<CompleteMultipartUploadResult xmlns="${XMLNS}"><Location>${escape(location)}</Location><Bucket>${escape(bucket)}</Bucket><Key>${escape(key)}</Key><ETag>${escape(`"${object.etag}"`)}</ETag></CompleteMultipartUploadResult>`));
|
|
857
|
+
}
|
|
858
|
+
if (request.method === "DELETE") {
|
|
859
|
+
await engine.abortUpload(bucket, key, uploadId);
|
|
860
|
+
return new Response(null, { status: 204 });
|
|
861
|
+
}
|
|
862
|
+
throw new S3Error(501, "NotImplemented", "ListParts is not answered: a client keeps the etags of the parts it sent");
|
|
863
|
+
}
|
|
864
|
+
const ANSWER_WITHIN_MS = 1e3;
|
|
865
|
+
const KEEP_ALIVE_MS = 1e4;
|
|
866
|
+
/**
|
|
867
|
+
* The answer to a request whose work can take minutes, as S3 replies to CompleteMultipartUpload: as it
|
|
868
|
+
* stands if it is done within a second, refusals with their own status; else a 200 at once that
|
|
869
|
+
* sends a space every 10 seconds, so clients and proxies keep waiting, then the result or an Error.
|
|
870
|
+
*/
|
|
871
|
+
async function keptAlive(result) {
|
|
872
|
+
const settled = result.then((body) => ({ body }), (error) => ({ error }));
|
|
873
|
+
const early = await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve(null), ANSWER_WITHIN_MS))]);
|
|
874
|
+
if (early) {
|
|
875
|
+
if ("error" in early) throw early.error;
|
|
876
|
+
return xml(200, early.body);
|
|
877
|
+
}
|
|
878
|
+
const encoder = new TextEncoder();
|
|
879
|
+
let timer;
|
|
880
|
+
const body = new ReadableStream({
|
|
881
|
+
start(controller) {
|
|
882
|
+
controller.enqueue(encoder.encode(`<?xml version="1.0" encoding="UTF-8"?>\n`));
|
|
883
|
+
timer = setInterval(() => controller.enqueue(encoder.encode(" ")), KEEP_ALIVE_MS);
|
|
884
|
+
settled.then((outcome) => {
|
|
885
|
+
clearInterval(timer);
|
|
886
|
+
if ("body" in outcome) controller.enqueue(encoder.encode(outcome.body));
|
|
887
|
+
else {
|
|
888
|
+
const error = outcome.error;
|
|
889
|
+
const [, code] = error instanceof S3Error ? [error.status, error.code] : error instanceof StorageError ? statusOf(error.code, error.reason) : [500, "InternalError"];
|
|
890
|
+
controller.enqueue(encoder.encode(`<Error><Code>${code}</Code><Message>${escape(error instanceof Error ? error.message : "The upload did not complete")}</Message><RequestId>${crypto.randomUUID()}</RequestId></Error>`));
|
|
891
|
+
}
|
|
892
|
+
controller.close();
|
|
893
|
+
});
|
|
894
|
+
},
|
|
895
|
+
cancel() {
|
|
896
|
+
clearInterval(timer);
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
return new Response(body, {
|
|
900
|
+
status: 200,
|
|
901
|
+
headers: { "content-type": "application/xml" }
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
async function deleteObjects(call, bucket) {
|
|
905
|
+
const text = new TextDecoder().decode(call.body);
|
|
906
|
+
const keys = [...text.matchAll(/<Key>([\s\S]*?)<\/Key>/g)].map((m) => unescape(m[1]));
|
|
907
|
+
if (keys.length > 1e3) throw new S3Error(400, "MalformedXML", "At most 1,000 keys are deleted at once");
|
|
908
|
+
const quiet = /<Quiet>\s*true\s*<\/Quiet>/i.test(text);
|
|
909
|
+
const deleting = /* @__PURE__ */ new Map();
|
|
910
|
+
const deleted = (key) => {
|
|
911
|
+
let done = deleting.get(key);
|
|
912
|
+
if (!done) deleting.set(key, done = (async () => (checkKey(key), await call.engine.delete(bucket, key)))());
|
|
913
|
+
return done;
|
|
914
|
+
};
|
|
915
|
+
const outcomes = await Promise.allSettled(keys.map(deleted));
|
|
916
|
+
const results = [];
|
|
917
|
+
for (const [i, outcome] of outcomes.entries()) {
|
|
918
|
+
const key = keys[i];
|
|
919
|
+
if (outcome.status === "fulfilled") {
|
|
920
|
+
if (!quiet) results.push(`<Deleted><Key>${escape(key)}</Key></Deleted>`);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
const error = outcome.reason;
|
|
924
|
+
if (!(error instanceof StorageError)) throw error;
|
|
925
|
+
results.push(`<Error><Key>${escape(key)}</Key><Code>${statusOf(error.code, error.reason)[1]}</Code><Message>${escape(error.message)}</Message></Error>`);
|
|
926
|
+
}
|
|
927
|
+
return xml(200, `<DeleteResult xmlns="${XMLNS}">${results.join("")}</DeleteResult>`);
|
|
928
|
+
}
|
|
929
|
+
async function listObjects(call, bucket) {
|
|
930
|
+
const q = call.url.searchParams;
|
|
931
|
+
const v2 = q.get("list-type") === "2";
|
|
932
|
+
const encode = q.get("encoding-type") === "url";
|
|
933
|
+
const out = (value) => escape(encode ? encodeURIComponent(value) : value);
|
|
934
|
+
const prefix = q.get("prefix") ?? "", delimiter = q.get("delimiter") || void 0;
|
|
935
|
+
const maxKeys = q.has("max-keys") ? Number(q.get("max-keys")) : 1e3;
|
|
936
|
+
if (!Number.isSafeInteger(maxKeys) || maxKeys < 0) throw new S3Error(400, "InvalidArgument", "max-keys is not a whole number");
|
|
937
|
+
const token = q.get("continuation-token");
|
|
938
|
+
const after = v2 ? token !== null ? tokenKey(token) : q.get("start-after") ?? void 0 : q.get("marker") ?? void 0;
|
|
939
|
+
const listing = maxKeys === 0 ? {
|
|
940
|
+
objects: [],
|
|
941
|
+
prefixes: []
|
|
942
|
+
} : await call.engine.list(bucket, {
|
|
943
|
+
prefix,
|
|
944
|
+
limit: Math.min(maxKeys, 1e3),
|
|
945
|
+
...delimiter ? { delimiter } : {},
|
|
946
|
+
...after ? { after } : {}
|
|
947
|
+
});
|
|
948
|
+
const fields = [
|
|
949
|
+
`<Name>${escape(bucket)}</Name>`,
|
|
950
|
+
`<Prefix>${out(prefix)}</Prefix>`,
|
|
951
|
+
delimiter ? `<Delimiter>${out(delimiter)}</Delimiter>` : "",
|
|
952
|
+
`<MaxKeys>${maxKeys}</MaxKeys>`,
|
|
953
|
+
encode ? "<EncodingType>url</EncodingType>" : "",
|
|
954
|
+
`<IsTruncated>${listing.next !== void 0}</IsTruncated>`,
|
|
955
|
+
v2 ? `<KeyCount>${listing.objects.length + listing.prefixes.length}</KeyCount>` : `<Marker>${out(q.get("marker") ?? "")}</Marker>`,
|
|
956
|
+
v2 && token !== null ? `<ContinuationToken>${escape(token)}</ContinuationToken>` : "",
|
|
957
|
+
v2 && q.has("start-after") ? `<StartAfter>${out(q.get("start-after"))}</StartAfter>` : "",
|
|
958
|
+
listing.next !== void 0 ? v2 ? `<NextContinuationToken>${keyToken(listing.next)}</NextContinuationToken>` : `<NextMarker>${out(listing.next)}</NextMarker>` : "",
|
|
959
|
+
...listing.objects.map((o) => `<Contents><Key>${out(o.key)}</Key><LastModified>${iso(o.uploadedAt)}</LastModified><ETag>${escape(`"${o.etag}"`)}</ETag><Size>${o.size}</Size><StorageClass>STANDARD</StorageClass></Contents>`),
|
|
960
|
+
...listing.prefixes.map((p) => `<CommonPrefixes><Prefix>${out(p)}</Prefix></CommonPrefixes>`)
|
|
961
|
+
];
|
|
962
|
+
return xml(200, `<ListBucketResult xmlns="${XMLNS}">${fields.join("")}</ListBucketResult>`);
|
|
963
|
+
}
|
|
964
|
+
const keyToken = (key) => btoa(String.fromCharCode(...new TextEncoder().encode(key))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
965
|
+
function tokenKey(token) {
|
|
966
|
+
try {
|
|
967
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(atob(token.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)));
|
|
968
|
+
} catch {
|
|
969
|
+
throw new S3Error(400, "InvalidArgument", "The continuation token is not one this server gave");
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function listBuckets(call) {
|
|
973
|
+
const buckets = Object.keys(call.credential.buckets ?? {}).sort().map((name) => `<Bucket><Name>${escape(name)}</Name><CreationDate>${iso(0)}</CreationDate></Bucket>`).join("");
|
|
974
|
+
return xml(200, `<ListAllMyBucketsResult xmlns="${XMLNS}"><Owner><ID>project</ID></Owner><Buckets>${buckets}</Buckets></ListAllMyBucketsResult>`);
|
|
975
|
+
}
|
|
976
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
977
|
+
function escape(value) {
|
|
978
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
979
|
+
}
|
|
980
|
+
function unescape(value) {
|
|
981
|
+
return value.replace(/&(lt|gt|quot|apos|amp|#(\d+)|#x([0-9a-f]+));/gi, (_, name, dec, hexa) => dec ? String.fromCodePoint(Number(dec)) : hexa ? String.fromCodePoint(parseInt(hexa, 16)) : {
|
|
982
|
+
lt: "<",
|
|
983
|
+
gt: ">",
|
|
984
|
+
quot: "\"",
|
|
985
|
+
apos: "'",
|
|
986
|
+
amp: "&"
|
|
987
|
+
}[name.toLowerCase()]);
|
|
988
|
+
}
|
|
989
|
+
function xml(status, body) {
|
|
990
|
+
return new Response(`<?xml version="1.0" encoding="UTF-8"?>\n${body}`, {
|
|
991
|
+
status,
|
|
992
|
+
headers: { "content-type": "application/xml" }
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
function errorResponse(request, status, code, message, resource) {
|
|
996
|
+
if (request.method === "HEAD") return new Response(null, {
|
|
997
|
+
status,
|
|
998
|
+
headers: { "x-amz-error-code": code }
|
|
999
|
+
});
|
|
1000
|
+
return xml(status, `<Error><Code>${code}</Code><Message>${escape(message)}</Message><Resource>${escape(resource)}</Resource></Error>`);
|
|
1001
|
+
}
|
|
1002
|
+
//#endregion
|
|
1003
|
+
export { s3 };
|