11/**
22 * Google Cloud Storage service for uploading media assets.
33 *
4+ * Uses native Node.js `crypto` for JWT signing — no external auth libraries needed.
5+ * Authenticates via service account JWT → OAuth2 access token exchange.
6+ *
47 * Requires env vars:
58 * - GCS_BUCKET
69 * - GCS_PROJECT_ID
1013 * @module lib/services/gcs
1114 */
1215
16+ import * as crypto from "crypto" ;
17+
18+ // ---------------------------------------------------------------------------
19+ // Types
20+ // ---------------------------------------------------------------------------
21+
1322export interface GCSConfig {
1423 bucket : string ;
1524 projectId : string ;
@@ -28,30 +37,189 @@ export interface UploadResult {
2837 size : number ;
2938}
3039
40+ // ---------------------------------------------------------------------------
41+ // Module-level token cache
42+ // ---------------------------------------------------------------------------
43+
44+ interface CachedToken {
45+ accessToken : string ;
46+ /** Epoch ms when the token expires */
47+ expiresAt : number ;
48+ }
49+
50+ let cachedToken : CachedToken | null = null ;
51+
52+ /** Tokens are refreshed when within this many ms of expiry */
53+ const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000 ; // 5 minutes
54+
55+ // ---------------------------------------------------------------------------
56+ // Config
57+ // ---------------------------------------------------------------------------
58+
3159/**
3260 * Get GCS configuration from environment variables.
3361 * Throws if any required env var is missing.
62+ *
63+ * The private key may contain literal `\\n` sequences from the env var;
64+ * these are converted to real newline characters.
3465 */
3566export function getGCSConfig ( ) : GCSConfig {
3667 const bucket = process . env . GCS_BUCKET ;
3768 const projectId = process . env . GCS_PROJECT_ID ;
3869 const clientEmail = process . env . GCS_CLIENT_EMAIL ;
39- const privateKey = process . env . GCS_PRIVATE_KEY ;
70+ let privateKey = process . env . GCS_PRIVATE_KEY ;
4071
4172 if ( ! bucket || ! projectId || ! clientEmail || ! privateKey ) {
73+ const missing = [
74+ ! bucket && "GCS_BUCKET" ,
75+ ! projectId && "GCS_PROJECT_ID" ,
76+ ! clientEmail && "GCS_CLIENT_EMAIL" ,
77+ ! privateKey && "GCS_PRIVATE_KEY" ,
78+ ] . filter ( Boolean ) ;
4279 throw new Error (
43- " [GCS] Missing required environment variables. Need: GCS_BUCKET, GCS_PROJECT_ID, GCS_CLIENT_EMAIL, GCS_PRIVATE_KEY"
80+ ` [GCS] Missing required environment variables: ${ missing . join ( ", " ) } `
4481 ) ;
4582 }
4683
84+ // Convert literal \n to real newlines (common when env vars are set via
85+ // .env files or CI secret managers)
86+ privateKey = privateKey . replace ( / \\ n / g, "\n" ) ;
87+
4788 return { bucket, projectId, clientEmail, privateKey } ;
4889}
4990
91+ // ---------------------------------------------------------------------------
92+ // JWT helpers
93+ // ---------------------------------------------------------------------------
94+
95+ /**
96+ * Base64url-encode a Buffer or string (no padding).
97+ */
98+ function base64url ( input : Buffer | string ) : string {
99+ const buf = typeof input === "string" ? Buffer . from ( input , "utf8" ) : input ;
100+ return buf . toString ( "base64url" ) ;
101+ }
102+
103+ /**
104+ * Create a signed JWT for the Google OAuth2 token endpoint.
105+ *
106+ * @see https://developers.google.com/identity/protocols/oauth2/service-account#authorizingrequests
107+ */
108+ function createServiceAccountJWT ( config : GCSConfig ) : string {
109+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
110+ const expiry = now + 3600 ; // 1 hour
111+
112+ const header = {
113+ alg : "RS256" ,
114+ typ : "JWT" ,
115+ } ;
116+
117+ const payload = {
118+ iss : config . clientEmail ,
119+ scope : "https://www.googleapis.com/auth/devstorage.read_write" ,
120+ aud : "https://oauth2.googleapis.com/token" ,
121+ iat : now ,
122+ exp : expiry ,
123+ } ;
124+
125+ const encodedHeader = base64url ( JSON . stringify ( header ) ) ;
126+ const encodedPayload = base64url ( JSON . stringify ( payload ) ) ;
127+ const signingInput = `${ encodedHeader } .${ encodedPayload } ` ;
128+
129+ let signature : Buffer ;
130+ try {
131+ const signer = crypto . createSign ( "RSA-SHA256" ) ;
132+ signer . update ( signingInput ) ;
133+ signer . end ( ) ;
134+ signature = signer . sign ( config . privateKey ) ;
135+ } catch ( err ) {
136+ throw new Error (
137+ `[GCS] Failed to sign JWT — check that GCS_PRIVATE_KEY is a valid PEM RSA private key. ` +
138+ `Original error: ${ err instanceof Error ? err . message : String ( err ) } `
139+ ) ;
140+ }
141+
142+ const encodedSignature = base64url ( signature ) ;
143+ return `${ signingInput } .${ encodedSignature } ` ;
144+ }
145+
146+ // ---------------------------------------------------------------------------
147+ // OAuth2 token exchange
148+ // ---------------------------------------------------------------------------
149+
150+ /**
151+ * Exchange a signed JWT for a Google OAuth2 access token.
152+ */
153+ async function exchangeJWTForToken ( jwt : string ) : Promise < CachedToken > {
154+ const body = new URLSearchParams ( {
155+ grant_type : "urn:ietf:params:oauth:grant-type:jwt-bearer" ,
156+ assertion : jwt ,
157+ } ) ;
158+
159+ let response : Response ;
160+ try {
161+ response = await fetch ( "https://oauth2.googleapis.com/token" , {
162+ method : "POST" ,
163+ headers : { "Content-Type" : "application/x-www-form-urlencoded" } ,
164+ body : body . toString ( ) ,
165+ } ) ;
166+ } catch ( err ) {
167+ throw new Error (
168+ `[GCS] Network error exchanging JWT for access token: ${
169+ err instanceof Error ? err . message : String ( err )
170+ } `
171+ ) ;
172+ }
173+
174+ if ( ! response . ok ) {
175+ const text = await response . text ( ) ;
176+ throw new Error (
177+ `[GCS] Token exchange failed (HTTP ${ response . status } ): ${ text } `
178+ ) ;
179+ }
180+
181+ const data = ( await response . json ( ) ) as {
182+ access_token : string ;
183+ expires_in : number ;
184+ token_type : string ;
185+ } ;
186+
187+ if ( ! data . access_token ) {
188+ throw new Error (
189+ `[GCS] Token exchange returned no access_token: ${ JSON . stringify ( data ) } `
190+ ) ;
191+ }
192+
193+ return {
194+ accessToken : data . access_token ,
195+ expiresAt : Date . now ( ) + data . expires_in * 1000 ,
196+ } ;
197+ }
198+
199+ /**
200+ * Get a valid access token, using the cache when possible.
201+ * Automatically refreshes when within 5 minutes of expiry.
202+ */
203+ async function getAccessToken ( ) : Promise < string > {
204+ if ( cachedToken && cachedToken . expiresAt - Date . now ( ) > TOKEN_REFRESH_MARGIN_MS ) {
205+ return cachedToken . accessToken ;
206+ }
207+
208+ const config = getGCSConfig ( ) ;
209+ const jwt = createServiceAccountJWT ( config ) ;
210+ cachedToken = await exchangeJWTForToken ( jwt ) ;
211+ return cachedToken . accessToken ;
212+ }
213+
214+ // ---------------------------------------------------------------------------
215+ // Upload
216+ // ---------------------------------------------------------------------------
217+
50218/**
51219 * Upload a buffer to Google Cloud Storage.
52220 *
53- * Uses the GCS JSON API with a service account JWT for authentication .
54- * Once GCS credentials are provided, this will be fully implemented .
221+ * Uses the GCS JSON API with `uploadType=media` (simple upload) .
222+ * Objects are made publicly readable via `predefinedAcl=publicRead` .
55223 *
56224 * @param buffer - File content as a Buffer
57225 * @param path - Destination path within the bucket (e.g., "audio/video-123.mp3")
@@ -64,16 +232,55 @@ export async function uploadToGCS(
64232 contentType : string
65233) : Promise < UploadResult > {
66234 const config = getGCSConfig ( ) ;
235+ const token = await getAccessToken ( ) ;
236+
237+ const encodedPath = encodeURIComponent ( path ) ;
238+ const uploadUrl =
239+ `https://storage.googleapis.com/upload/storage/v1/b/${ config . bucket } /o` +
240+ `?uploadType=media&name=${ encodedPath } &predefinedAcl=publicRead` ;
241+
242+ let response : Response ;
243+ try {
244+ response = await fetch ( uploadUrl , {
245+ method : "POST" ,
246+ headers : {
247+ "Content-Type" : contentType ,
248+ Authorization : `Bearer ${ token } ` ,
249+ "Content-Length" : String ( buffer . length ) ,
250+ } ,
251+ body : buffer as unknown as BodyInit ,
252+ } ) ;
253+ } catch ( err ) {
254+ throw new Error (
255+ `[GCS] Network error uploading to gs://${ config . bucket } /${ path } : ${
256+ err instanceof Error ? err . message : String ( err )
257+ } `
258+ ) ;
259+ }
260+
261+ if ( ! response . ok ) {
262+ const text = await response . text ( ) ;
263+ throw new Error (
264+ `[GCS] Upload failed (HTTP ${ response . status } ) for gs://${ config . bucket } /${ path } : ${ text } `
265+ ) ;
266+ }
267+
268+ const result = ( await response . json ( ) ) as { name : string ; size : string } ;
67269
68- // TODO: Implement JWT signing for service account auth
69- // TODO: Upload via GCS JSON API
70- // For now, throw a clear error indicating credentials are needed
71- throw new Error (
72- `[GCS] Upload not yet implemented — awaiting GCS credentials. ` +
73- `Would upload ${ buffer . length } bytes to gs://${ config . bucket } /${ path } `
74- ) ;
270+ const publicUrl = `https://storage.googleapis.com/${ config . bucket } /${ path } ` ;
271+
272+ return {
273+ url : publicUrl ,
274+ gcsPath : `${ config . bucket } /${ result . name ?? path } ` ,
275+ contentType,
276+ size : buffer . length ,
277+ } ;
75278}
76279
280+ // ---------------------------------------------------------------------------
281+ // Convenience uploaders
282+ // ---------------------------------------------------------------------------
283+
77284/**
78285 * Upload an audio file (MP3) to GCS.
79286 *
@@ -106,23 +313,35 @@ export async function uploadVideo(
106313 return uploadToGCS ( buffer , path , "video/mp4" ) ;
107314}
108315
316+ // ---------------------------------------------------------------------------
317+ // Signed / public URL
318+ // ---------------------------------------------------------------------------
319+
109320/**
110- * Generate a signed URL for temporary access to a GCS object.
111- * Useful for giving Remotion Lambda access to audio files.
321+ * Get a URL for accessing a GCS object.
322+ *
323+ * Since uploaded objects use `predefinedAcl=publicRead`, they are publicly
324+ * accessible. This function returns the public URL directly rather than
325+ * generating a V4 signed URL. The `expiresInMinutes` parameter is accepted
326+ * for interface compatibility but is effectively ignored — the public URL
327+ * does not expire.
112328 *
113- * @param path - GCS object path
114- * @param expiresInMinutes - URL expiry time in minutes (default: 60)
115- * @returns Signed URL string
329+ * If you later need true signed URLs (e.g., for private objects), replace
330+ * this with V4 signed URL generation using the service account private key.
331+ *
332+ * @param path - GCS object path (e.g., "audio/video-123.mp3")
333+ * @param expiresInMinutes - Ignored for public objects (kept for API compat)
334+ * @returns Public URL string
116335 */
117336export async function getSignedUrl (
118337 path : string ,
119338 expiresInMinutes = 60
120339) : Promise < string > {
340+ // We still validate config to fail fast if env vars are missing
121341 const config = getGCSConfig ( ) ;
122342
123- // TODO: Implement signed URL generation with JWT
124- throw new Error (
125- `[GCS] Signed URL generation not yet implemented — awaiting GCS credentials. ` +
126- `Would generate URL for gs://${ config . bucket } /${ path } (expires in ${ expiresInMinutes } m)`
127- ) ;
343+ // For public objects, the public URL is sufficient
344+ void expiresInMinutes ; // acknowledged but unused for public objects
345+
346+ return `https://storage.googleapis.com/${ config . bucket } /${ path } ` ;
128347}
0 commit comments