Skip to content

Commit

Permalink
Merge pull request #28 from Mile-Writings/feat/#26
Browse files Browse the repository at this point in the history
#26 [feat] s3 모듈 구현
  • Loading branch information
sohyundoh authored Jan 8, 2024
2 parents 9cd91ec + 62597b0 commit fe23c32
Show file tree
Hide file tree
Showing 15 changed files with 266 additions and 13 deletions.
1 change: 1 addition & 0 deletions module-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ dependencies {
implementation project(':module-domain')
implementation project(':module-common')
implementation project(':module-auth')
implementation project(':module-external')

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
31 changes: 31 additions & 0 deletions module-api/src/main/java/com/mile/controller/TestController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.mile.controller;

import com.mile.aws.utils.PreSignedUrlResponse;
import com.mile.aws.utils.S3BucketDirectory;
import com.mile.aws.utils.S3Service;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

@RestController
@RequestMapping("/api/test")
@RequiredArgsConstructor
public class TestController {
private final S3Service s3Service;

@PostMapping()
public void testImage(@RequestBody MultipartFile image) throws IOException {
s3Service.uploadImage(S3BucketDirectory.TEST_PREFIX, image);
}

@GetMapping
public PreSignedUrlResponse getPresignedUrl() {
return s3Service.getUploadPreSignedUrl(S3BucketDirectory.TEST_PREFIX);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
import com.mile.exception.message.SuccessMessage;
import com.mile.external.client.dto.UserLoginRequest;
import com.mile.token.service.TokenService;
import com.mile.user.serivce.UserService;
import com.mile.user.serivce.dto.AccessTokenGetSuccess;
import com.mile.user.serivce.dto.LoginSuccessResponse;
import com.mile.user.service.UserService;
import com.mile.user.service.dto.AccessTokenGetSuccess;
import com.mile.user.service.dto.LoginSuccessResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import com.mile.dto.ErrorResponse;
import com.mile.dto.SuccessResponse;
import com.mile.external.client.dto.UserLoginRequest;
import com.mile.user.serivce.dto.AccessTokenGetSuccess;
import com.mile.user.serivce.dto.LoginSuccessResponse;
import com.mile.user.service.dto.AccessTokenGetSuccess;
import com.mile.user.service.dto.LoginSuccessResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@ public enum ErrorMessage {
MOIM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 글모임이 존재하지 않습니다."),
CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 모임의 주제가 존재하지 않습니다."),
HANDLER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "요청하신 URL은 정보가 없습니다."),

COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당 댓글이 존재하지 않습니다."),
/*
Bad Request
*/
ENUM_VALUE_BAD_REQUEST(HttpStatus.BAD_REQUEST.value(), "요청한 값이 유효하지 않습니다."),
AUTHENTICATION_CODE_EXPIRED(HttpStatus.BAD_REQUEST.value(), "인가 코드가 만료되었습니다."),
SOCIAL_TYPE_BAD_REQUEST(HttpStatus.BAD_REQUEST.value(), "로그인 요청이 유효하지 않습니다."),
INVALID_BUCKET_PREFIX(HttpStatus.BAD_REQUEST.value(), "유효하지 않는 S3 버킷 디렉터리 이름입니다."),
VALIDATION_REQUEST_MISSING_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "요청 값이 유효하지 않습니다."),
BEARER_LOST_ERROR(HttpStatus.BAD_REQUEST.value(), "토큰의 요청에 Bearer이 담겨 있지 않습니다."),

IMAGE_EXTENSION_INVALID_ERROR(HttpStatus.BAD_REQUEST.value(), "이미지 확장자는 jpg, png, webp만 가능합니다."),
IMAGE_SIZE_INVALID_ERROR(HttpStatus.BAD_REQUEST.value(), "이미지 사이즈는 5MB를 넘을 수 없습니다."),
/*
Unauthorized
*/
Expand All @@ -47,6 +48,8 @@ public enum ErrorMessage {
/*
Internal Server Error
*/
IMAGE_UPLOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "S3 버킷에 이미지를 업로드하는 데 실패했습니다."),
IMAGE_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "S3 버킷으로부터 이미지를 삭제하는 데 실패했습니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류입니다.");

final int status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import com.mile.post.domain.Post;
import com.mile.post.repository.PostRepository;
import com.mile.post.service.dto.CommentCreateRequest;
import com.mile.user.service.UserService;
import com.mile.post.service.dto.CommentListResponse;
import com.mile.user.serivce.UserService;
import com.mile.writerName.serivce.WriterNameService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.mile.user.serivce;
package com.mile.user.service;

import com.mile.authentication.UserAuthentication;
import com.mile.exception.message.ErrorMessage;
Expand All @@ -13,8 +13,8 @@
import com.mile.token.service.TokenService;
import com.mile.user.domain.User;
import com.mile.user.repository.UserRepository;
import com.mile.user.serivce.dto.AccessTokenGetSuccess;
import com.mile.user.serivce.dto.LoginSuccessResponse;
import com.mile.user.service.dto.AccessTokenGetSuccess;
import com.mile.user.service.dto.LoginSuccessResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.mile.user.serivce.dto;
package com.mile.user.service.dto;

public record AccessTokenGetSuccess(
String accessToken
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.mile.user.serivce.dto;
package com.mile.user.service.dto;

public record LoginSuccessResponse(
String accessToken,
Expand Down
8 changes: 8 additions & 0 deletions module-external/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
bootJar { enabled = false }
jar { enabled = true }

dependencies {
implementation project(":module-common")
implementation("software.amazon.awssdk:bom:2.21.0")
implementation("software.amazon.awssdk:s3:2.21.0")
}
57 changes: 57 additions & 0 deletions module-external/src/main/java/com/mile/aws/config/AwsConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.mile.aws.config;


import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

@Configuration
public class AwsConfig {

private static String AWS_ACCESS_KEY_ID = "aws.accessKeyId";
private static String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey";

private final String accessKey;
private final String secretKey;
private final String regionString;

public AwsConfig(@Value("${aws-property.access-key}") final String accessKey,
@Value("${aws-property.secret-key}") final String secretKey,
@Value("${aws-property.aws-region}") final String regionString) {
this.accessKey = accessKey;
this.secretKey = secretKey;
this.regionString = regionString;
}

@Bean
public SystemPropertyCredentialsProvider systemPropertyCredentialsProvider() {
System.setProperty(AWS_ACCESS_KEY_ID, accessKey);
System.setProperty(AWS_SECRET_ACCESS_KEY, secretKey);
return SystemPropertyCredentialsProvider.create();
}

@Bean
public Region getRegion() {
return Region.of(regionString);
}

@Bean
public S3Client getS3Client() {
return S3Client.builder()
.region(getRegion())
.credentialsProvider(systemPropertyCredentialsProvider())
.build();
}

@Bean
public S3Presigner getS3PreSigner() {
return S3Presigner.builder()
.region(getRegion())
.credentialsProvider(systemPropertyCredentialsProvider())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.mile.aws.utils;

public record PreSignedUrlResponse(
String fileName,
String url
) {

public static PreSignedUrlResponse of(
final String fileName,
final String url
) {
return new PreSignedUrlResponse(fileName, url);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mile.aws.utils;


import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public enum S3BucketDirectory {

TEST_PREFIX("test/"),
POST_PREFIX("post/");

private final String name;

public String value() {
return this.name;
}
}
120 changes: 120 additions & 0 deletions module-external/src/main/java/com/mile/aws/utils/S3Service.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.mile.aws.utils;


import com.mile.aws.config.AwsConfig;
import com.mile.exception.message.ErrorMessage;
import com.mile.exception.model.BadRequestException;
import com.mile.exception.model.MileException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetUrlRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;

import java.io.IOException;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;

@Slf4j
@Component
public class S3Service {

private static final List<String> IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg", "image/webp");
private static final Long MAX_FILE_SIZE = 5 * 1024 * 1024L;

private static final Long PRE_SIGNED_URL_EXPIRE_MINUTE = 1L; // 만료시간 1분

private final String bucketName;
private final AwsConfig awsConfig;

public S3Service(@Value("${aws-property.s3-bucket-name}") final String bucketName, AwsConfig awsConfig) {
this.bucketName = bucketName;
this.awsConfig = awsConfig;
}

// Multipart 요청을 통한 이미지 업로드
public String uploadImage(final S3BucketDirectory directoryPath,
final MultipartFile image) throws IOException {
validateExtension(image);
validateFileSize(image);

String key = directoryPath.value() + generateImageFileName();
try {
final S3Client s3Client = awsConfig.getS3Client();
PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType(image.getContentType())
.contentDisposition("inline").build();

RequestBody requestBody = RequestBody.fromBytes(image.getBytes());
s3Client.putObject(request, requestBody);
key = s3Client.utilities().getUrl(GetUrlRequest.builder().bucket(bucketName).key(key).build()).toString();
return key;
} catch (RuntimeException e) {
throw new MileException(ErrorMessage.INVALID_BUCKET_PREFIX);
}
}


// PreSigned Url을 통한 이미지 업로드
public PreSignedUrlResponse getUploadPreSignedUrl(final S3BucketDirectory prefix) {
final String fileName = generateImageFileName(); // UUID 문자열
final String key = prefix.value() + fileName;

try {
final S3Presigner preSigner = awsConfig.getS3PreSigner();

PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucketName)
.key(key).build();

PutObjectPresignRequest preSignedUrlRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(PRE_SIGNED_URL_EXPIRE_MINUTE))
.putObjectRequest(request).build();

String url = preSigner.presignPutObject(preSignedUrlRequest).url().toString();
return PreSignedUrlResponse.of(fileName, url);
} catch (RuntimeException e) {
throw new MileException(ErrorMessage.IMAGE_UPLOAD_ERROR);
}
}

// S3 버킷에 업로드된 이미지 삭제
public void deleteImage(final String key) throws IOException {
try {
final S3Client s3Client = awsConfig.getS3Client();

s3Client.deleteObject((DeleteObjectRequest.Builder builder) ->
builder.bucket(bucketName)
.key(key).build());
} catch (RuntimeException e) {
throw new MileException(ErrorMessage.IMAGE_DELETE_ERROR);
}
}

private String generateImageFileName() {
return UUID.randomUUID().toString() + ".jpg";
}

private void validateExtension(MultipartFile image) {
String contentType = image.getContentType();
if (!IMAGE_EXTENSIONS.contains(contentType)) {
throw new BadRequestException(ErrorMessage.IMAGE_EXTENSION_INVALID_ERROR);
}
}

private void validateFileSize(MultipartFile image) {
if (image.getSize() > MAX_FILE_SIZE) {
throw new BadRequestException(ErrorMessage.IMAGE_SIZE_INVALID_ERROR);
}
}
}
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ include 'module-api'
include 'module-common'
include 'module-domain'
include 'module-auth'
include 'module-external'

0 comments on commit fe23c32

Please sign in to comment.