From c10089be968853f4eb86b22b7b2095db89072e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=98=81?= <80161826+dolong2@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:46:14 +0000 Subject: [PATCH 1/9] =?UTF-8?q?[feat]=20ByteArray=EB=A1=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EC=9E=91=EC=84=B1=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/common/file/spi/FileOperationPort.kt | 2 ++ .../global/adapter/FileOperationAdapter.kt | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) 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/infrastructure/global/adapter/FileOperationAdapter.kt b/src/main/kotlin/com/dcd/server/infrastructure/global/adapter/FileOperationAdapter.kt index d9c27f92..b6b9ff2c 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 @@ -55,6 +55,24 @@ class FileOperationAdapter : FileOperationPort { } } + override fun writeFileByBytes(path: Path, content: ByteArray) { + try { + val parentDir = path.parent + if (parentDir != null && !Files.exists(parentDir)) { + Files.createDirectories(parentDir) + } + Files.write( + path, + content, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ) + } catch (e: Exception) { + log.error("Failed to write file: ${path.toAbsolutePath()}", e) + throw FileOperationException() + } + } + override fun deleteFile(path: Path) { try { if (Files.exists(path)) { From 361b547f690a3913ccc3826571be4cc44ac6ff08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=98=81?= <80161826+dolong2@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:46:47 +0000 Subject: [PATCH 2/9] =?UTF-8?q?[feat]=20=EB=B3=BC=EB=A5=A8=EC=9D=98=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../volume/spi/VolumeFileStoragePort.kt | 10 ++++++ .../adapter/DockerVolumeFileStorageAdapter.kt | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/main/kotlin/com/dcd/server/core/domain/volume/spi/VolumeFileStoragePort.kt create mode 100644 src/main/kotlin/com/dcd/server/infrastructure/domain/volume/adapter/DockerVolumeFileStorageAdapter.kt 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/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..99d8a5b2 --- /dev/null +++ b/src/main/kotlin/com/dcd/server/infrastructure/domain/volume/adapter/DockerVolumeFileStorageAdapter.kt @@ -0,0 +1,35 @@ +package com.dcd.server.infrastructure.domain.volume.adapter + +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.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) + } + + override fun resolveTargetPath(volume: Volume, relativePath: String): Path { + val normalizedPath = relativePath.trim().removePrefix("/") + val rootPath = resolveVolumeRootPath(volume) + + return if (normalizedPath.isBlank()) { + rootPath + } else { + val sanitized = normalizedPath.split("/") + .filter { it.isNotBlank() } + .filter { it != "." && it != ".." } + .joinToString("/") + + rootPath.resolve(sanitized) + } + } +} From f1624fe0f27d45612db0bf8738a8987d6b2c425e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=98=81?= <80161826+dolong2@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:47:05 +0000 Subject: [PATCH 3/9] =?UTF-8?q?[feat]=20=EB=B3=BC=EB=A5=A8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=9C=A0=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../volume/usecase/UploadVolumeFileUseCase.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/main/kotlin/com/dcd/server/core/domain/volume/usecase/UploadVolumeFileUseCase.kt 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..1fa0acde --- /dev/null +++ b/src/main/kotlin/com/dcd/server/core/domain/volume/usecase/UploadVolumeFileUseCase.kt @@ -0,0 +1,47 @@ +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.VolumeNotFoundException +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.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) { + val workspace = workspaceInfo.workspace + ?: throw WorkspaceNotFoundException() + val volume = queryVolumePort.findById(volumeId) + ?: throw VolumeNotFoundException() + + if (workspace != volume.workspace) { + throw VolumeNotFoundException() + } + + if (file.isEmpty) { + return + } + + val targetPath = resolveVolumePath(volume, filePath) + try { + fileOperationPort.writeFileByBytes(targetPath, file.bytes) + } catch (e: FileOperationException) { + throw e + } + } + + private fun resolveVolumePath(volume: com.dcd.server.core.domain.volume.model.Volume, filePath: String): Path { + return volumeFileStoragePort.resolveTargetPath(volume, filePath) + } +} From d60db5e58532a3d2d8baec8972297650e44b8f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=98=81?= <80161826+dolong2@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:47:14 +0000 Subject: [PATCH 4/9] =?UTF-8?q?[feat]=20=EB=B3=BC=EB=A5=A8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/SecurityConfig.kt | 1 + .../domain/volume/VolumeWebAdapter.kt | 16 +++++++++++++- .../domain/volume/VolumeWebAdapterTest.kt | 22 ++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) 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..277cf630 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,15 @@ 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 + ): ResponseEntity = + uploadVolumeFileUseCase.execute(volumeId, path, file) + .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..ba088508 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) + + then("업로드 유스케이스가 실행되어야함") { + verify { uploadVolumeFileUseCase.execute(testVolumeId, "file", mockFile) } + } + then("상태코드 OK가 응답되어야함") { + result.statusCode shouldBe HttpStatus.OK + } + } + } }) \ No newline at end of file From c2f5d5b4388b65ebcf78255f22c019549e251222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=98=81?= <80161826+dolong2@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:54:29 +0000 Subject: [PATCH 5/9] =?UTF-8?q?[refac]=20=EA=B2=BD=EB=A1=9C=20traversal=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=EB=B0=8F=20=ED=8C=8C=EC=9D=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B4=80=EB=A0=A8=20=EC=98=88=EC=99=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dcd/server/core/common/error/ErrorCode.kt | 2 ++ .../InvalidVolumeFilePathException.kt | 6 +++++ .../exception/VolumeUploadFailureException.kt | 6 +++++ .../volume/usecase/UploadVolumeFileUseCase.kt | 22 +++++++++++++++++-- 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/com/dcd/server/core/domain/volume/exception/InvalidVolumeFilePathException.kt create mode 100644 src/main/kotlin/com/dcd/server/core/domain/volume/exception/VolumeUploadFailureException.kt 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/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/usecase/UploadVolumeFileUseCase.kt b/src/main/kotlin/com/dcd/server/core/domain/volume/usecase/UploadVolumeFileUseCase.kt index 1fa0acde..2015b29f 100644 --- 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 @@ -4,11 +4,14 @@ 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 @@ -34,14 +37,29 @@ class UploadVolumeFileUseCase( } val targetPath = resolveVolumePath(volume, filePath) + + if (targetPath.fileName.toString().isBlank()) { + throw InvalidVolumeFilePathException() + } + + val parentDir = targetPath.parent ?: throw InvalidVolumeFilePathException() + if (!Files.exists(parentDir)) { + fileOperationPort.createDirectory(parentDir) + } + try { fileOperationPort.writeFileByBytes(targetPath, file.bytes) } catch (e: FileOperationException) { - throw e + throw VolumeUploadFailureException() } } private fun resolveVolumePath(volume: com.dcd.server.core.domain.volume.model.Volume, filePath: String): Path { - return volumeFileStoragePort.resolveTargetPath(volume, filePath) + val normalizedPath = filePath.trim() + val invalid = normalizedPath.contains("..") || normalizedPath.contains("\\") + if (normalizedPath.isBlank() || invalid) { + throw InvalidVolumeFilePathException() + } + return volumeFileStoragePort.resolveTargetPath(volume, normalizedPath) } } From 1798bdf347898c326dbc767eb045ae9cd306e00b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=98=81?= <80161826+dolong2@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:59:29 +0000 Subject: [PATCH 6/9] =?UTF-8?q?[feat]=20=EC=83=81=EC=9C=84=20=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=86=A0=EB=A6=AC=20=EC=9E=90=EB=8F=99=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=97=AC=EB=B6=80=20=EC=98=B5=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/volume/usecase/UploadVolumeFileUseCase.kt | 9 ++++++--- .../presentation/domain/volume/VolumeWebAdapter.kt | 5 +++-- .../presentation/domain/volume/VolumeWebAdapterTest.kt | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) 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 index 2015b29f..b57ed2c2 100644 --- 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 @@ -22,7 +22,7 @@ class UploadVolumeFileUseCase( private val fileOperationPort: FileOperationPort, private val volumeFileStoragePort: VolumeFileStoragePort ) { - fun execute(volumeId: UUID, filePath: String, file: MultipartFile) { + fun execute(volumeId: UUID, filePath: String, file: MultipartFile, createDirectory: Boolean) { val workspace = workspaceInfo.workspace ?: throw WorkspaceNotFoundException() val volume = queryVolumePort.findById(volumeId) @@ -43,8 +43,11 @@ class UploadVolumeFileUseCase( } val parentDir = targetPath.parent ?: throw InvalidVolumeFilePathException() - if (!Files.exists(parentDir)) { - fileOperationPort.createDirectory(parentDir) + when { + createDirectory && !Files.exists(parentDir) -> fileOperationPort.createDirectory(parentDir) + createDirectory -> Unit + !createDirectory && !Files.exists(parentDir) -> throw InvalidVolumeFilePathException() + else -> Unit } try { 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 277cf630..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 @@ -111,8 +111,9 @@ class VolumeWebAdapter( @PathVariable workspaceId: String, @PathVariable volumeId: UUID, @RequestParam path: String, - @RequestParam file: MultipartFile + @RequestParam file: MultipartFile, + @RequestParam(defaultValue = "false") createDirectory: Boolean ): ResponseEntity = - uploadVolumeFileUseCase.execute(volumeId, path, file) + 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 ba088508..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 @@ -180,10 +180,10 @@ class VolumeWebAdapterTest : BehaviorSpec({ val mockFile = mockk() `when`("볼륨 파일 업로드 메서드를 실행하면") { - val result = volumeWebAdapter.uploadFile(testWorkspaceId, testVolumeId, "file", mockFile) + val result = volumeWebAdapter.uploadFile(testWorkspaceId, testVolumeId, "file", mockFile, true) then("업로드 유스케이스가 실행되어야함") { - verify { uploadVolumeFileUseCase.execute(testVolumeId, "file", mockFile) } + verify { uploadVolumeFileUseCase.execute(testVolumeId, "file", mockFile, true) } } then("상태코드 OK가 응답되어야함") { result.statusCode shouldBe HttpStatus.OK From 449bb746f91cfd6ee5a3d239704d24635af40e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=98=81?= <80161826+dolong2@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:11:15 +0000 Subject: [PATCH 7/9] =?UTF-8?q?[refac]=20=EB=B9=88=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=8F=84=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/domain/volume/usecase/UploadVolumeFileUseCase.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index b57ed2c2..04c5b977 100644 --- 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 @@ -32,8 +32,8 @@ class UploadVolumeFileUseCase( throw VolumeNotFoundException() } - if (file.isEmpty) { - return + if (file.originalFilename == null || file.originalFilename!!.isBlank()) { + throw InvalidVolumeFilePathException() } val targetPath = resolveVolumePath(volume, filePath) From 40869a80031dc59836dc3415c6f810e4bbdf309b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=98=81?= <80161826+dolong2@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:18:31 +0000 Subject: [PATCH 8/9] =?UTF-8?q?[refac]=20=EC=8B=AC=EB=B3=BC=EB=A6=AD=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EB=B0=A9=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../volume/usecase/UploadVolumeFileUseCase.kt | 4 ++ .../adapter/DockerVolumeFileStorageAdapter.kt | 45 ++++++++++++++----- .../global/adapter/FileOperationAdapter.kt | 3 ++ 3 files changed, 42 insertions(+), 10 deletions(-) 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 index 04c5b977..8036e7cb 100644 --- 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 @@ -50,6 +50,10 @@ class UploadVolumeFileUseCase( else -> Unit } + if (Files.exists(targetPath) && Files.isSymbolicLink(targetPath)) { + throw InvalidVolumeFilePathException() + } + try { fileOperationPort.writeFileByBytes(targetPath, file.bytes) } catch (e: FileOperationException) { 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 index 99d8a5b2..89eb1e60 100644 --- 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 @@ -1,9 +1,11 @@ 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.Path import java.nio.file.Paths @@ -14,22 +16,45 @@ class DockerVolumeFileStorageAdapter( 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) + return Paths.get(mountPoint).toAbsolutePath().normalize() } override fun resolveTargetPath(volume: Volume, relativePath: String): Path { - val normalizedPath = relativePath.trim().removePrefix("/") 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 != ".." } - return if (normalizedPath.isBlank()) { - rootPath - } else { - val sanitized = normalizedPath.split("/") - .filter { it.isNotBlank() } - .filter { it != "." && it != ".." } - .joinToString("/") + 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.exists(current) && Files.isSymbolicLink(current)) { + throw InvalidVolumeFilePathException() + } + } - rootPath.resolve(sanitized) + val finalPath = rootPath.resolve(segments.joinToString("/")).normalize() + if (!finalPath.startsWith(rootPath)) { + throw InvalidVolumeFilePathException() } + if (Files.exists(finalPath) && 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 b6b9ff2c..273c13bd 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 @@ -61,6 +61,9 @@ class FileOperationAdapter : FileOperationPort { if (parentDir != null && !Files.exists(parentDir)) { Files.createDirectories(parentDir) } + if (Files.exists(path) && Files.isSymbolicLink(path)) { + throw FileOperationException() + } Files.write( path, content, From 29e1829fc84691607ee93255d94cb47cf84f681d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=98=81?= <80161826+dolong2@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:26:57 +0000 Subject: [PATCH 9/9] =?UTF-8?q?[refac]=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20?= =?UTF-8?q?=EC=97=B4=EB=95=8C=20NOFOLLOW=5FLINKS=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/DockerVolumeFileStorageAdapter.kt | 5 ++-- .../global/adapter/FileOperationAdapter.kt | 30 +++++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) 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 index 89eb1e60..390bfe00 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -42,7 +43,7 @@ class DockerVolumeFileStorageAdapter( if (!current.startsWith(rootPath)) { throw InvalidVolumeFilePathException() } - if (Files.exists(current) && Files.isSymbolicLink(current)) { + if (Files.isSymbolicLink(current) || Files.exists(current, LinkOption.NOFOLLOW_LINKS) == false && Files.isSymbolicLink(current)) { throw InvalidVolumeFilePathException() } } @@ -51,7 +52,7 @@ class DockerVolumeFileStorageAdapter( if (!finalPath.startsWith(rootPath)) { throw InvalidVolumeFilePathException() } - if (Files.exists(finalPath) && Files.isSymbolicLink(finalPath)) { + if (Files.isSymbolicLink(finalPath) || Files.exists(finalPath, LinkOption.NOFOLLOW_LINKS) == false && Files.isSymbolicLink(finalPath)) { throw InvalidVolumeFilePathException() } 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 273c13bd..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,18 @@ 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() @@ -61,15 +70,18 @@ class FileOperationAdapter : FileOperationPort { if (parentDir != null && !Files.exists(parentDir)) { Files.createDirectories(parentDir) } - if (Files.exists(path) && Files.isSymbolicLink(path)) { + if (Files.isSymbolicLink(path)) { throw FileOperationException() } - Files.write( - path, - content, + 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)) + } } catch (e: Exception) { log.error("Failed to write file: ${path.toAbsolutePath()}", e) throw FileOperationException()