Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/app-portal/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
AIRTABLE_TOKEN_ID=
SCHEDULE_BASE_ID=
MENTOR_BASE_ID=
BEEHIIV_API_KEY=
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=
3 changes: 2 additions & 1 deletion apps/app-portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
23 changes: 18 additions & 5 deletions apps/app-portal/src/app/api/v1/uploads/sign/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
51 changes: 41 additions & 10 deletions apps/app-portal/src/components/uploads/FileUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<void>((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(
Expand All @@ -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;
Expand Down
30 changes: 24 additions & 6 deletions apps/app-portal/src/lib/uploads/gcs.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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();
29 changes: 28 additions & 1 deletion apps/app-portal/src/lib/uploads/service.ts
Original file line number Diff line number Diff line change
@@ -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()

Expand Down
23 changes: 21 additions & 2 deletions apps/app-portal/src/lib/uploads/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};
}
49 changes: 31 additions & 18 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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==

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down