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 c10898e1..7c92b2d7 100644 --- a/apps/app-portal/package.json +++ b/apps/app-portal/package.json @@ -10,7 +10,7 @@ "type-check": "tsc --noEmit" }, "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", @@ -32,6 +32,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 afa1b5ec..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" @@ -485,7 +484,6 @@ "@next/swc-linux-arm64-musl@14.2.33": version "14.2.33" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz#dc8903469e5c887b25e3c2217a048bd30c58d3d4" resolved "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz" integrity sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg== @@ -798,13 +796,6 @@ "@radix-ui/react-use-previous" "1.1.1" "@radix-ui/react-use-size" "1.1.1" -"@radix-ui/react-slot@1.2.4", "@radix-ui/react-slot@^1.2.4": - version "1.2.4" - resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz" - integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA== - dependencies: - "@radix-ui/react-compose-refs" "1.1.2" - "@radix-ui/react-use-callback-ref@1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz" @@ -1884,6 +1875,11 @@ data-view-byte-offset@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" +date-fns@^4.1.0, date-fns@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.4.0.tgz#806539edf45c616b2b76b5f78b88c56ed3c7e036" + integrity sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w== + debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.7: version "4.4.0" resolved "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz" @@ -3986,6 +3982,14 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +react-day-picker@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-10.0.1.tgz#8498e3f0ace2e9d03c209a5458c451a377c2d85c" + integrity sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w== + dependencies: + "@date-fns/tz" "^1.4.1" + date-fns "^4.1.0" + react-dom@^18.2.0: version "18.3.1" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz" @@ -3994,6 +3998,15 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.2" +react-dropzone@^15.0.0: + version "15.0.0" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-15.0.0.tgz#bd03c7c2b14fe4ea9db1a9c74502b85339f2e505" + integrity sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg== + dependencies: + attr-accept "^2.2.4" + file-selector "^2.1.0" + prop-types "^15.8.1" + react-hook-form@^7.75.0: version "7.76.0" resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.0.tgz" @@ -4280,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" @@ -4979,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"