diff --git a/src/main/kotlin/com/dcd/server/core/common/error/ErrorCode.kt b/src/main/kotlin/com/dcd/server/core/common/error/ErrorCode.kt index 092d6086..4e235a95 100644 --- a/src/main/kotlin/com/dcd/server/core/common/error/ErrorCode.kt +++ b/src/main/kotlin/com/dcd/server/core/common/error/ErrorCode.kt @@ -25,6 +25,7 @@ enum class ErrorCode( ALREADY_EXISTS_VOLUME("이미 존재하는 볼륨", 400), ALREADY_EXISTS_VOLUME_MOUNT("볼륨 마운트가 존재합니다.", 400), INVALID_VOLUME_OPTION("볼륨 설정이 올바르지않음", 400), + INVALID_VOLUME_FILE_PATH("볼륨 경로가 올바르지 않습니다.", 400), UNAUTHORIZED("권한이 없음", 401), EXPIRED_TOKEN("토큰이 만료됨", 401), @@ -72,6 +73,7 @@ enum class ErrorCode( FAILURE_VOLUME_CREATION("컨테이너 볼륨 생성에 실패했습니다.", 500), FAILURE_VOLUME_DELETE("컨테이너 볼륨 삭제에 실패했습니다.", 500), FAILURE_VOLUME_COPY("컨테이너 볼륨 복제에 실패했습니다.", 500), + FAILURE_VOLUME_UPLOAD("볼륨 파일 업로드에 실패했습니다.", 500), FAILURE_BLOOM_FILTER_RESERVATION("블룸 필터 예약에 실패했습니다.", 500), IMAGE_REGISTRY_RATE_LIMIT_EXCEEDED("도커 허브의 요청 제한을 초과했습니다.", 500), FILE_OPERATION_FAILED("파일 작업에 실패했습니다.", 500), diff --git a/src/main/kotlin/com/dcd/server/core/common/file/spi/FileOperationPort.kt b/src/main/kotlin/com/dcd/server/core/common/file/spi/FileOperationPort.kt index 301b35d0..0ca1d808 100644 --- a/src/main/kotlin/com/dcd/server/core/common/file/spi/FileOperationPort.kt +++ b/src/main/kotlin/com/dcd/server/core/common/file/spi/FileOperationPort.kt @@ -9,5 +9,7 @@ interface FileOperationPort { fun writeFile(path: Path, content: String) + fun writeFileByBytes(path: Path, content: ByteArray) + fun deleteFile(path: Path) } diff --git a/src/main/kotlin/com/dcd/server/core/domain/volume/exception/InvalidVolumeFilePathException.kt b/src/main/kotlin/com/dcd/server/core/domain/volume/exception/InvalidVolumeFilePathException.kt new file mode 100644 index 00000000..4cfb58de --- /dev/null +++ b/src/main/kotlin/com/dcd/server/core/domain/volume/exception/InvalidVolumeFilePathException.kt @@ -0,0 +1,6 @@ +package com.dcd.server.core.domain.volume.exception + +import com.dcd.server.core.common.error.BasicException +import com.dcd.server.core.common.error.ErrorCode + +class InvalidVolumeFilePathException : BasicException(ErrorCode.INVALID_VOLUME_FILE_PATH) diff --git a/src/main/kotlin/com/dcd/server/core/domain/volume/exception/VolumeUploadFailureException.kt b/src/main/kotlin/com/dcd/server/core/domain/volume/exception/VolumeUploadFailureException.kt new file mode 100644 index 00000000..ffae7081 --- /dev/null +++ b/src/main/kotlin/com/dcd/server/core/domain/volume/exception/VolumeUploadFailureException.kt @@ -0,0 +1,6 @@ +package com.dcd.server.core.domain.volume.exception + +import com.dcd.server.core.common.error.BasicException +import com.dcd.server.core.common.error.ErrorCode + +class VolumeUploadFailureException : BasicException(ErrorCode.FAILURE_VOLUME_UPLOAD) diff --git a/src/main/kotlin/com/dcd/server/core/domain/volume/spi/VolumeFileStoragePort.kt b/src/main/kotlin/com/dcd/server/core/domain/volume/spi/VolumeFileStoragePort.kt new file mode 100644 index 00000000..27c8cf97 --- /dev/null +++ b/src/main/kotlin/com/dcd/server/core/domain/volume/spi/VolumeFileStoragePort.kt @@ -0,0 +1,10 @@ +package com.dcd.server.core.domain.volume.spi + +import com.dcd.server.core.domain.volume.model.Volume +import java.nio.file.Path + +interface VolumeFileStoragePort { + fun resolveVolumeRootPath(volume: Volume): Path + + fun resolveTargetPath(volume: Volume, relativePath: String): Path +} diff --git a/src/main/kotlin/com/dcd/server/core/domain/volume/usecase/UploadVolumeFileUseCase.kt b/src/main/kotlin/com/dcd/server/core/domain/volume/usecase/UploadVolumeFileUseCase.kt new file mode 100644 index 00000000..8036e7cb --- /dev/null +++ b/src/main/kotlin/com/dcd/server/core/domain/volume/usecase/UploadVolumeFileUseCase.kt @@ -0,0 +1,72 @@ +package com.dcd.server.core.domain.volume.usecase + +import com.dcd.server.core.common.annotation.UseCase +import com.dcd.server.core.common.data.WorkspaceInfo +import com.dcd.server.core.common.file.exception.FileOperationException +import com.dcd.server.core.common.file.spi.FileOperationPort +import com.dcd.server.core.domain.volume.exception.InvalidVolumeFilePathException +import com.dcd.server.core.domain.volume.exception.VolumeNotFoundException +import com.dcd.server.core.domain.volume.exception.VolumeUploadFailureException +import com.dcd.server.core.domain.volume.spi.QueryVolumePort +import com.dcd.server.core.domain.volume.spi.VolumeFileStoragePort +import com.dcd.server.core.domain.workspace.exception.WorkspaceNotFoundException +import org.springframework.web.multipart.MultipartFile +import java.nio.file.Files +import java.nio.file.Path +import java.util.UUID + +@UseCase +class UploadVolumeFileUseCase( + private val queryVolumePort: QueryVolumePort, + private val workspaceInfo: WorkspaceInfo, + private val fileOperationPort: FileOperationPort, + private val volumeFileStoragePort: VolumeFileStoragePort +) { + fun execute(volumeId: UUID, filePath: String, file: MultipartFile, createDirectory: Boolean) { + val workspace = workspaceInfo.workspace + ?: throw WorkspaceNotFoundException() + val volume = queryVolumePort.findById(volumeId) + ?: throw VolumeNotFoundException() + + if (workspace != volume.workspace) { + throw VolumeNotFoundException() + } + + if (file.originalFilename == null || file.originalFilename!!.isBlank()) { + throw InvalidVolumeFilePathException() + } + + val targetPath = resolveVolumePath(volume, filePath) + + if (targetPath.fileName.toString().isBlank()) { + throw InvalidVolumeFilePathException() + } + + val parentDir = targetPath.parent ?: throw InvalidVolumeFilePathException() + when { + createDirectory && !Files.exists(parentDir) -> fileOperationPort.createDirectory(parentDir) + createDirectory -> Unit + !createDirectory && !Files.exists(parentDir) -> throw InvalidVolumeFilePathException() + else -> Unit + } + + if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { + throw InvalidVolumeFilePathException() + } + + try { + fileOperationPort.writeFileByBytes(targetPath, file.bytes) + } catch (e: FileOperationException) { + throw VolumeUploadFailureException() + } + } + + private fun resolveVolumePath(volume: com.dcd.server.core.domain.volume.model.Volume, filePath: String): Path { + val normalizedPath = filePath.trim() + val invalid = normalizedPath.contains("..") || normalizedPath.contains("\\") + if (normalizedPath.isBlank() || invalid) { + throw InvalidVolumeFilePathException() + } + return volumeFileStoragePort.resolveTargetPath(volume, normalizedPath) + } +} diff --git a/src/main/kotlin/com/dcd/server/infrastructure/domain/volume/adapter/DockerVolumeFileStorageAdapter.kt b/src/main/kotlin/com/dcd/server/infrastructure/domain/volume/adapter/DockerVolumeFileStorageAdapter.kt new file mode 100644 index 00000000..390bfe00 --- /dev/null +++ b/src/main/kotlin/com/dcd/server/infrastructure/domain/volume/adapter/DockerVolumeFileStorageAdapter.kt @@ -0,0 +1,61 @@ +package com.dcd.server.infrastructure.domain.volume.adapter + +import com.dcd.server.core.domain.volume.exception.InvalidVolumeFilePathException +import com.dcd.server.core.domain.volume.model.Volume +import com.dcd.server.core.domain.volume.spi.VolumeFileStoragePort +import com.github.dockerjava.api.DockerClient +import org.springframework.stereotype.Component +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.Paths + +@Component +class DockerVolumeFileStorageAdapter( + private val dockerClient: DockerClient +) : VolumeFileStoragePort { + override fun resolveVolumeRootPath(volume: Volume): Path { + val volumeInfo = dockerClient.inspectVolumeCmd(volume.volumeName).exec() + val mountPoint = volumeInfo.mountpoint ?: throw IllegalStateException("Volume mountpoint is null: ${volume.volumeName}") + return Paths.get(mountPoint).toAbsolutePath().normalize() + } + + override fun resolveTargetPath(volume: Volume, relativePath: String): Path { + val rootPath = resolveVolumeRootPath(volume) + val normalizedPath = relativePath.trim().removePrefix("/") + + if (normalizedPath.isBlank()) { + return rootPath + } + + val segments = normalizedPath.split('/') + .filter { it.isNotBlank() } + .map { it.trim() } + .filter { it != "." && it != ".." } + + if (segments.isEmpty() || segments.any { it == ".." || it == "." || it.isBlank() }) { + throw InvalidVolumeFilePathException() + } + + var current = rootPath + for (segment in segments) { + current = current.resolve(segment).normalize() + if (!current.startsWith(rootPath)) { + throw InvalidVolumeFilePathException() + } + if (Files.isSymbolicLink(current) || Files.exists(current, LinkOption.NOFOLLOW_LINKS) == false && Files.isSymbolicLink(current)) { + throw InvalidVolumeFilePathException() + } + } + + val finalPath = rootPath.resolve(segments.joinToString("/")).normalize() + if (!finalPath.startsWith(rootPath)) { + throw InvalidVolumeFilePathException() + } + if (Files.isSymbolicLink(finalPath) || Files.exists(finalPath, LinkOption.NOFOLLOW_LINKS) == false && Files.isSymbolicLink(finalPath)) { + throw InvalidVolumeFilePathException() + } + + return finalPath + } +} diff --git a/src/main/kotlin/com/dcd/server/infrastructure/global/adapter/FileOperationAdapter.kt b/src/main/kotlin/com/dcd/server/infrastructure/global/adapter/FileOperationAdapter.kt index d9c27f92..a5968c31 100644 --- a/src/main/kotlin/com/dcd/server/infrastructure/global/adapter/FileOperationAdapter.kt +++ b/src/main/kotlin/com/dcd/server/infrastructure/global/adapter/FileOperationAdapter.kt @@ -4,8 +4,11 @@ import com.dcd.server.core.common.file.exception.FileOperationException import com.dcd.server.core.common.file.spi.FileOperationPort import org.slf4j.LoggerFactory import org.springframework.stereotype.Component +import java.nio.ByteBuffer import java.nio.charset.StandardCharsets import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.OpenOption import java.nio.file.Path import java.nio.file.StandardOpenOption @@ -43,12 +46,42 @@ class FileOperationAdapter : FileOperationPort { if (parentDir != null && !Files.exists(parentDir)) { Files.createDirectories(parentDir) } - Files.write( - path, - content.toByteArray(StandardCharsets.UTF_8), + if (Files.isSymbolicLink(path)) { + throw FileOperationException() + } + val options = setOf( StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS ) + Files.newByteChannel(path, options).use { channel -> + channel.write(ByteBuffer.wrap(content.toByteArray(StandardCharsets.UTF_8))) + } + } catch (e: Exception) { + log.error("Failed to write file: ${path.toAbsolutePath()}", e) + throw FileOperationException() + } + } + + override fun writeFileByBytes(path: Path, content: ByteArray) { + try { + val parentDir = path.parent + if (parentDir != null && !Files.exists(parentDir)) { + Files.createDirectories(parentDir) + } + if (Files.isSymbolicLink(path)) { + throw FileOperationException() + } + val options = setOf( + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE, + LinkOption.NOFOLLOW_LINKS + ) + Files.newByteChannel(path, options).use { channel -> + channel.write(ByteBuffer.wrap(content)) + } } catch (e: Exception) { log.error("Failed to write file: ${path.toAbsolutePath()}", e) throw FileOperationException() diff --git a/src/main/kotlin/com/dcd/server/infrastructure/global/config/SecurityConfig.kt b/src/main/kotlin/com/dcd/server/infrastructure/global/config/SecurityConfig.kt index 63536d35..92fd3048 100644 --- a/src/main/kotlin/com/dcd/server/infrastructure/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/dcd/server/infrastructure/global/config/SecurityConfig.kt @@ -106,6 +106,7 @@ class SecurityConfig( it.requestMatchers(HttpMethod.GET, "/{workspaceId}/volume/{volumeId}").authenticated() it.requestMatchers(HttpMethod.POST, "/{workspaceId}/volume/{volumeId}/mount").authenticated() it.requestMatchers(HttpMethod.DELETE, "/{workspaceId}/volume/{volumeId}/mount").authenticated() + it.requestMatchers(HttpMethod.POST, "/{workspaceId}/volume/{volumeId}/files").authenticated() //when url not set it.anyRequest().denyAll() diff --git a/src/main/kotlin/com/dcd/server/presentation/domain/volume/VolumeWebAdapter.kt b/src/main/kotlin/com/dcd/server/presentation/domain/volume/VolumeWebAdapter.kt index 124d5285..76a8ca66 100644 --- a/src/main/kotlin/com/dcd/server/presentation/domain/volume/VolumeWebAdapter.kt +++ b/src/main/kotlin/com/dcd/server/presentation/domain/volume/VolumeWebAdapter.kt @@ -8,6 +8,7 @@ import com.dcd.server.core.domain.volume.usecase.GetOneVolumeUseCase import com.dcd.server.core.domain.volume.usecase.MountVolumeUseCase import com.dcd.server.core.domain.volume.usecase.UnMountVolumeUseCase import com.dcd.server.core.domain.volume.usecase.UpdateVolumeUseCase +import com.dcd.server.core.domain.volume.usecase.UploadVolumeFileUseCase import com.dcd.server.presentation.common.annotation.WebAdapter import com.dcd.server.presentation.domain.volume.data.extension.toDto import com.dcd.server.presentation.domain.volume.data.extension.toResponse @@ -26,6 +27,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.multipart.MultipartFile import java.util.UUID @WebAdapter("/{workspaceId}/volume") @@ -36,7 +38,8 @@ class VolumeWebAdapter( private val getAllVolumeUseCase: GetAllVolumeUseCase, private val getOneVolumeUseCase: GetOneVolumeUseCase, private val mountVolumeUseCase: MountVolumeUseCase, - private val unMountVolumeUseCase: UnMountVolumeUseCase + private val unMountVolumeUseCase: UnMountVolumeUseCase, + private val uploadVolumeFileUseCase: UploadVolumeFileUseCase ) { @PostMapping @WorkspaceOwnerVerification("#workspaceId") @@ -101,4 +104,16 @@ class VolumeWebAdapter( ): ResponseEntity = unMountVolumeUseCase.execute(volumeId, applicationId) .run { ResponseEntity.ok().build() } + + @PostMapping("/{volumeId}/files") + @WorkspaceOwnerVerification("#workspaceId") + fun uploadFile( + @PathVariable workspaceId: String, + @PathVariable volumeId: UUID, + @RequestParam path: String, + @RequestParam file: MultipartFile, + @RequestParam(defaultValue = "false") createDirectory: Boolean + ): ResponseEntity = + uploadVolumeFileUseCase.execute(volumeId, path, file, createDirectory) + .run { ResponseEntity.ok().build() } } \ No newline at end of file diff --git a/src/test/kotlin/com/dcd/server/presentation/domain/volume/VolumeWebAdapterTest.kt b/src/test/kotlin/com/dcd/server/presentation/domain/volume/VolumeWebAdapterTest.kt index 7e936638..0c2cfc58 100644 --- a/src/test/kotlin/com/dcd/server/presentation/domain/volume/VolumeWebAdapterTest.kt +++ b/src/test/kotlin/com/dcd/server/presentation/domain/volume/VolumeWebAdapterTest.kt @@ -14,6 +14,7 @@ import com.dcd.server.core.domain.volume.usecase.GetOneVolumeUseCase import com.dcd.server.core.domain.volume.usecase.MountVolumeUseCase import com.dcd.server.core.domain.volume.usecase.UnMountVolumeUseCase import com.dcd.server.core.domain.volume.usecase.UpdateVolumeUseCase +import com.dcd.server.core.domain.volume.usecase.UploadVolumeFileUseCase import com.dcd.server.presentation.domain.volume.data.extension.toResponse import com.dcd.server.presentation.domain.volume.data.request.CreateVolumeRequest import com.dcd.server.presentation.domain.volume.data.request.MountVolumeRequest @@ -34,6 +35,7 @@ class VolumeWebAdapterTest : BehaviorSpec({ val getOneVolumeUseCase = mockk(relaxUnitFun = true) val mountVolumeUseCase = mockk(relaxUnitFun = true) val unMountVolumeUseCase = mockk(relaxUnitFun = true) + val uploadVolumeFileUseCase = mockk(relaxUnitFun = true) val volumeWebAdapter = VolumeWebAdapter( createVolumeUseCase, @@ -42,7 +44,8 @@ class VolumeWebAdapterTest : BehaviorSpec({ getAllVolumeUseCase, getOneVolumeUseCase, mountVolumeUseCase, - unMountVolumeUseCase + unMountVolumeUseCase, + uploadVolumeFileUseCase ) given("워크스페이스 아이디와 볼륨 생성 요청이 주어지고") { @@ -170,4 +173,21 @@ class VolumeWebAdapterTest : BehaviorSpec({ } } } + + given("워크스페이스 아이디와 볼륨 아이디, 업로드 파일이 주어지고") { + val testWorkspaceId = UUID.randomUUID().toString() + val testVolumeId = UUID.randomUUID() + val mockFile = mockk() + + `when`("볼륨 파일 업로드 메서드를 실행하면") { + val result = volumeWebAdapter.uploadFile(testWorkspaceId, testVolumeId, "file", mockFile, true) + + then("업로드 유스케이스가 실행되어야함") { + verify { uploadVolumeFileUseCase.execute(testVolumeId, "file", mockFile, true) } + } + then("상태코드 OK가 응답되어야함") { + result.statusCode shouldBe HttpStatus.OK + } + } + } }) \ No newline at end of file