@storecraft/storage-s3-compatible 1.0.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/README.md +39 -0
- package/adapter.js +322 -0
- package/aws4fetch.js +428 -0
- package/index.js +1 -0
- package/package.json +34 -0
- package/tests/node.png +0 -0
- package/tests/storage.r2.test.js +85 -0
- package/tsconfig.json +14 -0
- package/types.public.d.ts +11 -0
package/README.md
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
# Storecraft S3 compatible storage
|
2
|
+
|
3
|
+
`fetch` ready support for an `S3` like storage:
|
4
|
+
- `Amazon S3`
|
5
|
+
- `Cloudflare R2`
|
6
|
+
- `DigitalOcean Spaces`
|
7
|
+
- `minIO` servers
|
8
|
+
|
9
|
+
Features:
|
10
|
+
- Works in any `js` runtime and platform that supports `fetch`
|
11
|
+
- Supports streaming `Get` / `Put` / `Delete`
|
12
|
+
- Supports `presigned` `Get` / `Put` requests to offload to client
|
13
|
+
|
14
|
+
## usage
|
15
|
+
|
16
|
+
```js
|
17
|
+
import { R2 } from '@storecraft/storage-s3-compatible'
|
18
|
+
|
19
|
+
const storage = new R2(
|
20
|
+
process.env.R2_BUCKET, process.env.R2_ACCOUNT_ID,
|
21
|
+
process.env.R2_ACCESS_KEY_ID, process.env.R2_SECRET_ACCESS_KEY
|
22
|
+
);
|
23
|
+
|
24
|
+
// write
|
25
|
+
await storage.putBlob(
|
26
|
+
'folder1/tomer.txt',
|
27
|
+
new Blob(['this is some text from tomer :)'])
|
28
|
+
);
|
29
|
+
|
30
|
+
// read
|
31
|
+
const { value } = await storage.getBlob('folder1/tomer.txt');
|
32
|
+
const url = await storage.getSigned('folder1/tomer.txt');
|
33
|
+
console.log('presign GET url ', url);
|
34
|
+
|
35
|
+
```
|
36
|
+
|
37
|
+
```text
|
38
|
+
Author: Tomer Shalev (tomer.shalev@gmail.com)
|
39
|
+
```
|
package/adapter.js
ADDED
@@ -0,0 +1,322 @@
|
|
1
|
+
import { App } from '@storecraft/core'
|
2
|
+
import { AwsClient } from './aws4fetch.js';
|
3
|
+
|
4
|
+
const types = {
|
5
|
+
'png': 'image/png',
|
6
|
+
'gif': 'image/gif',
|
7
|
+
'jpeg': 'image/jpeg',
|
8
|
+
'jpg': 'image/jpeg',
|
9
|
+
'tiff': 'image/tiff',
|
10
|
+
'webp': 'image/webp',
|
11
|
+
'txt': 'text/plain',
|
12
|
+
'json': 'application/json',
|
13
|
+
}
|
14
|
+
|
15
|
+
/**
|
16
|
+
*
|
17
|
+
* @param {string} name
|
18
|
+
*/
|
19
|
+
const infer_content_type = (name) => {
|
20
|
+
const idx = name.lastIndexOf('.');
|
21
|
+
if(!idx) return 'application/octet-stream';
|
22
|
+
const type = types[name.substring(idx + 1).trim()]
|
23
|
+
return type ?? 'application/octet-stream';
|
24
|
+
}
|
25
|
+
|
26
|
+
|
27
|
+
/**
|
28
|
+
* @typedef {import('./types.public.js').Config} Config
|
29
|
+
*/
|
30
|
+
|
31
|
+
/**
|
32
|
+
* The base S3 compatible class
|
33
|
+
* @typedef {import('@storecraft/core/v-storage').storage_driver} storage
|
34
|
+
*
|
35
|
+
* @implements {storage}
|
36
|
+
*/
|
37
|
+
export class S3CompatibleStorage {
|
38
|
+
|
39
|
+
/** @type {AwsClient} */ #_client;
|
40
|
+
/** @type {Config} */ #_config;
|
41
|
+
/** @type {string} */ #_url;
|
42
|
+
|
43
|
+
/**
|
44
|
+
*
|
45
|
+
* @param {Config} options
|
46
|
+
*/
|
47
|
+
#compute_url(options) {
|
48
|
+
const url = new URL(options.endpoint);
|
49
|
+
if(options.forcePathStyle) {
|
50
|
+
url.pathname = options.bucket;
|
51
|
+
} else {
|
52
|
+
url.host = `${options.bucket}.${url.host}`;
|
53
|
+
}
|
54
|
+
return url.toString();
|
55
|
+
}
|
56
|
+
|
57
|
+
|
58
|
+
/**
|
59
|
+
*
|
60
|
+
* @param {Config} config
|
61
|
+
*/
|
62
|
+
constructor(config) {
|
63
|
+
this.#_config = config;
|
64
|
+
this.#_client = new AwsClient({
|
65
|
+
accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey,
|
66
|
+
region: config.region ?? 'auto', service: 's3'
|
67
|
+
});
|
68
|
+
|
69
|
+
this.#_url = this.#compute_url(config);
|
70
|
+
}
|
71
|
+
|
72
|
+
get url() { return this.#_url; }
|
73
|
+
get client() { return this.#_client; }
|
74
|
+
get config() { return this.#_config; }
|
75
|
+
|
76
|
+
features() {
|
77
|
+
/** @type {import('@storecraft/core/v-storage').StorageFeatures} */
|
78
|
+
const f = {
|
79
|
+
supports_signed_urls: true
|
80
|
+
}
|
81
|
+
|
82
|
+
return f;
|
83
|
+
}
|
84
|
+
|
85
|
+
/**
|
86
|
+
*
|
87
|
+
* @type {storage["init"]}
|
88
|
+
*/
|
89
|
+
async init(app) { return this; }
|
90
|
+
|
91
|
+
// puts
|
92
|
+
|
93
|
+
/**
|
94
|
+
*
|
95
|
+
* @param {string} key
|
96
|
+
* @param {BodyInit} body
|
97
|
+
*/
|
98
|
+
async #put_internal(key, body) {
|
99
|
+
const r = await this.client.fetch(
|
100
|
+
this.get_file_url(key),
|
101
|
+
{
|
102
|
+
method: 'PUT',
|
103
|
+
body
|
104
|
+
}
|
105
|
+
);
|
106
|
+
|
107
|
+
return r.ok;
|
108
|
+
}
|
109
|
+
|
110
|
+
/**
|
111
|
+
*
|
112
|
+
* @param {string} key
|
113
|
+
* @param {Blob} blob
|
114
|
+
*/
|
115
|
+
async putBlob(key, blob) {
|
116
|
+
return this.#put_internal(key, blob);
|
117
|
+
}
|
118
|
+
|
119
|
+
/**
|
120
|
+
*
|
121
|
+
* @param {string} key
|
122
|
+
* @param {ArrayBuffer} buffer
|
123
|
+
*/
|
124
|
+
async putArraybuffer(key, buffer) {
|
125
|
+
return this.#put_internal(key, buffer);
|
126
|
+
}
|
127
|
+
|
128
|
+
/**
|
129
|
+
*
|
130
|
+
* @param {string} key
|
131
|
+
* @param {ReadableStream} stream
|
132
|
+
*/
|
133
|
+
async putStream(key, stream) {
|
134
|
+
return this.#put_internal(key, stream);
|
135
|
+
}
|
136
|
+
|
137
|
+
/**
|
138
|
+
*
|
139
|
+
* @param {string} key
|
140
|
+
* @returns {ReturnType<import('@storecraft/core/v-storage').storage_driver["putSigned"]>}
|
141
|
+
*/
|
142
|
+
async putSigned(key) {
|
143
|
+
const url = new URL(this.get_file_url(key));
|
144
|
+
const signed = await this.client.sign(
|
145
|
+
new Request(url, {
|
146
|
+
method: "PUT",
|
147
|
+
}),
|
148
|
+
{
|
149
|
+
aws: { signQuery: true },
|
150
|
+
}
|
151
|
+
);
|
152
|
+
|
153
|
+
return {
|
154
|
+
url: signed.url,
|
155
|
+
method: signed.method,
|
156
|
+
headers: Object.fromEntries(signed.headers.entries())
|
157
|
+
}
|
158
|
+
}
|
159
|
+
|
160
|
+
// gets
|
161
|
+
|
162
|
+
/** @param {string} key */
|
163
|
+
get_file_url(key) {
|
164
|
+
return `${this.#_url}/${key}`;
|
165
|
+
}
|
166
|
+
|
167
|
+
/** @param {string} key */
|
168
|
+
#get_request(key) {
|
169
|
+
return this.client.fetch(this.get_file_url(key));
|
170
|
+
}
|
171
|
+
|
172
|
+
/**
|
173
|
+
*
|
174
|
+
* @param {string} key
|
175
|
+
*/
|
176
|
+
async getArraybuffer(key) {
|
177
|
+
const r = await this.#get_request(key);
|
178
|
+
const b = await r.arrayBuffer();
|
179
|
+
return {
|
180
|
+
value: b,
|
181
|
+
metadata: {
|
182
|
+
contentType: infer_content_type(key)
|
183
|
+
}
|
184
|
+
};
|
185
|
+
}
|
186
|
+
|
187
|
+
/**
|
188
|
+
*
|
189
|
+
* @param {string} key
|
190
|
+
*/
|
191
|
+
async getBlob(key) {
|
192
|
+
const r = await this.#get_request(key);
|
193
|
+
const b = await r.blob();
|
194
|
+
return {
|
195
|
+
value: b,
|
196
|
+
metadata: {
|
197
|
+
contentType: infer_content_type(key)
|
198
|
+
}
|
199
|
+
};
|
200
|
+
}
|
201
|
+
|
202
|
+
/**
|
203
|
+
*
|
204
|
+
* @param {string} key
|
205
|
+
* @param {Response} key
|
206
|
+
*/
|
207
|
+
async getStream(key) {
|
208
|
+
|
209
|
+
const s = (await this.#get_request(key)).body
|
210
|
+
return {
|
211
|
+
value: s,
|
212
|
+
metadata: {
|
213
|
+
contentType: infer_content_type(key)
|
214
|
+
}
|
215
|
+
};
|
216
|
+
}
|
217
|
+
|
218
|
+
/**
|
219
|
+
*
|
220
|
+
* @param {string} key
|
221
|
+
* @returns {ReturnType<import('@storecraft/core/v-storage').storage_driver["getSigned"]>}
|
222
|
+
*/
|
223
|
+
async getSigned(key) {
|
224
|
+
const url = new URL(this.get_file_url(key));
|
225
|
+
// url.searchParams.set("X-Amz-Expires", "3600");
|
226
|
+
const signed = await this.client.sign(
|
227
|
+
new Request(url, {
|
228
|
+
method: "GET",
|
229
|
+
}),
|
230
|
+
{
|
231
|
+
aws: { signQuery: true },
|
232
|
+
}
|
233
|
+
);
|
234
|
+
|
235
|
+
return {
|
236
|
+
url: signed.url,
|
237
|
+
method: signed.method,
|
238
|
+
headers: Object.fromEntries(signed.headers.entries())
|
239
|
+
}
|
240
|
+
}
|
241
|
+
|
242
|
+
// remove
|
243
|
+
|
244
|
+
/**
|
245
|
+
*
|
246
|
+
* @param {string} key
|
247
|
+
*/
|
248
|
+
async remove(key) {
|
249
|
+
const r = await this.client.fetch(
|
250
|
+
this.get_file_url(key), { method: 'DELETE' }
|
251
|
+
);
|
252
|
+
return r.ok;
|
253
|
+
}
|
254
|
+
}
|
255
|
+
|
256
|
+
/**
|
257
|
+
* Cloudflare R2
|
258
|
+
*/
|
259
|
+
export class R2 extends S3CompatibleStorage {
|
260
|
+
|
261
|
+
/**
|
262
|
+
*
|
263
|
+
* @param {string} bucket
|
264
|
+
* @param {string} account_id
|
265
|
+
* @param {string} access_key_id
|
266
|
+
* @param {string} secret_access_key
|
267
|
+
*/
|
268
|
+
constructor(bucket, account_id, access_key_id, secret_access_key) {
|
269
|
+
super({
|
270
|
+
endpoint: `https://${account_id}.r2.cloudflarestorage.com`,
|
271
|
+
accessKeyId: access_key_id, secretAccessKey: secret_access_key,
|
272
|
+
bucket, forcePathStyle: true, region: 'auto'
|
273
|
+
})
|
274
|
+
}
|
275
|
+
|
276
|
+
}
|
277
|
+
|
278
|
+
/**
|
279
|
+
* Amazon S3
|
280
|
+
*/
|
281
|
+
export class S3 extends S3CompatibleStorage {
|
282
|
+
|
283
|
+
/**
|
284
|
+
*
|
285
|
+
* @param {string} bucket
|
286
|
+
* @param {string} region
|
287
|
+
* @param {string} access_key_id
|
288
|
+
* @param {string} secret_access_key
|
289
|
+
* @param {boolean} forcePathStyle
|
290
|
+
*/
|
291
|
+
constructor(bucket, region, access_key_id, secret_access_key, forcePathStyle=false) {
|
292
|
+
super({
|
293
|
+
endpoint: `https://s3${region ? ('.'+region) : ''}.amazonaws.com`,
|
294
|
+
accessKeyId: access_key_id, secretAccessKey: secret_access_key,
|
295
|
+
bucket, forcePathStyle, region
|
296
|
+
})
|
297
|
+
}
|
298
|
+
|
299
|
+
}
|
300
|
+
|
301
|
+
/**
|
302
|
+
* Digital Ocean spaces
|
303
|
+
*/
|
304
|
+
export class DigitalOceanSpaces extends S3CompatibleStorage {
|
305
|
+
|
306
|
+
/**
|
307
|
+
*
|
308
|
+
* @param {string} bucket
|
309
|
+
* @param {string} region 'nyc3' for example
|
310
|
+
* @param {string} access_key_id
|
311
|
+
* @param {string} secret_access_key
|
312
|
+
*/
|
313
|
+
constructor(bucket, region, access_key_id, secret_access_key) {
|
314
|
+
super({
|
315
|
+
endpoint: `https://${region}.digitaloceanspaces.com`,
|
316
|
+
accessKeyId: access_key_id, secretAccessKey: secret_access_key,
|
317
|
+
bucket, forcePathStyle: false, region: 'auto'
|
318
|
+
})
|
319
|
+
}
|
320
|
+
|
321
|
+
}
|
322
|
+
|
package/aws4fetch.js
ADDED
@@ -0,0 +1,428 @@
|
|
1
|
+
// @ts-check
|
2
|
+
|
3
|
+
/**
|
4
|
+
* @license MIT <https://opensource.org/licenses/MIT>
|
5
|
+
* @copyright Michael Hart 2022
|
6
|
+
*/
|
7
|
+
|
8
|
+
const encoder = new TextEncoder()
|
9
|
+
|
10
|
+
/** @type {Object.<string, string>} */
|
11
|
+
const HOST_SERVICES = {
|
12
|
+
appstream2: 'appstream',
|
13
|
+
cloudhsmv2: 'cloudhsm',
|
14
|
+
email: 'ses',
|
15
|
+
marketplace: 'aws-marketplace',
|
16
|
+
mobile: 'AWSMobileHubService',
|
17
|
+
pinpoint: 'mobiletargeting',
|
18
|
+
queue: 'sqs',
|
19
|
+
'git-codecommit': 'codecommit',
|
20
|
+
'mturk-requester-sandbox': 'mturk-requester',
|
21
|
+
'personalize-runtime': 'personalize',
|
22
|
+
}
|
23
|
+
|
24
|
+
// https://github.com/aws/aws-sdk-js/blob/cc29728c1c4178969ebabe3bbe6b6f3159436394/lib/signers/v4.js#L190-L198
|
25
|
+
const UNSIGNABLE_HEADERS = new Set([
|
26
|
+
'authorization',
|
27
|
+
'content-type',
|
28
|
+
'content-length',
|
29
|
+
'user-agent',
|
30
|
+
'presigned-expires',
|
31
|
+
'expect',
|
32
|
+
'x-amzn-trace-id',
|
33
|
+
'range',
|
34
|
+
'connection',
|
35
|
+
])
|
36
|
+
|
37
|
+
export class AwsClient {
|
38
|
+
/**
|
39
|
+
* @param {{
|
40
|
+
* accessKeyId: string
|
41
|
+
* secretAccessKey: string
|
42
|
+
* sessionToken?: string
|
43
|
+
* service?: string
|
44
|
+
* region?: string
|
45
|
+
* cache?: Map<string,ArrayBuffer>
|
46
|
+
* retries?: number
|
47
|
+
* initRetryMs?: number
|
48
|
+
* }} options
|
49
|
+
*/
|
50
|
+
constructor({ accessKeyId, secretAccessKey, sessionToken, service, region, cache, retries, initRetryMs }) {
|
51
|
+
if (accessKeyId == null) throw new TypeError('accessKeyId is a required option')
|
52
|
+
if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option')
|
53
|
+
this.accessKeyId = accessKeyId
|
54
|
+
this.secretAccessKey = secretAccessKey
|
55
|
+
this.sessionToken = sessionToken
|
56
|
+
this.service = service
|
57
|
+
this.region = region
|
58
|
+
this.cache = cache || new Map()
|
59
|
+
this.retries = retries != null ? retries : 10 // Up to 25.6 secs
|
60
|
+
this.initRetryMs = initRetryMs || 50
|
61
|
+
}
|
62
|
+
|
63
|
+
/**
|
64
|
+
* @typedef {RequestInit & {
|
65
|
+
* aws?: {
|
66
|
+
* accessKeyId?: string
|
67
|
+
* secretAccessKey?: string
|
68
|
+
* sessionToken?: string
|
69
|
+
* service?: string
|
70
|
+
* region?: string
|
71
|
+
* cache?: Map<string,ArrayBuffer>
|
72
|
+
* datetime?: string
|
73
|
+
* signQuery?: boolean
|
74
|
+
* appendSessionToken?: boolean
|
75
|
+
* allHeaders?: boolean
|
76
|
+
* singleEncode?: boolean
|
77
|
+
* }
|
78
|
+
* }} AwsRequestInit
|
79
|
+
*
|
80
|
+
* @param {RequestInfo} input
|
81
|
+
* @param {?AwsRequestInit} [init]
|
82
|
+
* @returns {Promise<Request>}
|
83
|
+
*/
|
84
|
+
async sign(input, init) {
|
85
|
+
if (input instanceof Request) {
|
86
|
+
const { method, url, headers, body } = input
|
87
|
+
init = Object.assign({ method, url, headers }, init)
|
88
|
+
if (init.body == null && headers.has('Content-Type')) {
|
89
|
+
init.body = body != null && headers.has('X-Amz-Content-Sha256') ? body : await input.clone().arrayBuffer()
|
90
|
+
}
|
91
|
+
input = url
|
92
|
+
}
|
93
|
+
const signer = new AwsV4Signer(Object.assign({ url: input }, init, this, init && init.aws))
|
94
|
+
const signed = Object.assign({}, init, await signer.sign())
|
95
|
+
delete signed.aws
|
96
|
+
try {
|
97
|
+
return new Request(signed.url.toString(), signed)
|
98
|
+
} catch (e) {
|
99
|
+
if (e instanceof TypeError) {
|
100
|
+
// https://bugs.chromium.org/p/chromium/issues/detail?id=1360943
|
101
|
+
return new Request(signed.url.toString(), Object.assign({ duplex: 'half' }, signed))
|
102
|
+
}
|
103
|
+
throw e
|
104
|
+
}
|
105
|
+
}
|
106
|
+
|
107
|
+
/**
|
108
|
+
* @param {RequestInfo} input
|
109
|
+
* @param {?AwsRequestInit} [init]
|
110
|
+
* @returns {Promise<Response>}
|
111
|
+
*/
|
112
|
+
async fetch(input, init) {
|
113
|
+
for (let i = 0; i <= this.retries; i++) {
|
114
|
+
const fetched = fetch(await this.sign(input, init))
|
115
|
+
if (i === this.retries) {
|
116
|
+
return fetched // No need to await if we're returning anyway
|
117
|
+
}
|
118
|
+
const res = await fetched
|
119
|
+
if (res.status < 500 && res.status !== 429) {
|
120
|
+
return res
|
121
|
+
}
|
122
|
+
await new Promise(resolve => setTimeout(resolve, Math.random() * this.initRetryMs * Math.pow(2, i)))
|
123
|
+
}
|
124
|
+
throw new Error('An unknown error occurred, ensure retries is not negative')
|
125
|
+
}
|
126
|
+
}
|
127
|
+
|
128
|
+
export class AwsV4Signer {
|
129
|
+
/**
|
130
|
+
* @param {{
|
131
|
+
* method?: string
|
132
|
+
* url: string
|
133
|
+
* headers?: HeadersInit
|
134
|
+
* body?: BodyInit | null
|
135
|
+
* accessKeyId: string
|
136
|
+
* secretAccessKey: string
|
137
|
+
* sessionToken?: string
|
138
|
+
* service?: string
|
139
|
+
* region?: string
|
140
|
+
* cache?: Map<string,ArrayBuffer>
|
141
|
+
* datetime?: string
|
142
|
+
* signQuery?: boolean
|
143
|
+
* appendSessionToken?: boolean
|
144
|
+
* allHeaders?: boolean
|
145
|
+
* singleEncode?: boolean
|
146
|
+
* }} options
|
147
|
+
*/
|
148
|
+
constructor({ method, url, headers, body, accessKeyId, secretAccessKey, sessionToken, service, region, cache, datetime, signQuery, appendSessionToken, allHeaders, singleEncode }) {
|
149
|
+
if (url == null) throw new TypeError('url is a required option')
|
150
|
+
if (accessKeyId == null) throw new TypeError('accessKeyId is a required option')
|
151
|
+
if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option')
|
152
|
+
|
153
|
+
this.method = method || (body ? 'POST' : 'GET')
|
154
|
+
this.url = new URL(url)
|
155
|
+
this.headers = new Headers(headers || {})
|
156
|
+
this.body = body
|
157
|
+
|
158
|
+
this.accessKeyId = accessKeyId
|
159
|
+
this.secretAccessKey = secretAccessKey
|
160
|
+
this.sessionToken = sessionToken
|
161
|
+
|
162
|
+
let guessedService, guessedRegion
|
163
|
+
if (!service || !region) {
|
164
|
+
;[guessedService, guessedRegion] = guessServiceRegion(this.url, this.headers)
|
165
|
+
}
|
166
|
+
/** @type {string} */
|
167
|
+
this.service = service || guessedService || ''
|
168
|
+
this.region = region || guessedRegion || 'us-east-1'
|
169
|
+
|
170
|
+
this.cache = cache || new Map()
|
171
|
+
this.datetime = datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
172
|
+
this.signQuery = signQuery
|
173
|
+
this.appendSessionToken = appendSessionToken || this.service === 'iotdevicegateway'
|
174
|
+
|
175
|
+
this.headers.delete('Host') // Can't be set in insecure env anyway
|
176
|
+
|
177
|
+
if (this.service === 's3' && !this.signQuery && !this.headers.has('X-Amz-Content-Sha256')) {
|
178
|
+
this.headers.set('X-Amz-Content-Sha256', 'UNSIGNED-PAYLOAD')
|
179
|
+
}
|
180
|
+
|
181
|
+
const params = this.signQuery ? this.url.searchParams : this.headers
|
182
|
+
|
183
|
+
params.set('X-Amz-Date', this.datetime)
|
184
|
+
if (this.sessionToken && !this.appendSessionToken) {
|
185
|
+
params.set('X-Amz-Security-Token', this.sessionToken)
|
186
|
+
}
|
187
|
+
|
188
|
+
// headers are always lowercase in keys()
|
189
|
+
this.signableHeaders = ['host', ...this.headers.keys()]
|
190
|
+
.filter(header => allHeaders || !UNSIGNABLE_HEADERS.has(header))
|
191
|
+
.sort()
|
192
|
+
|
193
|
+
this.signedHeaders = this.signableHeaders.join(';')
|
194
|
+
|
195
|
+
// headers are always trimmed:
|
196
|
+
// https://fetch.spec.whatwg.org/#concept-header-value-normalize
|
197
|
+
this.canonicalHeaders = this.signableHeaders
|
198
|
+
.map(header => header + ':' + (header === 'host' ? this.url.host : (this.headers.get(header) || '').replace(/\s+/g, ' ')))
|
199
|
+
.join('\n')
|
200
|
+
|
201
|
+
this.credentialString = [this.datetime.slice(0, 8), this.region, this.service, 'aws4_request'].join('/')
|
202
|
+
|
203
|
+
if (this.signQuery) {
|
204
|
+
if (this.service === 's3' && !params.has('X-Amz-Expires')) {
|
205
|
+
params.set('X-Amz-Expires', '86400') // 24 hours
|
206
|
+
}
|
207
|
+
params.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256')
|
208
|
+
params.set('X-Amz-Credential', this.accessKeyId + '/' + this.credentialString)
|
209
|
+
params.set('X-Amz-SignedHeaders', this.signedHeaders)
|
210
|
+
}
|
211
|
+
|
212
|
+
if (this.service === 's3') {
|
213
|
+
try {
|
214
|
+
/** @type {string} */
|
215
|
+
this.encodedPath = decodeURIComponent(this.url.pathname.replace(/\+/g, ' '))
|
216
|
+
} catch (e) {
|
217
|
+
this.encodedPath = this.url.pathname
|
218
|
+
}
|
219
|
+
} else {
|
220
|
+
this.encodedPath = this.url.pathname.replace(/\/+/g, '/')
|
221
|
+
}
|
222
|
+
if (!singleEncode) {
|
223
|
+
this.encodedPath = encodeURIComponent(this.encodedPath).replace(/%2F/g, '/')
|
224
|
+
}
|
225
|
+
this.encodedPath = encodeRfc3986(this.encodedPath)
|
226
|
+
|
227
|
+
const seenKeys = new Set()
|
228
|
+
this.encodedSearch = [...this.url.searchParams]
|
229
|
+
.filter(([k]) => {
|
230
|
+
if (!k) return false // no empty keys
|
231
|
+
if (this.service === 's3') {
|
232
|
+
if (seenKeys.has(k)) return false // first val only for S3
|
233
|
+
seenKeys.add(k)
|
234
|
+
}
|
235
|
+
return true
|
236
|
+
})
|
237
|
+
.map(pair => pair.map(p => encodeRfc3986(encodeURIComponent(p))))
|
238
|
+
.sort(([k1, v1], [k2, v2]) => k1 < k2 ? -1 : k1 > k2 ? 1 : v1 < v2 ? -1 : v1 > v2 ? 1 : 0)
|
239
|
+
.map(pair => pair.join('='))
|
240
|
+
.join('&')
|
241
|
+
}
|
242
|
+
|
243
|
+
/**
|
244
|
+
* @returns {Promise<{
|
245
|
+
* method: string
|
246
|
+
* url: URL
|
247
|
+
* headers: Headers
|
248
|
+
* body?: BodyInit | null
|
249
|
+
* }>}
|
250
|
+
*/
|
251
|
+
async sign() {
|
252
|
+
if (this.signQuery) {
|
253
|
+
this.url.searchParams.set('X-Amz-Signature', await this.signature())
|
254
|
+
if (this.sessionToken && this.appendSessionToken) {
|
255
|
+
this.url.searchParams.set('X-Amz-Security-Token', this.sessionToken)
|
256
|
+
}
|
257
|
+
} else {
|
258
|
+
this.headers.set('Authorization', await this.authHeader())
|
259
|
+
}
|
260
|
+
|
261
|
+
return {
|
262
|
+
method: this.method,
|
263
|
+
url: this.url,
|
264
|
+
headers: this.headers,
|
265
|
+
body: this.body,
|
266
|
+
}
|
267
|
+
}
|
268
|
+
|
269
|
+
/**
|
270
|
+
* @returns {Promise<string>}
|
271
|
+
*/
|
272
|
+
async authHeader() {
|
273
|
+
return [
|
274
|
+
'AWS4-HMAC-SHA256 Credential=' + this.accessKeyId + '/' + this.credentialString,
|
275
|
+
'SignedHeaders=' + this.signedHeaders,
|
276
|
+
'Signature=' + (await this.signature()),
|
277
|
+
].join(', ')
|
278
|
+
}
|
279
|
+
|
280
|
+
/**
|
281
|
+
* @returns {Promise<string>}
|
282
|
+
*/
|
283
|
+
async signature() {
|
284
|
+
const date = this.datetime.slice(0, 8)
|
285
|
+
const cacheKey = [this.secretAccessKey, date, this.region, this.service].join()
|
286
|
+
let kCredentials = this.cache.get(cacheKey)
|
287
|
+
if (!kCredentials) {
|
288
|
+
const kDate = await hmac('AWS4' + this.secretAccessKey, date)
|
289
|
+
const kRegion = await hmac(kDate, this.region)
|
290
|
+
const kService = await hmac(kRegion, this.service)
|
291
|
+
kCredentials = await hmac(kService, 'aws4_request')
|
292
|
+
this.cache.set(cacheKey, kCredentials)
|
293
|
+
}
|
294
|
+
return buf2hex(await hmac(kCredentials, await this.stringToSign()))
|
295
|
+
}
|
296
|
+
|
297
|
+
/**
|
298
|
+
* @returns {Promise<string>}
|
299
|
+
*/
|
300
|
+
async stringToSign() {
|
301
|
+
return [
|
302
|
+
'AWS4-HMAC-SHA256',
|
303
|
+
this.datetime,
|
304
|
+
this.credentialString,
|
305
|
+
buf2hex(await hash(await this.canonicalString())),
|
306
|
+
].join('\n')
|
307
|
+
}
|
308
|
+
|
309
|
+
/**
|
310
|
+
* @returns {Promise<string>}
|
311
|
+
*/
|
312
|
+
async canonicalString() {
|
313
|
+
return [
|
314
|
+
this.method.toUpperCase(),
|
315
|
+
this.encodedPath,
|
316
|
+
this.encodedSearch,
|
317
|
+
this.canonicalHeaders + '\n',
|
318
|
+
this.signedHeaders,
|
319
|
+
await this.hexBodyHash(),
|
320
|
+
].join('\n')
|
321
|
+
}
|
322
|
+
|
323
|
+
/**
|
324
|
+
* @returns {Promise<string>}
|
325
|
+
*/
|
326
|
+
async hexBodyHash() {
|
327
|
+
let hashHeader = this.headers.get('X-Amz-Content-Sha256') || (this.service === 's3' && this.signQuery ? 'UNSIGNED-PAYLOAD' : null)
|
328
|
+
if (hashHeader == null) {
|
329
|
+
if (this.body && typeof this.body !== 'string' && !('byteLength' in this.body)) {
|
330
|
+
throw new Error('body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header')
|
331
|
+
}
|
332
|
+
hashHeader = buf2hex(await hash(this.body || ''))
|
333
|
+
}
|
334
|
+
return hashHeader
|
335
|
+
}
|
336
|
+
}
|
337
|
+
|
338
|
+
/**
|
339
|
+
* @param {string | ArrayBufferView | ArrayBuffer} key
|
340
|
+
* @param {string} string
|
341
|
+
* @returns {Promise<ArrayBuffer>}
|
342
|
+
*/
|
343
|
+
async function hmac(key, string) {
|
344
|
+
// @ts-ignore // https://github.com/microsoft/TypeScript/issues/38715
|
345
|
+
const cryptoKey = await crypto.subtle.importKey(
|
346
|
+
'raw',
|
347
|
+
typeof key === 'string' ? encoder.encode(key) : key,
|
348
|
+
{ name: 'HMAC', hash: { name: 'SHA-256' } },
|
349
|
+
false,
|
350
|
+
['sign'],
|
351
|
+
)
|
352
|
+
return crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(string))
|
353
|
+
}
|
354
|
+
|
355
|
+
/**
|
356
|
+
* @param {string | ArrayBufferView | ArrayBuffer} content
|
357
|
+
* @returns {Promise<ArrayBuffer>}
|
358
|
+
*/
|
359
|
+
async function hash(content) {
|
360
|
+
// @ts-ignore // https://github.com/microsoft/TypeScript/issues/38715
|
361
|
+
return crypto.subtle.digest('SHA-256', typeof content === 'string' ? encoder.encode(content) : content)
|
362
|
+
}
|
363
|
+
|
364
|
+
/**
|
365
|
+
* @param {ArrayBuffer | ArrayLike<number> | SharedArrayBuffer} buffer
|
366
|
+
* @returns {string}
|
367
|
+
*/
|
368
|
+
function buf2hex(buffer) {
|
369
|
+
return Array.prototype.map.call(new Uint8Array(buffer), x => ('0' + x.toString(16)).slice(-2)).join('')
|
370
|
+
}
|
371
|
+
|
372
|
+
/**
|
373
|
+
* @param {string} urlEncodedStr
|
374
|
+
* @returns {string}
|
375
|
+
*/
|
376
|
+
function encodeRfc3986(urlEncodedStr) {
|
377
|
+
return urlEncodedStr.replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
|
378
|
+
}
|
379
|
+
|
380
|
+
/**
|
381
|
+
* @param {URL} url
|
382
|
+
* @param {Headers} headers
|
383
|
+
* @returns {string[]} [service, region]
|
384
|
+
*/
|
385
|
+
function guessServiceRegion(url, headers) {
|
386
|
+
const { hostname, pathname } = url
|
387
|
+
|
388
|
+
if (hostname.endsWith('.r2.cloudflarestorage.com')) {
|
389
|
+
return ['s3', 'auto']
|
390
|
+
}
|
391
|
+
if (hostname.endsWith('.backblazeb2.com')) {
|
392
|
+
const match = hostname.match(/^(?:[^.]+\.)?s3\.([^.]+)\.backblazeb2\.com$/)
|
393
|
+
return match != null ? ['s3', match[1]] : ['', '']
|
394
|
+
}
|
395
|
+
const match = hostname.replace('dualstack.', '').match(/([^.]+)\.(?:([^.]*)\.)?amazonaws\.com(?:\.cn)?$/)
|
396
|
+
let [service, region] = (match || ['', '']).slice(1, 3)
|
397
|
+
|
398
|
+
if (region === 'us-gov') {
|
399
|
+
region = 'us-gov-west-1'
|
400
|
+
} else if (region === 's3' || region === 's3-accelerate') {
|
401
|
+
region = 'us-east-1'
|
402
|
+
service = 's3'
|
403
|
+
} else if (service === 'iot') {
|
404
|
+
if (hostname.startsWith('iot.')) {
|
405
|
+
service = 'execute-api'
|
406
|
+
} else if (hostname.startsWith('data.jobs.iot.')) {
|
407
|
+
service = 'iot-jobs-data'
|
408
|
+
} else {
|
409
|
+
service = pathname === '/mqtt' ? 'iotdevicegateway' : 'iotdata'
|
410
|
+
}
|
411
|
+
} else if (service === 'autoscaling') {
|
412
|
+
const targetPrefix = (headers.get('X-Amz-Target') || '').split('.')[0]
|
413
|
+
if (targetPrefix === 'AnyScaleFrontendService') {
|
414
|
+
service = 'application-autoscaling'
|
415
|
+
} else if (targetPrefix === 'AnyScaleScalingPlannerFrontendService') {
|
416
|
+
service = 'autoscaling-plans'
|
417
|
+
}
|
418
|
+
} else if (region == null && service.startsWith('s3-')) {
|
419
|
+
region = service.slice(3).replace(/^fips-|^external-1/, '')
|
420
|
+
service = 's3'
|
421
|
+
} else if (service.endsWith('-fips')) {
|
422
|
+
service = service.slice(0, -5)
|
423
|
+
} else if (region && /-\d$/.test(service) && !/-\d$/.test(region)) {
|
424
|
+
;[service, region] = [region, service]
|
425
|
+
}
|
426
|
+
|
427
|
+
return [HOST_SERVICES[service] || service, region]
|
428
|
+
}
|
package/index.js
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
export * from './adapter.js'
|
package/package.json
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
{
|
2
|
+
"name": "@storecraft/storage-s3-compatible",
|
3
|
+
"version": "1.0.0",
|
4
|
+
"description": "Official S3-Compatible Storage adapter for storecraft",
|
5
|
+
"license": "MIT",
|
6
|
+
"author": "Tomer Shalev (https://github.com/store-craft)",
|
7
|
+
"homepage": "https://github.com/store-craft/storecraft",
|
8
|
+
"repository": {
|
9
|
+
"type": "git",
|
10
|
+
"url": "https://github.com/store-craft/storecraft.git",
|
11
|
+
"directory": "packages/storage-s3-compatible"
|
12
|
+
},
|
13
|
+
"keywords": [
|
14
|
+
"commerce",
|
15
|
+
"dashboard",
|
16
|
+
"code",
|
17
|
+
"storecraft"
|
18
|
+
],
|
19
|
+
"scripts": {
|
20
|
+
"storage-s3-compatible:test": "uvu -c",
|
21
|
+
"storage-s3-compatible:publish": "npm publish --access public"
|
22
|
+
},
|
23
|
+
"type": "module",
|
24
|
+
"main": "index.js",
|
25
|
+
"types": "./types.public.d.ts",
|
26
|
+
"dependencies": {
|
27
|
+
"@storecraft/core": "^1.0.0"
|
28
|
+
},
|
29
|
+
"devDependencies": {
|
30
|
+
"@types/node": "^20.11.0",
|
31
|
+
"uvu": "^0.5.6",
|
32
|
+
"dotenv": "^16.3.1"
|
33
|
+
}
|
34
|
+
}
|
package/tests/node.png
ADDED
Binary file
|
@@ -0,0 +1,85 @@
|
|
1
|
+
import 'dotenv/config';
|
2
|
+
import { test } from 'uvu';
|
3
|
+
import * as assert from 'uvu/assert';
|
4
|
+
import { R2 } from '../adapter.js'
|
5
|
+
import { readFile } from 'node:fs/promises';
|
6
|
+
import { homedir } from 'node:os'
|
7
|
+
import * as path from 'node:path';
|
8
|
+
|
9
|
+
const areBlobsEqual = async (blob1, blob2) => {
|
10
|
+
return !Buffer.from(await blob1.arrayBuffer()).compare(
|
11
|
+
Buffer.from(await blob2.arrayBuffer())
|
12
|
+
);
|
13
|
+
};
|
14
|
+
|
15
|
+
const storage = new R2(
|
16
|
+
process.env.R2_BUCKET, process.env.R2_ACCOUNT_ID,
|
17
|
+
process.env.R2_ACCESS_KEY_ID, process.env.R2_SECRET_ACCESS_KEY
|
18
|
+
);
|
19
|
+
|
20
|
+
test.before(async () => await storage.init())
|
21
|
+
|
22
|
+
test('blob put/get/delete', async () => {
|
23
|
+
const data = [
|
24
|
+
// {
|
25
|
+
// key: 'folder1/tomer.txt',
|
26
|
+
// blob: new Blob(['this is some text from tomer :)']),
|
27
|
+
// },
|
28
|
+
{
|
29
|
+
key: 'node2222.png',
|
30
|
+
blob: new Blob([await readFile('./node.png')])
|
31
|
+
}
|
32
|
+
];
|
33
|
+
|
34
|
+
data.forEach(
|
35
|
+
async d => {
|
36
|
+
// write
|
37
|
+
await storage.putBlob(d.key, d.blob);
|
38
|
+
// read
|
39
|
+
const { value: blob_read } = await storage.getBlob(d.key);
|
40
|
+
const url = await storage.getSigned(d.key);
|
41
|
+
console.log('presign GET url ', url);
|
42
|
+
|
43
|
+
// compare
|
44
|
+
const equal = await areBlobsEqual(blob_read, d.blob);
|
45
|
+
assert.ok(equal, 'Blobs are not equal !!!');
|
46
|
+
|
47
|
+
// delete
|
48
|
+
// await storage.remove(d.key);
|
49
|
+
}
|
50
|
+
);
|
51
|
+
|
52
|
+
});
|
53
|
+
|
54
|
+
test('blob put (presign)', async () => {
|
55
|
+
const data = [
|
56
|
+
// {
|
57
|
+
// key: 'folder1/tomer.txt',
|
58
|
+
// blob: new Blob(['this is some text from tomer :)']),
|
59
|
+
// },
|
60
|
+
{
|
61
|
+
key: 'node_test2.png',
|
62
|
+
blob: new Blob([await readFile('./node.png')])
|
63
|
+
}
|
64
|
+
];
|
65
|
+
|
66
|
+
data.forEach(
|
67
|
+
async d => {
|
68
|
+
// get put presigned url
|
69
|
+
const { url, method, headers } = await storage.putSigned(d.key);
|
70
|
+
// now let's use it to upload
|
71
|
+
const r = await fetch(
|
72
|
+
url, {
|
73
|
+
method,
|
74
|
+
headers,
|
75
|
+
body: d.blob
|
76
|
+
}
|
77
|
+
);
|
78
|
+
|
79
|
+
assert.ok(r.ok, 'upload failed')
|
80
|
+
}
|
81
|
+
);
|
82
|
+
|
83
|
+
});
|
84
|
+
|
85
|
+
test.run();
|
package/tsconfig.json
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
{
|
2
|
+
"compileOnSave": false,
|
3
|
+
"compilerOptions": {
|
4
|
+
"noEmit": true,
|
5
|
+
"allowJs": true,
|
6
|
+
"checkJs": true,
|
7
|
+
"target": "ESNext",
|
8
|
+
"resolveJsonModule": true,
|
9
|
+
"moduleResolution": "NodeNext",
|
10
|
+
"module": "NodeNext",
|
11
|
+
"composite": true,
|
12
|
+
},
|
13
|
+
"include": ["*", "src/*"]
|
14
|
+
}
|