From b8f95488adc59e59d9d0269fe0fea1433ee5b7ca Mon Sep 17 00:00:00 2001 From: larama-C Date: Sun, 21 Dec 2025 11:51:35 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20Apache=20Tika=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) 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") From 5a536d305727a807a53a3600fe4dad4a40e0bd3f Mon Sep 17 00:00:00 2001 From: larama-C Date: Sun, 21 Dec 2025 11:51:55 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B4=80=EB=A0=A8=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20FileErrorCode=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/error/code/FileErrorCode.java | 20 +++++++++++++++++++ .../global/error/code/UserErrorCode.java | 4 ---- 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/global/error/code/FileErrorCode.java 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", "현재 비밀번호가 일치하지 않습니다."); From e885d65adde15aa46de0d053402feaa683dc9f4a Mon Sep 17 00:00:00 2001 From: larama-C Date: Sun, 21 Dec 2025 11:52:07 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20Apache=20Tika=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=8C=8C=EC=9D=BC=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/users/service/UserService.java | 12 ++--- .../global/storage/ImageFileValidator.java | 53 +++++++++++++++++++ .../global/storage/ImageMimeType.java | 36 +++++++++++++ 3 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/global/storage/ImageFileValidator.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/global/storage/ImageMimeType.java 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/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()); + } +} +