-
Notifications
You must be signed in to change notification settings - Fork 1
Dev #154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Dev #154
Changes from all commits
381a0cd
be56279
92738c8
80e327c
2e9d8df
a3df1e4
a2409ca
55b6e4e
8d94cd1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ImageUploadResponse> uploadImage(@RequestPart("file") MultipartFile file) { | ||
| String imageUrl = storageService.uploadImage(file, "images"); | ||
| return ResponseEntity.ok(new ImageUploadResponse(imageUrl)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package com.valanse.valanse.dto.Storage; | ||
|
|
||
| public record ImageUploadResponse( | ||
| String imageUrl | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String> 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); | ||
| } | ||
|
Comment on lines
+57
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# MIME-only 검증 여부 확인 (바이너리 시그니처 검증 코드 유무 탐색)
fd -i R2StorageService.java src | xargs -I{} sh -c '
echo "== {} ==";
rg -n "ALLOWED_IMAGE_TYPES|getContentType|ImageIO|Tika|magic" "{}"
'Repository: ValanSee/ValanSe_Server Length of output: 348 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="src/main/java/com/valanse/valanse/service/StorageService/R2StorageService.java"
echo "===== validateImage() snippet ====="
nl -ba "$FILE" | sed -n '40,110p'
echo "===== allowed types set ====="
nl -ba "$FILE" | sed -n '15,55p'
echo "===== repo-wide image signature validation hints ====="
rg -n "ImageIO|Tika|magic|contentType\\(file\\.getContentType\\)|getInputStream\\(" src/main/java || true
echo "===== validateImage 호출/흐름 ====="
rg -n "validateImage\\(" -S src/main/java || trueRepository: ValanSee/ValanSe_Server Length of output: 164 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="src/main/java/com/valanse/valanse/service/StorageService/R2StorageService.java"
echo "===== validateImage() snippet (line 40-110) ====="
awk 'NR>=40 && NR<=110 {printf "%5d:%s\n", NR, $0}' "$FILE" || true
echo "===== allowed types set (line 15-55) ====="
awk 'NR>=15 && NR<=55 {printf "%5d:%s\n", NR, $0}' "$FILE" || true
echo "===== repo-wide image signature validation hints ====="
rg -n "ImageIO|Tika|magic|getInputStream\\(" src/main/java || true
rg -n "contentType\\(file\\.getContentType\\)|getContentType\\(\\)" src/main/java || true
echo "===== validateImage 호출/흐름 ====="
rg -n "validateImage\\(" src/main/java || trueRepository: ValanSee/ValanSe_Server Length of output: 4568 MIME 타입만으로는 이미지 검증이 부족합니다. 제안 diff+import javax.imageio.ImageIO;
+import java.io.InputStream;
@@
private void validateImage(MultipartFile file) {
@@
if (!ALLOWED_IMAGE_TYPES.contains(file.getContentType())) {
throw new ApiException("jpg, png, webp, gif 이미지 파일만 업로드할 수 있습니다.", HttpStatus.BAD_REQUEST);
}
+
+ try (InputStream in = file.getInputStream()) {
+ if (ImageIO.read(in) == null) {
+ throw new ApiException("유효한 이미지 파일이 아닙니다.", HttpStatus.BAD_REQUEST);
+ }
+ } catch (IOException e) {
+ throw new ApiException("이미지 파일을 읽을 수 없습니다.", HttpStatus.BAD_REQUEST);
+ }
}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)); | ||
| } | ||
|
Comment on lines
+34
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift S3 호출 검증이 불충분하고 파일 크기 검증 테스트가 누락되었습니다. 현재 테스트는 다음 개선사항을 권장합니다:
🧪 검증 강화 예시 `@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));
+
+ ArgumentCaptor<PutObjectRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
+ verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class));
+
+ PutObjectRequest request = requestCaptor.getValue();
+ assertThat(request.bucket()).isEqualTo("test-bucket");
+ assertThat(request.key()).startsWith("images/");
+ assertThat(request.contentType()).isEqualTo("image/png");
}추가 테스트 케이스: `@Test`
void 파일_크기가_제한을_초과하면_예외가_발생한다() {
byte[] largeContent = new byte[11 * 1024 * 1024]; // 11MB
MockMultipartFile file = new MockMultipartFile(
"file",
"large.png",
"image/png",
largeContent
);
assertThrows(ApiException.class, () -> storageService.uploadImage(file, "images"));
}
`@Test`
void 빈_파일은_업로드하지_않는다() {
MockMultipartFile file = new MockMultipartFile(
"file",
"empty.png",
"image/png",
new byte[0]
);
assertThrows(ApiException.class, () -> storageService.uploadImage(file, "images"));
}🤖 Prompt for AI Agents |
||
|
|
||
| @Test | ||
| void 이미지가_아닌_파일은_업로드하지_않는다() { | ||
| MockMultipartFile file = new MockMultipartFile( | ||
| "file", | ||
| "sample.txt", | ||
| "text/plain", | ||
| "text".getBytes() | ||
| ); | ||
|
|
||
| assertThrows(ApiException.class, () -> storageService.uploadImage(file, "images")); | ||
| } | ||
|
Comment on lines
+50
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift 예외 검증이 불충분하고 예외 매핑 테스트가 누락되었습니다. 현재 테스트는 다음 개선사항을 권장합니다:
🧪 예외 검증 강화 및 추가 테스트 예시 `@Test`
void 이미지가_아닌_파일은_업로드하지_않는다() {
MockMultipartFile file = new MockMultipartFile(
"file",
"sample.txt",
"text/plain",
"text".getBytes()
);
- assertThrows(ApiException.class, () -> storageService.uploadImage(file, "images"));
+ ApiException exception = assertThrows(ApiException.class,
+ () -> storageService.uploadImage(file, "images"));
+
+ assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.BAD_REQUEST);
+ verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}추가 테스트 케이스: `@Test`
void S3_업로드_실패_시_BAD_GATEWAY_예외가_발생한다() {
MockMultipartFile file = new MockMultipartFile(
"file",
"sample.png",
"image/png",
"image".getBytes()
);
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenThrow(S3Exception.builder().message("S3 error").build());
ApiException exception = assertThrows(ApiException.class,
() -> storageService.uploadImage(file, "images"));
assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.BAD_GATEWAY);
}
`@Test`
void IOException_발생_시_BAD_REQUEST_예외가_발생한다() throws Exception {
MultipartFile file = mock(MultipartFile.class);
when(file.getContentType()).thenReturn("image/png");
when(file.getOriginalFilename()).thenReturn("sample.png");
when(file.getSize()).thenReturn(100L);
when(file.getBytes()).thenThrow(new IOException("Read error"));
ApiException exception = assertThrows(ApiException.class,
() -> storageService.uploadImage(file, "images"));
assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.BAD_REQUEST);
}🤖 Prompt for AI Agents |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: ValanSee/ValanSe_Server
Length of output: 386
🏁 Script executed:
Repository: ValanSee/ValanSe_Server
Length of output: 186
🏁 Script executed:
Repository: ValanSee/ValanSe_Server
Length of output: 6433
🏁 Script executed:
Repository: ValanSee/ValanSe_Server
Length of output: 164
🏁 Script executed:
Repository: ValanSee/ValanSe_Server
Length of output: 4918
🏁 Script executed:
Repository: ValanSee/ValanSe_Server
Length of output: 49
🏁 Script executed:
Repository: ValanSee/ValanSe_Server
Length of output: 1136
🏁 Script executed:
Repository: ValanSee/ValanSe_Server
Length of output: 3036
R2 필수 설정값을 부팅 시점에 검증해 주세요.
@ConfigurationProperties만있고@Validated/@notblank 제약이 없어 null/빈 문자열이 그대로 바인딩됩니다.제안 diff
🤖 Prompt for AI Agents