diff --git a/apps/recnet-api/.env.sample b/apps/recnet-api/.env.sample index 354aa67b..02c943e1 100644 --- a/apps/recnet-api/.env.sample +++ b/apps/recnet-api/.env.sample @@ -23,4 +23,10 @@ export SMTP_PASS="ask for password" export SLACK_TOKEN="ask for token" # to be deprecated export SLACK_CLIENT_ID="ask for client id" export SLACK_CLIENT_SECRET="ask for client secret" -export SLACK_TOKEN_ENCRYPTION_KEY="ask for token encryption key" \ No newline at end of file +export SLACK_TOKEN_ENCRYPTION_KEY="ask for token encryption key" + +# AWS S3 +export AWS_BUCKET_NAME="ask for AWS bucket name" +export AWS_ACCESS_KEY_ID="ask for AWS access key id" +export AWS_SECRET_ACCESS_KEY="ask for AWS secret access key" +export AWS_BUCKET_REGION="ask for AWS bucket region" diff --git a/apps/recnet-api/.env.test b/apps/recnet-api/.env.test index 6312ab45..73a6c6ab 100644 --- a/apps/recnet-api/.env.test +++ b/apps/recnet-api/.env.test @@ -5,4 +5,10 @@ SMTP_USER=test_user SMTP_PASS=test_password SLACK_CLIENT_ID=test_client_id SLACK_CLIENT_SECRET=test_client_secret -SLACK_TOKEN_ENCRYPTION_KEY=test_token_encryption_key \ No newline at end of file +SLACK_TOKEN_ENCRYPTION_KEY=test_token_encryption_key + +# AWS S3 +export AWS_BUCKET_NAME=test_aws_bucket_name +export AWS_ACCESS_KEY_ID=test_aws_access_key_id +export AWS_SECRET_ACCESS_KEY=test_aws_secret_access_key +export AWS_BUCKET_REGION=test_aws_bucket_region \ No newline at end of file diff --git a/apps/recnet-api/src/app.module.ts b/apps/recnet-api/src/app.module.ts index 50ca342b..bd9dc1fb 100644 --- a/apps/recnet-api/src/app.module.ts +++ b/apps/recnet-api/src/app.module.ts @@ -9,6 +9,7 @@ import { ArticleModule } from "./modules/article/article.module"; import { EmailModule } from "./modules/email/email.module"; import { HealthModule } from "./modules/health/health.module"; import { InviteCodeModule } from "./modules/invite-code/invite-code.module"; +import { PhotoStorageModule } from "./modules/photo-storage/photo-storage.module"; import { RecModule } from "./modules/rec/rec.module"; import { StatModule } from "./modules/stat/stat.module"; import { SubscriptionModule } from "./modules/subscription/subscription.module"; @@ -32,6 +33,7 @@ import { LoggerMiddleware } from "./utils/middlewares/logger.middleware"; EmailModule, AnnouncementModule, SubscriptionModule, + PhotoStorageModule, ], controllers: [], providers: [], diff --git a/apps/recnet-api/src/config/common.config.ts b/apps/recnet-api/src/config/common.config.ts index 797f2a49..85782712 100644 --- a/apps/recnet-api/src/config/common.config.ts +++ b/apps/recnet-api/src/config/common.config.ts @@ -38,3 +38,10 @@ export const SlackConfig = registerAs("slack", () => ({ clientSecret: parsedEnv.SLACK_CLIENT_SECRET, tokenEncryptionKey: parsedEnv.SLACK_TOKEN_ENCRYPTION_KEY, })); + +export const S3Config = registerAs("s3", () => ({ + bucketName: parsedEnv.AWS_BUCKET_NAME, + accessKeyId: parsedEnv.AWS_ACCESS_KEY_ID, + secretAccessKey: parsedEnv.AWS_SECRET_ACCESS_KEY, + s3Region: parsedEnv.AWS_BUCKET_REGION, +})); diff --git a/apps/recnet-api/src/config/env.schema.ts b/apps/recnet-api/src/config/env.schema.ts index 01db647d..24612c9e 100644 --- a/apps/recnet-api/src/config/env.schema.ts +++ b/apps/recnet-api/src/config/env.schema.ts @@ -30,6 +30,11 @@ export const EnvSchema = z.object({ SLACK_TOKEN_ENCRYPTION_KEY: z .string() .transform((val) => Buffer.from(val, "base64")), + // AWS S3 config + AWS_BUCKET_NAME: z.string(), + AWS_ACCESS_KEY_ID: z.string(), + AWS_SECRET_ACCESS_KEY: z.string(), + AWS_BUCKET_REGION: z.string(), }); export const parseEnv = (env: Record) => { diff --git a/apps/recnet-api/src/modules/photo-storage/photo-storage.controller.ts b/apps/recnet-api/src/modules/photo-storage/photo-storage.controller.ts new file mode 100644 index 00000000..1725933c --- /dev/null +++ b/apps/recnet-api/src/modules/photo-storage/photo-storage.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Delete, Post, Query } from "@nestjs/common"; +import { ApiOperation } from "@nestjs/swagger"; + +import { PhotoStorageService } from "./photo-storage.service"; + +@Controller("photo-storage") +export class PhotoStorageController { + constructor(private readonly photoStorageService: PhotoStorageService) {} + @ApiOperation({ + summary: "Generate S3 Upload URL", + description: + "Generate a secure signed Url to upload profile photo to S3 bucket", + }) + @Post("upload-url") + generateUploadUrl(): Promise<{ url: string }> { + return this.photoStorageService.generateS3UploadUrl(); + } + + @ApiOperation({ + summary: "Delete S3 Object", + description: "Delete S3 Object (profile photo)", + }) + @Delete("photo") + async deleteS3Object(@Query("fileUrl") fileUrl: string): Promise { + return this.photoStorageService.deleteS3Object(fileUrl); + } +} diff --git a/apps/recnet-api/src/modules/photo-storage/photo-storage.module.ts b/apps/recnet-api/src/modules/photo-storage/photo-storage.module.ts new file mode 100644 index 00000000..ca8a3ddc --- /dev/null +++ b/apps/recnet-api/src/modules/photo-storage/photo-storage.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; + +import { PhotoStorageController } from "./photo-storage.controller"; +import { PhotoStorageService } from "./photo-storage.service"; + +@Module({ + controllers: [PhotoStorageController], + providers: [PhotoStorageService], +}) +export class PhotoStorageModule {} diff --git a/apps/recnet-api/src/modules/photo-storage/photo-storage.service.ts b/apps/recnet-api/src/modules/photo-storage/photo-storage.service.ts new file mode 100644 index 00000000..720975be --- /dev/null +++ b/apps/recnet-api/src/modules/photo-storage/photo-storage.service.ts @@ -0,0 +1,83 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; +import { DeleteObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { HttpStatus, Inject, Injectable } from "@nestjs/common"; +import { ConfigType } from "@nestjs/config"; +import { v4 as uuidv4 } from "uuid"; + +import { S3Config } from "@recnet-api/config/common.config"; +import { RecnetError } from "@recnet-api/utils/error/recnet.error"; +import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const"; + +@Injectable() +export class PhotoStorageService { + private readonly s3: S3Client; + + constructor( + @Inject(S3Config.KEY) + private readonly s3Config: ConfigType + ) { + this.s3 = new S3Client({ + region: this.s3Config.s3Region, + credentials: { + accessKeyId: this.s3Config.accessKeyId, + secretAccessKey: this.s3Config.secretAccessKey, + }, + }); + } + + async generateS3UploadUrl(): Promise<{ url: string }> { + // build time stamp as part of the image name, format: YYYY-MM-DD-HH-MM-SS + // e.g. the current date and time is February 21, 2025, 10:30:45 AM, + // the timestamp would be: 2025-02-21-10-30-45 + const timestamp = new Date() + .toLocaleString("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) + .replace(/[/,: ]/g, "-"); + + const imageName = `${timestamp}-${uuidv4()}`; + + const command = new PutObjectCommand({ + Bucket: this.s3Config.bucketName, + Key: imageName, + }); + + try { + const uploadURL = await getSignedUrl(this.s3, command, { expiresIn: 60 }); + return { url: uploadURL }; + } catch (error: unknown) { + throw new RecnetError( + ErrorCode.AWS_S3_GET_SIGNED_URL_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + async deleteS3Object(fileUrl: string): Promise { + // Extract the key (filename) from the URL + const urlParts = fileUrl.split("/"); + const key = urlParts[urlParts.length - 1]; + + const command = new DeleteObjectCommand({ + Bucket: this.s3Config.bucketName, + Key: key, + }); + + try { + await this.s3.send(command); + } catch (error: unknown) { + throw new RecnetError( + ErrorCode.AWS_S3_DELETE_OBJECT_ERROR, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/apps/recnet-api/src/utils/error/recnet.error.const.ts b/apps/recnet-api/src/utils/error/recnet.error.const.ts index f38c2949..911d5019 100644 --- a/apps/recnet-api/src/utils/error/recnet.error.const.ts +++ b/apps/recnet-api/src/utils/error/recnet.error.const.ts @@ -23,6 +23,8 @@ export const ErrorCode = { EMAIL_SEND_ERROR: 3000, FETCH_DIGITAL_LIBRARY_ERROR: 3001, SLACK_ERROR: 3002, + AWS_S3_GET_SIGNED_URL_ERROR: 3003, + AWS_S3_DELETE_OBJECT_ERROR: 3004, }; export const errorMessages = { @@ -49,4 +51,6 @@ export const errorMessages = { [ErrorCode.FETCH_DIGITAL_LIBRARY_ERROR]: "Fetch digital library error", [ErrorCode.SLACK_ERROR]: "Slack error", [ErrorCode.SLACK_ALREADY_INSTALLED]: "Slack already installed", + [ErrorCode.AWS_S3_GET_SIGNED_URL_ERROR]: "Failed to get AWS S3 signed URL", + [ErrorCode.AWS_S3_DELETE_OBJECT_ERROR]: "AWS S3 delete object error", }; diff --git a/apps/recnet/src/components/setting/profile/ProfileEditForm.tsx b/apps/recnet/src/components/setting/profile/ProfileEditForm.tsx index fe7a5f02..1ee6e797 100644 --- a/apps/recnet/src/components/setting/profile/ProfileEditForm.tsx +++ b/apps/recnet/src/components/setting/profile/ProfileEditForm.tsx @@ -10,7 +10,9 @@ import { TextArea, } from "@radix-ui/themes"; import { TRPCClientError } from "@trpc/client"; +import Image from "next/image"; import { useRouter, usePathname } from "next/navigation"; +import React from "react"; import { useForm, useFormState } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod"; @@ -61,11 +63,14 @@ const ProfileEditSchema = z.object({ .max(200, "Bio must contain at most 200 character(s)") .nullable(), url: z.string().url().nullable(), + photoUrl: z.string().url(), googleScholarLink: z.string().url().nullable(), semanticScholarLink: z.string().url().nullable(), openReviewUserName: z.string().nullable(), }); +const MAX_FILE_SIZE = 3 * 1024 * 1024; // 3MB in bytes + export function ProfileEditForm() { const utils = trpc.useUtils(); const router = useRouter(); @@ -83,6 +88,7 @@ export function ProfileEditForm() { affiliation: user?.affiliation ?? null, bio: user?.bio ?? null, url: user?.url ?? null, + photoUrl: user?.photoUrl ?? null, googleScholarLink: user?.googleScholarLink ?? null, semanticScholarLink: user?.semanticScholarLink ?? null, openReviewUserName: user?.openReviewUserName ?? null, @@ -92,6 +98,44 @@ export function ProfileEditForm() { const { isDirty } = useFormState({ control: control }); const updateProfileMutation = trpc.updateUser.useMutation(); + const generateUploadUrlMutation = trpc.generateUploadUrl.useMutation(); + const [isUploading, setIsUploading] = React.useState(false); + const [selectedFile, setSelectedFile] = React.useState(null); + const [photoPreviewUrl, setPhotoPreviewUrl] = React.useState( + null + ); + const [fileError, setFileError] = React.useState(null); + + const handleUploadS3 = async (formData: any) => { + if (!selectedFile) return; + try { + setIsUploading(true); + const uploadUrl = await generateUploadUrlMutation.mutateAsync(); + if (!uploadUrl?.url) { + throw new Error("Error getting S3 upload URL"); + } + const response = await fetch(uploadUrl.url, { + method: "PUT", + body: selectedFile, + headers: { + "Content-Type": selectedFile.type, + }, + }); + if (!response.ok) { + throw new Error("Upload failed"); + } + // The URL where the file will be accessible + const fileUrl = uploadUrl.url.split("?")[0]; + // update form data directly because the form data is already passed to the handleSubmit function + formData.photoUrl = fileUrl; + return formData; + } catch (error) { + console.error("Error uploading file:", error); + toast.error("Error uploading file. Please try again."); + } finally { + setIsUploading(false); + } + }; return (
) : null} +