You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
POST /api/v1/uploads/sign returns a real signed GCS URL. The frontend <FileUpload /> component can now PUT files directly to GCS.
Tasks
Verify the existing GCS setup
Confirm all five env vars are populated in your local .env: GOOGLE_CLOUD_PROJECT_ID, GOOGLE_CLOUD_STORAGE_RESUME_BUCKET, GOOGLE_CLOUD_STORAGE_RESUME_BUCKET_TEST, GOOGLE_CLOUD_PRIVATE_KEY, GOOGLE_CLOUD_EMAIL.
What this accomplishes: Confirms the credentials are present before writing any code that depends on them.
Confirm they're all listed in .env.example (without values).
What this accomplishes: New devs setting up the project know which vars to fill in.
Run a one-off script that creates a Storage client from the env vars and calls bucket.exists() on the test bucket. Should return [true].
What this accomplishes: Catches credential or config errors at the simplest possible layer, before integrating into the app.
Implement the GCS client
In src/lib/uploads/gcs.ts, install @google-cloud/storage and create a singleton Storage client using GOOGLE_CLOUD_PROJECT_ID, GOOGLE_CLOUD_EMAIL, and GOOGLE_CLOUD_PRIVATE_KEY.
What this accomplishes: One shared client per app instance instead of creating a new one per request. Standard Node best practice.
Export a getBucket() function that returns the test bucket when NODE_ENV !== 'production' and the prod bucket otherwise. Use GOOGLE_CLOUD_STORAGE_RESUME_BUCKET_TEST for the former and GOOGLE_CLOUD_STORAGE_RESUME_BUCKET for the latter.
What this accomplishes: Local development and CI never write to the production bucket, even by accident. The switch is automatic so devs don't have to think about it.
Implement validation
In src/lib/uploads/validation.ts, export ALLOWED_MIME_TYPES as a const array: ['application/pdf', 'image/png', 'image/jpeg'].
What this accomplishes: Single source of truth for what files are accepted. Frontend can import the same list to validate client-side.
Export MAX_FILE_SIZE_BYTES as 5 * 1024 * 1024 (5 MB).
Write validateUploadRequest({ mime, size, filename }): { ok: true } | { ok: false, error: string }. Check MIME is in the allowed list, size is positive and under the cap, and filename is non-empty.
What this accomplishes: Centralized validation logic that returns either success or a specific error message.
Write sanitizeFilename(filename): string that removes path separators (/, \), null bytes, leading dots, and limits length to 255 chars.
What this accomplishes: Prevents path traversal attacks (filenames like ../../etc/passwd) and other filename-based exploits.
What this accomplishes: The single function the API layer calls to get a signed URL. Encapsulates GCS specifics so the rest of the app doesn't need to know.
Validate inputs using validateUploadRequest. If invalid, throw a typed error the API can catch and convert to 400.
Generate a UUID for uploadId (use crypto.randomUUID()).
Sanitize the filename.
Construct the GCS object path: uploads/${userId}/${uploadId}/${sanitizedFilename}.
What this accomplishes: Files are organized predictably. All of one user's files live under one prefix, which makes per-user cleanup or export easy later.
Call bucket.file(path).getSignedUrl({ version: 'v4', action: 'write', expires: Date.now() + 15 * 60 * 1000, contentType: mime }) to get the signed URL.
What this accomplishes: V4 signed URLs are the current standard. 15-minute expiry is long enough for a normal upload but short enough to limit abuse if a URL leaks. Including contentType means the PUT must match — extra protection against MIME-substitution attacks.
Update src/app/api/v1/uploads/sign/route.ts. Implement POST: parse the request body for { filename, mime, size }, read userId from query string or header (placeholder), call createSignedUploadUrl, return the response.
What this accomplishes: The endpoint the frontend already calls now returns real signed URLs.
On validation errors, return 400 with the error message. On unexpected errors, log and return 500.
Add a comment // TODO: gate with requireUser() once Ticket 1 ships its helpers.
End-to-end test via curl
Hit the endpoint with curl: curl -X POST localhost:3000/api/v1/uploads/sign?userId=test -d '{"filename":"resume.pdf","mime":"application/pdf","size":12345}'. Confirm the response contains a uploadUrl, uploadId, and expiresAt.
What this accomplishes: Proves the endpoint works without involving the frontend.
PUT a real file to the returned uploadUrl: curl -X PUT -H "Content-Type: application/pdf" --data-binary @somefile.pdf "<uploadUrl>". Confirm a 200 response.
What this accomplishes: Proves the signed URL actually works against GCS. If this step fails, the GCS setup is wrong.
Open the GCS console and verify the file is in the bucket at the expected path.
Test the validation paths
Hit the endpoint with a bad MIME type (e.g., "mime":"application/x-executable"). Confirm 400 with a clear error message.
Hit with an oversize file ("size": 10000000). Confirm 400.
Hit with a malicious filename ("filename":"../../etc/passwd"). Confirm the sanitizer strips the path components and the request succeeds with a safe filename.
Test from the frontend
Run the dev server, go to /uploads-demo (from the frontend sprint), drop a PDF, watch the network tab.
What this accomplishes: Proves the full pipeline (frontend dropzone → sign endpoint → PUT to GCS) works end-to-end as a real user would experience it.
Definition of done
A PDF dropped on /uploads-demo actually appears in the GCS bucket within seconds.
All three validation tests (bad MIME, oversize, malicious filename) behave correctly.
Goal
POST /api/v1/uploads/signreturns a real signed GCS URL. The frontend<FileUpload />component can now PUT files directly to GCS.Tasks
Verify the existing GCS setup
.env:GOOGLE_CLOUD_PROJECT_ID,GOOGLE_CLOUD_STORAGE_RESUME_BUCKET,GOOGLE_CLOUD_STORAGE_RESUME_BUCKET_TEST,GOOGLE_CLOUD_PRIVATE_KEY,GOOGLE_CLOUD_EMAIL..env.example(without values).Storageclient from the env vars and callsbucket.exists()on the test bucket. Should return[true].Implement the GCS client
src/lib/uploads/gcs.ts, install@google-cloud/storageand create a singletonStorageclient usingGOOGLE_CLOUD_PROJECT_ID,GOOGLE_CLOUD_EMAIL, andGOOGLE_CLOUD_PRIVATE_KEY.getBucket()function that returns the test bucket whenNODE_ENV !== 'production'and the prod bucket otherwise. UseGOOGLE_CLOUD_STORAGE_RESUME_BUCKET_TESTfor the former andGOOGLE_CLOUD_STORAGE_RESUME_BUCKETfor the latter.Implement validation
src/lib/uploads/validation.ts, exportALLOWED_MIME_TYPESas a const array:['application/pdf', 'image/png', 'image/jpeg'].MAX_FILE_SIZE_BYTESas 5 * 1024 * 1024 (5 MB).validateUploadRequest({ mime, size, filename }): { ok: true } | { ok: false, error: string }. Check MIME is in the allowed list, size is positive and under the cap, and filename is non-empty.sanitizeFilename(filename): stringthat removes path separators (/,\), null bytes, leading dots, and limits length to 255 chars.../../etc/passwd) and other filename-based exploits.Implement
createSignedUploadUrlsrc/lib/uploads/service.ts, writecreateSignedUploadUrl({ userId, filename, mime, size }): Promise<{ uploadUrl, uploadId, expiresAt }>.validateUploadRequest. If invalid, throw a typed error the API can catch and convert to 400.uploadId(usecrypto.randomUUID()).uploads/${userId}/${uploadId}/${sanitizedFilename}.bucket.file(path).getSignedUrl({ version: 'v4', action: 'write', expires: Date.now() + 15 * 60 * 1000, contentType: mime })to get the signed URL.contentTypemeans the PUT must match — extra protection against MIME-substitution attacks.{ uploadUrl, uploadId, expiresAt: new Date(Date.now() + 15 * 60 * 1000) }.Wire the API endpoint
src/app/api/v1/uploads/sign/route.ts. ImplementPOST: parse the request body for{ filename, mime, size }, readuserIdfrom query string or header (placeholder), callcreateSignedUploadUrl, return the response.// TODO: gate with requireUser() once Ticket 1 ships its helpers.End-to-end test via curl
curl -X POST localhost:3000/api/v1/uploads/sign?userId=test -d '{"filename":"resume.pdf","mime":"application/pdf","size":12345}'. Confirm the response contains auploadUrl,uploadId, andexpiresAt.uploadUrl:curl -X PUT -H "Content-Type: application/pdf" --data-binary @somefile.pdf "<uploadUrl>". Confirm a 200 response.Test the validation paths
"mime":"application/x-executable"). Confirm 400 with a clear error message."size": 10000000). Confirm 400."filename":"../../etc/passwd"). Confirm the sanitizer strips the path components and the request succeeds with a safe filename.Test from the frontend
/uploads-demo(from the frontend sprint), drop a PDF, watch the network tab.Definition of done
/uploads-demoactually appears in the GCS bucket within seconds..env.exampledocuments all four GCS env vars.