1
- import { sign } from 'jsonwebtoken '
1
+ import { Storage } from '@google-cloud/storage '
2
2
import mime from 'mime-types'
3
3
import path from 'path'
4
4
import { Logger } from '../utils/Logger'
5
5
import FileStoreManager , { FileAssetDir } from './FileStoreManager'
6
6
7
- interface CloudKey {
8
- clientEmail : string
9
- privateKeyId : string
10
- privateKey : string
11
- }
12
-
13
7
export default class GCSManager extends FileStoreManager {
14
- static GOOGLE_EXPIRY = 3600
15
8
// e.g. development, production
16
9
private envSubDir : string
17
10
// e.g. action-files.parabol.co
18
11
private bucket : string
19
- private accessToken : string | undefined
20
-
21
12
// The CDN_BASE_URL without the env, e.g. storage.google.com/:bucket
22
13
baseUrl : string
23
- private cloudKey : CloudKey
14
+ private storage : Storage
15
+
24
16
constructor ( ) {
25
17
super ( )
26
18
const {
@@ -30,6 +22,7 @@ export default class GCSManager extends FileStoreManager {
30
22
GOOGLE_CLOUD_PRIVATE_KEY ,
31
23
GOOGLE_CLOUD_PRIVATE_KEY_ID
32
24
} = process . env
25
+
33
26
if ( ! CDN_BASE_URL || CDN_BASE_URL === 'key_CDN_BASE_URL' ) {
34
27
throw new Error ( 'CDN_BASE_URL ENV VAR NOT SET' )
35
28
}
@@ -43,6 +36,7 @@ export default class GCSManager extends FileStoreManager {
43
36
if ( ! GOOGLE_GCS_BUCKET ) {
44
37
throw new Error ( 'GOOGLE_GCS_BUCKET ENV VAR NOT SET' )
45
38
}
39
+
46
40
const baseUrl = new URL ( CDN_BASE_URL . replace ( / ^ \/ + / , 'https://' ) )
47
41
const { hostname, pathname} = baseUrl
48
42
if ( ! hostname || ! pathname ) {
@@ -52,64 +46,17 @@ export default class GCSManager extends FileStoreManager {
52
46
throw new Error ( 'CDN_BASE_URL must end with the env, no trailing slash, e.g. /production' )
53
47
54
48
this . envSubDir = pathname . split ( '/' ) . at ( - 1 ) as string
55
-
56
49
this . baseUrl = baseUrl . href . slice ( 0 , baseUrl . href . lastIndexOf ( this . envSubDir ) )
57
-
58
50
this . bucket = GOOGLE_GCS_BUCKET
59
- this . cloudKey = {
60
- clientEmail : GOOGLE_CLOUD_CLIENT_EMAIL ,
61
- privateKey : GOOGLE_CLOUD_PRIVATE_KEY . replace ( / \\ n / gm, '\n' ) ,
62
- privateKeyId : GOOGLE_CLOUD_PRIVATE_KEY_ID
63
- }
64
- // refresh the token every hour
65
- // do this on an interval vs. on demand to reduce request latency
66
- // unref it so things like pushToCDN can exit
67
- setInterval (
68
- async ( ) => {
69
- this . accessToken = await this . getFreshAccessToken ( )
70
- } ,
71
- ( GCSManager . GOOGLE_EXPIRY - 100 ) * 1000
72
- ) . unref ( )
73
- }
74
51
75
- private async getFreshAccessToken ( ) {
76
- const authUrl = 'https://www.googleapis.com/oauth2/v4/token'
77
- const { clientEmail, privateKeyId, privateKey} = this . cloudKey
78
- try {
79
- // GCS only accepts OAuth2 Tokens
80
- // To get a token, we self-sign a JWT, then trade it in for an OAuth2 Token
81
- const jwt = sign (
82
- {
83
- scope : 'https://www.googleapis.com/auth/devstorage.read_write'
84
- } ,
85
- privateKey ,
86
- {
87
- algorithm : 'RS256' ,
88
- audience : authUrl ,
89
- subject : clientEmail ,
90
- issuer : clientEmail ,
91
- keyid : privateKeyId ,
92
- expiresIn : GCSManager . GOOGLE_EXPIRY
93
- }
94
- )
95
- const accessTokenRes = await fetch ( authUrl , {
96
- method : 'POST' ,
97
- body : JSON . stringify ( {
98
- grant_type : 'urn:ietf:params:oauth:grant-type:jwt-bearer' ,
99
- assertion : jwt
100
- } )
101
- } )
102
- const accessTokenJson = await accessTokenRes . json ( )
103
- return accessTokenJson . access_token
104
- } catch ( e ) {
105
- return undefined
106
- }
107
- }
108
-
109
- private async getAccessToken ( ) {
110
- if ( this . accessToken ) return this . accessToken
111
- this . accessToken = await this . getFreshAccessToken ( )
112
- return this . accessToken
52
+ // Initialize Google Cloud Storage client with credentials
53
+ this . storage = new Storage ( {
54
+ credentials : {
55
+ client_email : GOOGLE_CLOUD_CLIENT_EMAIL ,
56
+ private_key : GOOGLE_CLOUD_PRIVATE_KEY . replace ( / \\ n / gm, '\n' ) ,
57
+ private_key_id : GOOGLE_CLOUD_PRIVATE_KEY_ID
58
+ }
59
+ } )
113
60
}
114
61
115
62
protected async putUserFile ( file : Buffer < ArrayBufferLike > , partialPath : string ) {
@@ -118,28 +65,32 @@ export default class GCSManager extends FileStoreManager {
118
65
}
119
66
120
67
protected async putFile ( file : Buffer < ArrayBufferLike > , fullPath : string ) {
121
- const url = new URL ( `https://storage.googleapis.com/upload/storage/v1/b/${ this . bucket } /o` )
122
- url . searchParams . append ( 'uploadType' , 'media' )
123
- url . searchParams . append ( 'name' , fullPath )
124
- const accessToken = await this . getAccessToken ( )
125
68
try {
126
- await fetch ( url , {
127
- method : 'POST' ,
128
- body : file ,
129
- headers : {
130
- Authorization : `Bearer ${ accessToken } ` ,
131
- Accept : 'application/json' ,
132
- 'Content-Type' : mime . lookup ( fullPath ) || ''
133
- }
134
- } )
69
+ const bucket = this . storage . bucket ( this . bucket )
70
+ const blob = bucket . file ( fullPath )
71
+
72
+ // Set the content type based on file extension
73
+ const contentType = mime . lookup ( fullPath ) || 'application/octet-stream'
74
+ const options = {
75
+ contentType,
76
+ resumable : false
77
+ }
78
+
79
+ await blob . save ( file , options )
135
80
} catch ( e ) {
136
- // https://github.com/nodejs/undici/issues/583#issuecomment-1577475664
137
- // GCS will cause undici to error randomly with `SocketError: other side closed` `code: 'UND_ERR_SOCKET'`
138
- if ( ( e as any ) . cause ?. code === 'UND_ERR_SOCKET' ) {
81
+ // Handle specific socket errors that might occur with GCS
82
+ if (
83
+ ( e as any ) . code === 'ETIMEDOUT' ||
84
+ ( e as any ) . code === 'ECONNRESET' ||
85
+ ( e as any ) . cause ?. code === 'UND_ERR_SOCKET'
86
+ ) {
139
87
Logger . log ( ' Retrying GCS Post:' , fullPath )
140
88
await this . putFile ( file , fullPath )
89
+ } else {
90
+ throw e
141
91
}
142
92
}
93
+
143
94
return this . getPublicFileLocation ( fullPath )
144
95
}
145
96
@@ -151,17 +102,47 @@ export default class GCSManager extends FileStoreManager {
151
102
prependPath ( partialPath : string , assetDir : FileAssetDir = 'store' ) {
152
103
return path . join ( this . envSubDir , assetDir , partialPath )
153
104
}
105
+
154
106
getPublicFileLocation ( fullPath : string ) {
155
107
return encodeURI ( `${ this . baseUrl } ${ fullPath } ` )
156
108
}
109
+
157
110
async checkExists ( partialPath : string , assetDir ?: FileAssetDir ) {
158
- const fullPath = encodeURIComponent ( this . prependPath ( partialPath , assetDir ) )
159
- const url = `https://storage.googleapis.com/storage/v1/b/${ this . bucket } /o/${ fullPath } `
160
- const res = await fetch ( url )
161
- return res . status !== 404
111
+ const fullPath = this . prependPath ( partialPath , assetDir )
112
+ const bucket = this . storage . bucket ( this . bucket )
113
+ const file = bucket . file ( fullPath )
114
+
115
+ try {
116
+ const [ exists ] = await file . exists ( )
117
+ return exists
118
+ } catch ( e ) {
119
+ Logger . error ( 'Error checking if file exists:' , e )
120
+ return false
121
+ }
162
122
}
163
- async presignUrl ( url : string ) : Promise < string > {
164
- // not implemented yet!
165
- return url
123
+
124
+ async presignUrl ( url : string , expiresInMinutes : number = 60 ) : Promise < string > {
125
+ // Extract the file path from the URL
126
+ const filePathMatch = url . match ( new RegExp ( `${ this . baseUrl } (.*)` ) )
127
+ if ( ! filePathMatch || ! filePathMatch [ 1 ] ) {
128
+ return url // Return original URL if we can't extract the path
129
+ }
130
+
131
+ const filePath = decodeURI ( filePathMatch [ 1 ] )
132
+ const bucket = this . storage . bucket ( this . bucket )
133
+ const file = bucket . file ( filePath )
134
+
135
+ try {
136
+ // Generate a signed URL
137
+ const [ signedUrl ] = await file . getSignedUrl ( {
138
+ action : 'read' ,
139
+ expires : Date . now ( ) + expiresInMinutes * 60 * 1000
140
+ } )
141
+
142
+ return signedUrl
143
+ } catch ( e ) {
144
+ Logger . error ( 'Error generating signed URL:' , e )
145
+ return url // Return the original URL if signing fails
146
+ }
166
147
}
167
148
}
0 commit comments