Skip to content
Merged
2 changes: 1 addition & 1 deletion momogo-api/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions momogo-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
"파일 저장 중 시스템 오류가 발생했습니다.",
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}

This file was deleted.

Loading