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
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ dependencies {
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17'
implementation 'org.mapstruct:mapstruct:1.6.3'
// AWS SDK (S3)
implementation platform('software.amazon.awssdk:bom:2.46.7')
implementation 'software.amazon.awssdk:s3'
// Spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT 기능을 위한 jjwt 라이브러리
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.codeit.team5.mopl.binarycontent;

import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;

/**
* 허용 이미지 확장자와 그에 대응하는 Content-Type을 한 곳에서 관리한다.
* 확장자를 추가하려면 이 enum에 상수만 추가하면 된다.
*/
public enum ImageExtension {
JPG("image/jpeg"),
JPEG("image/jpeg"),
PNG("image/png"),
GIF("image/gif"),
WEBP("image/webp");

private final String contentType;

ImageExtension(String contentType) {
this.contentType = contentType;
}

public String contentType() {
return contentType;
}

public static Optional<ImageExtension> from(String extension) {
if (extension == null || extension.isBlank()) {
return Optional.empty();
}
String normalized = extension.toLowerCase(Locale.ROOT);
return Arrays.stream(values())
.filter(e -> e.name().toLowerCase(Locale.ROOT).equals(normalized))
.findFirst();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
public record BinaryContentUploadEvent(
UUID binaryContentId,
String key,
byte[] bytes
byte[] bytes,
String contentType
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.codeit.team5.mopl.binarycontent.exception;

import com.codeit.team5.mopl.global.exception.BusinessException;
import org.springframework.http.HttpStatus;

public class InvalidImageExtensionException extends BusinessException {

public InvalidImageExtensionException(String extension) {
super(HttpStatus.BAD_REQUEST, "허용되지 않는 이미지 확장자입니다: " + extension);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.codeit.team5.mopl.binarycontent.listener;

import com.codeit.team5.mopl.binarycontent.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent;
import com.codeit.team5.mopl.binarycontent.service.BinaryContentService;
Expand All @@ -23,7 +23,7 @@ public class BinaryContentUploadListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(BinaryContentUploadEvent event) {
try {
binaryContentStorage.store(event.key(), event.bytes());
binaryContentStorage.store(event.key(), event.bytes(), event.contentType());
binaryContentService.updateUploadStatus(event.binaryContentId(), BinaryContentUploadStatus.COMPLETED);
log.debug("파일 업로드 완료 - binaryContentId: {}", event.binaryContentId());
} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.codeit.team5.mopl.binarycontent.storage;

public interface BinaryContentStorage {

String toUrl(String key);

void store(String key, byte[] bytes, String contentType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.codeit.team5.mopl.binarycontent.storage;

/**
* 저장소 키와 그에 대응하는 Content-Type을 함께 전달하는 값 객체.
*/
public record GeneratedKey(
String key,
String contentType
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.codeit.team5.mopl.binarycontent.storage;

/**
* 저장소 키의 최상위 폴더(prefix). 용도별로 추가하려면 상수만 추가하면 된다.
*/
public enum StorageDirectory {
THUMBNAIL("thumbnails"),
PROFILE("profiles");

private final String value;

StorageDirectory(String value) {
this.value = value;
}

public String value() {
return value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.codeit.team5.mopl.binarycontent.storage;

import com.codeit.team5.mopl.binarycontent.ImageExtension;
import com.codeit.team5.mopl.binarycontent.exception.InvalidImageExtensionException;
import java.util.Locale;
import java.util.UUID;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

/**
* 저장소 키 생성 책임을 담당한다.
* 확장자 검증 → 폴더 prefix/소유자/난수 조합 → Content-Type 결정을 한 곳에서 처리한다.
*/
@Component
public class StorageKeyFactory {

public GeneratedKey generate(StorageDirectory directory, UUID ownerId, String originalFilename) {
String extension = StringUtils.getFilenameExtension(originalFilename);
ImageExtension imageExtension = ImageExtension.from(extension)
.orElseThrow(() -> new InvalidImageExtensionException(extension));
String key = directory.value() + "/" + ownerId + "/" + UUID.randomUUID()
+ "." + imageExtension.name().toLowerCase(Locale.ROOT);
return new GeneratedKey(key, imageExtension.contentType());
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.codeit.team5.mopl.binarycontent.storage.local;

import com.codeit.team5.mopl.binarycontent.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.exception.FileStorageException;
import com.codeit.team5.mopl.binarycontent.exception.UploadDirectoryInitException;
import java.io.IOException;
Expand Down Expand Up @@ -31,7 +31,7 @@ public String toUrl(String key) {
}

@Override
public void store(String key, byte[] bytes) {
public void store(String key, byte[] bytes, String contentType) {
Path destination = uploadDir.resolve(key);
try {
Files.createDirectories(destination.getParent());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
package com.codeit.team5.mopl.binarycontent.storage.s3;

import com.codeit.team5.mopl.binarycontent.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.exception.BinaryContentStorageException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

@Slf4j
@Component
@ConditionalOnProperty(name = "mopl.storage.type", havingValue = "s3")
public class S3BinaryContentStorage implements BinaryContentStorage {

private final S3StorageProperties properties;
private final S3Client s3Client;

public S3BinaryContentStorage(S3StorageProperties properties) {
public S3BinaryContentStorage(S3StorageProperties properties, S3Client s3Client) {
this.properties = properties;
this.s3Client = s3Client;
}

@Override
Expand All @@ -20,7 +30,19 @@ public String toUrl(String key) {
}

@Override
public void store(String key, byte[] bytes) {
throw new UnsupportedOperationException("S3 스토리지가 아직 구현되지 않았습니다.");
public void store(String key, byte[] bytes, String contentType) {
try {
s3Client.putObject(
PutObjectRequest.builder()
.bucket(properties.bucketName())
.key(key)
.contentType(contentType)
.build(),
RequestBody.fromBytes(bytes));
log.debug("S3 업로드 완료: key={}, contentType={}", key, contentType);
} catch (SdkException e) {
throw new BinaryContentStorageException(
HttpStatus.INTERNAL_SERVER_ERROR, "S3 업로드 실패: key=" + key, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.codeit.team5.mopl.binarycontent.support;

import com.codeit.team5.mopl.binarycontent.ImageExtension;
import com.codeit.team5.mopl.binarycontent.exception.BinaryContentStorageException;
import com.codeit.team5.mopl.binarycontent.exception.InvalidImageExtensionException;
import com.codeit.team5.mopl.global.dto.FileRequest;
import java.io.IOException;
import lombok.experimental.UtilityClass;
import org.springframework.http.HttpStatus;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

/**
* MultipartFile(web 타입)을 서비스 계층용 {@link FileRequest}로 변환한다.
* 변환 시점에 확장자를 검증(fail-fast)하고 바이트를 읽어, web/IO 의존을 컨트롤러 경계에서 차단한다.
*/
@UtilityClass
public class MultipartFiles {

public FileRequest toImageResource(MultipartFile file) {
if (file == null || file.isEmpty()) {
return null;
}
String extension = StringUtils.getFilenameExtension(file.getOriginalFilename());
if (ImageExtension.from(extension).isEmpty()) {
throw new InvalidImageExtensionException(extension);
}
try {
return new FileRequest(file.getBytes(), file.getOriginalFilename());
} catch (IOException e) {
throw new BinaryContentStorageException(
HttpStatus.INTERNAL_SERVER_ERROR,
"파일을 읽을 수 없습니다: " + file.getOriginalFilename(), e);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
33 changes: 33 additions & 0 deletions src/main/java/com/codeit/team5/mopl/config/StorageConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,44 @@

import com.codeit.team5.mopl.binarycontent.storage.local.LocalStorageProperties;
import com.codeit.team5.mopl.binarycontent.storage.s3.S3StorageProperties;
import java.util.Objects;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

@Configuration
@EnableConfigurationProperties({LocalStorageProperties.class, S3StorageProperties.class})
public class StorageConfig {

// mopl.storage.type=s3 일 때만 S3Client 빈 생성
@Bean
@ConditionalOnProperty(name = "mopl.storage.type", havingValue = "s3")
public S3Client s3Client(S3StorageProperties props) {
String region = Objects.requireNonNull(props.region(), "mopl.storage.s3.region 설정이 필요합니다");

String accessKey = props.accessKey();
String secretKey = props.secretKey();

boolean hasAccessKey = accessKey != null && !accessKey.isBlank();
boolean hasSecretKey = secretKey != null && !secretKey.isBlank();
if (hasAccessKey != hasSecretKey) {
throw new IllegalStateException("accessKey와 secretKey는 함께 설정해야 합니다");
}

AwsCredentialsProvider credentialsProvider = hasAccessKey
? StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))
: DefaultCredentialsProvider.builder().build(); // prod: ECS task role 자격증명
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(credentialsProvider)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
import com.codeit.team5.mopl.content.service.ContentService;
import com.codeit.team5.mopl.binarycontent.support.MultipartFiles;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -31,7 +32,7 @@ public ResponseEntity<ContentResponse> postContent(
@RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail
) {
log.info("Content Create request: POST /api/contents");
ContentResponse response = contentService.create(request, thumbnail);
ContentResponse response = contentService.create(request, MultipartFiles.toImageResource(thumbnail));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
Loading
Loading