Skip to content
2 changes: 2 additions & 0 deletions src/main/kotlin/com/dcd/server/core/common/error/ErrorCode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ interface FileOperationPort {

fun writeFile(path: Path, content: String)

fun writeFileByBytes(path: Path, content: ByteArray)

fun deleteFile(path: Path)
}
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<OpenOption>(
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<OpenOption>(
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -101,4 +104,16 @@ class VolumeWebAdapter(
): ResponseEntity<Void> =
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<Void> =
uploadVolumeFileUseCase.execute(volumeId, path, file, createDirectory)
.run { ResponseEntity.ok().build() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,6 +35,7 @@ class VolumeWebAdapterTest : BehaviorSpec({
val getOneVolumeUseCase = mockk<GetOneVolumeUseCase>(relaxUnitFun = true)
val mountVolumeUseCase = mockk<MountVolumeUseCase>(relaxUnitFun = true)
val unMountVolumeUseCase = mockk<UnMountVolumeUseCase>(relaxUnitFun = true)
val uploadVolumeFileUseCase = mockk<UploadVolumeFileUseCase>(relaxUnitFun = true)

val volumeWebAdapter = VolumeWebAdapter(
createVolumeUseCase,
Expand All @@ -42,7 +44,8 @@ class VolumeWebAdapterTest : BehaviorSpec({
getAllVolumeUseCase,
getOneVolumeUseCase,
mountVolumeUseCase,
unMountVolumeUseCase
unMountVolumeUseCase,
uploadVolumeFileUseCase
)

given("워크스페이스 아이디와 볼륨 생성 요청이 주어지고") {
Expand Down Expand Up @@ -170,4 +173,21 @@ class VolumeWebAdapterTest : BehaviorSpec({
}
}
}

given("워크스페이스 아이디와 볼륨 아이디, 업로드 파일이 주어지고") {
val testWorkspaceId = UUID.randomUUID().toString()
val testVolumeId = UUID.randomUUID()
val mockFile = mockk<org.springframework.web.multipart.MultipartFile>()

`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
}
}
}
})
Loading