diff --git a/.gitignore b/.gitignore index 9bf8e68..b2d9456 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ out/ ### Env ### .env +docs/ diff --git a/src/main/java/com/example/hackathon/domain/image/config/S3Config.java b/src/main/java/com/example/hackathon/domain/image/config/S3Config.java index 9864e93..daad296 100644 --- a/src/main/java/com/example/hackathon/domain/image/config/S3Config.java +++ b/src/main/java/com/example/hackathon/domain/image/config/S3Config.java @@ -8,7 +8,6 @@ import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.S3Client; @Configuration @@ -22,19 +21,11 @@ public S3Config(S3Properties properties) { } /** - * presigned URL 을 만드는 서명기. 실제 S3 를 호출하지 않고 로컬에서 서명만 한다. + * S3 업로드용 클라이언트. 미션 인증 사진 업로드에 쓰인다. * * access-key/secret-key 가 주입되면 그 자격증명을 쓰고, 비어 있으면 * 기본 자격증명 체인(환경변수·EC2 IAM 역할 등)에 위임한다. */ - @Bean - public S3Presigner s3Presigner() { - return S3Presigner.builder() - .region(resolveRegion()) - .credentialsProvider(credentialsProvider()) - .build(); - } - @Bean public S3Client s3Client() { return S3Client.builder() diff --git a/src/main/java/com/example/hackathon/domain/image/controller/ImageController.java b/src/main/java/com/example/hackathon/domain/image/controller/ImageController.java deleted file mode 100644 index ca67b75..0000000 --- a/src/main/java/com/example/hackathon/domain/image/controller/ImageController.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.hackathon.domain.image.controller; - -import com.example.hackathon.domain.image.dto.PresignedUrlRequest; -import com.example.hackathon.domain.image.dto.PresignedUrlResponse; -import com.example.hackathon.domain.image.service.PresignedUrlService; -import com.example.hackathon.global.response.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Tag(name = "Image", description = "이미지 업로드 (S3 Presigned URL)") -@RestController -@RequestMapping("/api/images") -@RequiredArgsConstructor -public class ImageController { - - private final PresignedUrlService presignedUrlService; - - @Operation(summary = "업로드 URL 발급", - description = "S3 에 직접 PUT 할 presigned URL 을 발급한다. 업로드 후 imageUrl 을 미션 인증 API 에 전달한다.") - @PostMapping("/presigned-url") - public ResponseEntity> issue(@Valid @RequestBody PresignedUrlRequest request) { - PresignedUrlResponse response = presignedUrlService.issue(request); - return ResponseEntity.ok(ApiResponse.ok("업로드 URL이 발급되었습니다.", response)); - } -} diff --git a/src/main/java/com/example/hackathon/domain/image/dto/PresignedUrlRequest.java b/src/main/java/com/example/hackathon/domain/image/dto/PresignedUrlRequest.java deleted file mode 100644 index 6edf33b..0000000 --- a/src/main/java/com/example/hackathon/domain/image/dto/PresignedUrlRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.hackathon.domain.image.dto; - -import jakarta.validation.constraints.NotBlank; - -public record PresignedUrlRequest( - - @NotBlank(message = "파일명은 필수입니다.") - String fileName, - - @NotBlank(message = "contentType 은 필수입니다.") - String contentType -) { -} diff --git a/src/main/java/com/example/hackathon/domain/image/dto/PresignedUrlResponse.java b/src/main/java/com/example/hackathon/domain/image/dto/PresignedUrlResponse.java deleted file mode 100644 index ad79bd8..0000000 --- a/src/main/java/com/example/hackathon/domain/image/dto/PresignedUrlResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.hackathon.domain.image.dto; - -public record PresignedUrlResponse( - String uploadUrl, - String imageUrl, - long expiresIn -) { -} diff --git a/src/main/java/com/example/hackathon/domain/image/service/PresignedUrlService.java b/src/main/java/com/example/hackathon/domain/image/service/PresignedUrlService.java deleted file mode 100644 index 35b35b1..0000000 --- a/src/main/java/com/example/hackathon/domain/image/service/PresignedUrlService.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.example.hackathon.domain.image.service; - -import com.example.hackathon.domain.image.config.S3Properties; -import com.example.hackathon.domain.image.dto.PresignedUrlRequest; -import com.example.hackathon.domain.image.dto.PresignedUrlResponse; -import com.example.hackathon.global.exception.BusinessException; -import com.example.hackathon.global.exception.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; - -import java.time.Duration; -import java.util.Map; -import java.util.UUID; - -/** - * 미션 인증 이미지 업로드용 presigned URL 발급. - * - * 클라이언트는 발급받은 uploadUrl 로 S3 에 직접 PUT 하고, - * 업로드가 끝나면 imageUrl 을 미션 인증 API 로 넘긴다. - * 이 서비스는 미션 로그 존재 여부를 검증하지 않는다(그건 인증 API 몫). - */ -@Service -@RequiredArgsConstructor -public class PresignedUrlService { - - private static final String KEY_PREFIX = "mission/"; - - /** 허용 이미지 형식 → 파일 확장자. */ - private static final Map ALLOWED_CONTENT_TYPES = Map.of( - "image/jpeg", "jpg", - "image/png", "png" - ); - - private final S3Presigner s3Presigner; - private final S3Properties s3Properties; - - public PresignedUrlResponse issue(PresignedUrlRequest request) { - String extension = ALLOWED_CONTENT_TYPES.get(request.contentType()); - if (extension == null) { - throw new BusinessException(ErrorCode.IMAGE_ERROR_400_INVALID_CONTENT_TYPE); - } - - String bucket = s3Properties.s3().bucket(); - String key = KEY_PREFIX + UUID.randomUUID() + "." + extension; - long expireSeconds = s3Properties.s3().presignedUrlExpirationSeconds(); - - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(bucket) - .key(key) - .contentType(request.contentType()) - .build(); - - PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofSeconds(expireSeconds)) - .putObjectRequest(putObjectRequest) - .build(); - - try { - PresignedPutObjectRequest presigned = s3Presigner.presignPutObject(presignRequest); - String imageUrl = toPublicUrl(bucket, key); - return new PresignedUrlResponse(presigned.url().toString(), imageUrl, expireSeconds); - } catch (Exception e) { - throw new BusinessException(ErrorCode.IMAGE_ERROR_500_PRESIGNED_URL_FAILED); - } - } - - /** 버킷이 공개 읽기이므로 업로드 완료 후 이 주소로 바로 접근된다. */ - private String toPublicUrl(String bucket, String key) { - return "https://%s.s3.%s.amazonaws.com/%s".formatted(bucket, s3Properties.region(), key); - } -} diff --git a/src/main/java/com/example/hackathon/global/exception/ErrorCode.java b/src/main/java/com/example/hackathon/global/exception/ErrorCode.java index f76b8b7..69d9cb0 100644 --- a/src/main/java/com/example/hackathon/global/exception/ErrorCode.java +++ b/src/main/java/com/example/hackathon/global/exception/ErrorCode.java @@ -32,7 +32,6 @@ public enum ErrorCode { IMAGE_ERROR_400_EMPTY_FILE(HttpStatus.BAD_REQUEST, "이미지 파일이 비어 있습니다."), IMAGE_ERROR_400_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "이미지 파일은 10MB를 초과할 수 없습니다."), IMAGE_ERROR_500_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드에 실패했습니다."), - IMAGE_ERROR_500_PRESIGNED_URL_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드 URL 발급에 실패했습니다."), // 미션 MISSION_ERROR_400_BEFORE_DETOX_START(HttpStatus.BAD_REQUEST, "디톡스 시작 시간 이전입니다."), diff --git a/src/test/java/com/example/hackathon/domain/image/service/PresignedUrlServiceTest.java b/src/test/java/com/example/hackathon/domain/image/service/PresignedUrlServiceTest.java deleted file mode 100644 index b86785b..0000000 --- a/src/test/java/com/example/hackathon/domain/image/service/PresignedUrlServiceTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.example.hackathon.domain.image.service; - -import com.example.hackathon.domain.image.config.S3Properties; -import com.example.hackathon.domain.image.dto.PresignedUrlRequest; -import com.example.hackathon.domain.image.dto.PresignedUrlResponse; -import com.example.hackathon.global.exception.BusinessException; -import com.example.hackathon.global.exception.ErrorCode; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -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.presigner.S3Presigner; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * presign 은 네트워크 호출 없이 로컬에서 서명만 하므로, 더미 자격증명으로 - * 실제 S3 없이 URL 형식을 검증할 수 있다. CI 에도 AWS 자격증명이 필요 없다. - */ -class PresignedUrlServiceTest { - - private static final String BUCKET = "test-bucket"; - private static final String REGION = "ap-northeast-2"; - - private static S3Presigner presigner; - private PresignedUrlService service; - - @BeforeAll - static void setUpPresigner() { - presigner = S3Presigner.builder() - .region(Region.of(REGION)) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create("dummy-access", "dummy-secret"))) - .build(); - } - - @AfterAll - static void tearDown() { - presigner.close(); - } - - PresignedUrlServiceTest() { - S3Properties props = new S3Properties(REGION, - new S3Properties.S3(BUCKET, "dummy-access", "dummy-secret", 300)); - this.service = new PresignedUrlService(presigner, props); - } - - @Test - @DisplayName("jpeg 요청이면 mission/ 경로에 .jpg 키로 업로드 URL 과 공개 imageUrl 을 발급한다") - void issue_jpeg() { - PresignedUrlResponse response = service.issue( - new PresignedUrlRequest("photo.jpg", "image/jpeg")); - - assertThat(response.uploadUrl()).contains(BUCKET).contains("X-Amz-Signature"); - assertThat(response.imageUrl()) - .startsWith("https://" + BUCKET + ".s3." + REGION + ".amazonaws.com/mission/") - .endsWith(".jpg"); - assertThat(response.expiresIn()).isEqualTo(300); - // 업로드 URL 과 공개 URL 은 같은 객체 키를 가리켜야 한다. - assertThat(response.uploadUrl()).contains(objectKey(response.imageUrl())); - } - - @Test - @DisplayName("png 요청이면 .png 확장자로 발급한다") - void issue_png() { - PresignedUrlResponse response = service.issue( - new PresignedUrlRequest("photo.png", "image/png")); - - assertThat(response.imageUrl()).endsWith(".png"); - } - - @Test - @DisplayName("매 발급마다 객체 키(UUID)가 달라 덮어쓰기가 나지 않는다") - void issue_uniqueKey() { - String url1 = service.issue(new PresignedUrlRequest("a.jpg", "image/jpeg")).imageUrl(); - String url2 = service.issue(new PresignedUrlRequest("a.jpg", "image/jpeg")).imageUrl(); - - assertThat(url1).isNotEqualTo(url2); - } - - @Test - @DisplayName("허용하지 않는 형식이면 IMAGE_ERROR_400_INVALID_CONTENT_TYPE") - void issue_invalidContentType() { - assertThatThrownBy(() -> service.issue(new PresignedUrlRequest("x.gif", "image/gif"))) - .isInstanceOf(BusinessException.class) - .extracting("errorCode") - .isEqualTo(ErrorCode.IMAGE_ERROR_400_INVALID_CONTENT_TYPE); - } - - private String objectKey(String imageUrl) { - return imageUrl.substring(imageUrl.indexOf("mission/")); - } -}