Skip to content

Commit 7199727

Browse files
chore: refactor GCSManager to use official Google Cloud Storage library
1 parent 473f83b commit 7199727

File tree

3 files changed

+260
-458
lines changed

3 files changed

+260
-458
lines changed
+70-89
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,18 @@
1-
import {sign} from 'jsonwebtoken'
1+
import {Storage} from '@google-cloud/storage'
22
import mime from 'mime-types'
33
import path from 'path'
44
import {Logger} from '../utils/Logger'
55
import FileStoreManager, {FileAssetDir} from './FileStoreManager'
66

7-
interface CloudKey {
8-
clientEmail: string
9-
privateKeyId: string
10-
privateKey: string
11-
}
12-
137
export default class GCSManager extends FileStoreManager {
14-
static GOOGLE_EXPIRY = 3600
158
// e.g. development, production
169
private envSubDir: string
1710
// e.g. action-files.parabol.co
1811
private bucket: string
19-
private accessToken: string | undefined
20-
2112
// The CDN_BASE_URL without the env, e.g. storage.google.com/:bucket
2213
baseUrl: string
23-
private cloudKey: CloudKey
14+
private storage: Storage
15+
2416
constructor() {
2517
super()
2618
const {
@@ -30,6 +22,7 @@ export default class GCSManager extends FileStoreManager {
3022
GOOGLE_CLOUD_PRIVATE_KEY,
3123
GOOGLE_CLOUD_PRIVATE_KEY_ID
3224
} = process.env
25+
3326
if (!CDN_BASE_URL || CDN_BASE_URL === 'key_CDN_BASE_URL') {
3427
throw new Error('CDN_BASE_URL ENV VAR NOT SET')
3528
}
@@ -43,6 +36,7 @@ export default class GCSManager extends FileStoreManager {
4336
if (!GOOGLE_GCS_BUCKET) {
4437
throw new Error('GOOGLE_GCS_BUCKET ENV VAR NOT SET')
4538
}
39+
4640
const baseUrl = new URL(CDN_BASE_URL.replace(/^\/+/, 'https://'))
4741
const {hostname, pathname} = baseUrl
4842
if (!hostname || !pathname) {
@@ -52,64 +46,17 @@ export default class GCSManager extends FileStoreManager {
5246
throw new Error('CDN_BASE_URL must end with the env, no trailing slash, e.g. /production')
5347

5448
this.envSubDir = pathname.split('/').at(-1) as string
55-
5649
this.baseUrl = baseUrl.href.slice(0, baseUrl.href.lastIndexOf(this.envSubDir))
57-
5850
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-
}
7451

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+
})
11360
}
11461

11562
protected async putUserFile(file: Buffer<ArrayBufferLike>, partialPath: string) {
@@ -118,28 +65,32 @@ export default class GCSManager extends FileStoreManager {
11865
}
11966

12067
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()
12568
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)
13580
} 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+
) {
13987
Logger.log(' Retrying GCS Post:', fullPath)
14088
await this.putFile(file, fullPath)
89+
} else {
90+
throw e
14191
}
14292
}
93+
14394
return this.getPublicFileLocation(fullPath)
14495
}
14596

@@ -151,17 +102,47 @@ export default class GCSManager extends FileStoreManager {
151102
prependPath(partialPath: string, assetDir: FileAssetDir = 'store') {
152103
return path.join(this.envSubDir, assetDir, partialPath)
153104
}
105+
154106
getPublicFileLocation(fullPath: string) {
155107
return encodeURI(`${this.baseUrl}${fullPath}`)
156108
}
109+
157110
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+
}
162122
}
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+
}
166147
}
167148
}

packages/server/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"@dicebear/core": "^8.0.1",
7979
"@dicebear/initials": "^8.0.1",
8080
"@emoji-mart/data": "^1.2.1",
81+
"@google-cloud/storage": "^7.15.2",
8182
"@graphql-tools/schema": "^9.0.16",
8283
"@hocuspocus/extension-database": "^2.15.2",
8384
"@hocuspocus/extension-redis": "^2.15.2",

0 commit comments

Comments
 (0)