Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6c61ddf
chore: `.pem` ignore 처리
yechan-kim Dec 29, 2025
ae47688
chore: oci object storage 관련 의존성 추가
yechan-kim Dec 29, 2025
33dcfda
chore: oci object storage 관련 profile 추가
yechan-kim Dec 29, 2025
7906330
feat: oci object storage 설정 추가
yechan-kim Dec 29, 2025
6a95069
feat: Pre-Authenticated Url 응답 DTO 추가
yechan-kim Dec 29, 2025
6b6e5d5
feat: Pre-Authenticated Url 생성 기능 구현
yechan-kim Dec 29, 2025
cee2430
feat: oci object storage 의 object 주소를 반환하는 기능 추가
yechan-kim Dec 29, 2025
b06b1db
feat: oci object storage Rest 컨트롤러 추가
yechan-kim Dec 29, 2025
b3aad05
feat: oci object storage API 보안 설정 추가
yechan-kim Dec 29, 2025
2343a16
delete: 불필요한 의존성 삭제
yechan-kim Dec 29, 2025
d20c197
test: Pre-Authenticated Url 생성 기능에 대한 테스트 코드 작성
yechan-kim Dec 29, 2025
12b8a5e
delete: 미사용 코드 삭제
yechan-kim Dec 31, 2025
607cf83
fix: 오탈자 수정
yechan-kim Dec 31, 2025
dae92e8
feat: 파라미터 검증 로직 추가
yechan-kim Dec 31, 2025
f770890
refactor: 표현 단순화
yechan-kim Dec 31, 2025
4bb8573
fix: 오탈자 수정
yechan-kim Jan 1, 2026
57fdf9c
refactor: 상수를 top-level로 이동
yechan-kim Jan 1, 2026
777feb7
feat: 지원서 상태 조회 API 및 DTO 구현
jwnnoh Dec 30, 2025
697d922
feat: 지원서 상태 조회 기능 구현
jwnnoh Dec 30, 2025
04047c1
test: 지원서 상태 조회 기능 테스트 구현
jwnnoh Dec 30, 2025
da6f028
fix: 비즈니스 로직에 따른 인터뷰 일정 및 장소 전달 방식 수정
jwnnoh Dec 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@ src/test/generated/

### env ###
.env

