Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
package com.codeit.team5.mopl.binarycontent;

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

public interface BinaryContentStorage {

Set<String> ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "webp");

default String generateKey(UUID contentId, String originalFilename) {
default String generateKey(StorageDirectory directory, UUID ownerId, String originalFilename) {
String extension = StringUtils.getFilenameExtension(originalFilename);
String lowerExt = extension != null ? extension.toLowerCase(Locale.ROOT) : null;
String normalizedExt = (lowerExt != null && ALLOWED_EXTENSIONS.contains(lowerExt))
? "." + lowerExt
: "";
return "thumbnails/" + contentId + "/" + UUID.randomUUID() + normalizedExt;
String normalizedExt = ImageExtension.from(extension)
.map(e -> "." + e.name().toLowerCase(Locale.ROOT))
.orElse("");
Comment thread
dstle marked this conversation as resolved.
Outdated
return directory.value() + "/" + ownerId + "/" + UUID.randomUUID() + normalizedExt;
}

default ImageExtension validateImageKey(String key) {
Comment thread
dstle marked this conversation as resolved.
Outdated
String extension = StringUtils.getFilenameExtension(key);
return ImageExtension.from(extension)
.orElseThrow(() -> new InvalidImageExtensionException(extension));
}

String toUrl(String key);
Expand Down
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
@@ -0,0 +1,19 @@
package com.codeit.team5.mopl.binarycontent;

/**
* 저장소 키의 최상위 폴더(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,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
Expand Up @@ -32,6 +32,7 @@ public String toUrl(String key) {

@Override
public void store(String key, byte[] bytes) {
validateImageKey(key);
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.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 @@ -21,6 +31,19 @@ public String toUrl(String key) {

@Override
public void store(String key, byte[] bytes) {
throw new UnsupportedOperationException("S3 스토리지가 아직 구현되지 않았습니다.");
String contentType = validateImageKey(key).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);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.codeit.team5.mopl.content.service;

import com.codeit.team5.mopl.binarycontent.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.StorageDirectory;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
import com.codeit.team5.mopl.binarycontent.event.BinaryContentUploadEvent;
import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository;
Expand All @@ -13,9 +14,9 @@
import com.codeit.team5.mopl.content.repository.ContentRepository;
import com.codeit.team5.mopl.content.repository.ContentStatsRepository;
import com.codeit.team5.mopl.content.entity.ContentTag;
import com.codeit.team5.mopl.global.dto.FileRequest;
import com.codeit.team5.mopl.tag.entity.Tag;
import com.codeit.team5.mopl.tag.repository.TagRepository;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
Expand All @@ -25,7 +26,6 @@
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@Service
@Transactional(readOnly = true)
Expand All @@ -42,23 +42,19 @@ public class ContentService {
private final ApplicationEventPublisher eventPublisher;

@Transactional
public ContentResponse create(ContentCreateRequest request, MultipartFile thumbnail) {
public ContentResponse create(ContentCreateRequest request, FileRequest thumbnail) {
Content content = contentRepository.save(Content.createByAdmin(
request.type(),
request.title(),
request.description()
));

if (thumbnail != null && !thumbnail.isEmpty()) {
try {
String key = binaryContentStorage.generateKey(content.getId(), thumbnail.getOriginalFilename());
BinaryContent binaryContent = binaryContentRepository.save(
BinaryContent.pending(binaryContentStorage.toUrl(key)));
content.attachThumbnail(binaryContent);
eventPublisher.publishEvent(new BinaryContentUploadEvent(binaryContent.getId(), key, thumbnail.getBytes()));
} catch (IOException e) {
log.warn("썸네일 바이트 읽기 실패 - contentId: {}", content.getId(), e);
}
if (thumbnail != null) {
String key = binaryContentStorage.generateKey(StorageDirectory.THUMBNAIL, content.getId(), thumbnail.filename());
BinaryContent binaryContent = binaryContentRepository.save(
BinaryContent.pending(binaryContentStorage.toUrl(key)));
content.attachThumbnail(binaryContent);
eventPublisher.publishEvent(new BinaryContentUploadEvent(binaryContent.getId(), key, thumbnail.bytes()));
}

attachTags(content, request.tags());
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/com/codeit/team5/mopl/global/dto/FileRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.codeit.team5.mopl.global.dto;

import java.util.Arrays;
import java.util.Objects;

/**
* 업로드 파일을 웹 계층(MultipartFile)에서 분리한 순수 데이터 홀더.
* 서비스 계층이 web 타입에 의존하지 않도록 컨트롤러에서 변환하여 전달한다.
* Content-Type은 클라이언트 값을 신뢰하지 않고 저장 시점에 확장자로 결정한다.
*/
public record FileRequest(
byte[] bytes,
String filename
) {

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof FileRequest other)) {
return false;
}
return Arrays.equals(bytes, other.bytes) && Objects.equals(filename, other.filename);
}

@Override
public int hashCode() {
return Objects.hash(Arrays.hashCode(bytes), filename);
}

@Override
public String toString() {
return "FileRequest{filename='" + filename + "', size="
+ (bytes != null ? bytes.length : 0) + "}";
}
}
Loading
Loading