diff --git a/.github/workflows/cd-aws.yml b/.github/workflows/cd-aws.yml index c50466c..e0ea6e9 100644 --- a/.github/workflows/cd-aws.yml +++ b/.github/workflows/cd-aws.yml @@ -34,6 +34,13 @@ jobs: echo "${{ secrets.APPLICATION }}" > ./src/main/resources/application.yaml echo "${{ secrets.P12_BASE64 }}" | base64 --decode > ./src/main/resources/test.p12 + - name: Set up CloudFront environment variables + env: + CLF_PRIVATE_KEY: ${{ secrets.CLF_PRIVATE_KEY }} + CLF_KEY_PAIR_ID: ${{ secrets.CLF_KEY_PAIR_ID }} + run: echo "Environment variables set" + + - name: Build with Gradle run: ./gradlew build -x test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b4a625..db5e84b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,11 @@ jobs: build-and-test: # Job 이름 runs-on: ubuntu-latest # 작업 실행 환경 + env: + # GitHub Secrets 주입 + CLOUDFRONT_PRIVATE_KEY: ${{ secrets.CLF_PRIVATE_KEY }} + CLOUDFRONT_KEY_PAIR_ID: ${{ secrets.CLF_KEY_PAIR_ID }} + steps: - name: Checkout code # Step 이름 uses: actions/checkout@v4 # GitHub Action 사용 (코드 체크아웃) diff --git a/build.gradle.kts b/build.gradle.kts index 5167e88..345b7f5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,6 +40,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-oauth2-client") implementation("org.springframework.boot:spring-boot-starter-security") implementation("io.github.oshai:kotlin-logging-jvm:5.1.1") + implementation("org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE") + implementation("software.amazon.awssdk:s3:2.20.32") + implementation("com.amazonaws:aws-java-sdk-cloudfront:1.12.592") // 최신 버전 사용, signed URL 생성을 위해 필요 } kotlin { diff --git a/src/main/kotlin/com/example/toyTeam6Airbnb/Image/ImageController.kt b/src/main/kotlin/com/example/toyTeam6Airbnb/Image/ImageController.kt new file mode 100644 index 0000000..2ddb38f --- /dev/null +++ b/src/main/kotlin/com/example/toyTeam6Airbnb/Image/ImageController.kt @@ -0,0 +1,92 @@ +package com.example.toyTeam6Airbnb.Image + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import java.nio.file.Paths +import java.util.Date + +@RestController +@RequestMapping("/api/images") +class ImageController( + private val imageService: ImageService +) { + + // 이미지 업로드 엔드포인트 + @PostMapping("/upload") + fun uploadImage( + @RequestParam("file") file: MultipartFile, + @RequestParam("key") key: String + ): ResponseEntity { + return try { + val tempFile = Paths.get(System.getProperty("java.io.tmpdir"), file.originalFilename).toFile() + file.transferTo(tempFile) // MultipartFile을 임시 파일로 저장 + + imageService.uploadFile(tempFile.absolutePath, key) // S3에 업로드 + tempFile.delete() // 임시 파일 삭제 + + ResponseEntity("Image uploaded successfully with key: $key", HttpStatus.OK) + } catch (e: Exception) { + ResponseEntity("Failed to upload image: ${e.message}", HttpStatus.INTERNAL_SERVER_ERROR) + } + } + + @GetMapping("/{key}") + fun getImageSignedUrl( + @PathVariable("key") key: String + ): ResponseEntity { + return try { + val expirationDate = Date(System.currentTimeMillis() + 3600_000) // 1시간 유효 + + val signedUrl = imageService.generateSignedUrl( + "https://d3m9s5wmwvsq01.cloudfront.net", + key, + expirationDate + ) + + ResponseEntity.ok(signedUrl) + } catch (e: Exception) { + ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to generate signed URL: ${e.message}") + } + } +} + +// // 이미지 다운로드 엔드포인트 +// @GetMapping("/download") +// fun downloadImage( +// @RequestParam("key") key: String, +// @RequestParam("destination") destination: String +// ): ResponseEntity { +// return try { +// imageService.downloadFile(key, destination) // S3에서 다운로드 +// ResponseEntity("Image downloaded successfully to: $destination", HttpStatus.OK) +// } catch (e: Exception) { +// ResponseEntity("Failed to download image: ${e.message}", HttpStatus.INTERNAL_SERVER_ERROR) +// } +// } + +// // 이미지를 화면에 표시, 보안 취약 +// @GetMapping("/{key}") +// fun getImage( +// @PathVariable("key") key: String +// ): ResponseEntity { +// return try { +// // CloudFront 배포 URL +// val cloudFrontUrl = "https://d3m9s5wmwvsq01.cloudfront.net/$key" +// +// // 302 Redirect +// ResponseEntity.status(HttpStatus.FOUND) +// .header("Location", cloudFrontUrl) +// .build() +// } catch (e: Exception) { +// ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) +// .body(null) +// } +// } diff --git a/src/main/kotlin/com/example/toyTeam6Airbnb/Image/ImageService.kt b/src/main/kotlin/com/example/toyTeam6Airbnb/Image/ImageService.kt new file mode 100644 index 0000000..eda7c22 --- /dev/null +++ b/src/main/kotlin/com/example/toyTeam6Airbnb/Image/ImageService.kt @@ -0,0 +1,92 @@ +package com.example.toyTeam6Airbnb.Image + +import com.amazonaws.services.cloudfront.CloudFrontUrlSigner +import com.amazonaws.services.cloudfront.util.SignerUtils +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import java.io.File +import java.nio.file.Paths +import java.security.PrivateKey +import java.util.Date + +@Service +class ImageService() { + + @Value("\${cloudfront.private-key}") + private lateinit var privateKey: String + + @Value("\${cloudfront.key-pair-id}") + private lateinit var keyPairId: String + private val s3Client: S3Client = S3Client.builder() + .region(Region.AP_NORTHEAST_2) // 원하는 리전 설정 + .build() + private val bucketName: String = "waffle-team6-storage" + private val cloudFrontUrl: String = "https://d3m9s5wmwvsq01.cloudfront.net" + + // 파일 업로드 메서드 + fun uploadFile(filePath: String, key: String) { + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build(), + RequestBody.fromFile(Paths.get(filePath)) + ) + } + +// // 파일 다운로드 메서드 +// @Transactional +// fun downloadFile(key: String, destination: String) { +// s3Client.getObject( +// GetObjectRequest.builder() +// .bucket(bucketName) +// .key(key) +// .build(), +// Paths.get(destination) +// ) +// } + + // CloudFront signed URL 생성 메서드 + fun generateSignedUrl( + domain: String, + key: String, + expiration: Date + ): String { + // privateKey와 keyPairId는 GitHub Secrets에서 가져온 값이 자동으로 주입됨 + return createSignedUrl(domain, key, privateKey, keyPairId, expiration) + } + + // CloudFront 서명 URL 생성 메서드 + // createSignedUrl 메서드 구현 + private fun createSignedUrl( + domain: String, + key: String, + privateKey: String, + keyPairId: String, + expiration: Date + ): String { + try { + // 1. 임시 파일 생성 및 비공개 키 쓰기 + val tempFile = File.createTempFile("privateKey", ".pem") + tempFile.deleteOnExit() // 애플리케이션 종료 시 자동 삭제 + tempFile.writeText(privateKey) + + // 2. Private Key 객체 로드 + val privateKeyObj: PrivateKey = SignerUtils.loadPrivateKey(tempFile) + + // 3. CloudFront 서명된 URL 생성 + return CloudFrontUrlSigner.getSignedURLWithCannedPolicy( + "$domain/$key", // 리소스 URL + keyPairId, // Key Pair ID + privateKeyObj, // PrivateKey 객체 + expiration // 만료 시간 + ) + } catch (e: Exception) { + throw RuntimeException("Failed to create signed URL: ${e.message}", e) + } + } +} diff --git a/src/main/kotlin/com/example/toyTeam6Airbnb/config/SecurityConfig.kt b/src/main/kotlin/com/example/toyTeam6Airbnb/config/SecurityConfig.kt index 24157ee..716f0f5 100644 --- a/src/main/kotlin/com/example/toyTeam6Airbnb/config/SecurityConfig.kt +++ b/src/main/kotlin/com/example/toyTeam6Airbnb/config/SecurityConfig.kt @@ -88,6 +88,7 @@ class SecurityConfig( authorize(HttpMethod.GET, "/api/v1/reviews/**", permitAll) authorize("/error", permitAll) authorize("/redirect", permitAll) + authorize(HttpMethod.GET, "/api/images/**", permitAll) authorize(anyRequest, authenticated) } formLogin {