### key ###
*.pem
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ dependencies {

implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")

implementation("com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.77.2")
implementation("com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.77.2")

testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
testImplementation("com.h2database:h2")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package land.leets.domain.storage.presentation

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import jakarta.validation.constraints.NotBlank
import land.leets.domain.storage.presentation.dto.PreAuthenticatedUrlResponse
import land.leets.domain.storage.usecase.GetPreAuthenticatedUrl
import land.leets.global.error.ErrorResponse
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.RequestParam
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/storages")
class StorageController(
private val getPreAuthenticatedUrl: GetPreAuthenticatedUrl
) {

@Operation(summary = "Pre-Authenticated Url 발급", description = "OCI Object Storage에 파일 업로드를 위한 Pre-Authenticated Url을 발급합니다.")
@ApiResponses(
ApiResponse(responseCode = "200"),
ApiResponse(responseCode = "400", content = [Content(schema = Schema(implementation = ErrorResponse::class))]),
ApiResponse(responseCode = "403", content = [Content(schema = Schema(implementation = ErrorResponse::class))]),
ApiResponse(responseCode = "404", content = [Content(schema = Schema(implementation = ErrorResponse::class))]),
ApiResponse(responseCode = "500", content = [Content(schema = Schema(implementation = ErrorResponse::class))])
)
@PostMapping("/pre-authenticated-url")
fun getPreAuthenticatedUrl(
@NotBlank
@Parameter(description = "버킷에 업로드할 파일 이름 및 경로", example = "profile/{username}.jpg")
@RequestParam fileName: String
): ResponseEntity<PreAuthenticatedUrlResponse> {
return ResponseEntity.ok(getPreAuthenticatedUrl.execute(fileName))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package land.leets.domain.storage.presentation.dto

data class PreAuthenticatedUrlResponse(
val url: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package land.leets.domain.storage.usecase

interface GetObjectUrl {
fun execute(fileName: String): String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package land.leets.domain.storage.usecase

import com.oracle.bmc.objectstorage.ObjectStorage
import org.springframework.beans.factory.annotation.Value


class GetObjectUrlImpl(
private val objectStorage: ObjectStorage,
@Value("\${oci.bucket.name}") private val bucketName: String,
@Value("\${oci.bucket.namespace}") private val bucketNamespace: String,
) : GetObjectUrl {

override fun execute(fileName: String): String =
"${objectStorage.endpoint}/n/$bucketNamespace/b/$bucketName/o/$fileName"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package land.leets.domain.storage.usecase

import land.leets.domain.storage.presentation.dto.PreAuthenticatedUrlResponse

interface GetPreAuthenticatedUrl {
fun execute(fileName: String): PreAuthenticatedUrlResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package land.leets.domain.storage.usecase

import com.oracle.bmc.objectstorage.ObjectStorage
import com.oracle.bmc.objectstorage.model.CreatePreauthenticatedRequestDetails
import com.oracle.bmc.objectstorage.requests.CreatePreauthenticatedRequestRequest
import land.leets.domain.storage.presentation.dto.PreAuthenticatedUrlResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.*

@Service
class GetPreAuthenticatedUrlImpl(
private val objectStorage: ObjectStorage,
@Value("\${oci.bucket.name}") private val bucketName: String,
@Value("\${oci.bucket.namespace}") private val bucketNamespace: String,
) : GetPreAuthenticatedUrl {

companion object {
private const val PRESIGNED_URL_EXPIRATION_MINUTES = 15L
private const val PRESIGNED_REQUEST_NAME_PREFIX = "PAR_Request_"
}

override fun execute(fileName: String): PreAuthenticatedUrlResponse {
val uniqueFileName = generateUniqueFileName(fileName)
val expirationTime = calculateExpirationTime()

val details = buildPreAuthenticatedRequestDetails(uniqueFileName, expirationTime)
val request = buildPreAuthenticatedRequest(details)

val response = objectStorage.createPreauthenticatedRequest(request)
val fullUrl = buildFullUrl(response.preauthenticatedRequest.accessUri)

return PreAuthenticatedUrlResponse(fullUrl)
}

private fun generateUniqueFileName(fileName: String): String {
val uuid = UUID.randomUUID()

return if ('/' in fileName) {
"${fileName.substringBeforeLast('/')}/${uuid}_${fileName.substringAfterLast('/')}"
} else {
"${uuid}_$fileName"
}
}

private fun calculateExpirationTime(): Date =
Date.from(Instant.now().plus(PRESIGNED_URL_EXPIRATION_MINUTES, ChronoUnit.MINUTES))

private fun buildPreAuthenticatedRequestDetails(
fileName: String,
expirationTime: Date
): CreatePreauthenticatedRequestDetails =
CreatePreauthenticatedRequestDetails.builder().apply {
name("$PRESIGNED_REQUEST_NAME_PREFIX${UUID.randomUUID()}")
objectName(fileName)
accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite)
timeExpires(expirationTime)
}.build()

private fun buildPreAuthenticatedRequest(
details: CreatePreauthenticatedRequestDetails
): CreatePreauthenticatedRequestRequest =
CreatePreauthenticatedRequestRequest.builder().apply {
namespaceName(bucketNamespace)
bucketName(bucketName)
createPreauthenticatedRequestDetails(details)
}.build()

private fun buildFullUrl(accessUri: String): String =
"${objectStorage.endpoint}$accessUri"
}
49 changes: 49 additions & 0 deletions src/main/kotlin/land/leets/global/config/OciConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package land.leets.global.config

import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider
import com.oracle.bmc.objectstorage.ObjectStorage
import com.oracle.bmc.objectstorage.ObjectStorageClient
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.io.FileInputStream
import java.io.InputStream
import java.util.function.Supplier

@Configuration
class OciConfig(
@Value("\${oci.tenant-id}")
private val tenantId: String,
@Value("\${oci.user-id}")
private val userId: String,
@Value("\${oci.fingerprint}")
private val fingerprint: String,
@Value("\${oci.private-key-path}")
private val privateKeyPath: String,
@Value("\${oci.region}")
private val region: String,
@Value("\${oci.bucket.namespace}")
private val bucketNamespace: String
) {

@Bean
fun objectStorageClient(): ObjectStorage =
createStorageClient(createAuthenticationProvider())
.also { client ->
client.endpoint = "https://$bucketNamespace.objectstorage.$region.oci.customer-oci.com"
}

private fun createAuthenticationProvider(): SimpleAuthenticationDetailsProvider =
SimpleAuthenticationDetailsProvider.builder().apply {
tenantId(tenantId)
userId(userId)
fingerprint(fingerprint)
privateKeySupplier(createPrivateKeySupplier())
}.build()

private fun createPrivateKeySupplier(): Supplier<InputStream> =
Supplier { FileInputStream(privateKeyPath) }

private fun createStorageClient(provider: SimpleAuthenticationDetailsProvider): ObjectStorage =
ObjectStorageClient.builder().build(provider)
}
3 changes: 3 additions & 0 deletions src/main/kotlin/land/leets/global/config/SecurityConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ class SecurityConfig(
// images
authorize(HttpMethod.GET, "/images/{imageName}", permitAll)

// storages
authorize(HttpMethod.POST, "/storages/pre-authenticated-url", hasAnyAuthority(AuthRole.ROLE_USER.role))

// default
authorize(anyRequest, denyAll)
}
Expand Down
10 changes: 10 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,13 @@ target:
image:
path: images/

oci:
tenant-id: ${OCI_TENANT_ID}
user-id: ${OCI_USER_ID}
fingerprint: ${OCI_FINGERPRINT}
private-key-path: ${OCI_PRIVATE_KEY_PATH}
region: ${OCI_REGION}
bucket:
name: ${OCI_BUCKET_NAME}
namespace: ${OCI_BUCKET_NAMESPACE}

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package land.leets.domain.storage.usecase

import com.oracle.bmc.objectstorage.ObjectStorage
import com.oracle.bmc.objectstorage.model.PreauthenticatedRequest
import com.oracle.bmc.objectstorage.responses.CreatePreauthenticatedRequestResponse
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify

class GetPreAuthenticatedUrlImplTest : DescribeSpec({

val objectStorage = mockk<ObjectStorage>()
val bucketName = "test-bucket"
val namespaceName = "test-namespace"
val region = "ap-seoul-1"
val endpoint = "https://$namespaceName.objectstorage.$region.oci.customer-oci.com"

val getPresignedUrl = GetPreAuthenticatedUrlImpl(objectStorage, bucketName, namespaceName)

describe("GetPreAuthenticatedUrl") {
context("Presigned URL 생성을 요청할 때") {
val fileName = "test.jpg"
val accessUri = "/p/some-random-string/b/bucket/o/test.jpg"

val request = PreauthenticatedRequest.builder().accessUri(accessUri).build()
val response = CreatePreauthenticatedRequestResponse.builder()
.preauthenticatedRequest(request)
.build()

every { objectStorage.createPreauthenticatedRequest(any()) } returns response
every { objectStorage.endpoint } returns endpoint

it("객체 업로드 권한이 있는 전체 URL을 반환한다") {
val result = getPresignedUrl.execute(fileName)

result.url shouldBe "$endpoint$accessUri"
verify { objectStorage.createPreauthenticatedRequest(any()) }
}
}
}
})