diff --git a/client/src/module/recruiter/profile/RecruiterProfilePage.tsx b/client/src/module/recruiter/profile/RecruiterProfilePage.tsx index 147689cc..ac35f3dd 100644 --- a/client/src/module/recruiter/profile/RecruiterProfilePage.tsx +++ b/client/src/module/recruiter/profile/RecruiterProfilePage.tsx @@ -15,6 +15,7 @@ import { AlignLeft, } from "lucide-react"; import api from "../../../lib/axios"; +import { uploadDirectToS3 } from "../../../utils/upload"; import { useAuthStore } from "../../../lib/auth.store"; import { LoadingScreen } from "../../../components/LoadingScreen"; import toast from "@/components/ui/toast"; @@ -126,16 +127,15 @@ export default function RecruiterProfilePage() { setCropSrc(null); setUploadingPic(true); try { - const fd = new FormData(); - fd.append("file", blob, "cropped.jpg"); - const res = await api.post("/upload/profile-pic", fd, { - headers: { "Content-Type": "multipart/form-data" }, + const file = new File([blob], "cropped.jpg", { type: blob.type || "image/jpeg" }); + const res = await uploadDirectToS3({ + file, + folder: "profile-pics", + endpoint: "/profile-pic", }); - - const u = res.data.user || res.data; - - // Map path properly if relative string path is returned by localhost server - let imagePath = u.profilePic ?? ""; + + const u = res.user || res; + let imagePath = u.profilePic || u.fileUrl || u.url || ""; if (imagePath && !imagePath.startsWith("http")) { imagePath = `${api.defaults.baseURL?.replace("/api", "") || "http://localhost:3000"}/${imagePath.replace(/^\//, "")}`; } diff --git a/client/src/module/student/applications/ApplyPage.tsx b/client/src/module/student/applications/ApplyPage.tsx index 61ac51e8..94bf7f4a 100644 --- a/client/src/module/student/applications/ApplyPage.tsx +++ b/client/src/module/student/applications/ApplyPage.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, FileText, ExternalLink, Check, MapPin, IndianRupee, Clock, S import { Navbar } from "../../../components/Navbar"; import { DynamicFieldRenderer } from "../../../components/DynamicFieldRenderer"; import api from "../../../lib/axios"; +import { uploadDirectToS3 } from "../../../utils/upload"; import { queryKeys } from "../../../lib/query-keys"; import type { Job, CustomFieldDefinition, User } from "../../../lib/types"; import { LoadingScreen } from "../../../components/LoadingScreen"; @@ -56,12 +57,12 @@ export default function ApplyPage() { try { const finalAnswers = { ...customFieldAnswers }; for (const [fieldId, file] of Object.entries(fileUploads)) { - const formData = new FormData(); - formData.append("file", file); - const uploadRes = await api.post("/upload/resume", formData, { - headers: { "Content-Type": "multipart/form-data" }, + const uploadRes = await uploadDirectToS3({ + file, + folder: "resumes", + endpoint: "/profile-resume", }); - finalAnswers[fieldId] = uploadRes.data.file.url; + finalAnswers[fieldId] = uploadRes.file?.url || uploadRes.fileUrl || uploadRes.url || ""; } await api.post(`/student/jobs/${actualJobId}/apply`, { diff --git a/client/src/module/student/ats/AtsScorePage.tsx b/client/src/module/student/ats/AtsScorePage.tsx index b01a62b8..7432ccfc 100644 --- a/client/src/module/student/ats/AtsScorePage.tsx +++ b/client/src/module/student/ats/AtsScorePage.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router"; import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; import toast from "@/components/ui/toast"; import { motion, AnimatePresence } from "framer-motion"; +import { uploadDirectToS3 } from "../../../utils/upload"; import { Upload, FileText, @@ -282,12 +283,12 @@ export default function AtsScorePage() { }> => { let url = resumeUrl; if (file) { - const formData = new FormData(); - formData.append("file", file); - const uploadRes = await api.post("/upload/profile-resume", formData, { - headers: { "Content-Type": "multipart/form-data" }, + const uploadRes = await uploadDirectToS3({ + file, + folder: "resumes", + endpoint: "/profile-resume", }); - url = uploadRes.data.file.url; + url = uploadRes.file?.url || uploadRes.fileUrl || uploadRes.url || url; setResumeUrl(url); } if (!url) throw new Error("Please upload a resume PDF first."); diff --git a/client/src/module/student/companies/AddCompanyPage.tsx b/client/src/module/student/companies/AddCompanyPage.tsx index 04fdc25b..12835c42 100644 --- a/client/src/module/student/companies/AddCompanyPage.tsx +++ b/client/src/module/student/companies/AddCompanyPage.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useNavigate } from "react-router"; import { Building2, Loader2, Plus, X } from "lucide-react"; import toast from "@/components/ui/toast"; +import { uploadDirectToS3 } from "@/utils/upload"; import api from "../../../lib/axios"; import { Button } from "../../../components/ui/button"; @@ -51,12 +52,11 @@ export default function AddCompanyPage() { let logoUrl: string | undefined; if (logoFile) { - const formData = new FormData(); - formData.append("file", logoFile); - const uploadRes = await api.post("/upload/resume", formData, { - headers: { "Content-Type": "multipart/form-data" }, + const uploadRes = await uploadDirectToS3({ + file: logoFile, + folder: "company-logos", }); - logoUrl = uploadRes.data.file.url; + logoUrl = uploadRes.fileUrl; } const body: Record = { diff --git a/client/src/module/student/profile/StudentProfilePage.tsx b/client/src/module/student/profile/StudentProfilePage.tsx index 93b50ad3..83a90cf0 100644 --- a/client/src/module/student/profile/StudentProfilePage.tsx +++ b/client/src/module/student/profile/StudentProfilePage.tsx @@ -9,6 +9,7 @@ import { import { Link } from "react-router"; import type { VerifiedSkill, ProjectItem, AchievementItem } from "../../../lib/types"; import api from "../../../lib/axios"; +import { uploadDirectToS3 } from "../../../utils/upload"; import { useAuthStore } from "../../../lib/auth.store"; import { SEO } from "../../../components/SEO"; import { LoadingScreen } from "../../../components/LoadingScreen"; @@ -445,30 +446,31 @@ export default function StudentProfilePage() { const handleCropComplete = async (blob: Blob) => { const isProfile = cropType === "profile"; const setUploading = isProfile ? setUploadingPic : setUploadingCover; - const endpoint = isProfile ? "/upload/profile-pic" : "/upload/cover-image"; const field = isProfile ? "profilePic" : "coverImage"; setCropSrc(null); setCropType(null); setUploading(true); try { - const fd = new FormData(); - fd.append("file", blob, "cropped.jpg"); - const res = await api.post(endpoint, fd, { headers: { "Content-Type": "multipart/form-data" } }); - - // Extract the updated user or look for a direct secure_url/filePath returned by your API - const u = res.data.user || res.data; - - // Get the image path. If the backend returns a relative path, we map it to the backend server URL - let imagePath = u[field] ?? ""; + const file = new File([blob], "cropped.jpg", { type: blob.type || "image/jpeg" }); + const res = await uploadDirectToS3({ + file, + folder: isProfile ? "profile-pics" : "cover-images", + endpoint: isProfile ? "/profile-pic" : "/cover-image", + }); + + const u = res.user || res; + let imagePath = u[field] || u.fileUrl || u.url || ""; if (imagePath && !imagePath.startsWith("http")) { - // Fallback for local backend uploads: prepend server address if not an external cloud URL (like Cloudinary) imagePath = `${api.defaults.baseURL?.replace("/api", "") || "http://localhost:3000"}/${imagePath.replace(/^\//, "")}`; } - setForm((prev) => ({ ...prev, [field]: imagePath })); - syncUser({ ...form, [field]: imagePath }); - + setForm((prev) => { + const next = { ...prev, [field]: imagePath }; + syncUser(next); + return next; + }); + toast.success(isProfile ? "Profile picture updated!" : "Cover image updated!"); } catch (error) { console.error("Upload rendering error:", error); @@ -483,12 +485,18 @@ export default function StudentProfilePage() { if (!file) return; setUploadingResume(true); try { - const fd = new FormData(); - fd.append("file", file); - const res = await api.post("/upload/profile-resume", fd, { headers: { "Content-Type": "multipart/form-data" } }); - const u = res.data.user; - setForm((prev) => ({ ...prev, resumes: u.resumes ?? [] })); - syncUser({ ...form, resumes: u.resumes ?? [] }); + const res = await uploadDirectToS3({ + file, + folder: "resumes", + endpoint: "/profile-resume", + }); + const u = res.user || res; + const resumes = u.resumes ?? []; + setForm((prev) => { + const next = { ...prev, resumes }; + syncUser(next); + return next; + }); toast.success("Resume uploaded!"); } catch (err: unknown) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to upload resume"; diff --git a/client/src/utils/upload.ts b/client/src/utils/upload.ts new file mode 100644 index 00000000..e887eeab --- /dev/null +++ b/client/src/utils/upload.ts @@ -0,0 +1,68 @@ +interface UploadProfileParams { + file: File; + folder: 'resumes' | 'profile-pics' | 'cover-images' | 'company-logos'; + endpoint?: '/profile-resume' | '/profile-pic' | '/cover-image'; +} + +export const uploadDirectToS3 = async ({ file, folder, endpoint }: UploadProfileParams) => { + try { + const apiUrl = (import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:3000"; + + // Get the Pre-signed URL from your backend + const presignRes = await fetch(`${apiUrl}/api/upload/presigned-url`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fileName: file.name, + fileType: file.type, + folder: folder, + }), + }); + + if (!presignRes.ok) throw new Error('Failed to get upload URL'); + const { uploadUrl, fileUrl } = await presignRes.json(); + + // Upload the file DIRECTLY to AWS S3 + const s3UploadRes = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': file.type, + }, + body: file, + }); + + if (!s3UploadRes.ok) { + const bodyText = await s3UploadRes.text(); + throw new Error(`S3 upload failed: ${s3UploadRes.status} ${s3UploadRes.statusText} ${bodyText}`); + } + + if (endpoint) { + // Tell your backend to save the new file URL + const updateRes = await fetch(`${apiUrl}/api/upload${endpoint}`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fileUrl: fileUrl, + originalName: file.name, + size: file.size, + mimeType: file.type, + }), + }); + + if (!updateRes.ok) throw new Error('Failed to save file to profile'); + const result = await updateRes.json(); + return result; + } + + return { fileUrl }; + } catch (error) { + console.error('Upload Error:', error); + throw error; + } +}; \ No newline at end of file diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json index 8c66b1cd..9586a8ba 100644 --- a/client/tsconfig.app.json +++ b/client/tsconfig.app.json @@ -26,9 +26,8 @@ "noUncheckedSideEffectImports": true, /* Path aliases */ - "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["./src/*"] } }, "include": ["src"], diff --git a/server/src/middleware/upload.middleware.ts b/server/src/middleware/upload.middleware.ts deleted file mode 100644 index 39df624f..00000000 --- a/server/src/middleware/upload.middleware.ts +++ /dev/null @@ -1,74 +0,0 @@ -import multer from "multer"; -import os from "os"; -import path from "path"; - -// Use OS temp directory for disk storage - files are streamed to disk -// instead of being held entirely in memory, preventing OOM on concurrent uploads. -const storage = multer.diskStorage({ - destination: (_req, _file, cb) => { - cb(null, os.tmpdir()); - }, - filename: (_req, file, cb) => { - const ext = path.extname(file.originalname); - const safeName = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9_-]/g, "_"); - cb(null, `${safeName}-${Date.now()}-${Math.round(Math.random() * 1e7)}${ext}`); - }, -}); - -const resumeFilter = (_req: Express.Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => { - const allowed = [ - "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ]; - if (allowed.includes(file.mimetype)) { - cb(null, true); - } else { - cb(new Error("Only PDF and Word documents are allowed")); - } -}; - -const imageFilter = (_req: Express.Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => { - const allowed = ["image/jpeg", "image/png"]; - if (allowed.includes(file.mimetype)) { - cb(null, true); - } else { - cb(new Error("Only JPEG, PNG images are allowed")); - } -}; - -const anyFileFilter = (_req: Express.Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => { - const allowed = [ - "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "text/plain", - ]; - if (allowed.includes(file.mimetype)) { - cb(null, true); - } else { - cb(new Error("File type not allowed")); - } -}; - -export const uploadResume = multer({ - storage, - fileFilter: resumeFilter, - limits: { fileSize: 10 * 1024 * 1024 }, -}).single("file"); - -export const uploadImage = multer({ - storage, - fileFilter: imageFilter, - limits: { fileSize: 2 * 1024 * 1024 }, -}).single("file"); - -export const uploadSingle = multer({ - storage, - fileFilter: anyFileFilter, - limits: { fileSize: 10 * 1024 * 1024 }, -}).single("file"); diff --git a/server/src/module/upload/upload.controller.ts b/server/src/module/upload/upload.controller.ts index bf498422..bdd06e69 100644 --- a/server/src/module/upload/upload.controller.ts +++ b/server/src/module/upload/upload.controller.ts @@ -1,10 +1,9 @@ import type { Request, Response } from "express"; -import path from "path"; -import fs from "fs"; +import * as path from "path"; +import * as fs from "fs"; import { fileURLToPath } from "url"; -import { uploadToS3, deleteFromS3, getS3KeyFromUrl, signUrls } from "../../utils/s3.utils.js"; +import { createUniqueS3Key, deleteFromS3, getS3KeyFromUrl, signUrls, generatePresignedUploadUrl } from "../../utils/s3.utils.js"; import { prisma } from "../../database/db.js"; -import { validateOrReject } from "../../utils/file-validation.utils.js"; const MAX_RESUMES = 2; @@ -12,50 +11,17 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const UPLOADS_DIR = path.join(__dirname, "../../../uploads"); -function buildS3Key(folder: string, userId: number, originalName: string): string { - const ext = path.extname(originalName); - const safeName = path.basename(originalName, ext).replace(/[^a-zA-Z0-9_-]/g, "_"); - const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e7)}`; - return `${folder}/${userId}/${safeName}-${uniqueSuffix}${ext}`; +function getExpectedS3UrlPrefix(): string { + const bucketName = process.env.AWS_S3_BUCKET || process.env.AWS_BUCKET_NAME || ""; + const region = process.env.AWS_REGION || "ap-south-1"; + return `https://${bucketName}.s3.${region}.amazonaws.com/`; } -/** Save file locally as fallback when S3 is unavailable */ -function saveLocally(buffer: Buffer, folder: string, userId: number, originalName: string): string { - const ext = path.extname(originalName); - const safeName = path.basename(originalName, ext).replace(/[^a-zA-Z0-9_-]/g, "_"); - const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e7)}`; - const relDir = path.join(folder, String(userId)); - const absDir = path.join(UPLOADS_DIR, relDir); - - fs.mkdirSync(absDir, { recursive: true }); - - const filename = `${safeName}-${uniqueSuffix}${ext}`; - fs.writeFileSync(path.join(absDir, filename), buffer); - - return `/uploads/${relDir}/${filename}`.replace(/\\/g, "/"); -} - -/** Read file from disk (multer disk storage) and clean up temp file */ -function readAndCleanup(filePath: string): Buffer { - const buffer = fs.readFileSync(filePath); - fs.unlink(filePath, () => {}); - return buffer; -} - -/** Try S3 first, fall back to local storage */ -async function uploadWithFallback(buffer: Buffer, folder: string, userId: number, originalName: string, mimeType: string): Promise { - const key = buildS3Key(folder, userId, originalName); - try { - const url = await uploadToS3(buffer, key, mimeType); - return url; - } catch (err: unknown) { - const error = err as Error; - console.error("[S3] Upload failed, falling back to local:", error.message); - return saveLocally(buffer, folder, userId, originalName); - } +function isValidS3FileUrl(url: unknown): url is string { + return typeof url === "string" && url.startsWith(getExpectedS3UrlPrefix()); } -/** Delete a file - handles both S3 URLs and local paths */ +/** Delete a file - keeps local fallback support just in case users have legacy local files */ function deleteFile(url: string): void { const s3Key = getS3KeyFromUrl(url); if (s3Key) { @@ -67,66 +33,51 @@ function deleteFile(url: string): void { } export class UploadController { - /** Generic file upload - S3 with local fallback (used by ATS etc.) */ - async uploadFile(req: Request, res: Response) { + + /** * NEW: Generate Pre-signed URL for direct client-to-S3 uploads + */ + async getPresignedUrl(req: Request, res: Response) { try { - if (!req.file) { - return res.status(400).json({ message: "No file uploaded" }); - } - if (!req.user) { - return res.status(401).json({ message: "Authentication required" }); - } - - const userId = req.user.id; + if (!req.user) return res.status(401).json({ message: "Authentication required" }); - // Validate actual file content matches claimed MIME type - await validateOrReject( - req.file.path, - ["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "image/jpeg", "image/png", "image/webp", "text/plain"], - "File content does not match allowed file types", - ); + const { fileName, fileType, folder = "uploads" } = req.body; + if (!fileName || !fileType) { + return res.status(400).json({ message: "fileName and fileType are required" }); + } - const buffer = readAndCleanup(req.file.path); - const url = await uploadWithFallback(buffer, "uploads", userId, req.file.originalname, req.file.mimetype); + const fileKey = createUniqueS3Key(folder, String(req.user.id), fileName); + const uploadUrl = await generatePresignedUploadUrl(fileKey, fileType); + const bucketName = process.env.AWS_S3_BUCKET || process.env.AWS_BUCKET_NAME || ""; + const region = process.env.AWS_REGION || "ap-south-1"; + const fileUrl = `https://${bucketName}.s3.${region}.amazonaws.com/${fileKey}`; - return res.status(200).json({ - message: "File uploaded successfully", - file: { - url, - originalName: req.file.originalname, - size: req.file.size, - mimeType: req.file.mimetype, - }, - }); + return res.status(200).json({ uploadUrl, fileKey, fileUrl }); } catch (error) { console.error(error); return res.status(500).json({ message: "Internal Server Error" }); } } - /** Upload profile picture - S3 with local fallback */ + /** Save profile picture URL (Called AFTER frontend uploads to S3) */ async uploadProfilePic(req: Request, res: Response) { try { if (!req.user) return res.status(401).json({ message: "Authentication required" }); - if (!req.file) return res.status(400).json({ message: "No image uploaded" }); + + const { fileUrl } = req.body; + if (!isValidS3FileUrl(fileUrl)) { + return res.status(400).json({ message: "Invalid fileUrl origin" }); + } const userId = req.user.id; const current = await prisma.user.findUnique({ where: { id: userId }, select: { profilePic: true } }); - await validateOrReject(req.file.path, ["image/jpeg", "image/png", "image/webp"], "File content is not a valid image"); - - const buffer = readAndCleanup(req.file.path); - const url = await uploadWithFallback(buffer, "profile-pics", userId, req.file.originalname, req.file.mimetype); - const user = await prisma.user.update({ where: { id: userId }, - data: { profilePic: url }, + data: { profilePic: fileUrl }, select: { id: true, name: true, email: true, role: true, contactNo: true, profilePic: true, coverImage: true, resumes: true, company: true, designation: true, createdAt: true }, }); - if (current?.profilePic) { - deleteFile(current.profilePic); - } + if (current?.profilePic) deleteFile(current.profilePic); return res.status(200).json({ message: "Profile picture updated", user }); } catch (error) { @@ -135,29 +86,26 @@ export class UploadController { } } - /** Upload cover/banner image - S3 with local fallback */ + /** Save cover image URL (Called AFTER frontend uploads to S3) */ async uploadCoverImage(req: Request, res: Response) { try { if (!req.user) return res.status(401).json({ message: "Authentication required" }); - if (!req.file) return res.status(400).json({ message: "No image uploaded" }); + + const { fileUrl } = req.body; + if (!isValidS3FileUrl(fileUrl)) { + return res.status(400).json({ message: "Invalid fileUrl origin" }); + } const userId = req.user.id; const current = await prisma.user.findUnique({ where: { id: userId }, select: { coverImage: true } }); - await validateOrReject(req.file.path, ["image/jpeg", "image/png", "image/webp"], "File content is not a valid image"); - - const buffer = readAndCleanup(req.file.path); - const url = await uploadWithFallback(buffer, "cover-images", userId, req.file.originalname, req.file.mimetype); - const user = await prisma.user.update({ where: { id: userId }, - data: { coverImage: url }, + data: { coverImage: fileUrl }, select: { id: true, name: true, email: true, role: true, contactNo: true, profilePic: true, coverImage: true, resumes: true, company: true, designation: true, createdAt: true }, }); - if (current?.coverImage) { - deleteFile(current.coverImage); - } + if (current?.coverImage) deleteFile(current.coverImage); return res.status(200).json({ message: "Cover image updated", user }); } catch (error) { @@ -166,32 +114,26 @@ export class UploadController { } } - /** Upload resume - S3 with local fallback, max 2 per student */ + /** Save resume URL (Called AFTER frontend uploads to S3) */ async uploadProfileResume(req: Request, res: Response) { try { if (!req.user) return res.status(401).json({ message: "Authentication required" }); - if (!req.file) return res.status(400).json({ message: "No file uploaded" }); + + const { fileUrl, originalName, size, mimeType } = req.body; + if (!isValidS3FileUrl(fileUrl)) { + return res.status(400).json({ message: "Invalid fileUrl origin" }); + } const userId = req.user.id; const current = await prisma.user.findUnique({ where: { id: userId }, select: { resumes: true } }); - await validateOrReject( - req.file.path, - ["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], - "File content is not a valid resume document", - ); - - const buffer = readAndCleanup(req.file.path); - const url = await uploadWithFallback(buffer, "resumes", userId, req.file.originalname, req.file.mimetype); - - // If at max, delete the oldest resume to make room let updatedResumes = current?.resumes ?? []; if (updatedResumes.length >= MAX_RESUMES) { const oldest = updatedResumes[0]!; deleteFile(oldest); - updatedResumes = [...updatedResumes.slice(1), url]; + updatedResumes = [...updatedResumes.slice(1), fileUrl]; } else { - updatedResumes = [...updatedResumes, url]; + updatedResumes = [...updatedResumes, fileUrl]; } const user = await prisma.user.update({ @@ -202,9 +144,9 @@ export class UploadController { const signedResumes = await signUrls(user.resumes); return res.status(200).json({ - message: "Resume uploaded", + message: "Resume updated", user: { ...user, resumes: signedResumes }, - file: { url, originalName: req.file.originalname, size: req.file.size, mimeType: req.file.mimetype }, + file: { url: fileUrl, originalName, size, mimeType }, }); } catch (error) { console.error(error); @@ -220,9 +162,7 @@ export class UploadController { const { url: rawUrl } = req.body as { url?: string }; if (!rawUrl) return res.status(400).json({ message: "Resume URL is required" }); - // Strip query params (signed URLs have ?X-Amz-... params) const url = rawUrl.split("?")[0]!; - const userId = req.user.id; const current = await prisma.user.findUnique({ where: { id: userId }, select: { resumes: true } }); @@ -238,12 +178,12 @@ export class UploadController { }); deleteFile(url); - const signedResumes = await signUrls(user.resumes); + return res.status(200).json({ message: "Resume deleted", user: { ...user, resumes: signedResumes } }); } catch (error) { console.error(error); return res.status(500).json({ message: "Internal Server Error" }); } } -} +} \ No newline at end of file diff --git a/server/src/module/upload/upload.routes.ts b/server/src/module/upload/upload.routes.ts index a13ba974..8620aae1 100644 --- a/server/src/module/upload/upload.routes.ts +++ b/server/src/module/upload/upload.routes.ts @@ -1,20 +1,20 @@ import { Router } from "express"; import { UploadController } from "./upload.controller.js"; import { authMiddleware } from "../../middleware/auth.middleware.js"; -import { uploadSingle, uploadResume, uploadImage } from "../../middleware/upload.middleware.js"; const uploadController = new UploadController(); export const uploadRouter = Router(); +// Protect all upload routes uploadRouter.use(authMiddleware); -// Generic uploads (used by ATS etc.) -uploadRouter.post("/resume", uploadResume, (req, res) => uploadController.uploadFile(req, res)); -uploadRouter.post("/attachment", uploadSingle, (req, res) => uploadController.uploadFile(req, res)); +// NEW: Route to generate pre-signed URL for direct client-to-S3 uploads +uploadRouter.post("/presigned-url", (req, res) => uploadController.getPresignedUrl(req, res)); -// Profile-specific uploads -uploadRouter.post("/profile-pic", uploadImage, (req, res) => uploadController.uploadProfilePic(req, res)); -uploadRouter.post("/cover-image", uploadImage, (req, res) => uploadController.uploadCoverImage(req, res)); -uploadRouter.post("/profile-resume", uploadResume, (req, res) => uploadController.uploadProfileResume(req, res)); -uploadRouter.delete("/profile-resume", (req, res) => uploadController.deleteProfileResume(req, res)); +// UPDATED: Profile-specific endpoints (No more multer middleware!) +// These now expect a JSON body like: { "fileUrl": "https://..." } +uploadRouter.post("/profile-pic", (req, res) => uploadController.uploadProfilePic(req, res)); +uploadRouter.post("/cover-image", (req, res) => uploadController.uploadCoverImage(req, res)); +uploadRouter.post("/profile-resume", (req, res) => uploadController.uploadProfileResume(req, res)); +uploadRouter.delete("/profile-resume", (req, res) => uploadController.deleteProfileResume(req, res)); \ No newline at end of file diff --git a/server/src/utils/s3.utils.ts b/server/src/utils/s3.utils.ts index 29b7306d..6381ac52 100644 --- a/server/src/utils/s3.utils.ts +++ b/server/src/utils/s3.utils.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import { S3Client, PutObjectCommand, @@ -21,6 +22,12 @@ function getBucketUrl(): string { return `https://${BUCKET}.s3.${REGION}.amazonaws.com`; } +export function createUniqueS3Key(folder: string, userId: string, fileName: string): string { + const cleanFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, "_"); + const uniqueId = randomUUID(); + return `${folder}/${String(userId)}/${uniqueId}-${cleanFileName}`; +} + export async function uploadToS3( buffer: Buffer, key: string, @@ -80,3 +87,16 @@ export async function signUrl(url: string, expiresIn = 3600): Promise { export async function signUrls(urls: string[], expiresIn = 3600): Promise { return Promise.all(urls.map((u) => signUrl(u, expiresIn))); } + + +export const generatePresignedUploadUrl = async (fileKey: string, fileType: string) => { + const command = new PutObjectCommand({ + Bucket: process.env.AWS_S3_BUCKET, + Key: fileKey, + ContentType: fileType, // Enforces that the client uploads the correct file type + }); + + // URL expires in 5 minutes (300 seconds) + const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 300 }); + return uploadUrl; +}; \ No newline at end of file