@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 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
+
@@ -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
+ }