Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ public void removeProblem(Long problemId) {
// 문제 이미지가 있으면 삭제
if (!findProblem.getImageUrl().isEmpty()) {
for(String fileUrl : findProblem.getImageUrl()) {
s3Uploader.delete(fileUrl);
s3Uploader.delete(fileUrl, "problem");
}
}

Expand Down Expand Up @@ -163,7 +163,7 @@ private void updateProblemImage(Problem problem, MultipartFile newImage) {
// 기존 이미지가 있다면 삭제 처리
if (!problem.getImageUrl().isEmpty()) {
for(String fileUrl : problem.getImageUrl()) {
s3Uploader.delete(fileUrl);
s3Uploader.delete(fileUrl, "problem");
}
problem.clearImages();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package org.ezcode.codetest.application.usermanagement.user.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "비밀번호 리셋을 위한 입력")
public record ResetPasswordRequest (
@NotBlank(message = "임시 재설정 토큰은 필수입니다")
@Schema(description = "비밀번호 리셋 유저의 토큰 - 유효 시간 10분")
String tempResetToken,

@NotBlank(message = "새 비밀번호는 필수입니다")
@Schema(description = "변경할 비밀번호", example = "myPassword@@!")
String newPassword,

@NotBlank(message = "비밀번호 확인은 필수입니다")
@Schema(description = "변경할 비밀번호 확인용 재입력", example = "myPassword@@!")
String newPasswordConfirm
){
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.ezcode.codetest.application.usermanagement.user.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@Schema(description = "Admin 권한을 부여")
@AllArgsConstructor
public class GrantAdminRoleResponse {
private final String message;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.ezcode.codetest.application.usermanagement.user.dto.response;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class UserProfileImageResponse {
private final String message;

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import java.time.temporal.ChronoUnit;
import java.util.List;

import org.ezcode.codetest.application.usermanagement.user.dto.response.GrantAdminRoleResponse;
import org.ezcode.codetest.application.usermanagement.user.dto.response.UserProfileImageResponse;
import org.ezcode.codetest.application.usermanagement.user.model.UsersByWeek;
import org.ezcode.codetest.domain.submission.dto.WeeklySolveCount;
import org.ezcode.codetest.application.usermanagement.user.dto.request.ChangeUserPasswordRequest;
Expand All @@ -13,16 +15,24 @@
import org.ezcode.codetest.application.usermanagement.user.dto.response.WithdrawUserResponse;
import org.ezcode.codetest.domain.submission.service.SubmissionDomainService;
import org.ezcode.codetest.domain.user.exception.AuthException;
import org.ezcode.codetest.domain.user.exception.UserException;
import org.ezcode.codetest.domain.user.exception.code.AuthExceptionCode;
import org.ezcode.codetest.domain.user.exception.code.UserExceptionCode;
import org.ezcode.codetest.domain.user.model.entity.AuthUser;
import org.ezcode.codetest.domain.user.model.entity.User;
import org.ezcode.codetest.domain.user.model.enums.AuthType;
import org.ezcode.codetest.domain.user.model.enums.UserRole;
import org.ezcode.codetest.domain.user.service.MailService;
import org.ezcode.codetest.domain.user.service.UserDomainService;
import org.ezcode.codetest.infrastructure.s3.S3Directory;
import org.ezcode.codetest.infrastructure.s3.S3Uploader;
import org.ezcode.codetest.infrastructure.s3.exception.S3Exception;
import org.ezcode.codetest.infrastructure.s3.exception.code.S3ExceptionCode;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -35,6 +45,7 @@ public class UserService {
private final UserDomainService userDomainService;
private final SubmissionDomainService submissionDomainService;
private final RedisTemplate<String, String> redisTemplate;
private final S3Uploader s3Uploader;

@Transactional(readOnly = true)
public UserInfoResponse getUserInfo(AuthUser authUser) {
Expand Down Expand Up @@ -126,4 +137,57 @@ public void resetAllUsersTokensWeekly(LocalDateTime startDateTime, LocalDateTime
userDomainService.resetReviewTokensForUsers(UsersByWeek.from(counts, weekLength));
}

@Transactional
public UserProfileImageResponse uploadUserProfileImage(AuthUser authUser, MultipartFile image) {
User user = userDomainService.getUserById(authUser.getId());
if (user.getProfileImageUrl()!=null) {
s3Uploader.delete(user.getProfileImageUrl(), "profile");
}
String profileImageUrl = uploadProfileImage(image);

user.modifyProfileImage(profileImageUrl);
return new UserProfileImageResponse(profileImageUrl);
}
Comment on lines +140 to +150
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

프로필 이미지 업로드 로직을 개선하세요.

현재 구현에서 몇 가지 개선할 점이 있습니다:

  1. MultipartFile이 null이거나 비어있을 때의 처리 로직이 명확하지 않습니다.
  2. S3 삭제 실패 시에도 새 이미지 업로드가 진행됩니다.

다음과 같이 개선할 수 있습니다:

 @Transactional
 public UserProfileImageResponse uploadUserProfileImage(AuthUser authUser, MultipartFile image) {
+    if (image == null || image.isEmpty()) {
+        throw new S3Exception(S3ExceptionCode.EMPTY_FILE);
+    }
+    
     User user = userDomainService.getUserById(authUser.getId());
+    String oldImageUrl = user.getProfileImageUrl();
+    
+    // 새 이미지를 먼저 업로드
+    String profileImageUrl = uploadProfileImage(image);
+    
+    // 기존 이미지가 있다면 삭제 (새 이미지 업로드 성공 후)
-    if (user.getProfileImageUrl()!=null) {
-        s3Uploader.delete(user.getProfileImageUrl(), "profile");
+    if (oldImageUrl != null) {
+        try {
+            s3Uploader.delete(oldImageUrl, "profile");
+        } catch (Exception e) {
+            log.warn("기존 프로필 이미지 삭제 실패 - url: {}", oldImageUrl, e);
+            // 기존 이미지 삭제 실패해도 새 이미지 업로드는 진행
+        }
     }
-    String profileImageUrl = uploadProfileImage(image);

     user.modifyProfileImage(profileImageUrl);
     return new UserProfileImageResponse(profileImageUrl);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Transactional
public UserProfileImageResponse uploadUserProfileImage(AuthUser authUser, MultipartFile image) {
User user = userDomainService.getUserById(authUser.getId());
if (user.getProfileImageUrl()!=null) {
s3Uploader.delete(user.getProfileImageUrl(), "profile");
}
String profileImageUrl = uploadProfileImage(image);
user.modifyProfileImage(profileImageUrl);
return new UserProfileImageResponse(profileImageUrl);
}
@Transactional
public UserProfileImageResponse uploadUserProfileImage(AuthUser authUser, MultipartFile image) {
if (image == null || image.isEmpty()) {
throw new S3Exception(S3ExceptionCode.EMPTY_FILE);
}
User user = userDomainService.getUserById(authUser.getId());
String oldImageUrl = user.getProfileImageUrl();
// 새 이미지를 먼저 업로드
String profileImageUrl = uploadProfileImage(image);
// 기존 이미지가 있다면 삭제 (새 이미지 업로드 성공 후)
if (oldImageUrl != null) {
try {
s3Uploader.delete(oldImageUrl, "profile");
} catch (Exception e) {
log.warn("기존 프로필 이미지 삭제 실패 - url: {}", oldImageUrl, e);
// 기존 이미지 삭제 실패해도 새 이미지 업로드는 진행
}
}
user.modifyProfileImage(profileImageUrl);
return new UserProfileImageResponse(profileImageUrl);
}
🤖 Prompt for AI Agents
In
src/main/java/org/ezcode/codetest/application/usermanagement/user/service/UserService.java
around lines 140 to 150, the uploadUserProfileImage method lacks checks for null
or empty MultipartFile and proceeds with new image upload even if S3 deletion
fails. Fix this by first validating that the MultipartFile is not null and not
empty, returning an appropriate response or throwing an exception if it is.
Then, modify the S3 deletion call to handle potential failures gracefully, such
as by catching exceptions and preventing the new image upload if deletion fails,
ensuring consistent state and error handling.


private String uploadProfileImage(MultipartFile image) {
try {
return s3Uploader.upload(image, S3Directory.PROFILE.getDir());
} catch (Exception e) {
log.error("프로필 이미지 업로드 실패 - image {}", image, e);
throw new S3Exception(S3ExceptionCode.S3_UPLOAD_FAILED);
}
}

@Transactional
public UserProfileImageResponse deleteUserProfileImage(AuthUser authUser) {
User user = userDomainService.getUserById(authUser.getId());

String oldImageUrl = user.getProfileImageUrl();

// S3에서 기존 이미지 파일 삭제
if (oldImageUrl != null) {
try {
s3Uploader.delete(oldImageUrl, "profile");
user.modifyProfileImage(null);
} catch (Exception e) {
log.warn("프로필 이미지 삭제 실패 - url: {}", oldImageUrl, e);
}
}

return new UserProfileImageResponse(null);
}

@Transactional
public GrantAdminRoleResponse grantAdminRole(AuthUser authUser, Long userId) {
if (authUser.getId().equals(userId)) {
throw new UserException(UserExceptionCode.GRANT_ADMIN_SELF);
}
User user = userDomainService.getUserById(userId);
if (user.getRole().equals(UserRole.ADMIN)) {
throw new UserException(UserExceptionCode.ALREADY_ADMIN_USER);
}
user.modifyUserRole(UserRole.ADMIN);

return new GrantAdminRoleResponse("ADMIN 권한을 부여합니다");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers(new DispatcherTypeRequestMatcher(DispatcherType.ASYNC)).permitAll()
.requestMatchers(
SecurityPath.PUBLIC_PATH).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") //어드민 권한 필요 (문제 생성, 관리 등)
.requestMatchers(HttpMethod.GET,
"/api/problems/*/discussions",
"/api/problems/{problemId}/discussions/{discussionId}/replies",
"/api/problems/{problemId}/discussions/{discussionId}/replies/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN") //어드민 권한 필요 (문제 생성, 관리 등)
.anyRequest().authenticated() //나머지는 일반 인증
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
.queryParam("refreshToken", refreshToken)
.build().toUriString();

//JSON 문자열로 바꿔서 클라이언트에게 응답 본문으로 전달
OAuthResponse oAuthResponse = new OAuthResponse(accessToken, refreshToken);

response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(oAuthResponse));
// response.sendRedirect(targetUri);
response.sendRedirect(targetUri);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
tier
);


// 인가를 위한 권한 정보 저장
Collection<? extends GrantedAuthority> authorities =
List.of(new SimpleGrantedAuthority("ROLE_" + authUser.getRole().name()));
Expand All @@ -78,6 +79,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
Authentication authToken;
authToken = new UsernamePasswordAuthenticationToken(authUser, null, authorities);


// SecurityContextHolder(세션)에 토큰 담기
SecurityContextHolder.getContext().setAuthentication(authToken);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ public boolean validateToken(String refreshToken) {
}

public String createEmailToken(Long userId, String email) {
if ( email == null ) {
if ( email == null || userId == null) {
throw new IllegalArgumentException("토큰에 필요한 필수 매개변수가 null입니다.");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public class SecurityPath {
"/login/oauth2/**", //OAuth로그인 접근
"/api/auth/**",
"/api/oauth2/**",
"/oauth/**",
"/login",
"/ezlogin",
"/login/**",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public enum AuthExceptionCode implements ResponseCode {
PASSWORD_IS_SAME(false, HttpStatus.BAD_REQUEST, "기존 비밀번호와 같습니다. 새로운 비밀번호는 기존 비밀번호와 달라야합니다."),
ALREADY_WITHDRAW_USER(false, HttpStatus.NOT_FOUND, "탈퇴된 회원입니다."),
TOKEN_ENCODE_FAIL(false, HttpStatus.BAD_REQUEST, "토큰 인코딩에 실패했습니다."),
REDIRECT_URI_NOT_FOUND(false, HttpStatus.BAD_REQUEST, "redirect_uri를 찾을 수 없습니다");
REDIRECT_URI_NOT_FOUND(false, HttpStatus.BAD_REQUEST, "redirect_uri를 찾을 수 없습니다"),
INVALID_AUTH_USER(false, HttpStatus.BAD_REQUEST, "ADMIN만 접근 가능합니다");

private final boolean success;
private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ public enum UserExceptionCode implements ResponseCode {
NOT_ENOUGH_TOKEN(false, HttpStatus.BAD_REQUEST, "리뷰 토큰이 부족합니다."),
NOT_MATCH_CODE(false, HttpStatus.BAD_REQUEST, "이메일 인증 코드가 일치하지 않습니다."),
NO_GITHUB_INFO(false, HttpStatus.BAD_REQUEST, "깃허브 정보가 없습니다."),
NO_GITHUB_REPO(false, HttpStatus.BAD_REQUEST, "해당하는 Repository를 찾을 수 없습니다.");
NO_GITHUB_REPO(false, HttpStatus.BAD_REQUEST, "해당하는 Repository를 찾을 수 없습니다."),
GRANT_ADMIN_SELF(false, HttpStatus.BAD_REQUEST, "본인에게 ADMIN 권한을 부여할 수 없습니다."),
ALREADY_ADMIN_USER(false, HttpStatus.BAD_REQUEST, "이미 ADMIN 권한을 가진 유저입니다.");

private final boolean success;
private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.ezcode.codetest.domain.user.model.enums.Tier;
import org.ezcode.codetest.domain.user.model.enums.UserRole;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.Getter;
Expand All @@ -29,7 +30,9 @@ public AuthUser(Long id, String username, String nickname, String email, UserRol
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(() -> "ROLE_" + role.name()); }
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}

@Override
public String getPassword() { return ""; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
Expand Down Expand Up @@ -196,4 +198,12 @@ public void setGitPushStatus(boolean gitPushStatus) {
public boolean getGitPushStatus() {
return gitPushStatus;
}

public void modifyProfileImage(String profileImageUrl) {
this.profileImageUrl = profileImageUrl;
}

public void modifyUserRole(UserRole userRole) {
this.role = userRole;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,15 @@ public String upload(MultipartFile multipartFile, String dirName) {
}

// 이미지 삭제
public void delete(String fileUrl) {
public void delete(String fileUrl, String dirName) {
try {
String fileName = extractKeyFromProblemUrl(fileUrl);
String fileName = "";
if (dirName.equalsIgnoreCase("problem")) {
fileName = extractKeyFromProblemUrl(fileUrl);
}
if (dirName.equalsIgnoreCase("profile")) {
fileName = extractKeyFromProfileUrl(fileUrl);
}
amazonS3.deleteObject(bucket, fileName); // S3 내 이미지 객체 제거.
log.info("S3에서 이미지 삭제 완료: {}", fileName);
} catch (Exception e) {
Expand All @@ -77,4 +83,9 @@ private String extractKeyFromProblemUrl(String fileUrl) {
// S3 주소 포맷 기준으로 잘라내기
return fileUrl.substring(fileUrl.indexOf("problem/"));
}

// 프로필 이미지 URL 가져오기
private String extractKeyFromProfileUrl(String profileFileUrl) {
return profileFileUrl.substring(profileFileUrl.indexOf("profile/"));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.ezcode.codetest.presentation.usermanagement;

import java.io.IOException;
import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand All @@ -27,7 +28,7 @@ public class OAuth2Controller {
프론트엔드에서 GitHub 로그인 버튼 클릭 시 이 API를 먼저 호출
redirect_uri는 로그인 완료 후 accessToken과 refreshToken을 전달받을 프론트의 콜백 URL

예시: GET /api/oauth2/authorize/github?redirect_uri=https://ezcode.my/oauth/callback
예시: GET /api/oauth2/authorize/google?redirect_uri=https://ezcode.my
""")
@Parameters({
@Parameter(name = "redirect_uri", description = "프론트 콜백 URI", required = true, example = "https://ezcode.my/oauth/callback (이 uri는 예시이니 편하신걸로 바꾸심 됩니당)")
Expand All @@ -39,10 +40,15 @@ public void redirectToProvider(
HttpServletResponse response,
@RequestParam(required = false) String redirect_uri
) throws IOException {
if (redirect_uri != null) {
if (redirect_uri != null && isValidRedirectUri(redirect_uri)) {
request.getSession().setAttribute("redirect_uri", redirect_uri);
}

response.sendRedirect("/oauth2/authorization/" + provider);
}

private boolean isValidRedirectUri(String uri) {
List<String> allowedDomains = List.of("http://localhost:8080", "http://localhost:3000","https://ezcode.my");
return allowedDomains.stream().anyMatch(uri::startsWith);
}
}
Loading