Skip to content

Commit

Permalink
Feat: issue55 profile photo (#392)
Browse files Browse the repository at this point in the history
## Description

This PR adds a new edit profile photo feature. Prior to this, RecNet
displays a user's Google account profile photo and does not allow
changes. This feature allows any RecNet user to upload a profile photo,
which is stored in an AWS S3 bucket and make updates to the user profile
information in the database.

## Frontend Changes
* Modified
`/apps/recnet/src/components/setting/profile/ProfileEditForm.tsx` adding
new form element and function:
* Added profile photo `<input>` element with a photo preview image to
accept and display the selected photo.
* Added `handleUploadS3` function to get a secure uploadUrl from the
backend, put objects to the S3 bucket, and update the form data.
* Modified the `onSubmit` function to call the `handleUploadS3` function
when a user saves the form.
* Updated the `ProfileEditSchema`, `useForm` hook to include the
photoUrl field.
* Added a `getUploadUrlMutation` hook to send a request to the backend
to get a secure S3 upload URL.
* Added new state hooks `[isUploading, setIsUploading]`, `[selectedFile,
setSelectedFile]`, `[photoPreviewUrl, setPhotoPreviewUrl]` to manage the
selected photo and upload status.
* Created a new router `getS3UploadUrl` in the
`/apps/recnet/src/server/routers/user.ts`
* Modified the router `updateUser` in
`/apps/recnet/src/server/routers/user.ts` to fetch the original photoUrl
and delete the corresponding S3 file if a new profile photo is uploaded.

## Backend Changes
* Added a new folder `s3` under `/apps/recnet-api/src/modules`
* Created a `s3.controler.ts` to handle requests to the `s3url`
endpoint.
* Created a `s3.service.ts`. Included a `getS3UploadUrl` method and a
`deleteS3Object` method.
    * Created a `s3.module.ts` to package and export the s3 class.
* Modified `common.config.ts` under `/apps/recnet-api/src/config` to
register AWS S3 configuration.
* Updated `env.schema.ts` under /apps/recnet-api/src/config` to include
the s3 environment variable data types.

<!--- This project only accepts pull requests related to open issues -->
<!--- If suggesting a new feature or change, please discuss it in an
issue first -->
<!--- If fixing a bug, there should be an issue describing it with steps
to reproduce -->
<!--- Please link to the issue here: -->

## Other Changes
* Updated `app.module.ts` under `/apps/recnet-api/src` to import and
include the `S3Module`.
* Updated `package.json` to include `aws-sdk` .
* Install aws-sdk: `@aws-sdk/client-s3 @aws-sdk/s3-request-presigner`.
Make sure to use aws-sdk v3.

<!-- Other thing to say -->

## How to Test

1. Log in to your account.
2. Navigate to the Profile page.
3. Click on the "Settings" button and open the first tab "Edit profile".
4. Click on "Select file" to choose a file and attach to the form.
5. Click on the "Save" button and wait for the upload to complete.
6. Check your profile photo and it should be updated to the new photo
you uploaded.

## Screenshots (if appropriate):
<img width="666" alt="edit_profile"
src="https://github.com/user-attachments/assets/8edf4cdc-360e-4004-9f0d-bfb1fa99ca2f"
/>
<img width="668" alt="choose_photo"
src="https://github.com/user-attachments/assets/10c96782-3774-4b48-945a-b8cb99bd3847"
/>
<img width="694" alt="uploading_photo"
src="https://github.com/user-attachments/assets/b184154e-ea81-46d8-ad4d-7feaa52a814d"
/>
<img width="847" alt="profile_photo_updated"
src="https://github.com/user-attachments/assets/22aa1810-f451-4c30-b80c-8d42aaea6575"
/>

## TODO
- Add AWS S3 configurations in the `.env` file under `/apps/recnet-api`
for the dev and prod environment for deployment
  • Loading branch information
zherujiang authored Feb 24, 2025
2 parents 2b09639 + 2f805dc commit 157be46
Show file tree
Hide file tree
Showing 15 changed files with 1,708 additions and 101 deletions.
8 changes: 7 additions & 1 deletion apps/recnet-api/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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"
8 changes: 7 additions & 1 deletion apps/recnet-api/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
2 changes: 2 additions & 0 deletions apps/recnet-api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,6 +33,7 @@ import { LoggerMiddleware } from "./utils/middlewares/logger.middleware";
EmailModule,
AnnouncementModule,
SubscriptionModule,
PhotoStorageModule,
],
controllers: [],
providers: [],
Expand Down
7 changes: 7 additions & 0 deletions apps/recnet-api/src/config/common.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
5 changes: 5 additions & 0 deletions apps/recnet-api/src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
return this.photoStorageService.deleteS3Object(fileUrl);
}
}
10 changes: 10 additions & 0 deletions apps/recnet-api/src/modules/photo-storage/photo-storage.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
83 changes: 83 additions & 0 deletions apps/recnet-api/src/modules/photo-storage/photo-storage.service.ts
Original file line number Diff line number Diff line change
@@ -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<typeof S3Config>
) {
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<void> {
// 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
);
}
}
}
4 changes: 4 additions & 0 deletions apps/recnet-api/src/utils/error/recnet.error.const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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",
};
110 changes: 107 additions & 3 deletions apps/recnet/src/components/setting/profile/ProfileEditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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<File | null>(null);
const [photoPreviewUrl, setPhotoPreviewUrl] = React.useState<string | null>(
null
);
const [fileError, setFileError] = React.useState<string | null>(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 (
<form
Expand All @@ -104,8 +148,12 @@ export function ProfileEditForm() {
console.error("Invalid form data.");
return;
}
// Handle the file upload if there's a selected file
if (selectedFile) {
res.data = await handleUploadS3(res.data);
}
// if no changes, close dialog
if (!isDirty) {
if (!isDirty && !selectedFile) {
setOpen(false);
return;
}
Expand Down Expand Up @@ -183,6 +231,62 @@ export function ProfileEditForm() {
</Text>
) : null}
</label>
<label>
<Text as="div" size="2" mb="1" weight="medium">
Profile Photo
</Text>
<input
type="file"
accept="image/*"
onChange={async (e: React.ChangeEvent<HTMLInputElement>) => {
setFileError(null);
if (!e.target.files || e.target.files.length === 0) {
setSelectedFile(null);
setPhotoPreviewUrl(null);
return;
}
const file = e.target.files[0];
if (file.size > MAX_FILE_SIZE) {
setFileError("File size must be less than 3MB");
setSelectedFile(null);
setPhotoPreviewUrl(null);
return;
}

setSelectedFile(file);
// Cleanup previous preview URL if it exists
if (photoPreviewUrl) {
URL.revokeObjectURL(photoPreviewUrl);
}
// Create preview URL for the selected image
const objectUrl = URL.createObjectURL(file);
setPhotoPreviewUrl(objectUrl);
}}
/>
{fileError && (
<Text size="1" color="red">
{fileError}
</Text>
)}
{formState.errors?.photoUrl ? (
<Text size="1" color="red">
{formState.errors.photoUrl.message}
</Text>
) : null}
{photoPreviewUrl && (
<Image
src={photoPreviewUrl}
alt="Profile photo preview"
width={100}
height={100}
style={{
objectFit: "cover",
borderRadius: "50%",
marginTop: "12px",
}}
/>
)}
</label>
<label>
<Text as="div" size="2" mb="1" weight="medium">
Affiliation
Expand Down Expand Up @@ -313,9 +417,9 @@ export function ProfileEditForm() {
"bg-gray-5": !formState.isValid,
})}
type="submit"
disabled={!formState.isValid}
disabled={!formState.isValid || isUploading}
>
Save
{isUploading ? "Uploading photo..." : "Save"}
</Button>
</Flex>
</form>
Expand Down
Loading

0 comments on commit 157be46

Please # to comment.