diff --git a/.github/workflows/_deploy.yml b/.github/workflows/_deploy.yml new file mode 100644 index 0000000..fbcf07d --- /dev/null +++ b/.github/workflows/_deploy.yml @@ -0,0 +1,71 @@ +name: deploy-template + +on: + workflow_call: + inputs: + ocir_repository: + required: true + type: string + dockerfile: + required: true + type: string + description: "Dockerfile path (e.g., Dockerfile, batch/Dockerfile)" + gradle_task: + required: true + type: string + description: "Gradle task to build the jar (e.g., :bootJar, :batch:bootJar)" + platform: + required: false + type: string + default: "linux/arm64" + secrets: + OCI_AUTH_TOKEN: + required: true + DEPLOYER_APP_ID: + required: true + DEPLOYER_APP_PRIVATE_KEY: + required: true + +jobs: + deploy: + name: Build and Push + runs-on: ubuntu-24.04-arm + + env: + IMAGE_TAG: ${{ github.run_number }} + BUILD_NUMBER: ${{ github.run_number }} + OCIR_REGISTRY: yny.ocir.io + OCIR_NAMESPACE: ax1dvc8vmenm + OCIR_USERNAME: ax1dvc8vmenm/members/waffle-deployer + OCIR_REPOSITORY: ${{ inputs.ocir_repository }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - name: Build Jar + working-directory: hangsha + run: ./gradlew ${{ inputs.gradle_task }} + + - name: Login to OCIR + run: echo "${{ secrets.OCI_AUTH_TOKEN }}" | docker login $OCIR_REGISTRY -u $OCIR_USERNAME --password-stdin + + - name: Docker build, tag, and push image to OCIR + id: build-push-image + working-directory: hangsha + run: | + docker build \ + -f ${{ inputs.dockerfile }} \ + -t $OCIR_REGISTRY/$OCIR_NAMESPACE/$OCIR_REPOSITORY:$IMAGE_TAG \ + . \ + --platform ${{ inputs.platform }} + docker push $OCIR_REGISTRY/$OCIR_NAMESPACE/$OCIR_REPOSITORY:$IMAGE_TAG + echo "image=$OCIR_REGISTRY/$OCIR_NAMESPACE/$OCIR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT diff --git a/.github/workflows/deploy-api-dev.yml b/.github/workflows/deploy-api-dev.yml new file mode 100644 index 0000000..1ec4aea --- /dev/null +++ b/.github/workflows/deploy-api-dev.yml @@ -0,0 +1,17 @@ +name: Deploy-api-dev + +on: + push: + branches: [ develop ] + +jobs: + deploy: + uses: ./.github/workflows/_deploy.yml + with: + ocir_repository: hangsha-dev/hangsha-server + dockerfile: Dockerfile + gradle_task: :bootJar + secrets: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} + DEPLOYER_APP_ID: ${{ secrets.DEPLOYER_APP_ID }} + DEPLOYER_APP_PRIVATE_KEY: ${{ secrets.DEPLOYER_APP_PRIVATE_KEY }} diff --git a/.github/workflows/deploy-api-prod.yml b/.github/workflows/deploy-api-prod.yml new file mode 100644 index 0000000..0532efa --- /dev/null +++ b/.github/workflows/deploy-api-prod.yml @@ -0,0 +1,17 @@ +name: Deploy-api-prod + +on: + push: + branches: [ main ] + +jobs: + deploy: + uses: ./.github/workflows/_deploy.yml + with: + ocir_repository: hangsha-prod/hangsha-server + dockerfile: Dockerfile + gradle_task: :bootJar + secrets: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} + DEPLOYER_APP_ID: ${{ secrets.DEPLOYER_APP_ID }} + DEPLOYER_APP_PRIVATE_KEY: ${{ secrets.DEPLOYER_APP_PRIVATE_KEY }} diff --git a/.github/workflows/deploy-batch-dev.yml b/.github/workflows/deploy-batch-dev.yml new file mode 100644 index 0000000..802ab55 --- /dev/null +++ b/.github/workflows/deploy-batch-dev.yml @@ -0,0 +1,17 @@ +name: Deploy-batch-dev + +on: + push: + branches: [ develop ] + +jobs: + deploy: + uses: ./.github/workflows/_deploy.yml + with: + ocir_repository: hangsha-dev/hangsha-batch + dockerfile: batch/Dockerfile + gradle_task: :batch:bootJar + secrets: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} + DEPLOYER_APP_ID: ${{ secrets.DEPLOYER_APP_ID }} + DEPLOYER_APP_PRIVATE_KEY: ${{ secrets.DEPLOYER_APP_PRIVATE_KEY }} diff --git a/.github/workflows/deploy-batch-prod.yml b/.github/workflows/deploy-batch-prod.yml new file mode 100644 index 0000000..11f34d8 --- /dev/null +++ b/.github/workflows/deploy-batch-prod.yml @@ -0,0 +1,17 @@ +name: Deploy-batch-prod + +on: + push: + branches: [ main ] + +jobs: + deploy: + uses: ./.github/workflows/_deploy.yml + with: + ocir_repository: hangsha-prod/hangsha-batch + dockerfile: batch/Dockerfile + gradle_task: :batch:bootJar + secrets: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} + DEPLOYER_APP_ID: ${{ secrets.DEPLOYER_APP_ID }} + DEPLOYER_APP_PRIVATE_KEY: ${{ secrets.DEPLOYER_APP_PRIVATE_KEY }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 2b68656..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Deploy to EC2 - -on: - push: - branches: ["main"] - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: "17" - distribution: "temurin" - - - name: Build with Gradle - working-directory: ./hangsha - run: | - chmod +x gradlew - ./gradlew clean build -x test - - - name: Log in to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Build and push server image - uses: docker/build-push-action@v4 - with: - context: ./hangsha - file: ./hangsha/Dockerfile - push: true - tags: ${{ secrets.DOCKER_USERNAME }}/campus-calendar-server:latest - - - name: Build and push batch image - uses: docker/build-push-action@v4 - with: - context: ./hangsha - file: ./hangsha/batch/Dockerfile - push: true - tags: ${{ secrets.DOCKER_USERNAME }}/campus-calendar-batch:latest - - - name: Upload docker-compose.prod.yml to EC2 - uses: appleboy/scp-action@v1 - with: - host: ${{ secrets.EC2_HOST }} - username: ubuntu - key: ${{ secrets.EC2_KEY }} - source: "hangsha/docker-compose.prod.yml" - target: "/home/ubuntu/campus" - - - name: Deploy to EC2 - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.EC2_HOST }} - username: ubuntu - key: ${{ secrets.EC2_KEY }} - port: 22 - timeout: 30s - command_timeout: 20m - script: | - set -euo pipefail - - APP_DIR=/home/ubuntu/campus - mkdir -p "$APP_DIR" - - if [ -f "$APP_DIR/hangsha/docker-compose.prod.yml" ]; then - mv "$APP_DIR/hangsha/docker-compose.prod.yml" "$APP_DIR/docker-compose.prod.yml" - rm -rf "$APP_DIR/hangsha" - fi - - if [ -f "/home/ubuntu/.env" ]; then - cp /home/ubuntu/.env "$APP_DIR/.env" - fi - - cd "$APP_DIR" - - echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin - - export DOCKER_USERNAME="${{ secrets.DOCKER_USERNAME }}" - export DB_NAME="campus_db" - export DB_USER="user" - export DB_PASS="password" - export DB_ROOT_PASSWORD="root" - - docker compose -f docker-compose.prod.yml pull - docker compose -f docker-compose.prod.yml up -d - docker compose -f docker-compose.prod.yml --profile batch pull batch - - docker compose -f docker-compose.prod.yml ps - docker logs --tail 200 campus-server || true - - docker image prune -f \ No newline at end of file diff --git a/hangsha/Dockerfile b/hangsha/Dockerfile index 7f220c0..b5e64ab 100644 --- a/hangsha/Dockerfile +++ b/hangsha/Dockerfile @@ -1,15 +1,17 @@ -FROM eclipse-temurin:17-jdk-alpine +FROM eclipse-temurin:17-jdk WORKDIR /app # 컨테이너 기본 타임존을 KST로 맞추기 -RUN apk add --no-cache tzdata \ - && cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime \ - && echo "Asia/Seoul" > /etc/timezone +RUN apt-get update \ + && apt-get install -y --no-install-recommends tzdata \ + && ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime \ + && echo "Asia/Seoul" > /etc/timezone \ + && rm -rf /var/lib/apt/lists/* ENV TZ=Asia/Seoul ARG JAR_FILE=build/libs/*.jar COPY ${JAR_FILE} app.jar -ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "/app/app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "/app/app.jar"] diff --git a/hangsha/batch/build.gradle.kts b/hangsha/batch/build.gradle.kts index 47b1b6d..6d601da 100644 --- a/hangsha/batch/build.gradle.kts +++ b/hangsha/batch/build.gradle.kts @@ -17,10 +17,27 @@ java { repositories { mavenCentral() + maven { + url = uri("https://maven.pkg.github.com/wafflestudio/spring-waffle") + credentials { + username = "wafflestudio" + password = findProperty("gpr.key") as String? + ?: System.getenv("GITHUB_TOKEN") + ?: runCatching { + ProcessBuilder("gh", "auth", "token") + .start() + .inputStream + .bufferedReader() + .readText() + .trim() + }.getOrDefault("") + } + } } dependencies { implementation(project(":common")) + implementation("com.wafflestudio.spring:spring-boot-starter-waffle-oci-vault:1.1.0") implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-data-jdbc") diff --git a/hangsha/batch/src/main/kotlin/com/team1/hangsha/batch/BatchApplication.kt b/hangsha/batch/src/main/kotlin/com/team1/hangsha/batch/BatchApplication.kt index 06723f5..d11dd80 100644 --- a/hangsha/batch/src/main/kotlin/com/team1/hangsha/batch/BatchApplication.kt +++ b/hangsha/batch/src/main/kotlin/com/team1/hangsha/batch/BatchApplication.kt @@ -1,6 +1,7 @@ package com.team1.hangsha.batch import com.team1.hangsha.config.DatabaseConfig +import com.team1.hangsha.config.TestValueLogger import com.team1.hangsha.com.team1.hangsha.config.JacksonConfig import com.team1.hangsha.event.service.EventSyncService import org.springframework.boot.WebApplicationType @@ -13,7 +14,8 @@ import org.springframework.context.annotation.Import DatabaseConfig::class, JacksonConfig::class, EventSyncService::class, -) + TestValueLogger::class, +) // for explicit bean import class BatchApplication fun main(args: Array) { diff --git a/hangsha/batch/src/main/resources/application.yml b/hangsha/batch/src/main/resources/application.yml index eca862d..fd71ced 100644 --- a/hangsha/batch/src/main/resources/application.yml +++ b/hangsha/batch/src/main/resources/application.yml @@ -11,4 +11,6 @@ spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver jackson: - time-zone: Asia/Seoul \ No newline at end of file + time-zone: Asia/Seoul + +test: \ No newline at end of file diff --git a/hangsha/build.gradle.kts b/hangsha/build.gradle.kts index cec5c2d..6b30413 100644 --- a/hangsha/build.gradle.kts +++ b/hangsha/build.gradle.kts @@ -22,10 +22,29 @@ configurations { repositories { mavenCentral() + maven { + url = uri("https://maven.pkg.github.com/wafflestudio/spring-waffle") + credentials { + username = "wafflestudio" + password = findProperty("gpr.key") as String? + ?: System.getenv("GITHUB_TOKEN") + ?: runCatching { + ProcessBuilder("gh", "auth", "token") + .start() + .inputStream + .bufferedReader() + .readText() + .trim() + }.getOrDefault("") + } + } } dependencies { implementation(project(":common")) + implementation("com.wafflestudio.spring:spring-boot-starter-waffle-oci-vault:1.1.0") + implementation("com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.80.1") + // TODO: common의 dependency를 root api, batch에 공유하는 과정에서 문제 -> 나중에 해결하기. implementation("org.springframework.boot:spring-boot-starter-data-jdbc") implementation("org.springframework.boot:spring-boot-starter-validation") diff --git a/hangsha/common/build.gradle.kts b/hangsha/common/build.gradle.kts index 8ae5288..511a30b 100644 --- a/hangsha/common/build.gradle.kts +++ b/hangsha/common/build.gradle.kts @@ -17,6 +17,8 @@ repositories { mavenCentral() } + + dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jdbc") implementation("org.springframework:spring-web") @@ -24,6 +26,10 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation(kotlin("stdlib")) + // oci sdk for storage service + implementation("com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.80.1") + implementation("com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.80.1") + } kotlin { diff --git a/hangsha/common/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt b/hangsha/common/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt new file mode 100644 index 0000000..7470553 --- /dev/null +++ b/hangsha/common/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt @@ -0,0 +1,52 @@ +package com.team1.hangsha.common.upload + +import com.oracle.bmc.objectstorage.ObjectStorage +import com.oracle.bmc.objectstorage.requests.PutObjectRequest +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.UUID + +@Service +class OciUploadService( + private val objectStorage: ObjectStorage, + @Value("\${oci.storage.namespace}") + private val namespace: String, + @Value("\${oci.storage.bucket}") + private val bucket: String, + @Value("\${oci.storage.region}") + private val region: String, +) { + fun uploadFile(prefix: String?, file: MultipartFile): String { + val objectName = buildObjectName(prefix, file.originalFilename) + val contentType = file.contentType ?: "application/octet-stream" + + file.inputStream.use { input -> + val request = PutObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .objectName(objectName) + .contentLength(file.size) + .contentType(contentType) + .putObjectBody(input) + .build() + objectStorage.putObject(request) + } + + return buildPublicUrl(objectName) + } + + private fun buildObjectName(prefix: String?, originalFilename: String?): String { + val safePrefix = prefix?.trim()?.trim('/')?.takeIf { it.isNotBlank() } + val filename = originalFilename?.takeIf { it.isNotBlank() } ?: "upload-${UUID.randomUUID()}" + return if (safePrefix == null) filename else "$safePrefix/$filename" + } + + private fun buildPublicUrl(objectName: String): String { + val encodedObjectName = URLEncoder.encode(objectName, StandardCharsets.UTF_8) + .replace("+", "%20") + return "https://objectstorage.$region.oraclecloud.com/n/$namespace/b/$bucket/o/$encodedObjectName" + } +} diff --git a/hangsha/common/src/main/kotlin/com/team1/hangsha/common/upload/dto/UploadResponse.kt b/hangsha/common/src/main/kotlin/com/team1/hangsha/common/upload/dto/UploadResponse.kt new file mode 100644 index 0000000..6176e2c --- /dev/null +++ b/hangsha/common/src/main/kotlin/com/team1/hangsha/common/upload/dto/UploadResponse.kt @@ -0,0 +1,5 @@ +package com.team1.hangsha.common.upload.dto + +data class UploadResponse( + val url: String, +) diff --git a/hangsha/common/src/main/kotlin/com/team1/hangsha/config/OciAuthProbe.kt b/hangsha/common/src/main/kotlin/com/team1/hangsha/config/OciAuthProbe.kt new file mode 100644 index 0000000..df23093 --- /dev/null +++ b/hangsha/common/src/main/kotlin/com/team1/hangsha/config/OciAuthProbe.kt @@ -0,0 +1,28 @@ +package com.team1.hangsha.config + +import com.oracle.bmc.objectstorage.ObjectStorage +import com.oracle.bmc.objectstorage.requests.GetNamespaceRequest +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +@Component +class OciAuthProbe( + private val objectStorage: ObjectStorage, + @Value("\${oci.auth.verify:false}") private val verify: Boolean, +) { + private val log = LoggerFactory.getLogger(javaClass) + + @EventListener(ApplicationReadyEvent::class) + fun verifyAuth() { + if (!verify) return + try { + val response = objectStorage.getNamespace(GetNamespaceRequest.builder().build()) + log.info("[oci-auth] OK: namespace={}", response.value) + } catch (e: Exception) { + log.error("[oci-auth] FAILED: {}", e.message, e) + } + } +} diff --git a/hangsha/common/src/main/kotlin/com/team1/hangsha/config/OciConfig.kt b/hangsha/common/src/main/kotlin/com/team1/hangsha/config/OciConfig.kt new file mode 100644 index 0000000..eaf77a7 --- /dev/null +++ b/hangsha/common/src/main/kotlin/com/team1/hangsha/config/OciConfig.kt @@ -0,0 +1,45 @@ +package com.team1.hangsha.config + +import com.oracle.bmc.auth.BasicAuthenticationDetailsProvider +import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider +import com.oracle.bmc.auth.InstancePrincipalsAuthenticationDetailsProvider +import com.oracle.bmc.objectstorage.ObjectStorage +import com.oracle.bmc.objectstorage.ObjectStorageClient +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OciConfig( + @Value("\${oci.auth.type:auto}") + private val authType: String, + @Value("\${oci.auth.profile:DEFAULT}") + private val configProfile: String, + @Value("\${oci.storage.region}") + private val region: String, +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Bean + fun ociAuthProvider(): BasicAuthenticationDetailsProvider { + return when (authType.trim().lowercase()) { + "auto" -> try { + InstancePrincipalsAuthenticationDetailsProvider.builder().build() + } catch (e: Exception) { + log.info("OCI Instance Principal failed; falling back to config file auth: {}", e.message) + ConfigFileAuthenticationDetailsProvider(configProfile) + } + "config" -> ConfigFileAuthenticationDetailsProvider(configProfile) + "instance_principal" -> InstancePrincipalsAuthenticationDetailsProvider.builder().build() + else -> throw IllegalArgumentException("Unsupported oci.auth.type: $authType") + } + } + + @Bean + fun objectStorageClient(authProvider: BasicAuthenticationDetailsProvider): ObjectStorage { + return ObjectStorageClient.builder() + .region(region) + .build(authProvider) + } +} diff --git a/hangsha/common/src/main/kotlin/com/team1/hangsha/config/TestValueLogger.kt b/hangsha/common/src/main/kotlin/com/team1/hangsha/config/TestValueLogger.kt new file mode 100644 index 0000000..9b9d11b --- /dev/null +++ b/hangsha/common/src/main/kotlin/com/team1/hangsha/config/TestValueLogger.kt @@ -0,0 +1,18 @@ +package com.team1.hangsha.config + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import jakarta.annotation.PostConstruct + +@Component +class TestValueLogger( + @Value("\${test:}") private val testValue: String, +) { + private val log = LoggerFactory.getLogger(TestValueLogger::class.java) + + @PostConstruct + fun logTestValue() { + log.info("[test-value] test={}", testValue) + } +} diff --git a/hangsha/common/src/main/resources/META-INF/spring.factories b/hangsha/common/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..2ac64bd --- /dev/null +++ b/hangsha/common/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +com.wafflestudio.spring.ocivault.config.OciVaultEnvironmentPostProcessor diff --git a/hangsha/common/src/main/resources/application-common.yml b/hangsha/common/src/main/resources/application-common.yml index 99de074..0022acb 100644 --- a/hangsha/common/src/main/resources/application-common.yml +++ b/hangsha/common/src/main/resources/application-common.yml @@ -1,3 +1,11 @@ +oci: + auth: + verify: true # get namespace when booting + storage: + namespace: ax1dvc8vmenm + region: ap-chuncheon-1 + +--- spring: config: activate: @@ -7,22 +15,48 @@ spring: username: user password: password +oci: + auth: + type: config + vault: + secret-ids: "ocid1.vaultsecret.oc1.ap-chuncheon-1.amaaaaaat2m5lbqawu5rdzjsnl3zot7ii3svbmup5akjqljppt4d5wwjimgq" + storage: + bucket: hangsha-asset-dev + +logging: # for local debugging + level: + org.springframework.boot.context.config: TRACE + org.springframework.core.env: DEBUG + com.oracle.bmc: DEBUG + com.team1.hangsha: DEBUG + --- spring: config: activate: on-profile: dev datasource: - url: ${SPRING_DATASOURCE_URL} - username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} + url: + username: + password: +oci: + vault: + secret-ids: "ocid1.vaultsecret.oc1.ap-chuncheon-1.amaaaaaat2m5lbqagobbswqnpk5ezitpkdb7kidfrnhuuotu5yudac62bhpa" + storage: + bucket: hangsha-asset-dev --- spring: config: activate: on-profile: prod datasource: - url: ${SPRING_DATASOURCE_URL} - username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} \ No newline at end of file + url: + username: + password: + +oci: + vault: + secret-ids: "ocid1.vaultsecret.oc1.ap-chuncheon-1.amaaaaaat2m5lbqaf77fsikezk5n2xakqhaokn33iedgj7wv6pxqa4q23aea" + storage: + bucket: hangsha-asset \ No newline at end of file diff --git a/hangsha/docker-compose.prod.yml b/hangsha/docker-compose.prod.yml index 39e896f..3ddd356 100644 --- a/hangsha/docker-compose.prod.yml +++ b/hangsha/docker-compose.prod.yml @@ -30,6 +30,7 @@ services: SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/${DB_NAME:-campus_db}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 SPRING_DATASOURCE_USERNAME: ${DB_USER:-user} SPRING_DATASOURCE_PASSWORD: ${DB_PASS:-password} + JWT_SECRET: ${JWT_SECRET} UPLOAD_DIR: /data/uploads UPLOAD_PUBLIC_BASE_URL: ${UPLOAD_PUBLIC_BASE_URL:-https://hangsha.site/static} volumes: diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt b/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt new file mode 100644 index 0000000..b16e3a5 --- /dev/null +++ b/hangsha/src/main/kotlin/com/team1/hangsha/common/upload/OciUploadService.kt @@ -0,0 +1,89 @@ +package com.team1.hangsha.common.upload + +import com.oracle.bmc.objectstorage.ObjectStorage +import com.oracle.bmc.objectstorage.requests.PutObjectRequest +import com.team1.hangsha.common.error.DomainException +import com.team1.hangsha.common.error.ErrorCode +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.UUID + +@Service +class OciUploadService( + private val objectStorage: ObjectStorage, + private val uploadProperties: UploadProperties, + @Value("\${oci.storage.namespace}") private val namespace: String, + @Value("\${oci.storage.bucket}") private val bucket: String, + @Value("\${oci.storage.region}") private val region: String, +) { + fun uploadFile(prefix: String?, file: MultipartFile): String { + if (file.isEmpty || file.size <= 0) { + throw DomainException(ErrorCode.UPLOAD_FILE_EMPTY) + } + if (file.size > uploadProperties.maxSizeBytes) { + throw DomainException(ErrorCode.UPLOAD_FAILED, "파일이 너무 큽니다 (max=${uploadProperties.maxSizeBytes} bytes)") + } + + val ext = guessExtension(file.originalFilename, file.contentType ?: "") + val safePrefix = sanitizePrefix(prefix) + val objectName = "$safePrefix/${UUID.randomUUID()}.$ext" + + try { + val request = PutObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .objectName(objectName) + .contentLength(file.size) + .contentType(file.contentType) + .putObjectBody(file.inputStream) + .build() + objectStorage.putObject(request) + } catch (e: Exception) { + throw DomainException(ErrorCode.UPLOAD_FAILED, cause = e) + } + + return buildObjectUrl(objectName) + } + + private fun buildObjectUrl(objectName: String): String { + val encoded = encodeObjectName(objectName) + return "https://objectstorage.$region.oraclecloud.com/n/$namespace/b/$bucket/o/$encoded" + } + + private fun encodeObjectName(objectName: String): String { + return objectName + .split('/') + .joinToString("/") { segment -> + URLEncoder.encode(segment, StandardCharsets.UTF_8) + .replace("+", "%20") + } + } + + private fun guessExtension(originalFilename: String?, contentType: String): String { + val fromName = originalFilename + ?.substringAfterLast('.', missingDelimiterValue = "") + ?.lowercase() + ?.takeIf { it.matches(Regex("[a-z0-9]{1,8}")) } + + if (!fromName.isNullOrBlank()) return fromName + + return when (contentType.lowercase()) { + "image/jpeg", "image/jpg" -> "jpg" + "image/png" -> "png" + "image/gif" -> "gif" + "image/webp" -> "webp" + else -> "bin" + } + } + + private fun sanitizePrefix(prefix: String?): String { + val trimmed = prefix?.trim()?.trim('/')?.ifBlank { null } ?: "uploads/tmp" + if (trimmed.contains("..")) { + throw DomainException(ErrorCode.INVALID_REQUEST, "Invalid path") + } + return trimmed + } +} diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/config/SecurityConfig.kt b/hangsha/src/main/kotlin/com/team1/hangsha/config/SecurityConfig.kt index 194540a..5021296 100644 --- a/hangsha/src/main/kotlin/com/team1/hangsha/config/SecurityConfig.kt +++ b/hangsha/src/main/kotlin/com/team1/hangsha/config/SecurityConfig.kt @@ -68,6 +68,7 @@ class SecurityConfig( "/admin/events/delete", // 파일 업로드 "/static/**", + "/api/v1/uploads/oci/**", ).permitAll() .anyRequest().authenticated() } @@ -80,4 +81,4 @@ class SecurityConfig( */ return http.build() } -} \ No newline at end of file +} diff --git a/hangsha/src/main/kotlin/com/team1/hangsha/upload/OciUploadController.kt b/hangsha/src/main/kotlin/com/team1/hangsha/upload/OciUploadController.kt new file mode 100644 index 0000000..391d632 --- /dev/null +++ b/hangsha/src/main/kotlin/com/team1/hangsha/upload/OciUploadController.kt @@ -0,0 +1,25 @@ +package com.team1.hangsha.upload + +import com.team1.hangsha.common.upload.OciUploadService +import com.team1.hangsha.common.upload.dto.UploadResponse +import org.springframework.http.MediaType +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 + +@RestController +@RequestMapping("/api/v1/uploads/oci") +class OciUploadController( + private val ociUploadService: OciUploadService, +) { + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun upload( + @RequestParam("file") file: MultipartFile, + @RequestParam("prefix", required = false) prefix: String?, + ): UploadResponse { + val url = ociUploadService.uploadFile(prefix, file) + return UploadResponse(url) + } +} diff --git a/hangsha/src/main/resources/application.yml b/hangsha/src/main/resources/application.yml index 972a1ad..0cf565c 100644 --- a/hangsha/src/main/resources/application.yml +++ b/hangsha/src/main/resources/application.yml @@ -26,15 +26,15 @@ spring: client: registration: google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} + client-id: + client-secret: scope: - email - profile redirect-uri: "{baseUrl}/login/oauth2/code/google" naver: - client-id: ${NAVER_CLIENT_ID} - client-secret: ${NAVER_CLIENT_SECRET} + client-id: + client-secret: client-authentication-method: client_secret_post authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/naver" @@ -43,8 +43,8 @@ spring: - email client-name: Naver kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET} + client-id: + client-secret: client-authentication-method: client_secret_post authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/kakao" @@ -65,7 +65,7 @@ spring: user-name-attribute: id jwt: - secret: ${JWT_SECRET} # TODO: jwt secret 또한 secret var 설정 + secret: access-expiration-ms: 3600000 refresh-expiration-ms: 1209600000 @@ -96,6 +96,7 @@ logging: level: com.team1.hangsha : DEBUG +test: --- # ========================================== # local profile setting