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
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ dependencies {
// Firebase-admin
implementation 'com.google.firebase:firebase-admin:9.2.0'

// AWS SDK
implementation 'software.amazon.awssdk:s3:2.25.33'
implementation 'software.amazon.awssdk:auth:2.25.33' // IAM 인증 관련
implementation 'software.amazon.awssdk:sts:2.25.33' // STS (IAM Role 인증 필요시)

// Tika : 이미지 타입 검사
implementation 'org.apache.tika:tika-core:2.5.0'

// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.batch:spring-batch-test'
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/com/ject/studytrip/global/config/CdnConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.ject.studytrip.global.config;

import com.ject.studytrip.global.config.properties.CdnProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(CdnProperties.class)
public class CdnConfig {}
34 changes: 34 additions & 0 deletions src/main/java/com/ject/studytrip/global/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.ject.studytrip.global.config;

import com.ject.studytrip.global.config.properties.S3Properties;
import lombok.RequiredArgsConstructor;
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.DefaultCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

@Configuration
@EnableConfigurationProperties(S3Properties.class)
@RequiredArgsConstructor
public class S3Config {
private final S3Properties props;

@Bean
public S3Client s3Client() {
return S3Client.builder()
.region(Region.of(props.region()))
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
}

@Bean
public S3Presigner s3Presigner() {
return S3Presigner.builder()
.region(Region.of(props.region()))
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/ject/studytrip/global/config/TikaConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.ject.studytrip.global.config;

import org.apache.tika.Tika;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TikaConfig {

@Bean
public Tika tika() {
return new Tika();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.ject.studytrip.global.config.properties;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "cdn")
public record CdnProperties(String domain) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.ject.studytrip.global.config.properties;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "aws.s3")
public record S3Properties(String bucket, String region, long presignExpiresInMinutes) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.ject.studytrip.global.resolver;

import com.ject.studytrip.global.config.properties.CdnProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class CdnUrlBuilder {
private final CdnProperties props;

public String build(String key) {
return props.domain() + "/" + key;
}
}
23 changes: 23 additions & 0 deletions src/main/java/com/ject/studytrip/global/util/FilenameUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.ject.studytrip.global.util;

import java.util.UUID;

public final class FilenameUtil {
private static final String FILENAME_PATTERN = "%s.%s";

private FilenameUtil() {}

public static String createNewFilename(String ext) {
String newFilename = UUID.randomUUID().toString();
return FILENAME_PATTERN.formatted(newFilename, ext);
}

public static String extractExtension(String filename) {
if (filename == null) return null;

int i = filename.lastIndexOf('.');
if (i < 0 || i == filename.length() - 1) return null;

return filename.substring(i + 1).toLowerCase();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.ject.studytrip.image.application.dto;

public record PresignedImageInfo(String tmpKey, String presignedUrl) {
public static PresignedImageInfo of(String tmpKey, String presignedUrl) {
return new PresignedImageInfo(tmpKey, presignedUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.ject.studytrip.image.application.service;

import com.ject.studytrip.global.exception.CustomException;
import com.ject.studytrip.global.util.FilenameUtil;
import com.ject.studytrip.image.application.dto.PresignedImageInfo;
import com.ject.studytrip.image.domain.constants.ImageConstants;
import com.ject.studytrip.image.domain.factory.ImageKeyFactory;
import com.ject.studytrip.image.domain.policy.ImagePolicy;
import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo;
import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider;
import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class ImageService {

private final S3ImageStorageProvider s3Provider;
private final TikaImageProbeProvider tikaProvider;

// Presigned URL 발급
public PresignedImageInfo presign(String keyPrefix, String id, String originFilename) {
// 키 prefix 검증
ImagePolicy.validateKeyPrefix(keyPrefix);

// 확장자 추출, 검증
String ext = FilenameUtil.extractExtension(originFilename);
ImagePolicy.validateExtension(ext);

// 새로운 파일명 생성
String filename = FilenameUtil.createNewFilename(ext);

// 임시 키 생성
String tmpKey = ImageKeyFactory.createTmpKey(keyPrefix, id, filename);

// Presigned URL 생성
String presignedUrl = s3Provider.issuePresignedUrl(tmpKey);

return PresignedImageInfo.of(tmpKey, presignedUrl);
}

// 업로드된 이미지 확정
// S3 자체 에러 시에는 cleanup 실행 X
// 이미지 파일 크기, MIME 등 도메인 정책 검증에 실패하면 cleanup 실행
public String confirm(String tmpKey) {
// 임시 이미지 키 검증
ImagePolicy.validateKey(tmpKey);

// 업로드된 이미지 HEAD 조회
ImageHeadInfo head = s3Provider.getHeadByKey(tmpKey);

// 이미지 크기 검증, 검증 실패 시 이미지 삭제
validateSizeWithCleanup(tmpKey, head.contentLength());

// MIME 추출 및 판별, 검증 실패 시 이미지 삭제
validateMimeWithCleanup(tmpKey, head.contentLength());

// 임시 -> 최종 이미지 복사 및 키 반환
return moveToFinalLocation(tmpKey);
}

// 업로드 취소, 업로드된 이미지 삭제
public void cancel(List<String> uploadedKeys) {
s3Provider.deleteByKeys(uploadedKeys);
}

// 이미지 사이즈 검증, 실패 시 삭제
private void validateSizeWithCleanup(String tmpKey, long contentLength) {
try {
ImagePolicy.validateSize(contentLength);
} catch (CustomException e) {
cleanupAndThrow(tmpKey, e);
}
}

// 이미지 MIME 추출 및 검증, 실패 시 삭제
private void validateMimeWithCleanup(String tmpKey, long len) {
int maxBytes = (int) Math.min(len, ImageConstants.PROBE_BYTES);
byte[] prefix = s3Provider.readPrefix(tmpKey, maxBytes);
String mime = tikaProvider.detectMime(prefix);

try {
ImagePolicy.validateMime(mime);
} catch (CustomException e) {
cleanupAndThrow(tmpKey, e);
}
}

// 최종 경로에 이미지 복사
private String moveToFinalLocation(String tmpKey) {
String finalKey = ImageKeyFactory.toFinalKey(tmpKey);
ImagePolicy.validateKey(finalKey);
s3Provider.copyByKey(tmpKey, finalKey);
return finalKey;
}

// 삭제 및 예외 처리
private void cleanupAndThrow(String tmpKey, CustomException exception) {
s3Provider.deleteByKey(tmpKey);
throw exception;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.ject.studytrip.image.domain.constants;

import java.util.Set;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.springframework.util.unit.DataSize;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class ImageConstants {

public static final Set<String> ALLOWED_MIME_TYPES =
Set.of("image/jpeg", "image/png", "image/webp");

public static final long MAX_IMAGE_SIZE_BYTES = DataSize.ofMegabytes(5).toBytes();
public static final long MIN_IMAGE_SIZE_BYTES = 1L;

public static final int PROBE_BYTES = 32 * 1024; // 32KB

public static final String KEY_PATTERN = "%s/%s/%s";
public static final String TMP_PREFIX = "tmp/";
public static final Set<String> ALLOWED_OBJECT_KEY_PREFIXES =
Set.of("members", "study-logs", "trip-reports");

public static final Set<String> ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.ject.studytrip.image.domain.error;

import com.ject.studytrip.global.exception.error.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@RequiredArgsConstructor
public enum ImageErrorCode implements ErrorCode {
INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 확장자 입니다."),
INVALID_IMAGE_MIME(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 MIME 입니다."),
EMPTY_IMAGE(HttpStatus.BAD_REQUEST, "이미지 파일이 비어 있습니다."),
IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "이미지 파일 크기가 허용된 최대 크기(5MB)를 초과했습니다."),
INVALID_IMAGE_KEY_PREFIX(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 키 PREFIX 입니다."),
INVALID_IMAGE_KEY(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 키 입니다."),
;

private final HttpStatus status;
private final String message;

@Override
public String getName() {
return this.name();
}

@Override
public HttpStatus getStatus() {
return this.status;
}

@Override
public String getMessage() {
return this.message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.ject.studytrip.image.domain.factory;

import static com.ject.studytrip.image.domain.constants.ImageConstants.*;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class ImageKeyFactory {
public static String createTmpKey(String keyPrefix, String id, String filename) {
return TMP_PREFIX + KEY_PATTERN.formatted(keyPrefix, id, filename);
}

public static String toFinalKey(String tmpKey) {
if (tmpKey == null || tmpKey.isBlank() || !tmpKey.startsWith(TMP_PREFIX)) {
return null;
}
return tmpKey.substring(TMP_PREFIX.length());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.ject.studytrip.image.domain.policy;

import static com.ject.studytrip.image.domain.constants.ImageConstants.*;
import static org.springframework.util.StringUtils.hasText;

import com.ject.studytrip.global.exception.CustomException;
import com.ject.studytrip.image.domain.error.ImageErrorCode;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ImagePolicy {

public static void validateExtension(String ext) {
if (!hasText(ext) || !ALLOWED_EXTENSIONS.contains(ext)) {
throw new CustomException(ImageErrorCode.INVALID_IMAGE_EXTENSION);
}
}

public static void validateMime(String mime) {
if (!hasText(mime) || !ALLOWED_MIME_TYPES.contains(mime)) {
throw new CustomException(ImageErrorCode.INVALID_IMAGE_MIME);
}
}

public static void validateSize(long contentLength) {
if (contentLength < MIN_IMAGE_SIZE_BYTES) {
throw new CustomException(ImageErrorCode.EMPTY_IMAGE);
}

if (contentLength > MAX_IMAGE_SIZE_BYTES) {
throw new CustomException(ImageErrorCode.IMAGE_SIZE_EXCEEDED);
}
}

public static void validateKeyPrefix(String keyPrefix) {
if (!hasText(keyPrefix) || !ALLOWED_OBJECT_KEY_PREFIXES.contains(keyPrefix)) {
throw new CustomException(ImageErrorCode.INVALID_IMAGE_KEY_PREFIX);
}
}

public static void validateKey(String key) {
if (!hasText(key)) {
throw new CustomException(ImageErrorCode.INVALID_IMAGE_KEY);
}
}
}
Loading