diff --git a/build.gradle b/build.gradle index 245813d..9936c0a 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'software.amazon.awssdk:s3:2.25.40' // Swagger 설정 implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' @@ -75,4 +76,4 @@ sourceSets { tasks.named('test') { useJUnitPlatform() // JUnit5 명시적 활성화 -} \ No newline at end of file +} diff --git a/src/main/java/com/valanse/valanse/common/config/R2Config.java b/src/main/java/com/valanse/valanse/common/config/R2Config.java new file mode 100644 index 0000000..8577b4d --- /dev/null +++ b/src/main/java/com/valanse/valanse/common/config/R2Config.java @@ -0,0 +1,26 @@ +package com.valanse.valanse.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +import java.net.URI; + +@Configuration +public class R2Config { + + @Bean + public S3Client s3Client(R2Properties properties) { + return S3Client.builder() + .region(Region.of("auto")) + .endpointOverride(URI.create(properties.getEndpoint())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()) + )) + .forcePathStyle(true) + .build(); + } +} diff --git a/src/main/java/com/valanse/valanse/common/config/R2Properties.java b/src/main/java/com/valanse/valanse/common/config/R2Properties.java new file mode 100644 index 0000000..9cbb93d --- /dev/null +++ b/src/main/java/com/valanse/valanse/common/config/R2Properties.java @@ -0,0 +1,64 @@ +package com.valanse.valanse.common.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "cloudflare.r2") +public class R2Properties { + + private String accountId; + private String endpoint; + private String bucket; + private String accessKey; + private String secretKey; + private String publicUrl; + + public String getAccountId() { + return accountId; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public String getPublicUrl() { + return publicUrl; + } + + public void setPublicUrl(String publicUrl) { + this.publicUrl = publicUrl; + } +} diff --git a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java index 3551cbc..400cec9 100644 --- a/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java +++ b/src/main/java/com/valanse/valanse/common/config/SecurityConfig.java @@ -62,6 +62,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.POST, "/votes/*/vote-options/*").authenticated() .requestMatchers(HttpMethod.POST, "/votes/*/comments").authenticated() .requestMatchers(HttpMethod.POST, "/comments/*/like").authenticated() + .requestMatchers(HttpMethod.POST, "/storage/images").authenticated() // PUT - 인증 필요 (수정) .requestMatchers(HttpMethod.PUT, "/votes/*").authenticated() @@ -101,13 +102,7 @@ public CorsConfigurationSource corsConfigurationSource() { "https://valan-se-web.vercel.app", "https://valanse.kr", "https://develop.valanse.kr", - "https://backendbase.store", - "http://backendbase.store:8080", - "http://backendbase.store:8081", - "http://backendbase.store:8082", - "https://backendbase.store:8080", - "https://backendbase.store:8081", - "https://backendbase.store:8082", + "https://valanserver.store", "http://valanserver.store", "http://valanserver.store:8080", "http://valanserver.store:8081", diff --git a/src/main/java/com/valanse/valanse/controller/StorageController.java b/src/main/java/com/valanse/valanse/controller/StorageController.java new file mode 100644 index 0000000..ec5f4ab --- /dev/null +++ b/src/main/java/com/valanse/valanse/controller/StorageController.java @@ -0,0 +1,32 @@ +package com.valanse.valanse.controller; + +import com.valanse.valanse.dto.Storage.ImageUploadResponse; +import com.valanse.valanse.service.StorageService.StorageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "파일 업로드 API", description = "이미지 업로드 관련 기능") +@RestController +@RequestMapping("/storage") +@RequiredArgsConstructor +public class StorageController { + + private final StorageService storageService; + + @Operation( + summary = "이미지 업로드", + description = "이미지 파일을 Cloudflare R2에 업로드하고 공개 URL을 반환합니다." + ) + @PostMapping(value = "/images", consumes = "multipart/form-data") + public ResponseEntity uploadImage(@RequestPart("file") MultipartFile file) { + String imageUrl = storageService.uploadImage(file, "images"); + return ResponseEntity.ok(new ImageUploadResponse(imageUrl)); + } +} diff --git a/src/main/java/com/valanse/valanse/dto/Storage/ImageUploadResponse.java b/src/main/java/com/valanse/valanse/dto/Storage/ImageUploadResponse.java new file mode 100644 index 0000000..b9ad372 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Storage/ImageUploadResponse.java @@ -0,0 +1,6 @@ +package com.valanse.valanse.dto.Storage; + +public record ImageUploadResponse( + String imageUrl +) { +} diff --git a/src/main/java/com/valanse/valanse/service/StorageService/R2StorageService.java b/src/main/java/com/valanse/valanse/service/StorageService/R2StorageService.java new file mode 100644 index 0000000..3e30859 --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/StorageService/R2StorageService.java @@ -0,0 +1,82 @@ +package com.valanse.valanse.service.StorageService; + +import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.common.config.R2Properties; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +import java.io.IOException; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class R2StorageService implements StorageService { + + private static final long MAX_IMAGE_SIZE = 5 * 1024 * 1024; + private static final Set ALLOWED_IMAGE_TYPES = Set.of( + "image/jpeg", + "image/png", + "image/webp", + "image/gif" + ); + + private final S3Client s3Client; + private final R2Properties properties; + + @Override + public String uploadImage(MultipartFile file, String directory) { + validateImage(file); + + String objectKey = buildObjectKey(file, directory); + PutObjectRequest request = PutObjectRequest.builder() + .bucket(properties.getBucket()) + .key(objectKey) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + + try { + s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + } catch (IOException e) { + throw new ApiException("이미지 파일을 읽을 수 없습니다.", HttpStatus.BAD_REQUEST); + } catch (S3Exception e) { + throw new ApiException("이미지 업로드에 실패했습니다.", HttpStatus.BAD_GATEWAY); + } + + return properties.getPublicUrl().replaceAll("/+$", "") + "/" + objectKey; + } + + private void validateImage(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ApiException("업로드할 이미지 파일을 선택해주세요.", HttpStatus.BAD_REQUEST); + } + + if (file.getSize() > MAX_IMAGE_SIZE) { + throw new ApiException("이미지는 5MB 이하만 업로드할 수 있습니다.", HttpStatus.BAD_REQUEST); + } + + if (!ALLOWED_IMAGE_TYPES.contains(file.getContentType())) { + throw new ApiException("jpg, png, webp, gif 이미지 파일만 업로드할 수 있습니다.", HttpStatus.BAD_REQUEST); + } + } + + private String buildObjectKey(MultipartFile file, String directory) { + String cleanDirectory = StringUtils.hasText(directory) + ? directory.replaceAll("^/+|/+$", "") + : "images"; + String extension = StringUtils.getFilenameExtension(file.getOriginalFilename()); + String filename = extension == null + ? UUID.randomUUID().toString() + : UUID.randomUUID() + "." + extension.toLowerCase(); + + return cleanDirectory + "/" + filename; + } +} diff --git a/src/main/java/com/valanse/valanse/service/StorageService/StorageService.java b/src/main/java/com/valanse/valanse/service/StorageService/StorageService.java new file mode 100644 index 0000000..2d7f562 --- /dev/null +++ b/src/main/java/com/valanse/valanse/service/StorageService/StorageService.java @@ -0,0 +1,7 @@ +package com.valanse.valanse.service.StorageService; + +import org.springframework.web.multipart.MultipartFile; + +public interface StorageService { + String uploadImage(MultipartFile file, String directory); +} diff --git a/src/test/java/com/valanse/valanse/service/StorageService/R2StorageServiceTest.java b/src/test/java/com/valanse/valanse/service/StorageService/R2StorageServiceTest.java new file mode 100644 index 0000000..0736dfd --- /dev/null +++ b/src/test/java/com/valanse/valanse/service/StorageService/R2StorageServiceTest.java @@ -0,0 +1,61 @@ +package com.valanse.valanse.service.StorageService; + +import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.common.config.R2Properties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class R2StorageServiceTest { + + private S3Client s3Client; + private R2StorageService storageService; + + @BeforeEach + void setUp() { + s3Client = mock(S3Client.class); + + R2Properties properties = new R2Properties(); + properties.setBucket("test-bucket"); + properties.setPublicUrl("https://cdn.example.com/"); + + storageService = new R2StorageService(s3Client, properties); + } + + @Test + void 이미지를_R2에_업로드하고_공개_URL을_반환한다() { + MockMultipartFile file = new MockMultipartFile( + "file", + "sample.png", + "image/png", + "image".getBytes() + ); + + String imageUrl = storageService.uploadImage(file, "images"); + + assertThat(imageUrl).startsWith("https://cdn.example.com/images/"); + assertThat(imageUrl).endsWith(".png"); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + void 이미지가_아닌_파일은_업로드하지_않는다() { + MockMultipartFile file = new MockMultipartFile( + "file", + "sample.txt", + "text/plain", + "text".getBytes() + ); + + assertThrows(ApiException.class, () -> storageService.uploadImage(file, "images")); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 55457c4..ac9c63a 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -26,3 +26,12 @@ oauth: kakao: client-id: test-client-id redirect-uri: http://localhost + +cloudflare: + r2: + account-id: test-account-id + endpoint: http://localhost:9000 + bucket: test-bucket + access-key: test-access-key + secret-key: test-secret-key + public-url: http://localhost:9000/test-bucket