Skip to content

Commit

Permalink
Merge pull request #60 from jisung-in/feature/56-talkroom-comment-ima…
Browse files Browse the repository at this point in the history
…ge-s3

[Feature] TalkRoom 의견 이미지를 S3를 통해 저장 기능 구현
  • Loading branch information
AHNYUNKI authored Apr 4, 2024
2 parents afb3168 + f19d889 commit b2bf94a
Show file tree
Hide file tree
Showing 24 changed files with 546 additions and 17 deletions.
8 changes: 8 additions & 0 deletions .github/workflows/jisungin_dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ jobs:
DEV_SECRET_DIR_FILE_NAME: application-crawler.yml
run: echo $DEV_SECRET | base64 --decode >> $DEV_SECRET_DIR/$DEV_SECRET_DIR_FILE_NAME

# application-s3.yml
- name: Copy s3 Secret
env:
DEV_SECRET: ${{ secrets.APPLICATION_S3_YML }}
DEV_SECRET_DIR: src/main/resources
DEV_SECRET_DIR_FILE_NAME: application-s3.yml
run: echo $DEV_SECRET | base64 --decode >> $DEV_SECRET_DIR/$DEV_SECRET_DIR_FILE_NAME

# application-jwt.yml
# - name: Copy jwt Secret
# env:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ application-dev.yml
application-prod.yml
application-jwt.yml
application-oauth.yml
application-crawler.yml
application-crawler.yml
application-s3.yml
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ dependencies {
// Test Container
testImplementation "org.junit.jupiter:junit-jupiter:5.8.1"
testImplementation "org.testcontainers:junit-jupiter:1.17.3"
// S3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
}

tasks.named('bootBuildImage') {
Expand Down
26 changes: 26 additions & 0 deletions src/docs/asciidoc/api/image/image.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[[image-upload]]
=== 이미지 업로드

==== HTTP Request

include::{snippets}/image/upload/http-request.adoc[]
include::{snippets}/image/upload/request-parts.adoc[]
include::{snippets}/image/upload/form-parameters.adoc[]

==== HTTP Response

include::{snippets}/image/upload/http-response.adoc[]
include::{snippets}/image/upload/response-fields.adoc[]

[[image-remove]]
=== 이미지 제거

==== HTTP Request

include::{snippets}/image/delete/http-request.adoc[]
include::{snippets}/image/delete/form-parameters.adoc[]

==== HTTP Response

include::{snippets}/image/delete/http-response.adoc[]
include::{snippets}/image/delete/response-fields.adoc[]
7 changes: 6 additions & 1 deletion src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,9 @@ include::api/talkroomlike/talkroomlike.adoc[]
[[CommentLike-API]]
== CommentLike API

include::api/commentlike/commentlike.adoc[]
include::api/commentlike/commentlike.adoc[]

[[Image-API]]
== Image API

include::api/image/image.adoc[]
39 changes: 39 additions & 0 deletions src/main/java/com/jisungin/api/image/ImageController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.jisungin.api.image;

import com.jisungin.api.ApiResponse;
import com.jisungin.application.image.ImageService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RequestMapping("/v1")
@RequiredArgsConstructor
@RestController
public class ImageController {

private final ImageService imageService;

@PostMapping("/s3")
public ApiResponse<List<String>> upload(@RequestPart List<MultipartFile> files,
@RequestParam("dirName") String dirName) {
return ApiResponse.ok(imageService.upload(files, dirName));
}

@DeleteMapping("/s3")
public ApiResponse<Void> removeFile(@RequestParam("fileName") String fileName) {
imageService.removeFile(fileName);

return ApiResponse.<Void>builder()
.message("삭제 성공")
.status(HttpStatus.OK)
.build();
}

}
26 changes: 26 additions & 0 deletions src/main/java/com/jisungin/application/image/ImageService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.jisungin.application.image;

import com.jisungin.infra.s3.S3FileManager;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

@RequiredArgsConstructor
@Service
public class ImageService {

private final S3FileManager s3FileManager;

public List<String> upload(List<MultipartFile> multipartFiles, String dirName) {
return multipartFiles.stream()
.map(multipartFile -> s3FileManager.upload(multipartFile, dirName))
.collect(Collectors.toList());
}

public void removeFile(String fileName) {
s3FileManager.removeFile(fileName);
}

}
34 changes: 34 additions & 0 deletions src/main/java/com/jisungin/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.jisungin.config;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
@Profile("!default")
public class S3Config {

@Value("${cloud.aws.credentials.access-key}")
private String accessKey;

@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;

@Value("${cloud.aws.region.static}")
private String region;

@Bean
public AmazonS3 amazonS3Client() {
BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(awsCreds))
.build();
}

}
5 changes: 4 additions & 1 deletion src/main/java/com/jisungin/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ public enum ErrorCode {
LIKE_EXIST(400, "이미 좋아요를 눌렀습니다."),
REQUEST_TIME_OUT(408, "요청 시간이 만료 되었습니다."),
COMMENT_LIKE_NOT_FOUND(404, "의견 좋아요를 찾을 수 없습니다."),
REVIEW_LIKE_NOT_FOUND(404, "리뷰 좋아요를 찾을 수 없습니다.");
REVIEW_LIKE_NOT_FOUND(404, "리뷰 좋아요를 찾을 수 없습니다."),
IMAGE_NOT_FOUND(400, "파일이 없습니다."),
S3_UPLOAD_FAIL(400, "이미지 업로드가 실패되었습니다."),
NOT_IMAGE(400, "이미지 파일이 아닙니다.");


private final int code;
Expand Down
131 changes: 131 additions & 0 deletions src/main/java/com/jisungin/infra/s3/S3FileManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package com.jisungin.infra.s3;

import com.amazonaws.SdkClientException;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.DeleteObjectRequest;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.amazonaws.util.IOUtils;
import com.jisungin.exception.BusinessException;
import com.jisungin.exception.ErrorCode;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

@RequiredArgsConstructor
@Component
public class S3FileManager {

@Value("${cloud.aws.s3.bucket}")
private String bucket;

private final AmazonS3 amazonS3;

public String upload(MultipartFile multipartFiles, String dirName) {

validateMultipartFile(multipartFiles);

return this.uploadImage(multipartFiles, dirName);
}

public void removeFile(String fileName) {
String key = getKeyFromImage(fileName);
try {
amazonS3.deleteObject(new DeleteObjectRequest(bucket, key));
} catch (SdkClientException e) {
throw new BusinessException(ErrorCode.IMAGE_NOT_FOUND);
}
}

private String getKeyFromImage(String fileName) {
try {
URL url = new URL(fileName);
String path = url.getPath();
String desiredPart = path.replaceFirst("^/[^/]*/[^/]*/", "");
if (desiredPart.startsWith("/")) { // 맨 앞이 "/"로 시작하는 경우 첫 번째 문자를 제거
desiredPart = desiredPart.substring(1);
}
return URLDecoder.decode(desiredPart, "UTF-8");
} catch (MalformedURLException | UnsupportedEncodingException e) {
throw new BusinessException(ErrorCode.NOT_IMAGE);
}
}

private void validateMultipartFile(MultipartFile multipartFile) {
if (multipartFile.isEmpty() || Objects.isNull(multipartFile.getOriginalFilename())) {
throw new BusinessException(ErrorCode.IMAGE_NOT_FOUND);
}
}

private String uploadImage(MultipartFile multipartFile, String dirName) {
this.validateImageFileExtension(multipartFile.getOriginalFilename());
try {
return this.uploadImageToS3(multipartFile, dirName);
} catch (IOException e) {
throw new BusinessException(ErrorCode.S3_UPLOAD_FAIL);
}
}

private String uploadImageToS3(MultipartFile multipartFile, String dirName) throws IOException {
String originalFilename = multipartFile.getOriginalFilename();
String extension = originalFilename.substring(originalFilename.lastIndexOf("."));

String s3FileName = createS3FileName(originalFilename, dirName);

InputStream is = multipartFile.getInputStream();
byte[] bytes = IOUtils.toByteArray(is);

ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType("image/" + extension);
metadata.setContentLength(bytes.length);

ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);

try {
PutObjectRequest putObjectRequest = new PutObjectRequest(bucket, s3FileName, byteArrayInputStream, metadata)
.withCannedAcl(CannedAccessControlList.PublicRead);

amazonS3.putObject(putObjectRequest);
} catch (Exception e) {
throw new BusinessException(ErrorCode.S3_UPLOAD_FAIL);
} finally {
byteArrayInputStream.close();
is.close();
}

return amazonS3.getUrl(bucket, s3FileName).toString();
}

private static String createS3FileName(String originalFilename, String dirName) {
String s3FileName = dirName + "/" + UUID.randomUUID().toString().substring(0, 10) + originalFilename;
return s3FileName;
}

private void validateImageFileExtension(String filename) {
int lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex == -1) {
throw new BusinessException(ErrorCode.NOT_IMAGE);
}

String extension = filename.substring(lastDotIndex + 1).toLowerCase();
List<String> allowedExtensions = Arrays.asList("jpg", "jpeg", "png", "gif");

if (!allowedExtensions.contains(extension)) {
throw new BusinessException(ErrorCode.NOT_IMAGE);
}
}

}
8 changes: 7 additions & 1 deletion src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,10 @@ spring:

