diff --git a/.gitignore b/.gitignore index df8a6aef..7f267ac9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,12 +14,14 @@ client/captures .cxx local.properties client/app/release/ +client/app/google-services.json HELP.md build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +server/.env ### STS ### .apt_generated diff --git a/server/.envsample b/server/.envsample index 6670c276..06dbc4c2 100644 --- a/server/.envsample +++ b/server/.envsample @@ -1,3 +1,8 @@ DB_URL= +DB_NAME= DB_USERNAME= -DB_PASSWORD= \ No newline at end of file +DB_PASSWORD= + +NAVER_S3_ACCESS_KEY= +NAVER_S3_SECRET_KEY= +NAVER_S3_BUCKET= \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 1f2048f3..1556768d 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -41,6 +41,10 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + // Naver, Storage + implementation("software.amazon.awssdk:s3:2.25.60") + implementation("software.amazon.awssdk:auth:2.25.60") } kotlin { diff --git a/server/src/main/kotlin/com/andone/memorip/common/config/StorageConfig.kt b/server/src/main/kotlin/com/andone/memorip/common/config/StorageConfig.kt new file mode 100644 index 00000000..b727dec4 --- /dev/null +++ b/server/src/main/kotlin/com/andone/memorip/common/config/StorageConfig.kt @@ -0,0 +1,36 @@ +package com.andone.memorip.common.config + +import org.springframework.beans.factory.annotation.Value +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 software.amazon.awssdk.services.s3.S3Configuration +import java.net.URI + +@Configuration +class StorageConfig { + + @Bean + fun s3Client( + @Value("\${naver.s3.access-key}") accessKey: String, + @Value("\${naver.s3.secret-key}") secretKey: String, + @Value("\${naver.s3.endpoint}") endpoint: String, + @Value("\${naver.s3.region}") region: String + ): S3Client { + val credentials = AwsBasicCredentials.create(accessKey, secretKey) + + return S3Client.builder() + .endpointOverride(URI.create(endpoint)) + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .serviceConfiguration( + S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build() + ) + .build() + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/andone/memorip/common/handler/GlobalExceptionHandler.kt b/server/src/main/kotlin/com/andone/memorip/common/handler/GlobalExceptionHandler.kt index fe9e9e04..f1cc44bf 100644 --- a/server/src/main/kotlin/com/andone/memorip/common/handler/GlobalExceptionHandler.kt +++ b/server/src/main/kotlin/com/andone/memorip/common/handler/GlobalExceptionHandler.kt @@ -45,6 +45,16 @@ class GlobalExceptionHandler { .body(ApiResult.error(code.name, code.message)) } + @ExceptionHandler(IllegalArgumentException::class) + fun handleIllegalArgumentException( + e: IllegalArgumentException + ): ResponseEntity> { + val code = CommonExceptionCode.INVALID_PARAMETER + return ResponseEntity.status(code.status) + .body(ApiResult.error(code.name, e.message ?: code.message)) + } + + @ExceptionHandler(Exception::class) fun handleException(e: Exception): ResponseEntity> { logger.error("예상치 못한 예외 발생", e) diff --git a/server/src/main/kotlin/com/andone/memorip/domain/place/controller/PlaceImageController.kt b/server/src/main/kotlin/com/andone/memorip/domain/place/controller/PlaceImageController.kt new file mode 100644 index 00000000..a2dba2dd --- /dev/null +++ b/server/src/main/kotlin/com/andone/memorip/domain/place/controller/PlaceImageController.kt @@ -0,0 +1,28 @@ +package com.andone.memorip.domain.place.controller + +import com.andone.memorip.common.response.ApiResult +import com.andone.memorip.domain.place.dto.UploadPlaceImageResponse +import com.andone.memorip.domain.place.service.PlaceImageService +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 + +@RestController +@RequestMapping("/api/places/images") +class PlaceImageController( + private val placeImageService: PlaceImageService +) { + + @PostMapping + fun upload( + @RequestPart file: MultipartFile + ): ApiResult { + return ApiResult.success( + UploadPlaceImageResponse( + url = placeImageService.upload(file) + ) + ) + } +} diff --git a/server/src/main/kotlin/com/andone/memorip/domain/place/dto/UploadPlaceImageResponse.kt b/server/src/main/kotlin/com/andone/memorip/domain/place/dto/UploadPlaceImageResponse.kt new file mode 100644 index 00000000..53406d09 --- /dev/null +++ b/server/src/main/kotlin/com/andone/memorip/domain/place/dto/UploadPlaceImageResponse.kt @@ -0,0 +1,5 @@ +package com.andone.memorip.domain.place.dto + +data class UploadPlaceImageResponse( + val url: String +) diff --git a/server/src/main/kotlin/com/andone/memorip/domain/place/service/PlaceImageService.kt b/server/src/main/kotlin/com/andone/memorip/domain/place/service/PlaceImageService.kt new file mode 100644 index 00000000..323e620d --- /dev/null +++ b/server/src/main/kotlin/com/andone/memorip/domain/place/service/PlaceImageService.kt @@ -0,0 +1,24 @@ +package com.andone.memorip.domain.place.service + +import com.andone.memorip.infra.storage.StorageService +import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile + +@Service +class PlaceImageService( + private val storageService: StorageService +) { + private val MAX_FILE_SIZE = 5 * 1024 * 1024 + + fun upload(file: MultipartFile): String { + + require(file.size <= MAX_FILE_SIZE) { + "이미지 용량은 5MB를 초과할 수 없습니다." + } + + return storageService.upload( + file = file, + dir = "place" + ) + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/andone/memorip/infra/storage/StorageService.kt b/server/src/main/kotlin/com/andone/memorip/infra/storage/StorageService.kt new file mode 100644 index 00000000..a83a06df --- /dev/null +++ b/server/src/main/kotlin/com/andone/memorip/infra/storage/StorageService.kt @@ -0,0 +1,48 @@ +package com.andone.memorip.infra.storage + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import software.amazon.awssdk.services.s3.S3Client +import org.springframework.web.multipart.MultipartFile +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import java.util.UUID + +@Service +class StorageService( + private val s3Client: S3Client, + @Value("\${naver.s3.bucket}") private val bucket: String +) { + fun check() { + s3Client.headBucket { it.bucket(bucket) } + } + + fun upload( + file: MultipartFile, + dir: String + ): String { + + require(value = !file.isEmpty) { "빈 파일입니다" } + + val extension = file.originalFilename + ?.substringAfterLast('.', "") + ?.lowercase() + ?: throw IllegalArgumentException("파일 확장자가 없습니다") + + val key = "$dir/${UUID.randomUUID()}.$extension" + + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(file.contentType) + .contentLength(file.size) + .build(), + RequestBody.fromInputStream(file.inputStream, file.size) + ) + + return s3Client.utilities() + .getUrl { it.bucket(bucket).key(key) } + .toExternalForm() + } +} \ No newline at end of file diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 3fed4fea..91ef83bf 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -2,6 +2,11 @@ spring: application: name: memorip + servlet: + multipart: + max-file-size: 5MB + max-request-size: 5MB + # Database datasource: url: ${DB_URL:jdbc:postgresql://localhost:5432/memorip} @@ -22,3 +27,15 @@ spring: # Jackson (JSON null 필드 제외) jackson: default-property-inclusion: non_null + + + +# Naver/Object Storage +naver: + s3: + access-key: ${NAVER_S3_ACCESS_KEY} + secret-key: ${NAVER_S3_SECRET_KEY} + bucket: ${NAVER_S3_BUCKET} + endpoint: https://kr.object.ncloudstorage.com + region: kr-standard +