diff --git a/build.gradle.kts b/build.gradle.kts index 0bef24fc..ebba595d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -78,6 +78,9 @@ dependencies { // AWS S3 implementation("software.amazon.awssdk:s3") implementation("io.awspring.cloud:spring-cloud-aws-s3:3.0.2") + + // Apache Tika + implementation("org.apache.tika:tika-core:2.9.0") // 서버 정상 작동 확인용 implementation("org.springframework.boot:spring-boot-starter-actuator") diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java index d3bb04d4..a4df7ccd 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java @@ -12,6 +12,7 @@ import com.back.web7_9_codecrete_be.global.error.code.UserErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import com.back.web7_9_codecrete_be.global.storage.FileStorageService; +import com.back.web7_9_codecrete_be.global.storage.ImageFileValidator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; @@ -34,7 +35,7 @@ public class UserService { private final UserRestoreTokenRedisRepository userRestoreTokenRedisRepository; private final EmailService emailService; - private static final long MAX_PROFILE_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB + private final ImageFileValidator imageFileValidator; // 내 정보 조회 @Transactional(readOnly = true) @@ -74,14 +75,7 @@ public void deleteMyAccount(User user) { public String updateProfileImage(User user, MultipartFile file) { validateActiveUser(user); - // 파일 유효성 검사 - if (file == null || file.isEmpty()) { - throw new BusinessException(UserErrorCode.INVALID_PROFILE_IMAGE); - } - - if (file.getSize() > MAX_PROFILE_IMAGE_SIZE) { - throw new BusinessException(UserErrorCode.PROFILE_IMAGE_SIZE_EXCEEDED); - } + imageFileValidator.validateImageFile(file); // 기존 이미지 URL 보관 String oldImageUrl = user.getProfileImage(); diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/FileErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/FileErrorCode.java new file mode 100644 index 00000000..2c51752e --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/FileErrorCode.java @@ -0,0 +1,20 @@ +package com.back.web7_9_codecrete_be.global.error.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum FileErrorCode implements ErrorCode { + + FILE_EMPTY(HttpStatus.BAD_REQUEST, "F-101", "파일이 비어 있습니다."), + INVALID_IMAGE_TYPE(HttpStatus.BAD_REQUEST, "F-102", "허용되지 않은 이미지 파일입니다."), + EXTENSION_MISMATCH(HttpStatus.BAD_REQUEST, "F-103", "파일 확장자와 실제 파일 타입이 일치하지 않습니다."), + FILE_ANALYZE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "F-104", "파일 분석 중 오류가 발생했습니다."), + PROFILE_IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "F-105", "프로필 이미지 크기가 10MB를 초과했습니다."),; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java index 0297f683..c5fb3878 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java @@ -16,10 +16,6 @@ public enum UserErrorCode implements ErrorCode { USER_NOT_DELETED(HttpStatus.BAD_REQUEST, "U-104", "탈퇴 상태의 계정만 복구할 수 있습니다."), INVALID_RESTORE_TOKEN(HttpStatus.BAD_REQUEST, "U-105", "유효하지 않거나 만료된 복구 링크입니다."), - // 3xx - 입력값 / 파일 - INVALID_PROFILE_IMAGE(HttpStatus.BAD_REQUEST, "U-110", "유효하지 않은 프로필 이미지입니다."), - PROFILE_IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "U-111", "프로필 이미지 크기가 10MB를 초과했습니다."), - // 2xx - 인증 / 비밀번호 INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-120", "현재 비밀번호가 일치하지 않습니다."); diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/storage/ImageFileValidator.java b/src/main/java/com/back/web7_9_codecrete_be/global/storage/ImageFileValidator.java new file mode 100644 index 00000000..45085184 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/storage/ImageFileValidator.java @@ -0,0 +1,53 @@ +package com.back.web7_9_codecrete_be.global.storage; + +import com.back.web7_9_codecrete_be.global.error.code.FileErrorCode; +import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; +import org.apache.tika.Tika; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; + +@Component +public class ImageFileValidator { + + private final Tika tika = new Tika(); + private static final long MAX_PROFILE_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB + + public void validateImageFile(MultipartFile file) { + + // 파일 비어있는지 검사 + if (file == null || file.isEmpty()) { + throw new BusinessException(FileErrorCode.FILE_EMPTY); + } + + // 파일 크기 검사 + if (file.getSize() > MAX_PROFILE_IMAGE_SIZE) { + throw new BusinessException(FileErrorCode.PROFILE_IMAGE_SIZE_EXCEEDED); + } + + // 실제 MIME 타입 분석 + String detectedMimeType; + try (InputStream is = file.getInputStream()) { + detectedMimeType = tika.detect(is); + } catch (IOException e) { + throw new BusinessException(FileErrorCode.FILE_ANALYZE_FAILED); + } + + ImageMimeType imageMimeType = ImageMimeType.from(detectedMimeType); + + // 확장자 추출 + String filename = file.getOriginalFilename(); + if (filename == null || !filename.contains(".")) { + throw new BusinessException(FileErrorCode.EXTENSION_MISMATCH); + } + + String extension = filename.substring(filename.lastIndexOf('.') + 1); + + // 확장자 ↔ MIME 타입 매칭 + if (!imageMimeType.matches(extension)) { + throw new BusinessException(FileErrorCode.EXTENSION_MISMATCH); + } + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/storage/ImageMimeType.java b/src/main/java/com/back/web7_9_codecrete_be/global/storage/ImageMimeType.java new file mode 100644 index 00000000..0f0f2c9f --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/storage/ImageMimeType.java @@ -0,0 +1,36 @@ +package com.back.web7_9_codecrete_be.global.storage; + +import com.back.web7_9_codecrete_be.global.error.code.FileErrorCode; +import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; + +import java.util.Arrays; +import java.util.List; + +public enum ImageMimeType { + + JPEG("image/jpeg", List.of("jpg", "jpeg")), + PNG("image/png", List.of("png")), + WEBP("image/webp", List.of("webp")); + + private final String mimeType; + private final List extensions; + + ImageMimeType(String mimeType, List extensions) { + this.mimeType = mimeType; + this.extensions = extensions; + } + + public static ImageMimeType from(String detectedMimeType) { + return Arrays.stream(values()) + .filter(it -> it.mimeType.equals(detectedMimeType)) + .findFirst() + .orElseThrow(() -> + new BusinessException(FileErrorCode.INVALID_IMAGE_TYPE) + ); + } + + public boolean matches(String extension) { + return extensions.contains(extension.toLowerCase()); + } +} +