sql:
init:
mode: always
mode: always

servlet:
multipart:
max-file-size: 10MB
max-request-size: 100MB

3 changes: 2 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ spring:
- prod
include:
- oauth
- crawler
- crawler
- s3
8 changes: 7 additions & 1 deletion src/test/java/com/jisungin/ControllerTestSupport.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.jisungin.api.book.BookController;
import com.jisungin.api.comment.CommentController;
import com.jisungin.api.commentlike.CommentLikeController;
import com.jisungin.api.image.ImageController;
import com.jisungin.api.oauth.AuthContext;
import com.jisungin.api.review.ReviewController;
import com.jisungin.api.reviewlike.ReviewLikeController;
Expand All @@ -14,6 +15,7 @@
import com.jisungin.application.book.BookService;
import com.jisungin.application.comment.CommentService;
import com.jisungin.application.commentlike.CommentLikeService;
import com.jisungin.application.image.ImageService;
import com.jisungin.application.review.ReviewService;
import com.jisungin.application.reviewlike.ReviewLikeService;
import com.jisungin.application.talkroom.TalkRoomService;
Expand All @@ -32,7 +34,8 @@
CommentLikeController.class,
UserController.class,
BookController.class,
ReviewLikeController.class
ReviewLikeController.class,
ImageController.class
})
public abstract class ControllerTestSupport {

Expand Down Expand Up @@ -72,4 +75,7 @@ public abstract class ControllerTestSupport {
@MockBean
protected ReviewLikeService reviewLikeService;

@MockBean
protected ImageService imageService;

}
5 changes: 5 additions & 0 deletions src/test/java/com/jisungin/JisunginApplicationTests.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package com.jisungin;

import com.jisungin.infra.s3.S3FileManager;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;

@SpringBootTest
class JisunginApplicationTests {

@MockBean
private S3FileManager s3FileManager;

@Test
void contextLoads() {
}
Expand Down
Loading

0 comments on commit b2bf94a

Please sign in to comment.