Skip to content

Commit

Permalink
Merge pull request #30 from pyro-yolog/feat/#29-s3-upload
Browse files Browse the repository at this point in the history
Feat/#29 s3 upload
  • Loading branch information
yeonjy authored Jun 22, 2024
2 parents 4bcab6e + fdf45d1 commit 50b9721
Show file tree
Hide file tree
Showing 16 changed files with 355 additions and 0 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation "org.mapstruct:mapstruct:1.5.3.Final"
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/com/pyro/yolog/global/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.pyro.yolog.global.config;

import com.amazonaws.auth.AWSCredentials;
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;

@Configuration
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 amazonS3() {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);

return AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
22 changes: 22 additions & 0 deletions src/main/java/com/pyro/yolog/global/error/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.pyro.yolog.global.error;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum ErrorCode {
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버에 오류가 발생했습니다."),

// MEMBER
MEMBER_NOT_FOUND_ERROR(HttpStatus.NOT_FOUND, "회원 정보를 찾지 못했습니다." ),

// S3
FILE_UPLOAD_FAILURE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 파일 업로드에 실패했습니다."),
INVALID_FILE_EXTENSION_ERROR(HttpStatus.BAD_REQUEST, "잘못된 확장자의 파일 업로드를 시도했습니다."),
FILE_DELETE_FAILURE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 파일 삭제에 실패했습니다.");

private final HttpStatus status;
private final String errorMessage;
}
19 changes: 19 additions & 0 deletions src/main/java/com/pyro/yolog/global/error/ErrorResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.pyro.yolog.global.error;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public class ErrorResponse {
private final HttpStatus status;
private final String errorMessage;

public static ErrorResponse create(final ErrorCode errorCode) {
return new ErrorResponse(
errorCode.getStatus(),
errorCode.getErrorMessage()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.pyro.yolog.global.error;

import com.pyro.yolog.global.error.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleRuntimeException(BusinessException e) {
final ErrorCode errorCode = e.getErrorCode();
log.warn(e.getMessage());

return ResponseEntity
.status(errorCode.getStatus())
.body(new ErrorResponse(errorCode.getStatus(),
errorCode.getErrorMessage()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.pyro.yolog.global.error.advice;

import com.pyro.yolog.global.jwt.exception.NotFoundTokenException;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AdviceException {
@ExceptionHandler(NotFoundTokenException.class)
public ResponseEntity<HttpEntity> notFoundTokenException() {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.pyro.yolog.global.error.exception;

import com.pyro.yolog.global.error.ErrorCode;
import lombok.Getter;

@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;

public BusinessException(ErrorCode errorCode) {
super(errorCode.getErrorMessage());
this.errorCode = errorCode;
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.pyro.yolog.global.error.exception;

import com.pyro.yolog.global.error.ErrorCode;
import lombok.Getter;

@Getter
public class ExternalApiException extends RuntimeException {
private final ErrorCode errorCode;

public ExternalApiException(ErrorCode errorCode) {
this.errorCode = errorCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.pyro.yolog.global.jwt.exception;

public class NotFoundTokenException extends RuntimeException {
public NotFoundTokenException() {
}

public NotFoundTokenException(String message) {
super(message);
}

public NotFoundTokenException(String message, Throwable cause) {
super(message, cause);
}
}

38 changes: 38 additions & 0 deletions src/main/java/com/pyro/yolog/global/s3/api/S3ImageApi.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.pyro.yolog.global.s3.api;

import com.pyro.yolog.global.s3.dto.S3ImageDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import org.springframework.web.multipart.MultipartFile;

public interface S3ImageApi {
@Operation(
summary = "이미지 업로드",
description = "S3에 이미지를 업로드합니다. 해당 이미지의 URL을 반환받습니다.",
security = {@SecurityRequirement(name = "access_token")},
tags = {"IMAGE"}
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "201",
description = "Created"
)
})
S3ImageDto uploadImage(MultipartFile image);

@Operation(
summary = "이미지 삭제",
description = "S3에서 이미지를 삭제합니다.",
security = {@SecurityRequirement(name = "access_token")},
tags = {"IMAGE"}
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "204",
description = "No Content"
)
})
void deleteImage(S3ImageDto dto);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.pyro.yolog.global.s3.api;

import com.pyro.yolog.global.s3.dto.S3ImageDto;
import com.pyro.yolog.global.s3.service.S3ImageService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequiredArgsConstructor
@RequestMapping("images")
public class S3ImageImageController implements S3ImageApi {
private final S3ImageService s3ImageService;

@ResponseStatus(HttpStatus.CREATED)
@PostMapping("")
@Override
public S3ImageDto uploadImage(@RequestPart(value = "image", required = false) MultipartFile image) {
return s3ImageService.uploadImage(image);
}

@ResponseStatus(HttpStatus.CREATED)
@DeleteMapping("")
@Override
public void deleteImage(@RequestBody final S3ImageDto dto) {
s3ImageService.deleteImage(dto);
}
}
11 changes: 11 additions & 0 deletions src/main/java/com/pyro/yolog/global/s3/dto/S3ImageDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.pyro.yolog.global.s3.dto;

import lombok.*;

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class S3ImageDto {
String imageUrl;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.pyro.yolog.global.s3.exception;

import com.pyro.yolog.global.error.ErrorCode;
import com.pyro.yolog.global.error.exception.ExternalApiException;

public class FileDeleteFailureException extends ExternalApiException {

public FileDeleteFailureException() {
super(ErrorCode.FILE_DELETE_FAILURE_ERROR);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.pyro.yolog.global.s3.exception;

import com.pyro.yolog.global.error.ErrorCode;
import com.pyro.yolog.global.error.exception.ExternalApiException;

public class FileUploadFailureException extends ExternalApiException {

public FileUploadFailureException() {
super(ErrorCode.FILE_UPLOAD_FAILURE_ERROR);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.pyro.yolog.global.s3.exception;

import com.pyro.yolog.global.error.ErrorCode;
import com.pyro.yolog.global.error.exception.BusinessException;

public class InvalidFileExtensionException extends BusinessException {

public InvalidFileExtensionException() {
super(ErrorCode.INVALID_FILE_EXTENSION_ERROR);
}
}
89 changes: 89 additions & 0 deletions src/main/java/com/pyro/yolog/global/s3/service/S3ImageService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.pyro.yolog.global.s3.service;

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.pyro.yolog.global.s3.dto.S3ImageDto;
import com.pyro.yolog.global.s3.exception.FileDeleteFailureException;
import com.pyro.yolog.global.s3.exception.FileUploadFailureException;
import com.pyro.yolog.global.s3.exception.InvalidFileExtensionException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class S3ImageService {
private static final String IMAGE_DIRECTORY = "/image";

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

private final AmazonS3 amazonS3;

public S3ImageDto uploadImage(MultipartFile file) {
validateImageExtension(file.getOriginalFilename());
return new S3ImageDto(uploadImageToS3(file));
}

private void validateImageExtension(String fileName) {
int lastDotIndex = getLastDotIndex(fileName);
String extention = fileName.substring(lastDotIndex + 1).toLowerCase();
List<String> allowedExtentionList = Arrays.asList("jpg", "jpeg", "png", "gif");

if (!allowedExtentionList.contains(extention)) {
throw new InvalidFileExtensionException();
}
}

private int getLastDotIndex(String fileName) {
int lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex == -1) {
throw new InvalidFileExtensionException();
}
return lastDotIndex;
}

private String uploadImageToS3(MultipartFile file) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());
String s3FileName = UUID.randomUUID().toString().substring(0, 10)
+ file.getOriginalFilename();
try {
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME + IMAGE_DIRECTORY, s3FileName, file.getInputStream(), metadata).withCannedAcl(CannedAccessControlList.PublicRead);
amazonS3.putObject(putObjectRequest);
} catch (IOException e) {
throw new FileUploadFailureException();
}
return amazonS3.getUrl(BUCKET_NAME + IMAGE_DIRECTORY, s3FileName).toString();
}

public void deleteImage(S3ImageDto dto) {
String key = getKeyFromImageAddress(dto.getImageUrl());
amazonS3.deleteObject(new DeleteObjectRequest(BUCKET_NAME, key));
}

private String getKeyFromImageAddress(String imageAddress){
try{
URL url = new URL(imageAddress);
String decodingKey = URLDecoder.decode(url.getPath(), StandardCharsets.UTF_8);
return decodingKey.substring(1); // 맨 앞의 '/' 제거
}catch (MalformedURLException e){
throw new FileDeleteFailureException();
}
}
}

0 comments on commit 50b9721

Please sign in to comment.