diff --git a/apps/app-portal/.env.example b/apps/app-portal/.env.example index be8ac3d1..875492fb 100644 --- a/apps/app-portal/.env.example +++ b/apps/app-portal/.env.example @@ -1,4 +1,10 @@ AIRTABLE_TOKEN_ID= SCHEDULE_BASE_ID= MENTOR_BASE_ID= -BEEHIIV_API_KEY= \ No newline at end of file +BEEHIIV_API_KEY= + +GOOGLE_CLOUD_PROJECT_ID= +GOOGLE_CLOUD_STORAGE_RESUME_BUCKET= +GOOGLE_CLOUD_STORAGE_RESUME_BUCKET_TEST= +GOOGLE_CLOUD_PRIVATE_KEY= +GOOGLE_CLOUD_EMAIL= \ No newline at end of file diff --git a/apps/app-portal/package.json b/apps/app-portal/package.json index acfc2db0..9856042a 100644 --- a/apps/app-portal/package.json +++ b/apps/app-portal/package.json @@ -12,7 +12,7 @@ "setup-indexes": "node --env-file=.env --import tsx scripts/setup-indexes.ts" }, "dependencies": { - "@google-cloud/storage": "^7.19.0", + "@google-cloud/storage": "^7.21.0", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -34,6 +34,7 @@ "react-dropzone": "^15.0.0", "react-hook-form": "^7.75.0", "recharts": "3.8.0", + "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "zod": "^4.4.3" diff --git a/apps/app-portal/src/app/api/v1/uploads/sign/route.ts b/apps/app-portal/src/app/api/v1/uploads/sign/route.ts index a87a0798..abf1357b 100644 --- a/apps/app-portal/src/app/api/v1/uploads/sign/route.ts +++ b/apps/app-portal/src/app/api/v1/uploads/sign/route.ts @@ -1,11 +1,24 @@ // POST --> returns signed upload URL for GCS; stub returns 501 +import { createSignedUploadUrl, InvalidUploadError } from "@/lib/uploads/service"; import { NextResponse } from "next/server"; export async function POST(request: Request) { - await request.json(); - return NextResponse.json({ - uploadId: "mock-upload-id-67", - url: "https://mock.gcs.example.com/fake-signed-url", - }); + // TODO: gate with requireUser() once Ticket 1 ships its helpers + + const userId = "mock-user-id"; // placeholder until auth lands + + const { filename, mime, size } = await request.json(); + + try { + const { uploadId, uploadUrl, expiresAt } = await createSignedUploadUrl({ + userId, filename, mime, size, + }); + return NextResponse.json({ uploadId, uploadUrl: uploadUrl, expiresAt }); + } catch (err) { + if (err instanceof InvalidUploadError) { + return NextResponse.json({ error: err.message }, { status: 400 }); + } + return NextResponse.json({ error: "Unexpected error" }, { status: 500 }); + } } diff --git a/apps/app-portal/src/components/uploads/FileUpload.tsx b/apps/app-portal/src/components/uploads/FileUpload.tsx index b321a3c5..13238840 100644 --- a/apps/app-portal/src/components/uploads/FileUpload.tsx +++ b/apps/app-portal/src/components/uploads/FileUpload.tsx @@ -44,13 +44,14 @@ export default function FileUpload({ method: "POST", body: JSON.stringify({ filename: sanitizeFileName(firstFile.name), - mimeType: firstFile.type, + mime: firstFile.type, + size: firstFile.size, }), headers: { "Content-Type": "application/json" }, }); - const { uploadId, url } = await res.json(); - if (isMockUrl(url)) { + const { uploadId, uploadUrl } = await res.json(); + if (isMockUrl(uploadUrl)) { // fake response const INTERVAL_MS = 200; const CHUNKS = 20; @@ -72,18 +73,48 @@ export default function FileUpload({ setIsUploading(true); setTotalBytes(firstFile.size); } else { - // TODO: PUT to GCS + setIsUploading(true); + setTotalBytes(firstFile.size); + + try { + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", uploadUrl); + xhr.setRequestHeader("Content-Type", firstFile.type); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) setTransferredBytes(e.loaded); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + }; + + xhr.onerror = () => reject(new Error("Upload failed")); + + xhr.send(firstFile); + }); + + setTransferredBytes(firstFile.size); + setIsUploading(false); + setUploadedFile(firstFile); + onUploadComplete?.(uploadId, firstFile.name); + } catch { + setIsUploading(false); + // error to user here + return; + } } }, [onUploadComplete], ); function isMockUrl(url: string): boolean { - if (url.includes("mock") || url.includes("localhost")) { - return true; - } else { - return false; - } + return !!url && !url.startsWith("https://storage.googleapis.com"); } const accept = Object.fromEntries( @@ -101,7 +132,7 @@ export default function FileUpload({ const errorMessages = fileRejections.flatMap(({ errors }) => errors.map((e) => { - if (e.code === "file-too-large") return "File exceeds 10 MB limit."; + if (e.code === "file-too-large") return "File exceeds 5 MB limit."; if (e.code === "file-invalid-type") return "Only PDF, PNG, and JPEG files are accepted."; return e.message; diff --git a/apps/app-portal/src/lib/uploads/gcs.ts b/apps/app-portal/src/lib/uploads/gcs.ts index ba302f2e..393eeaa2 100644 --- a/apps/app-portal/src/lib/uploads/gcs.ts +++ b/apps/app-portal/src/lib/uploads/gcs.ts @@ -1,15 +1,26 @@ // GCS client singleton; bucket config - +import "server-only"; import { Storage, Bucket } from "@google-cloud/storage"; class GCSClient { private static instance: GCSClient; private storage: Storage; - private bucket: Bucket; private constructor() { - this.storage = new Storage(); - this.bucket = this.storage.bucket(""); + + const credentials = { + client_email: process.env.GOOGLE_CLOUD_EMAIL, + private_key: process.env.GOOGLE_CLOUD_PRIVATE_KEY?.replace(/\\n/g, "\n"), + } + + if (!process.env.GOOGLE_CLOUD_PROJECT_ID) { + throw new Error("Missing GCS env vars"); + } + + this.storage = new Storage({ + projectId: process.env.GOOGLE_CLOUD_PROJECT_ID, + credentials + }); } public static getInstance(): GCSClient { @@ -20,8 +31,15 @@ class GCSClient { } public getBucket(): Bucket { - return this.bucket; + if (process.env.NODE_ENV === 'production') { + return this.storage.bucket( + process.env.GOOGLE_CLOUD_STORAGE_RESUME_BUCKET! + ) + } + return this.storage.bucket( + process.env.GOOGLE_CLOUD_STORAGE_RESUME_BUCKET_TEST! + ); } } -export const gcsBucket = GCSClient.getInstance().getBucket(); +export const gcsBucket = () => GCSClient.getInstance().getBucket(); diff --git a/apps/app-portal/src/lib/uploads/service.ts b/apps/app-portal/src/lib/uploads/service.ts index 821f3c25..d382539a 100644 --- a/apps/app-portal/src/lib/uploads/service.ts +++ b/apps/app-portal/src/lib/uploads/service.ts @@ -1,6 +1,33 @@ // createSignedUploadUrl() -export function createSignedUploadUrl(): void {} +import { sanitizeFileName, validateUploadRequest } from "./validation"; +import { gcsBucket } from "./gcs"; + + +export class InvalidUploadError extends Error {} + +export async function createSignedUploadUrl({ userId, filename, mime, size } : { userId: string; filename: string; mime: string; size: number }): Promise<{ uploadUrl: string, uploadId: string, expiresAt: Date }> { + const result = validateUploadRequest({mime, size, filename}); + if (!result.ok) { + throw new InvalidUploadError(result.error); + } + + const uploadId = crypto.randomUUID(); + const cleanFileName = sanitizeFileName(filename); + + const path = `uploads/${userId}/${uploadId}/${cleanFileName}`; + + const expireDate = Date.now() + 15 * 60 * 1000; + + const [uploadUrl] = await gcsBucket().file(path).getSignedUrl({ + version: "v4", + action: "write", + expires: expireDate, + contentType: mime, + }) + + return { uploadUrl, uploadId, expiresAt: new Date(expireDate)} +} // createSignedDownloadUrl() diff --git a/apps/app-portal/src/lib/uploads/validation.ts b/apps/app-portal/src/lib/uploads/validation.ts index 581c6c6a..ccee8361 100644 --- a/apps/app-portal/src/lib/uploads/validation.ts +++ b/apps/app-portal/src/lib/uploads/validation.ts @@ -6,8 +6,27 @@ export const ALLOWED_MIME_TYPES = [ "image/jpeg", ] as const; -export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; +export const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; export function sanitizeFileName(filename: string): string { - return filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + let cleanName = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + cleanName = cleanName.replace(/^\.+/, ""); + cleanName = cleanName.slice(0, 255); + return cleanName || "unnamed"; +} + +export function validateUploadRequest({ mime, size, filename} : {mime: string; size: number; filename: string}) : {ok: true} | {ok: false, error: string} { + if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(mime)) { + return {ok: false, error: "Incorrect mime type"}; + } + if (size <= 0) { + return {ok: false, error: "File is empty"}; + } + if (size > MAX_FILE_SIZE_BYTES) { + return {ok: false, error: "Size of file is too large (over 5 MB)"}; + } + if (!filename || filename.trim().length === 0) { + return {ok: false, error: "Filename is required"} + } + return {ok: true}; } diff --git a/yarn.lock b/yarn.lock index 238fefc1..9d8ccf46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -332,10 +332,10 @@ resolved "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz" integrity sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g== -"@google-cloud/storage@^7.19.0": - version "7.19.0" - resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz" - integrity sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ== +"@google-cloud/storage@^7.21.0": + version "7.21.0" + resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-7.21.0.tgz#46b89afbdc3014e18b60afdfba5d9212a23e7b58" + integrity sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA== dependencies: "@google-cloud/paginator" "^5.0.0" "@google-cloud/projectify" "^4.0.0" @@ -351,7 +351,6 @@ p-limit "^3.0.1" retry-request "^7.0.0" teeny-request "^9.0.0" - uuid "^8.0.0" "@hookform/resolvers@^5.2.2": version "5.2.2" @@ -4294,6 +4293,11 @@ semver@^7.3.7, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3: resolved "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz" integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== +server-only@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/server-only/-/server-only-0.0.1.tgz#0f366bb6afb618c37c9255a314535dc412cd1c9e" + integrity sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA== + set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz" @@ -4993,11 +4997,6 @@ uuid@^14.0.0: resolved "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz" integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg== -uuid@^8.0.0: - version "8.3.2" - resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - uuid@^9.0.0, uuid@^9.0.1: version "9.0.1" resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz"