@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 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
+ }
@@ -0,0 +1,11 @@
1
+ export * from './index.js';
2
+
3
+ export type Config = {
4
+ endpoint: string;
5
+ bucket: string;
6
+ accessKeyId: string;
7
+ secretAccessKey: string;
8
+ region: string;
9
+ forcePathStyle: boolean;
10
+ }
11
+