@storecraft/storage-google 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 +46 -0
- package/adapter.js +280 -0
- package/adapter.utils.js +83 -0
- package/index.js +1 -0
- package/package.json +34 -0
- package/tests/node.png +0 -0
- package/tests/storage.test.js +85 -0
- package/tsconfig.json +14 -0
- package/types.public.d.ts +26 -0
package/README.md
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
# Storecraft Google Cloud Storage
|
2
|
+
|
3
|
+
`fetch` ready support for an `GCP` **Storage**
|
4
|
+
|
5
|
+
Features:
|
6
|
+
- Works in any `js` runtime and platform that supports `fetch`
|
7
|
+
- Supports streaming `Get` / `Put` / `Delete`
|
8
|
+
- Supports `presigned` `Get` / `Put` requests to offload to client
|
9
|
+
|
10
|
+
## How-to
|
11
|
+
1. Create a bucket at `GCP console` or even at `firebase`
|
12
|
+
2. Download the `service json file`
|
13
|
+
|
14
|
+
Use the values of the service file.
|
15
|
+
|
16
|
+
Note:
|
17
|
+
- You can use an empty constructor and upon `StoreCraft` init, the platform
|
18
|
+
environment variables will be used by this storage if needed.
|
19
|
+
|
20
|
+
```js
|
21
|
+
import { GoogleStorage } from '@storecraft/storage-google';
|
22
|
+
|
23
|
+
const storage = new GoogleStorage({
|
24
|
+
bucket: process.env.GS_BUCKET,
|
25
|
+
client_email: process.env.GS_CLIENT_EMAIL,
|
26
|
+
private_key: process.env.GS_PRIVATE_KEY,
|
27
|
+
private_key_id: process.env.GS_PRIVATE_KEY_ID
|
28
|
+
});
|
29
|
+
|
30
|
+
// write
|
31
|
+
await storage.putBlob(
|
32
|
+
'folder1/tomer.txt',
|
33
|
+
new Blob(['this is some text from tomer :)'])
|
34
|
+
);
|
35
|
+
|
36
|
+
// read
|
37
|
+
const { value } = await storage.getBlob('folder1/tomer.txt');
|
38
|
+
const url = await storage.getSigned('folder1/tomer.txt');
|
39
|
+
console.log('presign GET url ', url);
|
40
|
+
|
41
|
+
```
|
42
|
+
|
43
|
+
|
44
|
+
```text
|
45
|
+
Author: Tomer Shalev (tomer.shalev@gmail.com)
|
46
|
+
```
|
package/adapter.js
ADDED
@@ -0,0 +1,280 @@
|
|
1
|
+
import { getJWTFromServiceAccount, presign } from './adapter.utils.js';
|
2
|
+
|
3
|
+
const types = {
|
4
|
+
'png': 'image/png',
|
5
|
+
'gif': 'image/gif',
|
6
|
+
'jpeg': 'image/jpeg',
|
7
|
+
'jpg': 'image/jpeg',
|
8
|
+
'tiff': 'image/tiff',
|
9
|
+
'webp': 'image/webp',
|
10
|
+
'txt': 'text/plain',
|
11
|
+
'json': 'application/json',
|
12
|
+
}
|
13
|
+
|
14
|
+
/**
|
15
|
+
*
|
16
|
+
* @param {string} name
|
17
|
+
*/
|
18
|
+
const infer_content_type = (name) => {
|
19
|
+
const idx = name.lastIndexOf('.');
|
20
|
+
if(!idx) return 'application/octet-stream';
|
21
|
+
const type = types[name.substring(idx + 1).trim()]
|
22
|
+
return type ?? 'application/octet-stream';
|
23
|
+
}
|
24
|
+
|
25
|
+
|
26
|
+
/**
|
27
|
+
* @typedef {import('./types.public.js').ServiceFile} ServiceFile
|
28
|
+
* @typedef {import('./types.public.js').Config} Config
|
29
|
+
*/
|
30
|
+
|
31
|
+
/**
|
32
|
+
* Google Storage
|
33
|
+
* @typedef {import('@storecraft/core/v-storage').storage_driver} storage
|
34
|
+
*
|
35
|
+
* @implements {storage}
|
36
|
+
*/
|
37
|
+
export class GoogleStorage {
|
38
|
+
|
39
|
+
/** @type {Config} */ #_config;
|
40
|
+
|
41
|
+
/**
|
42
|
+
* @param {Config} [config]
|
43
|
+
*/
|
44
|
+
constructor(config) {
|
45
|
+
this.#_config = config;
|
46
|
+
}
|
47
|
+
|
48
|
+
get bucket() { return this.config.bucket; }
|
49
|
+
get config() { return this.#_config; }
|
50
|
+
|
51
|
+
/**
|
52
|
+
* @type {storage["init"]}
|
53
|
+
*/
|
54
|
+
async init(app) {
|
55
|
+
if(!app)
|
56
|
+
return this;
|
57
|
+
|
58
|
+
this.#_config = this.#_config ?? {
|
59
|
+
bucket: app.platform.env.GS_BUCKET,
|
60
|
+
client_email: app.platform.env.GS_CLIENT_EMAIL,
|
61
|
+
private_key: app.platform.env.GS_PRIVATE_KEY,
|
62
|
+
private_key_id: app.platform.env.GS_PRIVATE_KEY_ID,
|
63
|
+
}
|
64
|
+
|
65
|
+
return this;
|
66
|
+
}
|
67
|
+
|
68
|
+
features() {
|
69
|
+
/** @type {import('@storecraft/core/v-storage').StorageFeatures} */
|
70
|
+
const f = {
|
71
|
+
supports_signed_urls: true
|
72
|
+
}
|
73
|
+
|
74
|
+
return f;
|
75
|
+
}
|
76
|
+
|
77
|
+
// puts
|
78
|
+
|
79
|
+
/**
|
80
|
+
* @param {string} key
|
81
|
+
*/
|
82
|
+
put_file_url(key) {
|
83
|
+
const base = 'https://storage.googleapis.com/upload/storage/v1';
|
84
|
+
return `${base}/b/${this.bucket}/o?uploadType=media&name=${encodeURIComponent(key)}`
|
85
|
+
}
|
86
|
+
|
87
|
+
/**
|
88
|
+
*
|
89
|
+
* @param {string} key
|
90
|
+
* @param {BodyInit} body
|
91
|
+
*/
|
92
|
+
async #put_internal(key, body) {
|
93
|
+
const auth = 'Bearer ' + await getJWTFromServiceAccount(this.config);
|
94
|
+
|
95
|
+
const r = await fetch(
|
96
|
+
this.put_file_url(key),
|
97
|
+
{
|
98
|
+
method: 'POST',
|
99
|
+
body,
|
100
|
+
headers: {
|
101
|
+
Authorization: auth,
|
102
|
+
'Content-Type': 'image/png'
|
103
|
+
},
|
104
|
+
duplex: 'half'
|
105
|
+
}
|
106
|
+
);
|
107
|
+
|
108
|
+
return r.ok;
|
109
|
+
// console.log(r)
|
110
|
+
// console.log(JSON.stringify(await r.json(), null, 2))
|
111
|
+
}
|
112
|
+
|
113
|
+
/**
|
114
|
+
*
|
115
|
+
* @param {string} key
|
116
|
+
* @param {Blob} blob
|
117
|
+
*/
|
118
|
+
async putBlob(key, blob) {
|
119
|
+
return this.#put_internal(key, blob);
|
120
|
+
}
|
121
|
+
|
122
|
+
/**
|
123
|
+
*
|
124
|
+
* @param {string} key
|
125
|
+
* @param {ArrayBuffer} buffer
|
126
|
+
*/
|
127
|
+
async putArraybuffer(key, buffer) {
|
128
|
+
return this.#put_internal(key, buffer);
|
129
|
+
}
|
130
|
+
|
131
|
+
/**
|
132
|
+
*
|
133
|
+
* @param {string} key
|
134
|
+
* @param {ReadableStream} stream
|
135
|
+
*/
|
136
|
+
async putStream(key, stream) {
|
137
|
+
return this.#put_internal(key, stream);
|
138
|
+
}
|
139
|
+
|
140
|
+
/**
|
141
|
+
*
|
142
|
+
* @param {string} key
|
143
|
+
*/
|
144
|
+
async putSigned(key) {
|
145
|
+
const ct = infer_content_type(key);
|
146
|
+
const sf = this.config;
|
147
|
+
|
148
|
+
const url_signed = await presign({
|
149
|
+
pem_private_key: sf.private_key,
|
150
|
+
client_id_email: sf.client_email,
|
151
|
+
gcs_api_endpoint: 'https://storage.googleapis.com',
|
152
|
+
path: `/${this.bucket}/${key}`,
|
153
|
+
verb: 'PUT',
|
154
|
+
content_md5: '',
|
155
|
+
content_type: ct
|
156
|
+
});
|
157
|
+
|
158
|
+
return {
|
159
|
+
url: url_signed,
|
160
|
+
method: 'PUT',
|
161
|
+
headers: {
|
162
|
+
'Content-Type': ct
|
163
|
+
}
|
164
|
+
}
|
165
|
+
|
166
|
+
}
|
167
|
+
|
168
|
+
// gets
|
169
|
+
|
170
|
+
/** @param {string} key */
|
171
|
+
get_file_url(key) {
|
172
|
+
const base = 'https://storage.googleapis.com/storage/v1';
|
173
|
+
return `${base}/b/${this.bucket}/o/${encodeURIComponent(key)}`
|
174
|
+
}
|
175
|
+
|
176
|
+
/** @param {string} key */
|
177
|
+
async #get_request(key) {
|
178
|
+
const auth = 'Bearer ' + await getJWTFromServiceAccount(this.config);
|
179
|
+
return fetch(
|
180
|
+
this.get_file_url(key) + '?alt=media',
|
181
|
+
{
|
182
|
+
method: 'GET',
|
183
|
+
headers: {
|
184
|
+
Authorization: auth,
|
185
|
+
}
|
186
|
+
}
|
187
|
+
);
|
188
|
+
}
|
189
|
+
|
190
|
+
/**
|
191
|
+
*
|
192
|
+
* @param {string} key
|
193
|
+
*/
|
194
|
+
async getArraybuffer(key) {
|
195
|
+
const r = await this.#get_request(key);
|
196
|
+
const b = await r.arrayBuffer();
|
197
|
+
return {
|
198
|
+
value: b,
|
199
|
+
metadata: {
|
200
|
+
contentType: infer_content_type(key)
|
201
|
+
}
|
202
|
+
};
|
203
|
+
}
|
204
|
+
|
205
|
+
/**
|
206
|
+
*
|
207
|
+
* @param {string} key
|
208
|
+
*/
|
209
|
+
async getBlob(key) {
|
210
|
+
const r = await this.#get_request(key);
|
211
|
+
// console.log(await r.json())
|
212
|
+
// console.log(r)
|
213
|
+
const b = await r.blob();
|
214
|
+
return {
|
215
|
+
value: b,
|
216
|
+
metadata: {
|
217
|
+
contentType: infer_content_type(key)
|
218
|
+
}
|
219
|
+
};
|
220
|
+
}
|
221
|
+
|
222
|
+
/**
|
223
|
+
*
|
224
|
+
* @param {string} key
|
225
|
+
* @param {Response} key
|
226
|
+
*/
|
227
|
+
async getStream(key) {
|
228
|
+
|
229
|
+
const s = (await this.#get_request(key)).body
|
230
|
+
return {
|
231
|
+
value: s,
|
232
|
+
metadata: {
|
233
|
+
contentType: infer_content_type(key)
|
234
|
+
}
|
235
|
+
};
|
236
|
+
}
|
237
|
+
|
238
|
+
/**
|
239
|
+
*
|
240
|
+
* @param {string} key
|
241
|
+
*/
|
242
|
+
async getSigned(key) {
|
243
|
+
const sf = this.config;
|
244
|
+
const url_signed = await presign({
|
245
|
+
pem_private_key: sf.private_key,
|
246
|
+
client_id_email: sf.client_email,
|
247
|
+
gcs_api_endpoint: 'https://storage.googleapis.com',
|
248
|
+
path: `/${this.bucket}/${key}`,
|
249
|
+
verb: 'GET',
|
250
|
+
});
|
251
|
+
|
252
|
+
return {
|
253
|
+
url: url_signed,
|
254
|
+
method: 'GET',
|
255
|
+
}
|
256
|
+
}
|
257
|
+
|
258
|
+
// // remove
|
259
|
+
|
260
|
+
/**
|
261
|
+
*
|
262
|
+
* @param {string} key
|
263
|
+
*/
|
264
|
+
async remove(key) {
|
265
|
+
const Authorization = 'Bearer ' + await getJWTFromServiceAccount(this.config);
|
266
|
+
const r = await fetch(
|
267
|
+
this.get_file_url(key),
|
268
|
+
{
|
269
|
+
method: 'DELETE',
|
270
|
+
headers: {
|
271
|
+
Authorization
|
272
|
+
}
|
273
|
+
}
|
274
|
+
);
|
275
|
+
|
276
|
+
return r.ok;
|
277
|
+
// console.log(r)
|
278
|
+
}
|
279
|
+
}
|
280
|
+
|
package/adapter.utils.js
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
import { base64, jwt } from '@storecraft/core/v-crypto';
|
2
|
+
|
3
|
+
/**
|
4
|
+
*
|
5
|
+
* @param {import('./types.public.js').ServiceFile} sf Google service account json
|
6
|
+
* @param {string} [aud]
|
7
|
+
* @returns
|
8
|
+
*/
|
9
|
+
export async function getJWTFromServiceAccount(sf, aud=undefined) {
|
10
|
+
/** @type {Partial<import('@storecraft/core/v-crypto').jwt.JWTClaims> & Record<string, string>} */
|
11
|
+
const claims = {
|
12
|
+
scope: [
|
13
|
+
// 'https://www.googleapis.com/auth/cloud-platform',
|
14
|
+
// 'https://www.googleapis.com/auth/firebase.database',
|
15
|
+
// 'https://www.googleapis.com/auth/firebase.messaging',
|
16
|
+
// 'https://www.googleapis.com/auth/identitytoolkit',
|
17
|
+
// 'https://www.googleapis.com/auth/userinfo.email',
|
18
|
+
'https://www.googleapis.com/auth/iam',
|
19
|
+
'https://www.googleapis.com/auth/cloud-platform',
|
20
|
+
'https://www.googleapis.com/auth/devstorage.full_control'
|
21
|
+
].join(' '),
|
22
|
+
iss: sf.client_email,
|
23
|
+
sub: sf.client_email,
|
24
|
+
};
|
25
|
+
|
26
|
+
if(aud) claims.aud=aud;
|
27
|
+
|
28
|
+
const r = await jwt.create_with_pem(
|
29
|
+
sf.private_key,
|
30
|
+
claims, 3600,
|
31
|
+
{
|
32
|
+
kid: sf.private_key_id
|
33
|
+
}
|
34
|
+
);
|
35
|
+
|
36
|
+
return r.token;
|
37
|
+
}
|
38
|
+
|
39
|
+
/**
|
40
|
+
* Presign Google Storage resource
|
41
|
+
* @param {{
|
42
|
+
* pem_private_key: string,
|
43
|
+
* client_id_email: string,
|
44
|
+
* gcs_api_endpoint: string,
|
45
|
+
* path: string,
|
46
|
+
* verb: string,
|
47
|
+
* content_md5?: string,
|
48
|
+
* content_type?: string,
|
49
|
+
* expiration_delta?: number,
|
50
|
+
* }} signature
|
51
|
+
*/
|
52
|
+
export const presign = async ({pem_private_key, client_id_email, gcs_api_endpoint,
|
53
|
+
path, verb, content_md5 = '', content_type='', expiration_delta=3600 }) => {
|
54
|
+
|
55
|
+
const expiration = Math.floor(Date.now()/1000) + expiration_delta;
|
56
|
+
const digest = [
|
57
|
+
verb, content_md5 ?? '', content_type ?? '',
|
58
|
+
expiration, path
|
59
|
+
].join('\n');
|
60
|
+
|
61
|
+
const algorithm = {
|
62
|
+
name: 'RSASSA-PKCS1-v1_5',
|
63
|
+
hash: { name: 'SHA-256' },
|
64
|
+
}
|
65
|
+
// console.log(digest)
|
66
|
+
const privateKey = await jwt.import_pem_pkcs8(pem_private_key, algorithm);
|
67
|
+
const digest_buffer = new TextEncoder().encode(digest);
|
68
|
+
const signature_buffer = await crypto.subtle.sign(
|
69
|
+
algorithm,
|
70
|
+
privateKey,
|
71
|
+
digest_buffer
|
72
|
+
);
|
73
|
+
|
74
|
+
const signature_b64 = base64.fromUint8Array(new Uint8Array(signature_buffer));
|
75
|
+
const qp = {
|
76
|
+
'GoogleAccessId': client_id_email,
|
77
|
+
'Expires': expiration.toString(),
|
78
|
+
'Signature': signature_b64
|
79
|
+
};
|
80
|
+
const qp_string = new URLSearchParams(qp).toString();
|
81
|
+
|
82
|
+
return`${gcs_api_endpoint}${path}?${qp_string}`;
|
83
|
+
}
|
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-google",
|
3
|
+
"version": "1.0.0",
|
4
|
+
"description": "Google Storage support",
|
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-google"
|
12
|
+
},
|
13
|
+
"keywords": [
|
14
|
+
"commerce",
|
15
|
+
"dashboard",
|
16
|
+
"code",
|
17
|
+
"storecraft"
|
18
|
+
],
|
19
|
+
"scripts": {
|
20
|
+
"storage-google:test": "uvu -c",
|
21
|
+
"storage-google: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 { GoogleStorage } from '../adapter.js'
|
5
|
+
import { readFile } from 'node:fs/promises';
|
6
|
+
|
7
|
+
const areBlobsEqual = async (blob1, blob2) => {
|
8
|
+
return !Buffer.from(await blob1.arrayBuffer()).compare(
|
9
|
+
Buffer.from(await blob2.arrayBuffer())
|
10
|
+
);
|
11
|
+
};
|
12
|
+
|
13
|
+
const storage = new GoogleStorage({
|
14
|
+
bucket: process.env.GS_BUCKET, client_email: process.env.GS_CLIENT_EMAIL,
|
15
|
+
private_key: process.env.GS_PRIVATE_KEY, private_key_id: process.env.GS_PRIVATE_KEY_ID
|
16
|
+
});
|
17
|
+
|
18
|
+
test.before(async () => { await storage.init(undefined) });
|
19
|
+
|
20
|
+
test('blob put/get/delete', async () => {
|
21
|
+
const data = [
|
22
|
+
// {
|
23
|
+
// key: 'folder1/tomer.txt',
|
24
|
+
// blob: new Blob(['this is some text from tomer :)']),
|
25
|
+
// },
|
26
|
+
{
|
27
|
+
key: 'node2222.png',
|
28
|
+
blob: new Blob([await readFile('./node.png')])
|
29
|
+
}
|
30
|
+
];
|
31
|
+
|
32
|
+
data.forEach(
|
33
|
+
async d => {
|
34
|
+
// write
|
35
|
+
await storage.putBlob(d.key, d.blob);
|
36
|
+
// read
|
37
|
+
const { value: blob_read } = await storage.getBlob(d.key);
|
38
|
+
const url = await storage.getSigned(d.key);
|
39
|
+
console.log('presign GET url ', url);
|
40
|
+
|
41
|
+
// compare
|
42
|
+
const equal = await areBlobsEqual(blob_read, d.blob);
|
43
|
+
assert.ok(equal, 'Blobs are not equal !!!');
|
44
|
+
|
45
|
+
// delete
|
46
|
+
// await storage.remove(d.key);
|
47
|
+
}
|
48
|
+
);
|
49
|
+
|
50
|
+
});
|
51
|
+
|
52
|
+
test('blob put (presign)', async () => {
|
53
|
+
const data = [
|
54
|
+
// {
|
55
|
+
// key: 'folder1/tomer.txt',
|
56
|
+
// blob: new Blob(['this is some text from tomer :)']),
|
57
|
+
// },
|
58
|
+
{
|
59
|
+
key: 'node_test2.png',
|
60
|
+
blob: new Blob([await readFile('./node.png')])
|
61
|
+
}
|
62
|
+
];
|
63
|
+
|
64
|
+
data.forEach(
|
65
|
+
async d => {
|
66
|
+
// get put presigned url
|
67
|
+
const { url, method, headers } = await storage.putSigned(d.key);
|
68
|
+
// now let's use it to upload
|
69
|
+
const r = await fetch(
|
70
|
+
url, {
|
71
|
+
method,
|
72
|
+
headers,
|
73
|
+
body: d.blob
|
74
|
+
}
|
75
|
+
);
|
76
|
+
|
77
|
+
console.log(url)
|
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,26 @@
|
|
1
|
+
export * from './index.js';
|
2
|
+
|
3
|
+
export type ServiceFile = {
|
4
|
+
type?: string;
|
5
|
+
project_id?: string;
|
6
|
+
private_key_id?: string;
|
7
|
+
private_key?: string;
|
8
|
+
client_email?: string;
|
9
|
+
client_id?: string;
|
10
|
+
auth_uri?: string;
|
11
|
+
token_uri?: string;
|
12
|
+
auth_provider_x509_cert_url?: string;
|
13
|
+
client_x509_cert_url?: string;
|
14
|
+
universe_domain?: string;
|
15
|
+
}
|
16
|
+
|
17
|
+
export type Config = {
|
18
|
+
/** bucket name */
|
19
|
+
bucket: string;
|
20
|
+
/** client email from the service file */
|
21
|
+
client_email: string;
|
22
|
+
/** private key */
|
23
|
+
private_key: string;
|
24
|
+
/** private key id */
|
25
|
+
private_key_id: string;
|
26
|
+
}
|