diff --git a/momogo-api/src/main/resources/application.yaml b/momogo-api/src/main/resources/application.yaml index 110da2d..6e0506d 100644 --- a/momogo-api/src/main/resources/application.yaml +++ b/momogo-api/src/main/resources/application.yaml @@ -138,7 +138,7 @@ app: upload: allowed-extensions: jpg,jpeg,png,webp allowed-mime-types: image/jpeg,image/png,image/webp - max-mark-size: 10MB + max-file-size: 10MB # 물리 디스크 저장 경로 dir: ${APP_FILE_UPLOAD_DIR:./uploads} oauth2: diff --git a/momogo-core/build.gradle b/momogo-core/build.gradle index d6ecfb9..51ef047 100644 --- a/momogo-core/build.gradle +++ b/momogo-core/build.gradle @@ -27,10 +27,11 @@ dependencies { // PDF 라이브러리 implementation 'com.github.librepdf:openpdf:2.2.2' - // S3 프로필 이미지 저장용 및 WebP 이미지 포맷 지원 + // S3 프로필 이미지 저장용 및 WebP 이미지 포맷 지원, 이미지 리사이징 implementation platform('software.amazon.awssdk:bom:2.29.52') implementation 'software.amazon.awssdk:s3' - implementation 'com.twelvemonkeys.imageio:imageio-webp:3.12.0' + implementation 'org.sejda.imageio:webp-imageio:0.1.6' + implementation 'net.coobird:thumbnailator:0.4.21' // 분산환경 공용 인프라 (Redis, Kafka) - api/realtime/batch가 공통으로 사용 api 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java index 9ca610e..9b5eb10 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/LocalStorageService.java @@ -2,13 +2,16 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; -import com.momogo.core.common.util.ImageFileValidator; +import com.momogo.core.common.util.storage.ImageProcessor; +import com.momogo.core.common.util.storage.ImageResizeSpec; +import com.momogo.core.common.util.storage.StorageDirectoryValidator; import com.momogo.core.common.util.UrlUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; @@ -22,38 +25,57 @@ @ConditionalOnProperty(name = "app.storage.type", havingValue = "local", matchIfMissing = true) public class LocalStorageService implements StorageService { - private final ImageFileValidator imageFileValidator; - private final String uploadDir; + private final ImageProcessor imageProcessor; + private final Path uploadRoot; public LocalStorageService( - ImageFileValidator imageFileValidator, + ImageProcessor imageProcessor, @Value("${app.file.upload.dir:./uploads}") String uploadDir ) { - this.imageFileValidator = imageFileValidator; - this.uploadDir = Paths.get(uploadDir).toAbsolutePath().normalize().toString(); - log.info("[LocalStorageService] 파일 저장 절대 경로 지정 완료: {}", this.uploadDir); + this.imageProcessor = imageProcessor; + this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize(); + log.info("[LocalStorageService] 파일 저장 절대 경로 지정 완료: {}", this.uploadRoot); } @Override - public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { - // 이미지 유효성 정밀 검증 및 스트림 초기화 - InputStream validatedStream = imageFileValidator.validateImage(inputStream, originalFileName, contentType); + public String upload(InputStream inputStream, String originalFileName, String contentType, String directory, ImageResizeSpec resizeSpec) { + // try-with-resources 구문으로 원본 inputStream 및 validatedStream 자원 수명주기를 안전하게 관리 + try (InputStream src = inputStream) { + // 검증 실패로 예외가 발생하여도 src(inputStream)가 자동으로 close() 되도록 보장한다. + String safeDirectory = StorageDirectoryValidator.validate(directory); - String extension = ""; - if (originalFileName != null && originalFileName.contains(".")) { - extension = originalFileName.substring(originalFileName.lastIndexOf(".")); - } + ImageProcessor.ImageValidationResult validationResult = + imageProcessor.validateImage(src, originalFileName, contentType); - String savedFileName = UUID.randomUUID() + extension; - Path uploadPath = Paths.get(uploadDir, directory); - try { - Files.createDirectories(uploadPath); - Path targetPath = uploadPath.resolve(savedFileName); - Files.copy(validatedStream, targetPath, StandardCopyOption.REPLACE_EXISTING); + byte[] bytes = imageProcessor.resizeImage( + validationResult.data(), + validationResult.format(), + validationResult.width(), + validationResult.height(), + resizeSpec + ); + + try (InputStream validatedStream = new ByteArrayInputStream(bytes)) { + String savedFileName = UUID.randomUUID() + validationResult.extension(); + + // directory가 uploadRoot 바깥으로 빠져나가지 않는지 검증 (Path Traversal 방지) + Path uploadPath = resolveSafely(uploadRoot, safeDirectory); + Files.createDirectories(uploadPath); + + Path targetPath = resolveSafely(uploadPath, savedFileName); + Files.copy(validatedStream, targetPath, StandardCopyOption.REPLACE_EXISTING); - return savedFileName; + return savedFileName; + } + } catch (BusinessException e) { + // 검증 실패(잘못된 경로/확장자/해상도 등) + log.warn("[LocalStorageService] 파일 업로드 검증 실패 - originalFileName: {}, directory: {}", + originalFileName, directory, e); + throw e; } catch (IOException e) { - log.error("[StorageService] 파일 업로드 실패 - originalFileName: {}, directory: {}", originalFileName, directory, e); + // 디스크 I/O 등 시스템 오류 + log.error("[LocalStorageService] 파일 업로드 실패 - originalFileName: {}, directory: {}", + originalFileName, directory, e); throw new BusinessException( GlobalErrorCode.FILE_UPLOAD_FAILED, "파일 저장 중 시스템 오류가 발생했습니다.", @@ -79,16 +101,34 @@ public void delete(String fileUrl) { } try { - Path filePath = Paths.get(uploadDir).resolve(relativePath); + Path filePath = resolveSafely(uploadRoot, relativePath); boolean deleted = Files.deleteIfExists(filePath); if (deleted) { - log.info("[StorageService] 물리 파일 삭제 완료: {}", filePath.toAbsolutePath()); + log.info("[LocalStorageService] 물리 파일 삭제 완료: {}", filePath); } else { - log.warn("[StorageService] 삭제할 파일이 디스크에 존재하지 않습니다. {}", filePath.toAbsolutePath()); + log.warn("[LocalStorageService] 삭제할 파일이 디스크에 존재하지 않습니다. {}", filePath); } + } catch (BusinessException e) { + log.warn("[LocalStorageService] 허용되지 않는 삭제 경로 요청 차단: {}", fileUrl); } catch (IOException e) { - log.error("[StorageService] 물리 파일 삭제 실패: {}", fileUrl, e); + log.error("[LocalStorageService] 물리 파일 삭제 실패: {}", fileUrl, e); + } + } + + /** + * base 경로 하위로 relative를 결합한 뒤 정규화하고 결과 경로가 base 바깥으로 벗어나지 않는지 검증합니다. + * 절대 경로 위장( "../") 경로 조작을 모두 차단합니다. + */ + private Path resolveSafely(Path base, String relative) { + if (relative == null) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "유효하지 않은 경로입니다."); + } + Path resolved = base.resolve(relative).normalize(); + if (!resolved.startsWith(base)) { + log.warn("[LocalStorageService] 허용된 경로를 벗어난 요청 차단: base={}, relative={}", base, relative); + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 경로입니다."); } + return resolved; } } diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java index 882e58a..f4324ce 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/S3StorageService.java @@ -2,7 +2,9 @@ import com.momogo.core.common.exception.BusinessException; import com.momogo.core.common.exception.GlobalErrorCode; -import com.momogo.core.common.util.ImageFileValidator; +import com.momogo.core.common.util.storage.ImageProcessor; +import com.momogo.core.common.util.storage.ImageResizeSpec; +import com.momogo.core.common.util.storage.StorageDirectoryValidator; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -28,47 +30,57 @@ @ConditionalOnProperty(name = "app.storage.type", havingValue = "s3") public class S3StorageService implements StorageService { - private final ImageFileValidator imageFileValidator; + private final ImageProcessor imageProcessor; private final S3Client s3Client; private final String bucket; public S3StorageService( - ImageFileValidator imageFileValidator, + ImageProcessor imageProcessor, @Value("${app.aws.s3-bucket}") String bucket, @Value("${app.aws.region}") String region ) { - this.imageFileValidator = imageFileValidator; + this.imageProcessor = imageProcessor; this.bucket = bucket; this.s3Client = S3Client.builder().region(Region.of(region)).build(); log.info("[S3StorageService] 버킷 지정 완료: {}", bucket); } + @Override - public String upload(InputStream inputStream, String originalFileName, String contentType, String directory) { - // try-with-resources 구문으로 원본 inputStream 및 validatedStream 자원 수명주기를 안전하게 관리 - try (InputStream src = inputStream; - InputStream validatedStream = imageFileValidator.validateImage(src, originalFileName, contentType)) { - - String extension = ""; - if (originalFileName != null && originalFileName.contains(".")) { - extension = originalFileName.substring(originalFileName.lastIndexOf(".")); - } + public String upload(InputStream inputStream, String originalFileName, String contentType, String directory, ImageResizeSpec resizeSpec) { - String savedFileName = UUID.randomUUID() + extension; - String key = directory + "/" + savedFileName; + try (InputStream src = inputStream) { + // 검증 실패로 예외가 발생하여도 src(inputStream)가 자동으로 close() 되도록 보장한다. + String safeDirectory = StorageDirectoryValidator.validate(directory); + + ImageProcessor.ImageValidationResult validationResult = imageProcessor.validateImage(src, originalFileName, contentType); + + byte[] bytes = imageProcessor.resizeImage( + validationResult.data(), + validationResult.format(), + validationResult.width(), + validationResult.height(), + resizeSpec + ); + + String savedFileName = UUID.randomUUID() + validationResult.extension(); + String key = safeDirectory.isEmpty() ? savedFileName : safeDirectory + "/" + savedFileName; - byte[] bytes = validatedStream.readAllBytes(); s3Client.putObject( PutObjectRequest.builder() .bucket(bucket) .key(key) - .contentType(contentType) + .contentType(validationResult.detectedContentType()) .build(), RequestBody.fromBytes(bytes) ); return savedFileName; - + } catch (BusinessException e) { + // 검증 실패(잘못된 경로/확장자 등) + log.warn("[S3StorageService] 파일 업로드 검증 실패 - originalFileName: {}, directory: {}", originalFileName, directory, e); + throw e; } catch (IOException | SdkException e) { + // 인프라/시스템 오류 log.error("[S3StorageService] S3 파일 업로드 실패 - originalFileName: {}, directory: {}", originalFileName, directory, e); throw new BusinessException( GlobalErrorCode.FILE_UPLOAD_FAILED, diff --git a/momogo-core/src/main/java/com/momogo/core/common/storage/StorageService.java b/momogo-core/src/main/java/com/momogo/core/common/storage/StorageService.java index 8c42187..0907dc1 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/storage/StorageService.java +++ b/momogo-core/src/main/java/com/momogo/core/common/storage/StorageService.java @@ -1,10 +1,12 @@ package com.momogo.core.common.storage; +import com.momogo.core.common.util.storage.ImageResizeSpec; + import java.io.InputStream; public interface StorageService { - String upload(InputStream inputStream, String originalFileName, String contentType, String directory); + String upload(InputStream inputStream, String originalFileName, String contentType, String directory, ImageResizeSpec resizeSpec); void delete(String fileUrl); } diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/ImageFileValidator.java b/momogo-core/src/main/java/com/momogo/core/common/util/ImageFileValidator.java deleted file mode 100644 index 17195cc..0000000 --- a/momogo-core/src/main/java/com/momogo/core/common/util/ImageFileValidator.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.momogo.core.common.util; - -import com.momogo.core.common.exception.BusinessException; -import com.momogo.core.common.exception.GlobalErrorCode; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.util.unit.DataSize; - -import javax.imageio.ImageIO; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.util.List; -import java.util.Set; - -/** - * 프로필 이미지 등 파일 업로드 시 업로드될 파일의 유효성과 보안성을 검증하는 컴포넌트입니다. - * 확장자 및 MIME 타입을 검증하며, ImageIO를 사용하여 실제 이미지 파일 구조인지 검증합니다. - */ -@Component -public class ImageFileValidator { - - private final Set allowedExtensions; - private final Set allowedMimeTypes; - private final int maxMarkSize; - - public ImageFileValidator( - @Value("${app.file.upload.allowed-extensions}") List allowedExtensions, - @Value("${app.file.upload.allowed-mime-types}") List allowedMimeTypes, - @Value("${app.file.upload.max-mark-size:10MB}") DataSize maxMarkSize - ) { - this.allowedExtensions = Set.copyOf(allowedExtensions); - this.allowedMimeTypes = Set.copyOf(allowedMimeTypes); - this.maxMarkSize = (int) maxMarkSize.toBytes(); - } - - /** - * 파일 업로드 시 확장자, MIME 타입 및 실제 이미지 바이트 무결성을 일괄 검증합니다. - * - * @param inputStream 파일 데이터 스트림 - * @param originalFilename 원본 파일 이름 - * @param contentType 파일의 Content-Type - * @return 검증 후 다시 처음부터 읽을 수 있도록 분리 및 복사된 InputStream - */ - public InputStream validateImage(InputStream inputStream, String originalFilename, String contentType) { - // 1. 파일 이름 및 확장자 검사 - if (originalFilename == null || !originalFilename.contains(".")) { - throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "올바르지 않은 파일명입니다."); - } - String ext = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase(); - if (!allowedExtensions.contains(ext)) { - throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 파일 확장자입니다."); - } - - // 2. MIME 타입 검증 - if (contentType == null || !allowedMimeTypes.contains(contentType.toLowerCase())) { - throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 파일 타입(MIME)입니다."); - } - - // 3. 업로드 상한 내에서 바이트를 안전하게 복사 (OOM 방지 및 스트림 복제) - byte[] fileBytes = readWithLimit(inputStream, maxMarkSize); - - try { - // 4. 검증용 ByteArrayInputStream 생성하여 ImageIO 검증 - try (InputStream validationStream = new ByteArrayInputStream(fileBytes)) { - if (ImageIO.read(validationStream) == null) { - throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "손상되었거나 변조된 이미지 파일입니다."); - } - } - - // 5. 저장용으로 사용할 새로운 ByteArrayInputStream 반환 - return new ByteArrayInputStream(fileBytes); - - } catch (BusinessException e) { - throw e; - } catch (Exception e) { - throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "이미지 분석 중 오류가 발생했습니다."); - } - } - - /** - * 지정한 바이트 크기 상한(limit) 내에서만 스트림을 읽어 바이트 배열로 반환합니다. - * 상한을 초과할 경우 즉시 예외를 발생시켜 OOM을 예방합니다. - */ - private byte[] readWithLimit(InputStream inputStream, int limit) { - try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { - byte[] buffer = new byte[4096]; - int bytesRead; - int totalBytes = 0; - - while ((bytesRead = inputStream.read(buffer)) != -1) { - totalBytes += bytesRead; - if (totalBytes > limit) { - throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "업로드 가능한 최대 파일 크기를 초과했습니다."); - } - bos.write(buffer, 0, bytesRead); - } - return bos.toByteArray(); - } catch (BusinessException e) { - throw e; - } catch (Exception e) { - throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "파일을 읽는 중 오류가 발생했습니다."); - } - } -} diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageProcessor.java b/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageProcessor.java new file mode 100644 index 0000000..d8a52b4 --- /dev/null +++ b/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageProcessor.java @@ -0,0 +1,240 @@ +package com.momogo.core.common.util.storage; + +import com.momogo.core.common.exception.BusinessException; +import com.momogo.core.common.exception.GlobalErrorCode; +import lombok.extern.slf4j.Slf4j; +import net.coobird.thumbnailator.Thumbnails; +import net.coobird.thumbnailator.geometry.Positions; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.unit.DataSize; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 업로드될 파일의 유효성과 보안성을 검증하고, 필요 시 지정된 규격으로 + * 리사이징/압축을 수행하는 범용 이미지 처리 컴포넌트 + * 확장자를 1차 검증하고, 실제 바이트(매직바이트)를 기반으로 ImageIO가 인식하는 실제 이미지 포맷을 감지하여 + * 확장자와 일치하는지 교차 검증합니다. 클라이언트가 전달한 Content-Type 헤더는 신뢰하지 않습니다. + * ImageResizeSpec으로 리사이징 규격을 전달받아, 프로필 이미지 전용 규격 등 특정 도메인에 종속되지 않습니다. + */ +@Slf4j +@Component +public class ImageProcessor { + + private static final double OUTPUT_QUALITY = 0.85; + + // 픽셀 폭탄(decompression bomb) 방어용 최대 허용 픽셀 수 (예: 4000 x 4000 => 1600만 화소) + // 최대 1600만 * 4bytes 디코딩 기준 최대 약 61MB 메모리 사용 + private static final long MAX_PIXEL_COUNT = 4000L * 4000L; + + // 파일 확장자를 ImageIO 표준 포맷명으로 변환하여, 이미지 소스 분석 결과와 대조하기 위한 매핑 테이블 + private static final Map EXTENSION_TO_FORMAT = Map.of( + "jpg", "jpeg", + "jpeg", "jpeg", + "png", "png", + "webp", "webp" + ); + + // 감지된 이미지 포맷을 웹 표준 HTTP Content-Type (MIME Type)으로 변환하는 매핑 테이블 + private static final Map FORMAT_TO_MIME_TYPE = Map.of( + "jpeg", "image/jpeg", + "png", "image/png", + "webp", "image/webp" + ); + + private final Set allowedExtensions; + private final int maxFileSize; + + public ImageProcessor( + @Value("${app.file.upload.allowed-extensions}") List allowedExtensions, + @Value("${app.file.upload.max-file-size:10MB}") DataSize maxFileSize + ) { + this.allowedExtensions = allowedExtensions.stream() + .map(String::toLowerCase) + .collect(Collectors.toUnmodifiableSet()); + this.maxFileSize = (int) maxFileSize.toBytes(); + } + + /** + * 검증이 완료되면 완료된 원본 이미지 바이트, 감지된 ContentType/포맷, 확장자, + * 원본 가로/세로 픽셀 크기를 담는 결과 객체 + * + * @param inputStream 파일 데이터 스트림 + * @param originalFilename 원본 파일 이름 + * @param contentType 클라이언트가 전달한 Content-Type (참고용, 신뢰하지 않음) + * @return 검증된 스트림과 실제 감지된 Content-Type을 담은 결과 객체 + */ + public ImageValidationResult validateImage(InputStream inputStream, String originalFilename, String contentType) { + // 1. 파일 이름 및 확장자 검사 + if (originalFilename == null || !originalFilename.contains(".")) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "올바르지 않은 파일명입니다."); + } + String ext = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase(); + if (!allowedExtensions.contains(ext) || !EXTENSION_TO_FORMAT.containsKey(ext)) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 파일 확장자입니다."); + } + + // 2. 업로드 상한 내에서 바이트를 안전하게 복사 (OOM 방지 및 스트림 복제) + byte[] fileBytes = readWithLimit(inputStream, maxFileSize); + + // 3. 실제 바이트 기반 이미지 포맷 감지 및 검증 (contentType 파라미터는 사용하지 않음(변조 방지)) + ImageDimension dimension = detectAndValidateFormat(fileBytes); + + // 4. 확장자와 실제 감지된 포맷이 일치하는지 교차 검증 (위장 확장자 차단) + String expectedFormat = EXTENSION_TO_FORMAT.get(ext); + if (!expectedFormat.equals(dimension.format())) { + throw new BusinessException( + GlobalErrorCode.INVALID_INPUT, + "파일 내용이 확장자와 일치하지 않습니다.", + "ext=" + ext + ", detectedFormat=" + dimension.format() + ); + } + + String detectedMimeType = FORMAT_TO_MIME_TYPE.get(dimension.format()); + return new ImageValidationResult( + fileBytes, + detectedMimeType, + "." + ext, + dimension.format(), + dimension.width(), + dimension.height() + ); + } + + /** + * 검증 완료된 이미지를 지정된 규격으로 정사각형 중앙 크롭 라사이징 및 압축합니다. + * 원본이 목표 규격보다 작은 경우, 원본의 짧은 변을 기준으로 리사이징하여 + * 이미지가 억지로 확대되지 않도록 합니다. + * + * @param originalBytes 검증이 완료된 원본 이미지 바이트 + * @param format 감지된 이미지 포맷 + * @param originalWidth 원본 이미지 가로 픽셀 크기 + * @param originalHeight 원본 이미지 세로 픽셀 크기 + * @param spec 목표 리사이징 규격 + * @return 리사이징 및 압축이 완료된 이미지 바이트 + */ + public byte[] resizeImage(byte[] originalBytes, String format, int originalWidth, int originalHeight, ImageResizeSpec spec) { + + // 원본의 짧은 변과 목표 규격 중 더 작은 값을 정사각형 한 변으로 사용 + int targetSize = Math.min(Math.min(originalWidth, originalHeight), Math.min(spec.width(), spec.height())); + + try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + Thumbnails.of(new ByteArrayInputStream(originalBytes)) + .size(targetSize, targetSize) + .crop(Positions.CENTER) + .outputQuality(OUTPUT_QUALITY) // 85% 품질 압축 (용량 절감) + .outputFormat(format) + .toOutputStream(bos); + return bos.toByteArray(); + } catch (Exception e) { + log.error("[ImageProcessor] 이미지 리사이징 실패 - 원본 바이트 유지", e); + throw new BusinessException( + GlobalErrorCode.INVALID_INPUT, + "이미지 리사이징에 실패했습니다." + ); + } + } + + /** + * ImageIO 리더를 사용해 실제 이미지 포맷을 감지하고, 디코딩 전에 해상도(픽셀 수) 상한을 검사합니다. + * 전체 픽셀 디코딩 없이 헤더 수준에서 width/height를 읽어 압축 폭탄(decompression bomb)을 방어합니다. + */ + private ImageDimension detectAndValidateFormat(byte[] fileBytes) { + try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(fileBytes))) { + if (iis == null) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "손상되었거나 변조된 이미지 파일입니다."); + } + + Iterator readers = ImageIO.getImageReaders(iis); + if (!readers.hasNext()) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "손상되었거나 변조된 이미지 파일입니다."); + } + + // 파일 바이트를 해독할 수 있는 전용 디코더 객체 + ImageReader reader = readers.next(); + try { + // seekForwardOnly: 스트림을 순방향으로만 읽어 메모리 절약 + // ignoreMetadata: 부가 메타데이터를 무시하여 읽기 속도를 최대로 끌어올림 + reader.setInput(iis, true, true); + + // 전체 이미지 픽셀을 메모리에 올리지 않고 가로, 세로 픽셀 크기만 즉시 읽어옴 + int width = reader.getWidth(0); + int height = reader.getHeight(0); + // 초과 시 예외를 발생시켜 압축 폭탄 공격으로 인한 서버 메모리 고갈을 사전에 차단 + if ((long) width * height > MAX_PIXEL_COUNT) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "이미지 해상도가 허용 범위를 초과했습니다."); + } + + return new ImageDimension(reader.getFormatName().toLowerCase(), width, height); + } finally { + // 사용이 끝난 reader 객체를 메모리에서 해제 + reader.dispose(); + } + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "이미지 분석 중 오류가 발생했습니다."); + } + } + + /** + * 지정한 바이트 크기 상한(limit) 내에서만 스트림을 읽어 바이트 배열로 반환합니다. + * 상한을 초과할 경우 즉시 예외를 발생시켜 OOM을 예방합니다. + */ + private byte[] readWithLimit(InputStream inputStream, int limit) { + // 읽어들인 조각 바이트들을 하나로 모아 저장할 메모리 스트림을 생성 + try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[4096]; + int bytesRead; + int totalBytes = 0; + + // 스트림의 끝(EOF)에 도달할 때까지 4KB 조각 단위로 계속 읽음 + while ((bytesRead = inputStream.read(buffer)) != -1) { + if (totalBytes + bytesRead > limit) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "업로드 가능한 최대 파일 크기를 초과했습니다."); + } + totalBytes += bytesRead; + bos.write(buffer, 0, bytesRead); + } + return bos.toByteArray(); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "파일을 읽는 중 오류가 발생했습니다."); + } + } + + /** + * 검증 및 리사이징이 완료된 바이트 데이터, 감지된 Content-Type, 검증된 확장자(".jpg" 등)를 담는 결과 객체. + */ + public record ImageValidationResult( + byte[] data, + String detectedContentType, + String extension, + String format, + int width, + int height + ) { + public InputStream inputStream() { + return new ByteArrayInputStream(data); + } + } + + /** + * detectAndValidateFormat()의 반환값으로, 감지된 실제 이미지 포맷명과 픽셀 단위의 가로/세로 크기를 담습니다. + * 리사이징 여부(원본이 목표 규격보다 작은지) 판단에 사용됩니다. + */ + private record ImageDimension(String format, int width, int height) { + } +} diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageResizeSpec.java b/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageResizeSpec.java new file mode 100644 index 0000000..adc19da --- /dev/null +++ b/momogo-core/src/main/java/com/momogo/core/common/util/storage/ImageResizeSpec.java @@ -0,0 +1,19 @@ +package com.momogo.core.common.util.storage; + +/** + * 이미지 리사이징 규격(목표 가로/세로 픽셀 크기)을 나타내는 값 객체. + * 도메인별로 필요한 리사이징 규격을 상수로 미리 정의해두고 재사용합니다. + */ +public record ImageResizeSpec(int width, int height) { + + public ImageResizeSpec { + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("width, height는 0보다 커야 합니다."); + } + } + + /** + * 프로필 이미지 규격 (정사각형 300x300, 중앙 크롭) + */ + public static final ImageResizeSpec PROFILE = new ImageResizeSpec(300, 300); +} diff --git a/momogo-core/src/main/java/com/momogo/core/common/util/storage/StorageDirectoryValidator.java b/momogo-core/src/main/java/com/momogo/core/common/util/storage/StorageDirectoryValidator.java new file mode 100644 index 0000000..5fe4da2 --- /dev/null +++ b/momogo-core/src/main/java/com/momogo/core/common/util/storage/StorageDirectoryValidator.java @@ -0,0 +1,47 @@ +package com.momogo.core.common.util.storage; + +import com.momogo.core.common.exception.BusinessException; +import com.momogo.core.common.exception.GlobalErrorCode; + +import java.util.ArrayList; +import java.util.List; + +/** + * StorageService 구현체 간 directory 파라미터 처리 방식을 통일하기 위한 공통 검증기 + */ +public final class StorageDirectoryValidator { + + private StorageDirectoryValidator() { + } + + public static String validate(String directory) { + if (directory == null || directory.isBlank()) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "저장 경로(directory)가 지정되지 않았습니다."); + } + + // 윈도우 경로 구분자(\)를 표준 구분자(/)로 통일 + String normalized = directory.replace("\\", "/"); + boolean windowsAbsolute = normalized.length() >= 3 + && Character.isLetter(normalized.charAt(0)) + && normalized.charAt(1) == ':' + && normalized.charAt(2) == '/'; + if (normalized.startsWith("/") || windowsAbsolute) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 저장 경로입니다."); + } + // 슬래시(/) 기준으로 전체 경로 세그먼트 분할 (마지막 빈 세그먼트까지 포함) + String[] segments = normalized.split("/", -1); + + List cleanSegments = new ArrayList<>(); + for (String segment : segments) { + // 상위 디렉터리 접근(Path Traversal) 세그먼트 차단 + if ("..".equals(segment)) { + throw new BusinessException(GlobalErrorCode.INVALID_INPUT, "허용되지 않는 저장 경로입니다."); + } + // 현재 디렉터리(.) 및 중복/시작/끝 슬래시로 인한 빈 세그먼트 제외 + if (!segment.isEmpty() && !".".equals(segment)) { + cleanSegments.add(segment); + } + } + return String.join("/", cleanSegments); + } +} diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java index ce07441..fed6849 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java @@ -8,6 +8,7 @@ import com.momogo.core.common.storage.event.FileRollbackDeleteEvent; import com.momogo.core.common.util.EmailFormatter; import com.momogo.core.common.util.UrlUtils; +import com.momogo.core.common.util.storage.ImageResizeSpec; import com.momogo.core.domain.user.dto.UserSearchCondition; import com.momogo.core.domain.user.dto.request.ProfileImageUploadRequest; import com.momogo.core.domain.user.dto.request.UserCreateRequest; @@ -135,7 +136,8 @@ public UserResponse updateUser(UUID userId, UserUpdateRequest request, ProfileIm inputStream, profile.originalFilename(), profile.contentType(), - PROFILE_IMAGE_DIR + PROFILE_IMAGE_DIR, + ImageResizeSpec.PROFILE ); user.updateProfileImage(savedFileName);