diff --git a/.gitignore b/.gitignore index 4a251a82f..349ef8fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .idea .DS_Store + +s3proxy/src/main/resources/static/docs/index.html diff --git a/.gitmodules b/.gitmodules index 3c09b9341..94bc38746 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,7 @@ path = backend/src/main/resources/config url = git@github.com:zzimkkong/config.git branch = main +[submodule "s3proxy/src/main/resources/s3proxy-config"] + path = s3proxy/src/main/resources/s3proxy-config + url = git@github.com:zzimkkong/s3proxy-config.git + branch = main diff --git a/backend/build.gradle b/backend/build.gradle index b38b630ca..3c71d61b0 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -25,9 +25,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-webflux' - - implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' - + // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/StorageConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/StorageConfig.java deleted file mode 100644 index 9f58aaa33..000000000 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/StorageConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.woowacourse.zzimkkong.config; - -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.regions.Regions; -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; -import org.springframework.context.annotation.PropertySource; - -@Configuration -@PropertySource("classpath:config/awsS3.properties") -public class StorageConfig { - @Bean - @Profile({"prod", "dev"}) - public AmazonS3 amazonS3() { - return AmazonS3ClientBuilder - .standard() - .withRegion(Regions.AP_NORTHEAST_2) - .build(); - } - - @Bean(name = "amazonS3") - @Profile({"local", "test"}) - public AmazonS3 amazonS3Local( - @Value("${aws.access-key}") String accessKey, - @Value("${aws.secret-key}") String secretKey) { - BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey); - - return AmazonS3ClientBuilder - .standard() - .withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials)) - .withRegion(Regions.AP_NORTHEAST_2) - .build(); - } -} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java index 796738401..dfdcf8397 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java @@ -1,9 +1,8 @@ package com.woowacourse.zzimkkong.config; -import org.apache.http.HttpHeaders; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; -import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/CannotDeleteConvertedFileException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/CannotDeleteConvertedFileException.java new file mode 100644 index 000000000..b24d3ef49 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/CannotDeleteConvertedFileException.java @@ -0,0 +1,11 @@ +package com.woowacourse.zzimkkong.exception.infrastructure; + +import org.springframework.http.HttpStatus; + +public class CannotDeleteConvertedFileException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "변환된 이미지를 삭제하는 데에 실패했습니다. 관리자에게 문의하세요."; + + public CannotDeleteConvertedFileException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3ProxyRespondedFailException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3ProxyRespondedFailException.java new file mode 100644 index 000000000..831b73242 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3ProxyRespondedFailException.java @@ -0,0 +1,11 @@ +package com.woowacourse.zzimkkong.exception.infrastructure; + +import org.springframework.http.HttpStatus; + +public class S3ProxyRespondedFailException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "이미지 버킷 업로드에 실패했습니다."; + + public S3ProxyRespondedFailException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3UploadException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3UploadException.java index 6b8147796..d705eb2db 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3UploadException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3UploadException.java @@ -5,6 +5,10 @@ public class S3UploadException extends InfrastructureMalfunctionException { private static final String MESSAGE = "이미지 버킷 업로드에 실패했습니다."; + public S3UploadException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } + public S3UploadException(final Exception exception) { super(MESSAGE, exception, HttpStatus.INTERNAL_SERVER_ERROR); } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploader.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploader.java new file mode 100644 index 000000000..558949127 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploader.java @@ -0,0 +1,78 @@ +package com.woowacourse.zzimkkong.infrastructure.thumbnail; + +import com.woowacourse.zzimkkong.exception.infrastructure.S3ProxyRespondedFailException; +import com.woowacourse.zzimkkong.exception.infrastructure.S3UploadException; +import com.woowacourse.zzimkkong.infrastructure.thumbnail.StorageUploader; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Objects; + +@Component +public class S3ProxyUploader implements StorageUploader { + private static final String PATH_DELIMITER = "/"; + private static final String API_PATH = "/api/storage"; + private static final String CONTENT_DISPOSITION_HEADER_VALUE_FORMAT = "form-data; name=file; filename=%s"; + + private final WebClient proxyServerClient; + + public S3ProxyUploader( + @Value("${s3proxy.server-uri}") final String serverUri) { + this.proxyServerClient = WebClient.builder() + .baseUrl(serverUri) + .build(); + } + + @Override + public String upload(String directoryName, File uploadFile) { + try { + byte[] byteArrayOfFile = Files.readAllBytes(uploadFile.toPath()); + + MultipartBodyBuilder multipartBodyBuilder = new MultipartBodyBuilder(); + multipartBodyBuilder.part("file", new ByteArrayResource(byteArrayOfFile)) + .header(HttpHeaders.CONTENT_DISPOSITION, + String.format(CONTENT_DISPOSITION_HEADER_VALUE_FORMAT, uploadFile.getName())); + + return proxyServerClient + .method(HttpMethod.POST) + .uri(String.join(PATH_DELIMITER, API_PATH, directoryName)) + .header(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE) + .body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) + .exchangeToMono(clientResponse -> { + if (clientResponse.statusCode().equals(HttpStatus.CREATED)) { + String location = Objects.requireNonNull( + clientResponse.headers().asHttpHeaders().get(HttpHeaders.LOCATION)) + .stream().findFirst() + .orElseThrow(S3UploadException::new); + return Mono.just(location); + } + return Mono.error(S3ProxyRespondedFailException::new); + }) + .block(); + } catch (IOException exception) { + throw new S3UploadException(exception); + } + } + + @Override + public void delete(String directoryName, String fileName) { + proxyServerClient + .method(HttpMethod.DELETE) + .uri(String.join(PATH_DELIMITER, API_PATH, directoryName, fileName)) + .retrieve() + .bodyToMono(String.class) + .block(); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3Uploader.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3Uploader.java deleted file mode 100644 index 2d4fabc02..000000000 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3Uploader.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.woowacourse.zzimkkong.infrastructure.thumbnail; - -import com.amazonaws.AmazonClientException; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.DeleteObjectRequest; -import com.amazonaws.services.s3.model.PutObjectRequest; -import com.woowacourse.zzimkkong.exception.infrastructure.S3UploadException; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.io.File; - -@Component -public class S3Uploader implements StorageUploader { - private static final String S3_DOMAIN_FORMAT = "https://%s.s3.%s.amazonaws.com"; - private static final String PATH_DELIMITER = "/"; - - private final AmazonS3 amazonS3; - private final String bucketName; - private final String s3DomainUrl; - private final String urlReplacement; - - public S3Uploader( - final AmazonS3 amazonS3, - @Value("${aws.s3.bucket_name}") final String bucketName, - @Value("${aws.s3.region}") final String regionName, - @Value("${aws.s3.url_replacement}") final String urlReplacement) { - this.amazonS3 = amazonS3; - this.bucketName = bucketName; - this.s3DomainUrl = String.format(S3_DOMAIN_FORMAT, this.bucketName, regionName); - this.urlReplacement = urlReplacement; - } - - @Override - public String upload(final String directoryName, final File uploadFile) { - String fileName = directoryName + PATH_DELIMITER + uploadFile.getName(); - - try { - String resourceUrl = putS3(uploadFile, fileName); - return replaceUrl(resourceUrl, urlReplacement); - } catch (AmazonClientException exception) { - throw new S3UploadException(exception); - } - } - - private String putS3(final File uploadFile, final String fileName) { - amazonS3.putObject(new PutObjectRequest(bucketName, fileName, uploadFile)); - return amazonS3.getUrl(bucketName, fileName).toString(); - } - - private String replaceUrl(final String origin, final String replacement) { - return origin.replace(s3DomainUrl, replacement); - } - - @Override - public void delete(String directoryName, final String fileName) { - amazonS3.deleteObject(new DeleteObjectRequest(bucketName, directoryName + "/" + fileName)); - } -} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManager.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManager.java index 284c8ed95..1400277b9 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManager.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManager.java @@ -1,40 +1,9 @@ package com.woowacourse.zzimkkong.infrastructure.thumbnail; import com.woowacourse.zzimkkong.domain.Map; -import org.springframework.stereotype.Component; -import java.io.File; +public interface ThumbnailManager { + String uploadMapThumbnail(final String svgData, final Map map); -@Component -public class ThumbnailManager { - public static final String THUMBNAILS_DIRECTORY_NAME = "thumbnails"; - public static final String THUMBNAIL_EXTENSION = ".png"; - private static final String THUMBNAIL_FILE_FORMAT = "%s"; - - private final SvgConverter svgConverter; - private final StorageUploader storageUploader; - - public ThumbnailManager(final SvgConverter svgConverter, final StorageUploader storageUploader) { - this.svgConverter = svgConverter; - this.storageUploader = storageUploader; - } - - public String uploadMapThumbnail(final String svgData, final Map map) { - String fileName = makeThumbnailFileName(map); - File pngFile = svgConverter.convertSvgToPngFile(svgData, fileName); - - String thumbnailUrl = storageUploader.upload(THUMBNAILS_DIRECTORY_NAME, pngFile); - - pngFile.delete(); - return thumbnailUrl; - } - - public void deleteThumbnail(final Map map) { - String fileName = makeThumbnailFileName(map); - storageUploader.delete(THUMBNAILS_DIRECTORY_NAME, fileName + THUMBNAIL_EXTENSION); - } - - private String makeThumbnailFileName(final Map map) { - return String.format(THUMBNAIL_FILE_FORMAT, map.getId().toString()); - } + void deleteThumbnail(final Map map); } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImpl.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImpl.java new file mode 100644 index 000000000..15ff312af --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImpl.java @@ -0,0 +1,49 @@ +package com.woowacourse.zzimkkong.infrastructure.thumbnail; + +import com.woowacourse.zzimkkong.domain.Map; +import com.woowacourse.zzimkkong.exception.infrastructure.CannotDeleteConvertedFileException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.File; + +@Component +public class ThumbnailManagerImpl implements ThumbnailManager { + public static final String THUMBNAIL_EXTENSION = ".png"; + private static final String THUMBNAIL_FILE_FORMAT = "%s"; + + private final SvgConverter svgConverter; + private final StorageUploader storageUploader; + private final String thumbnailsDirectoryName; + + public ThumbnailManagerImpl( + final SvgConverter svgConverter, + final StorageUploader storageUploader, + @Value("${s3proxy.thumbnails-directory}") final String thumbnailsDirectoryName) { + this.svgConverter = svgConverter; + this.storageUploader = storageUploader; + this.thumbnailsDirectoryName = thumbnailsDirectoryName; + + } + + public String uploadMapThumbnail(final String svgData, final Map map) { + String fileName = makeThumbnailFileName(map); + File pngFile = svgConverter.convertSvgToPngFile(svgData, fileName); + + String thumbnailUrl = storageUploader.upload(thumbnailsDirectoryName, pngFile); + + if (!pngFile.delete()) { + throw new CannotDeleteConvertedFileException(); + } + return thumbnailUrl; + } + + public void deleteThumbnail(final Map map) { + String fileName = makeThumbnailFileName(map); + storageUploader.delete(thumbnailsDirectoryName, fileName + THUMBNAIL_EXTENSION); + } + + private String makeThumbnailFileName(final Map map) { + return String.format(THUMBNAIL_FILE_FORMAT, map.getId().toString()); + } +} diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index b51ca061c..abd7e3077 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -22,11 +22,9 @@ spring.mvc.view.suffix=.html jwt.token.secret-key=zzimkkong_secret_key_in_dev jwt.token.expire-length=86400000 -#s3 -aws.s3.bucket_name=zzimkkong-thumbnail-dev -aws.s3.region=ap-northeast-2 -aws.s3.url_replacement=https://d3tdpsdxqmqd52.cloudfront.net -cloud.aws.stack.auto=false +# s3 +s3proxy.server-uri=http://52.78.88.220:8080 +s3proxy.thumbnails-directory=thumbnails # svg converter converter.temp.location=/home/ubuntu/zzimkkong/tmp/ diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties index 2ab58e693..546c7076b 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -25,12 +25,9 @@ logging.level.jdbc.sqlonly=debug jwt.token.secret-key=zzimkkong_secret_key_in_dev jwt.token.expire-length=86400000 -#s3 -aws.s3.bucket_name=zzimkkong-personal -aws.s3.region=ap-northeast-2 -aws.s3.url_replacement=https://zzimkkong-personal.s3.ap-northeast-2.amazonaws.com -cloud.aws.stack.auto=false -cloud.aws.region.static=ap-northeast-2 +# s3 +s3proxy.server-uri=http://52.78.88.220:8080 +s3proxy.thumbnails-directory=thumbnails-local # svg converter converter.temp.location=src/main/resources/tmp/ diff --git a/backend/src/main/resources/application-test.properties b/backend/src/main/resources/application-test.properties index 7a98fa356..6b59e2a98 100644 --- a/backend/src/main/resources/application-test.properties +++ b/backend/src/main/resources/application-test.properties @@ -17,12 +17,9 @@ spring.mvc.view.suffix=.html jwt.token.secret-key=zzimkkong_secret_key_in_dev jwt.token.expire-length=86400000 -#s3 -aws.s3.bucket_name=zzimkkong-personal -aws.s3.region=ap-northeast-2 -aws.s3.url_replacement=https://zzimkkong-personal.s3.ap-northeast-2.amazonaws.com -cloud.aws.stack.auto=false -cloud.aws.region.static=ap-northeast-2 +# s3 +s3proxy.server-uri=http://52.78.88.220:8080 +s3proxy.thumbnails-directory=thumbnails-test # svg converter converter.temp.location=src/main/resources/tmp/ diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index 6505dd5bd..7002488c7 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit 6505dd5bda18db3466ca1f6d086fd9a7d72a020b +Subproject commit 7002488c7ab08a9db7d5562978bfc75133923eaa diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploaderTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploaderTest.java new file mode 100644 index 000000000..5e4e52293 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploaderTest.java @@ -0,0 +1,120 @@ +package com.woowacourse.zzimkkong.infrastructure.thumbnail; + +import com.woowacourse.zzimkkong.exception.infrastructure.S3ProxyRespondedFailException; +import com.woowacourse.zzimkkong.exception.infrastructure.S3UploadException; +import com.woowacourse.zzimkkong.infrastructure.thumbnail.S3ProxyUploader; +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +@SpringBootTest +@ActiveProfiles("test") +class S3ProxyUploaderTest { + private static final String URL_REGEX = "^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"; + private static final Pattern URL_PATTERN = Pattern.compile(URL_REGEX); + + @Autowired + private S3ProxyUploader s3ProxyUploader; + + private final String testDirectoryName = "testDirectoryName"; + private File testFile; + + @Test + @DisplayName("파일을 업로드한 후 url을 받아온다.") + void upload() { + // given + String filePath = getClass().getClassLoader().getResource("luther.png").getFile(); + testFile = new File(filePath); + + // when + String uri = s3ProxyUploader.upload(testDirectoryName, testFile); + Matcher matcher = URL_PATTERN.matcher(uri); + + // then + assertThat(matcher.find()).isTrue(); + } + + @Test + @DisplayName("업로드된 파일을 삭제한다.") + void delete() { + String filePath = getClass().getClassLoader().getResource("luther.png").getFile(); + testFile = new File(filePath); + + String uri = s3ProxyUploader.upload("testDirectoryName", testFile); + + // when + s3ProxyUploader.delete(testDirectoryName, testFile.getName()); + RestAssured.port = RestAssured.UNDEFINED_PORT; + ExtractableResponse response = RestAssured + .given().log().all() + .accept("application/json") + .when().get(uri) + .then().log().all().extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.FORBIDDEN.value()); + } + + @Test + @DisplayName("업로드시 서버측이 201 응답을 보내지 않으면 예외가 발생한다.") + void invalidUrl() { + // given + try(MockWebServer mockGithubServer = new MockWebServer()) { + mockGithubServer.start(); + mockGithubServer.enqueue(new MockResponse() + .setResponseCode(400)); + + String hostName = mockGithubServer.getHostName(); + int port = mockGithubServer.getPort(); + S3ProxyUploader s3ProxyUploader = new S3ProxyUploader("http://" + hostName + ":" + port); + String filePath = getClass().getClassLoader().getResource("luther.png").getFile(); + testFile = new File(filePath); + + // when, then + assertThatThrownBy(() -> s3ProxyUploader.upload("testdir", testFile)) + .isInstanceOf(S3ProxyRespondedFailException.class); + } catch (IOException ignored) { + } + } + + @Test + @DisplayName("업로드할 파일의 정보를 읽어오던 중 IOException이 발생하면 예외를 발생시킨다.") + void ioException() { + // given + File mockFile = mock(File.class); + BDDMockito.given(mockFile.toPath()) + .willReturn(Path.of("/ubuntu/home/invalidPath.png")); + + // when + assertThatThrownBy(() -> s3ProxyUploader.upload("testdir", mockFile)) + .isInstanceOf(S3UploadException.class); + } + + @AfterEach + void tearDown() { + if (testFile != null) { + s3ProxyUploader.delete(testDirectoryName, testFile.getName()); + testFile = null; + } + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3UploaderTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3UploaderTest.java deleted file mode 100644 index cf6404648..000000000 --- a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3UploaderTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.woowacourse.zzimkkong.infrastructure.thumbnail; - -import com.woowacourse.zzimkkong.infrastructure.thumbnail.BatikConverter; -import com.woowacourse.zzimkkong.infrastructure.thumbnail.S3Uploader; -import io.restassured.RestAssured; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.HttpStatus; -import org.springframework.test.context.ActiveProfiles; - -import java.io.File; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest -@ActiveProfiles("test") -class S3UploaderTest { - private static final String URL_REGEX = "^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"; - private static final Pattern URL_PATTERN = Pattern.compile(URL_REGEX); - - @Autowired - private S3Uploader s3Uploader; - - @Autowired - private BatikConverter batikConverter; - - private File testFile; - - @Test - @DisplayName("파일을 업로드한 후 url을 받아온다.") - void upload() { - // given - String rawSvgData = " "; - this.testFile = batikConverter.convertSvgToPngFile(rawSvgData, "testImageFileName"); - - // when - String url = s3Uploader.upload("testDirectoryName", testFile); - Matcher matcher = URL_PATTERN.matcher(url); - - // then - assertThat(matcher.find()).isTrue(); - } - - @Test - @DisplayName("업로드된 파일을 삭제한다.") - void delete() { - String rawSvgData = " "; - this.testFile = batikConverter.convertSvgToPngFile(rawSvgData, "testImageFileName"); - String url = s3Uploader.upload("testDirectoryName", testFile); - - // when - s3Uploader.delete("testDirectoryName", testFile.getName()); - RestAssured.port = RestAssured.UNDEFINED_PORT; - ExtractableResponse response = RestAssured - .given().log().all() - .accept("application/json") - .when().get(url) - .then().log().all().extract(); - - // then - assertThat(response.statusCode()).isEqualTo(HttpStatus.FORBIDDEN.value()); - } - - @AfterEach - void deleteFile() { - testFile.delete(); - } -} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImplTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImplTest.java new file mode 100644 index 000000000..8bb63469d --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImplTest.java @@ -0,0 +1,85 @@ +package com.woowacourse.zzimkkong.infrastructure.thumbnail; + +import com.woowacourse.zzimkkong.Constants; +import com.woowacourse.zzimkkong.domain.Map; +import com.woowacourse.zzimkkong.exception.infrastructure.CannotDeleteConvertedFileException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +import java.io.File; +import java.util.Random; + +import static com.woowacourse.zzimkkong.Constants.MAP_IMAGE_URL; +import static com.woowacourse.zzimkkong.Constants.MAP_SVG; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@SpringBootTest +@ActiveProfiles("test") +class ThumbnailManagerImplTest { + @Autowired + ThumbnailManagerImpl thumbnailManager; + + @MockBean + SvgConverter svgConverter; + + @MockBean + StorageUploader storageUploader; + + @Test + @DisplayName("Map의 svg 데이터와 Map을 받고 썸네일의 url을 받아온다.") + void uploadMapThumbnail() { + // given + Map mockMap = mock(Map.class); + long mapId = new Random().nextLong(); + given(mockMap.getId()) + .willReturn(mapId); + + File mockFile = mock(File.class); + given(svgConverter.convertSvgToPngFile(anyString(), anyString())) + .willReturn(mockFile); + + given(storageUploader.upload(anyString(), any(File.class))) + .willReturn(MAP_IMAGE_URL); + + given(mockFile.delete()) + .willReturn(true); + + // when + String mapThumbnailUrl = thumbnailManager.uploadMapThumbnail(MAP_SVG, mockMap); + + assertThat(mapThumbnailUrl).isEqualTo(MAP_IMAGE_URL); + } + + @Test + @DisplayName("임시로 생성된 파일을 지울 수 없다면 예외가 발생한다.") + void deleteFail() { + // given + Map mockMap = mock(Map.class); + long mapId = new Random().nextLong(); + given(mockMap.getId()) + .willReturn(mapId); + + File mockFile = mock(File.class); + given(svgConverter.convertSvgToPngFile(anyString(), anyString())) + .willReturn(mockFile); + + given(storageUploader.upload(anyString(), any(File.class))) + .willReturn(MAP_IMAGE_URL); + + given(mockFile.delete()) + .willReturn(false); + + // when, then + assertThatThrownBy(() -> thumbnailManager.uploadMapThumbnail(MAP_SVG, mockMap)) + .isInstanceOf(CannotDeleteConvertedFileException.class); + } +} diff --git a/backend/src/test/resources/luther.png b/backend/src/test/resources/luther.png new file mode 100644 index 000000000..c2eac157e Binary files /dev/null and b/backend/src/test/resources/luther.png differ diff --git a/s3proxy/.gitignore b/s3proxy/.gitignore new file mode 100644 index 000000000..c2065bc26 --- /dev/null +++ b/s3proxy/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/s3proxy/build.gradle b/s3proxy/build.gradle new file mode 100644 index 000000000..664331aa6 --- /dev/null +++ b/s3proxy/build.gradle @@ -0,0 +1,73 @@ +plugins { + id 'org.springframework.boot' version '2.5.4' + id 'io.spring.dependency-management' version '1.0.11.RELEASE' + id "org.asciidoctor.convert" version "1.5.10" + id 'java' +} + +group = 'com.woowacourse' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '11' + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +ext { + snippetsDir = file('build/generated-snippets') +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Multi-Part + implementation 'commons-io:commons-io:2.11.0' + implementation 'commons-fileupload:commons-fileupload:1.4' + + // AWS + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // Rest Docs + asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor' + testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' + + testImplementation 'io.rest-assured:rest-assured' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.projectreactor:reactor-test' +} + +test { + outputs.dir snippetsDir + useJUnitPlatform() +} + +asciidoctor { + inputs.dir snippetsDir + dependsOn test +} + +task createDocument(type: Copy) { + dependsOn asciidoctor + from file("build/asciidoc/html5/index.html") + into file("src/main/resources/static/docs") +} + +bootJar { + dependsOn createDocument + from("${asciidoctor.outputDir}/html5") { + into 'static/docs' + } +} + + diff --git a/s3proxy/gradle/wrapper/gradle-wrapper.jar b/s3proxy/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..7454180f2 Binary files /dev/null and b/s3proxy/gradle/wrapper/gradle-wrapper.jar differ diff --git a/s3proxy/gradle/wrapper/gradle-wrapper.properties b/s3proxy/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..da9702f9e --- /dev/null +++ b/s3proxy/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/s3proxy/gradlew b/s3proxy/gradlew new file mode 100755 index 000000000..744e882ed --- /dev/null +++ b/s3proxy/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MSYS* | MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/s3proxy/gradlew.bat b/s3proxy/gradlew.bat new file mode 100644 index 000000000..ac1b06f93 --- /dev/null +++ b/s3proxy/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/s3proxy/script/deploy.sh b/s3proxy/script/deploy.sh new file mode 100644 index 000000000..7ac72174a --- /dev/null +++ b/s3proxy/script/deploy.sh @@ -0,0 +1,20 @@ +PROFILE=$1 + +JAR_FILE_NAME=s3proxy-0.0.1-SNAPSHOT.jar + +echo "Checking currently running process id..." +RUNNING_PROCESS_ID=$(pgrep -fl java | awk '{print $1}') + +if [ -z "$RUNNING_PROCESS_ID" ]; then + echo "No s3Proxy server is running." +else + echo "Killing process whose id is $RUNNING_PROCESS_ID" + kill -15 $RUNNING_PROCESS_ID + sleep 5 +fi + +echo "Running jar file..." +nohup java -jar -Dspring.profiles.active=$PROFILE $JAR_FILE_NAME > ~/nohup.out 2>&1 & + +CURRENT_PROCESS_ID=$(pgrep -fl java | awk '{print $1}') +echo "Application is running as pid: $CURRENT_PROCESS_ID" diff --git a/s3proxy/settings.gradle b/s3proxy/settings.gradle new file mode 100644 index 000000000..78517cd75 --- /dev/null +++ b/s3proxy/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 's3proxy' diff --git a/s3proxy/src/docs/asciidoc/index.adoc b/s3proxy/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..d4d7b0d2b --- /dev/null +++ b/s3proxy/src/docs/asciidoc/index.adoc @@ -0,0 +1,24 @@ += ZZIMKKONG S3 Proxy Server Application API Document +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 +:sectlinks: + +== Multi-Part Upload + +=== Upload +==== Request +include::{snippets}/s3/post/path-parameters.adoc[] +include::{snippets}/s3/post/http-request.adoc[] + +==== Response +include::{snippets}/s3/post/http-response.adoc[] + +=== Delete +==== Request +include::{snippets}/s3/delete/path-parameters.adoc[] +include::{snippets}/s3/delete/http-request.adoc[] +==== Response +include::{snippets}/s3/delete/http-response.adoc[] diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/S3proxyApplication.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/S3proxyApplication.java new file mode 100644 index 000000000..1ff565504 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/S3proxyApplication.java @@ -0,0 +1,13 @@ +package com.woowacourse.s3proxy; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class S3proxyApplication { + + public static void main(String[] args) { + SpringApplication.run(S3proxyApplication.class, args); + } + +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/config/MultipartConfig.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/config/MultipartConfig.java new file mode 100644 index 000000000..da2013334 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/config/MultipartConfig.java @@ -0,0 +1,15 @@ +package com.woowacourse.s3proxy.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.multipart.commons.CommonsMultipartResolver; + +@Configuration +public class MultipartConfig { + @Bean(name = "multipartResolver") + public CommonsMultipartResolver multipartResolver() { + CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver(); + multipartResolver.setMaxUploadSize(1048576); // 1MB + return multipartResolver; + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/config/StorageConfig.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/config/StorageConfig.java new file mode 100644 index 000000000..6cd5f6262 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/config/StorageConfig.java @@ -0,0 +1,30 @@ +package com.woowacourse.s3proxy.config; + +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 +public class StorageConfig { + + @Bean + @Profile({"prod", "dev"}) + public AmazonS3 amazonS3() { + return AmazonS3ClientBuilder + .standard() + .build(); + } + + @Bean(name = "amazonS3") + @Profile({"test", "local"}) + public AmazonS3 amazonS3Local(@Value("${cloud.aws.region.static}") String region) { + + return AmazonS3ClientBuilder + .standard() + .withRegion(region) + .build(); + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/ControllerAdvice.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/ControllerAdvice.java new file mode 100644 index 000000000..8e3c40efd --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/ControllerAdvice.java @@ -0,0 +1,20 @@ +package com.woowacourse.s3proxy.controller; + +import com.woowacourse.s3proxy.dto.ErrorResponse; +import com.woowacourse.s3proxy.exception.S3ProxyException; +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 ControllerAdvice { + @ExceptionHandler(S3ProxyException.class) + public ResponseEntity s3ProxyExceptionHandler(final S3ProxyException exception) { + log.warn(exception.getMessage(), exception); + return ResponseEntity + .status(exception.getStatus()) + .body(ErrorResponse.from(exception)); + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/S3ProxyController.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/S3ProxyController.java new file mode 100644 index 000000000..d1337db4a --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/S3ProxyController.java @@ -0,0 +1,36 @@ +package com.woowacourse.s3proxy.controller; + +import com.woowacourse.s3proxy.service.S3Service; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; + +@Slf4j +@RestController +@RequestMapping("/api/storage") +public class S3ProxyController { + private final S3Service s3Service; + + public S3ProxyController(final S3Service s3Service) { + this.s3Service = s3Service; + } + + @PostMapping("/{directoryPath}") + public ResponseEntity submit( + @RequestParam("file") MultipartFile file, + @PathVariable("directoryPath") String directoryPath) { + URI location = s3Service.upload(file, directoryPath); + return ResponseEntity.created(location).build(); + } + + @DeleteMapping("/{directoryPath}/{fileName}") + public ResponseEntity delete( + @PathVariable("directoryPath") String directoryPath, + @PathVariable("fileName") String fileName) { + s3Service.delete(directoryPath, fileName); + return ResponseEntity.noContent().build(); + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/dto/ErrorResponse.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/dto/ErrorResponse.java new file mode 100644 index 000000000..41582fe6c --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/dto/ErrorResponse.java @@ -0,0 +1,19 @@ +package com.woowacourse.s3proxy.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ErrorResponse { + private String message; + + protected ErrorResponse(final String message) { + this.message = message; + } + + public static ErrorResponse from(final RuntimeException exception) { + return new ErrorResponse(exception.getMessage()); + } + +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3ProxyException.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3ProxyException.java new file mode 100644 index 000000000..5b783b2c6 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3ProxyException.java @@ -0,0 +1,19 @@ +package com.woowacourse.s3proxy.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public abstract class S3ProxyException extends RuntimeException { + protected final HttpStatus status; + + public S3ProxyException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + public S3ProxyException(String message, Throwable cause, HttpStatus status) { + super(message, cause); + this.status = status; + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3UploadException.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3UploadException.java new file mode 100644 index 000000000..6084d6379 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3UploadException.java @@ -0,0 +1,12 @@ +package com.woowacourse.s3proxy.exception; + +import org.springframework.http.HttpStatus; + +public class S3UploadException extends S3ProxyException { + private static final String MESSAGE = "이미지 버킷 업로드에 실패했습니다."; + + public S3UploadException(final Throwable cause) { + super(MESSAGE, cause, HttpStatus.INTERNAL_SERVER_ERROR); + } +} + diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/UnsupportedFileExtensionException.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/UnsupportedFileExtensionException.java new file mode 100644 index 000000000..d8176c0d0 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/UnsupportedFileExtensionException.java @@ -0,0 +1,11 @@ +package com.woowacourse.s3proxy.exception; + +import org.springframework.http.HttpStatus; + +public class UnsupportedFileExtensionException extends S3ProxyException { + private static final String MESSAGE = "지원하지 않는 확장자입니다."; + + public UnsupportedFileExtensionException() { + super(MESSAGE, HttpStatus.BAD_REQUEST); + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/infrastructure/S3Uploader.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/infrastructure/S3Uploader.java new file mode 100644 index 000000000..cb729b248 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/infrastructure/S3Uploader.java @@ -0,0 +1,82 @@ +package com.woowacourse.s3proxy.infrastructure; + +import com.amazonaws.AmazonClientException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.woowacourse.s3proxy.exception.S3UploadException; +import com.woowacourse.s3proxy.exception.UnsupportedFileExtensionException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; + +@Component +public class S3Uploader { + public static final String PATH_DELIMITER = "/"; + private static final String S3_HOST_URL_SUFFIX = "amazonaws.com"; + private static final int RESOURCE_URL_INDEX = 1; + + private final AmazonS3 amazonS3; + private final String bucketName; + private final String cloudFrontUrl; + + public S3Uploader( + final AmazonS3 amazonS3, + @Value("${aws.s3.bucket-name}") final String bucketName, + @Value("${aws.s3.mapped-cloudfront}") final String cloudFrontUrl) { + this.amazonS3 = amazonS3; + this.bucketName = bucketName; + this.cloudFrontUrl = cloudFrontUrl; + } + + public URI upload(MultipartFile multipartFile, String directoryPath) { + ObjectMetadata objectMetadata = createObjectMetadata(multipartFile); + + String fileName = multipartFile.getOriginalFilename(); + String fileFullPath = generateFullPath(directoryPath, fileName); + + try(InputStream inputStream = multipartFile.getInputStream()) { + + amazonS3.putObject(this.bucketName, fileFullPath, inputStream, objectMetadata); + + URL fileUrl = amazonS3.getUrl(this.bucketName, fileFullPath); + + return makeAccessibleUrl(fileUrl, cloudFrontUrl); + } catch (AmazonClientException | IOException exception) { + throw new S3UploadException(exception); + } + } + + private ObjectMetadata createObjectMetadata(MultipartFile multipartFile) { + String filename = multipartFile.getOriginalFilename(); + MediaType mediaType = MediaTypeFactory.getMediaType(filename) + .orElseThrow(UnsupportedFileExtensionException::new); + + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentType(mediaType.toString()); + objectMetadata.setContentLength(multipartFile.getSize()); + + return objectMetadata; + } + + private String generateFullPath(String directoryPath, String fileName) { + return directoryPath + PATH_DELIMITER + fileName; + } + + private URI makeAccessibleUrl(final URL origin, final String cloudFrontUrl) { + String uriWithoutHost = origin.toString().split(S3_HOST_URL_SUFFIX)[RESOURCE_URL_INDEX]; + String replacedUrl = cloudFrontUrl + uriWithoutHost; + return URI.create(replacedUrl); + } + + public void delete(final String fullPathOfFile) { + amazonS3.deleteObject(new DeleteObjectRequest(bucketName, fullPathOfFile)); + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/service/S3Service.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/service/S3Service.java new file mode 100644 index 000000000..ab41ed023 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/service/S3Service.java @@ -0,0 +1,26 @@ +package com.woowacourse.s3proxy.service; + +import com.woowacourse.s3proxy.infrastructure.S3Uploader; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; + +@Slf4j +@Service +public class S3Service { + private final S3Uploader s3Uploader; + + public S3Service(final S3Uploader s3Uploader) { + this.s3Uploader = s3Uploader; + } + + public URI upload(MultipartFile multipartFile, String directoryPath) { + return s3Uploader.upload(multipartFile, directoryPath); + } + + public void delete(String directoryPath, String fileName) { + s3Uploader.delete(directoryPath + S3Uploader.PATH_DELIMITER + fileName); + } +} diff --git a/s3proxy/src/main/resources/appenders/console-appender.xml b/s3proxy/src/main/resources/appenders/console-appender.xml new file mode 100644 index 000000000..a9c4fb53c --- /dev/null +++ b/s3proxy/src/main/resources/appenders/console-appender.xml @@ -0,0 +1,9 @@ + + + + + ${CONSOLE_LOG_PATTERN} + + + + diff --git a/s3proxy/src/main/resources/appenders/file-appender-debug.xml b/s3proxy/src/main/resources/appenders/file-appender-debug.xml new file mode 100644 index 000000000..d46254437 --- /dev/null +++ b/s3proxy/src/main/resources/appenders/file-appender-debug.xml @@ -0,0 +1,24 @@ + + + + ${FILE_PATH}/${DATE_FORMAT}/debug/debug.log + + + DEBUG + ACCEPT + DENY + + + + ${FILE_LOG_PATTERN} + + + + ${FILE_PATH}/%d{yyyy-MM-dd}/debug/debug_%i.log + 100MB + 100 + 10GB + + + + diff --git a/s3proxy/src/main/resources/appenders/file-appender-error.xml b/s3proxy/src/main/resources/appenders/file-appender-error.xml new file mode 100644 index 000000000..1c6d064dc --- /dev/null +++ b/s3proxy/src/main/resources/appenders/file-appender-error.xml @@ -0,0 +1,24 @@ + + + + ${FILE_PATH}/${DATE_FORMAT}/error/error.log + + + ERROR + ACCEPT + DENY + + + + ${FILE_LOG_PATTERN} + + + + ${FILE_PATH}/%d{yyyy-MM-dd}/error/error_%i.log + 100MB + 100 + 10GB + + + + diff --git a/s3proxy/src/main/resources/appenders/file-appender-info.xml b/s3proxy/src/main/resources/appenders/file-appender-info.xml new file mode 100644 index 000000000..abd5116ff --- /dev/null +++ b/s3proxy/src/main/resources/appenders/file-appender-info.xml @@ -0,0 +1,24 @@ + + + + ${FILE_PATH}/${DATE_FORMAT}/info/info.log + + + INFO + ACCEPT + DENY + + + + ${FILE_LOG_PATTERN} + + + + ${FILE_PATH}/%d{yyyy-MM-dd}/info/info_%i.log + 100MB + 100 + 10GB + + + + diff --git a/s3proxy/src/main/resources/appenders/file-appender-trace.xml b/s3proxy/src/main/resources/appenders/file-appender-trace.xml new file mode 100644 index 000000000..6f24e2375 --- /dev/null +++ b/s3proxy/src/main/resources/appenders/file-appender-trace.xml @@ -0,0 +1,24 @@ + + + + ${FILE_PATH}/${DATE_FORMAT}/trace/trace.log + + + TRACE + ACCEPT + DENY + + + + ${FILE_LOG_PATTERN} + + + + ${FILE_PATH}/%d{yyyy-MM-dd}/trace/trace_%i.log + 100MB + 100 + 10GB + + + + diff --git a/s3proxy/src/main/resources/appenders/file-appender-warn.xml b/s3proxy/src/main/resources/appenders/file-appender-warn.xml new file mode 100644 index 000000000..9908dd63d --- /dev/null +++ b/s3proxy/src/main/resources/appenders/file-appender-warn.xml @@ -0,0 +1,24 @@ + + + + ${FILE_PATH}/${DATE_FORMAT}/warn/warn.log + + + WARN + ACCEPT + DENY + + + + ${FILE_LOG_PATTERN} + + + + ${FILE_PATH}/%d{yyyy-MM-dd}/warn/warn_%i.log + 100MB + 100 + 10GB + + + + diff --git a/s3proxy/src/main/resources/application-dev.yml b/s3proxy/src/main/resources/application-dev.yml new file mode 100644 index 000000000..4d1ee6e7c --- /dev/null +++ b/s3proxy/src/main/resources/application-dev.yml @@ -0,0 +1,9 @@ +cloud: + aws: + stack: + auto: false +aws: + s3: + bucket-name: zzimkkong-thumbnail-dev + mapped-cloudfront: https://d3tdpsdxqmqd52.cloudfront.net + region: ap-northeast-2 diff --git a/s3proxy/src/main/resources/application-local.yml b/s3proxy/src/main/resources/application-local.yml new file mode 100644 index 000000000..bbbbb87dc --- /dev/null +++ b/s3proxy/src/main/resources/application-local.yml @@ -0,0 +1,14 @@ +cloud: + aws: + stack: + auto: false + credentials: + instance-profile: false + region: + static: ap-northeast-2 + +aws: + s3: + bucket-name: zzimkkong-personal + mapped-cloudfront: https://zzimkkong-personal.s3.ap-northeast-2.amazonaws.com + region: ap-northeast-2 diff --git a/s3proxy/src/main/resources/application-test.yml b/s3proxy/src/main/resources/application-test.yml new file mode 100644 index 000000000..bbbbb87dc --- /dev/null +++ b/s3proxy/src/main/resources/application-test.yml @@ -0,0 +1,14 @@ +cloud: + aws: + stack: + auto: false + credentials: + instance-profile: false + region: + static: ap-northeast-2 + +aws: + s3: + bucket-name: zzimkkong-personal + mapped-cloudfront: https://zzimkkong-personal.s3.ap-northeast-2.amazonaws.com + region: ap-northeast-2 diff --git a/s3proxy/src/main/resources/application.yml b/s3proxy/src/main/resources/application.yml new file mode 100644 index 000000000..d74c444c1 --- /dev/null +++ b/s3proxy/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: local diff --git a/s3proxy/src/main/resources/logback-spring.xml b/s3proxy/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..12913d945 --- /dev/null +++ b/s3proxy/src/main/resources/logback-spring.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/s3proxy/src/main/resources/s3proxy-config b/s3proxy/src/main/resources/s3proxy-config new file mode 160000 index 000000000..e33c8a691 --- /dev/null +++ b/s3proxy/src/main/resources/s3proxy-config @@ -0,0 +1 @@ +Subproject commit e33c8a691c32dfdc9e8f5ce4b33c753363b8a054 diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/Constants.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/Constants.java new file mode 100644 index 000000000..439c4ae2e --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/Constants.java @@ -0,0 +1,6 @@ +package com.woowacourse.s3proxy; + +public class Constants { + public static final String LUTHER_IMAGE_URI_CLOUDFRONT = "https://d3tdpsdxqmqd52.cloudfront.net/testdir/luther.png"; + public static final String LUTHER_IMAGE_URI_S3 = "https://zzimkkong-thumbnail-dev.s3.ap-northeast-2.amazonaws.com/testdir/luther.png"; +} diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/DocumentUtils.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/DocumentUtils.java new file mode 100644 index 000000000..4503786f0 --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/DocumentUtils.java @@ -0,0 +1,31 @@ +package com.woowacourse.s3proxy; + +import io.restassured.specification.RequestSpecification; +import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor; +import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor; + +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; + +public final class DocumentUtils { + private static RequestSpecification preConfiguredRequestSpecification; + + private DocumentUtils() { + } + + public static RequestSpecification getRequestSpecification() { + return preConfiguredRequestSpecification; + } + + public static void setRequestSpecification(RequestSpecification preConfiguredRequestSpecification) { + DocumentUtils.preConfiguredRequestSpecification = preConfiguredRequestSpecification; + } + + public static OperationRequestPreprocessor getRequestPreprocessor() { + return preprocessRequest(prettyPrint()); + } + + public static OperationResponsePreprocessor getResponsePreprocessor() { + return preprocessResponse(prettyPrint()); + } +} diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/AcceptanceTest.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/AcceptanceTest.java new file mode 100644 index 000000000..d927f021f --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/AcceptanceTest.java @@ -0,0 +1,37 @@ +package com.woowacourse.s3proxy.controller; + +import io.restassured.RestAssured; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static com.woowacourse.s3proxy.DocumentUtils.setRequestSpecification; +import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.documentationConfiguration; + +@ExtendWith({RestDocumentationExtension.class, SpringExtension.class}) +@AutoConfigureRestDocs +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public class AcceptanceTest { + @LocalServerPort + int port; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + RestAssured.port = this.port; + RequestSpecification spec = new RequestSpecBuilder() + .addFilter(documentationConfiguration(restDocumentation)) + .build(); + setRequestSpecification(spec); + } +} + + diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/S3ProxyControllerTest.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/S3ProxyControllerTest.java new file mode 100644 index 000000000..8c143f418 --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/S3ProxyControllerTest.java @@ -0,0 +1,93 @@ +package com.woowacourse.s3proxy.controller; + +import com.woowacourse.s3proxy.infrastructure.S3Uploader; +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.net.URI; + +import static com.woowacourse.s3proxy.Constants.LUTHER_IMAGE_URI_CLOUDFRONT; +import static com.woowacourse.s3proxy.DocumentUtils.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.document; + +class S3ProxyControllerTest extends AcceptanceTest { + @MockBean + S3Uploader s3Uploader; + + @BeforeEach + void setUp() { + given(s3Uploader.upload(any(MultipartFile.class), anyString())) + .willReturn(URI.create(LUTHER_IMAGE_URI_CLOUDFRONT)); + } + + @Test + @DisplayName("스토리지에 파일을 업로드한다.") + void upload() { + // given + String directory = "testdir"; + String filePath = getClass().getClassLoader().getResource("luther.png").getFile(); + File file = new File(filePath); + + // when + ExtractableResponse response = uploadFile(directory, file); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); + } + + @Test + @DisplayName("스토리지의 파일을 삭제한다.") + void delete() { + // given + String fileName = "filename.png"; + String directory = "testdir"; + + // when + ExtractableResponse response = deleteFile(directory, fileName); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); + } + + private ExtractableResponse uploadFile(String directory, File file) { + return RestAssured.given(getRequestSpecification()) + .log().all() + .filter(document( + "s3/post", getRequestPreprocessor(), getResponsePreprocessor(), + pathParameters(parameterWithName("directory").description("저장하고자 하는 스토리지 내의 디렉토리 이름")))) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .multiPart("file", file) + .pathParam("directory", directory) + .when().post("/api/storage/{directory}") + .then().log().all().extract(); + } + + private ExtractableResponse deleteFile(String directory, String fileName) { + return RestAssured.given(getRequestSpecification()) + .log().all() + .filter(document("s3/delete", getRequestPreprocessor(), getResponsePreprocessor(), + pathParameters( + parameterWithName("directory").description("저장하고자 하는 스토리지 내의 디렉토리 이름"), + parameterWithName("filename").description("삭제하고자 하는 파일의 이름(확장자 포함)")))) + .when() + .pathParam("directory", directory) + .pathParam("filename", fileName) + .delete("/api/storage/{directory}/{filename}") + .then().log().all().extract(); + } +} diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/infrastructure/S3UploaderTest.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/infrastructure/S3UploaderTest.java new file mode 100644 index 000000000..7bf98f5a2 --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/infrastructure/S3UploaderTest.java @@ -0,0 +1,71 @@ +package com.woowacourse.s3proxy.infrastructure; + +import com.amazonaws.services.s3.AmazonS3; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.util.Random; + +import static com.woowacourse.s3proxy.Constants.LUTHER_IMAGE_URI_S3; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class S3UploaderTest { + + @Test + @DisplayName("멀티 파트 파일을 업로드하고 URI를 얻어, 접근 가능한 URI(CloudFront)로 변경해 리턴한다.") + void upload() throws IOException { + // given + AmazonS3 amazonS3 = mock(AmazonS3.class); + String bucketName = "testBucketName"; + String cloudFrontUrl = "https://expectedCloudFrontUrl.net"; + + S3Uploader s3Uploader = new S3Uploader(amazonS3, bucketName, cloudFrontUrl); + + given(amazonS3.getUrl(anyString(), anyString())) + .willReturn(new URL(LUTHER_IMAGE_URI_S3)); + + MultipartFile mockMultipartFile = mock(MultipartFile.class); + given(mockMultipartFile.getOriginalFilename()) + .willReturn("somePngFileName.png"); + given(mockMultipartFile.getSize()) + .willReturn(new Random().nextLong()); + given(mockMultipartFile.getInputStream()) + .willReturn(mock(InputStream.class)); + + // when + String directoryName = "testDirectoryName"; + URI actual = s3Uploader.upload(mockMultipartFile, directoryName); + + String resourceUriWithoutHost = LUTHER_IMAGE_URI_S3.split("amazonaws.com")[1]; + String expectedUri = cloudFrontUrl + resourceUriWithoutHost; + + // then + assertThat(actual).isEqualTo(URI.create(expectedUri)); + } + + @Test + @DisplayName("경로를 입력받아 파일을 삭제할 수 있다.") + void delete() { + // given + AmazonS3 amazonS3 = mock(AmazonS3.class); + String bucketName = "testBucketName"; + String cloudFrontUrl = "https://testCloudFrontUrl.net"; + + S3Uploader s3Uploader = new S3Uploader(amazonS3, bucketName, cloudFrontUrl); + + String fileName = "filename.png"; + String directory = "directoryName"; + + // when, then + assertDoesNotThrow(() -> s3Uploader.delete(directory + "/" + fileName)); + } +} diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/service/S3ServiceTest.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/service/S3ServiceTest.java new file mode 100644 index 000000000..7e5c8de29 --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/service/S3ServiceTest.java @@ -0,0 +1,57 @@ +package com.woowacourse.s3proxy.service; + +import com.woowacourse.s3proxy.infrastructure.S3Uploader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; + +import static com.woowacourse.s3proxy.Constants.LUTHER_IMAGE_URI_CLOUDFRONT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class S3ServiceTest extends ServiceTest { + @MockBean + private S3Uploader s3Uploader; + + @Autowired + private S3Service s3Service; + + @BeforeEach + void mockingS3Uploader() { + given(s3Uploader.upload(any(MultipartFile.class), anyString())) + .willReturn(URI.create(LUTHER_IMAGE_URI_CLOUDFRONT)); + } + + @Test + @DisplayName("멀티파트로 전송된 파일을 요청한 디렉토리에 업로드한다.") + void upload() { + // given + MultipartFile multipartFile = mock(MultipartFile.class); + + // when + URI actual = s3Service.upload(multipartFile, "thumbnails"); + + // then + assertThat(actual).isEqualTo(URI.create(LUTHER_IMAGE_URI_CLOUDFRONT)); + } + + @Test + @DisplayName("스토리지의 파일을 삭제할 수 있다.") + void delete() { + // given + String fileName = "filename.png"; + String directory = "directoryName"; + + // when, then + assertDoesNotThrow(() -> s3Service.delete(directory, fileName)); + } +} diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/service/ServiceTest.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/service/ServiceTest.java new file mode 100644 index 000000000..e6bbd09a1 --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/service/ServiceTest.java @@ -0,0 +1,9 @@ +package com.woowacourse.s3proxy.service; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public class ServiceTest { +} diff --git a/s3proxy/src/test/resources/luther.png b/s3proxy/src/test/resources/luther.png new file mode 100644 index 000000000..c2eac157e Binary files /dev/null and b/s3proxy/src/test/resources/luther.png differ