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
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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", "현재 비밀번호가 일치하지 않습니다.");

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> extensions;

ImageMimeType(String mimeType, List<String> 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());
}
}