diff --git a/Dockerfile b/Dockerfile index 96798f7..497f052 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,5 +9,10 @@ RUN mkdir /app COPY --from=build /home/gradle/src/build/libs/*.jar /app/hestia.jar ENV API_KEY= ENV BASE_DIRECTORY= +ENV PUBLIC_DIRECTORY= +ENV JWT_AUDIENCE= +ENV JWT_ISSUER= +ENV JWT_REALM= +ENV JWT_SECRET= ENTRYPOINT ["java","-jar","/app/hestia.jar"] LABEL org.opencontainers.image.source=https://github.com/LotuxPunk/Hestia diff --git a/build.gradle.kts b/build.gradle.kts index 8d13725..4600a3b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { implementation("io.ktor:ktor-server-status-pages:$ktor_version") implementation("io.ktor:ktor-server-auth:$ktor_version") implementation("io.ktor:ktor-server-auth-jwt:$ktor_version") + implementation("io.ktor:ktor-server-caching-headers:$ktor_version") // Cache4k implementation("io.github.reactivecircus.cache4k:cache4k:0.13.0") @@ -58,5 +59,6 @@ dependencies { testImplementation("io.ktor:ktor-client-content-negotiation-jvm") testImplementation("io.ktor:ktor-client-core:$ktor_version") testImplementation("io.ktor:ktor-client-cio:$ktor_version") + testImplementation("io.ktor:ktor-server-test-host-jvm:$ktor_version") testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") } diff --git a/src/main/kotlin/be/vandeas/controller/v1/FileController.kt b/src/main/kotlin/be/vandeas/controller/v1/FileController.kt index 4bd20e6..4199593 100644 --- a/src/main/kotlin/be/vandeas/controller/v1/FileController.kt +++ b/src/main/kotlin/be/vandeas/controller/v1/FileController.kt @@ -141,7 +141,8 @@ fun Route.fileControllerV1() = route("/file") { if (fileName == null) { val options = DirectoryDeleteOptions( path = path, - recursive = recursive + recursive = recursive, + public = false ) when (val result = fileService.deleteDirectory(authorization, options)) { @@ -153,8 +154,9 @@ fun Route.fileControllerV1() = route("/file") { } } else { val options = FileDeleteOptions( - path = call.request.queryParameters["path"] ?: throw IllegalArgumentException("path query parameter is required"), - fileName = call.request.queryParameters["fileName"] ?: "" + path = path, + fileName = fileName, + public = false ) when (val result = fileService.deleteFile(authorization, options)) { diff --git a/src/main/kotlin/be/vandeas/controller/v2/FileController.kt b/src/main/kotlin/be/vandeas/controller/v2/FileController.kt index c9dd40f..f78cf23 100644 --- a/src/main/kotlin/be/vandeas/controller/v2/FileController.kt +++ b/src/main/kotlin/be/vandeas/controller/v2/FileController.kt @@ -9,10 +9,15 @@ import io.ktor.http.* import io.ktor.http.content.* import io.ktor.server.application.* import io.ktor.server.auth.* +import io.ktor.server.http.content.* +import io.ktor.server.plugins.cachingheaders.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import org.koin.ktor.ext.inject +import java.nio.file.Paths +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.days fun Route.fileControllerV2() = route("/file") { @@ -64,10 +69,17 @@ fun Route.fileControllerV2() = route("/file") { is FileCreationResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) is FileCreationResult.NotFound -> call.respond(HttpStatusCode.NotFound, mapOf("path" to options.path)) - is FileCreationResult.Success -> call.respond( - HttpStatusCode.Created, - FileNameWithPath(path = options.path, fileName = options.fileName) - ) + is FileCreationResult.Success -> { + val resultPath = result.path.parent.pathString.replace( + "${if (options.public) System.getenv("PUBLIC_DIRECTORY") else System.getenv("BASE_DIRECTORY")}/", + if (options.public) "public" else "" + ) + + call.respond( + HttpStatusCode.Created, + FileNameWithPath(path = resultPath, fileName = options.fileName) + ) + } } } @@ -77,6 +89,7 @@ fun Route.fileControllerV2() = route("/file") { var fileName: String? = null var path: String? = null var data: ByteArray? = null + var public = false multipart.forEachPart { part -> when (part) { @@ -84,6 +97,7 @@ fun Route.fileControllerV2() = route("/file") { when (part.name) { "path" -> path = part.value "fileName" -> fileName = part.value + "public" -> public = part.value.toBoolean() } } @@ -103,7 +117,8 @@ fun Route.fileControllerV2() = route("/file") { val options = BytesFileCreationOptions( path = path!!, fileName = fileName!!, - content = data!! + content = data!!, + public = public ) when (val result = fileLogic.createFile(options)) { @@ -114,10 +129,17 @@ fun Route.fileControllerV2() = route("/file") { is FileCreationResult.Failure -> call.respond(HttpStatusCode.InternalServerError, result.message) is FileCreationResult.NotFound -> call.respond(HttpStatusCode.NotFound, mapOf("path" to options.path)) - is FileCreationResult.Success -> call.respond( - HttpStatusCode.Created, - FileNameWithPath(path = options.path, fileName = options.fileName) - ) + is FileCreationResult.Success -> { + val resultPath = result.path.parent.pathString.replace( + "${if (options.public) System.getenv("PUBLIC_DIRECTORY") else System.getenv("BASE_DIRECTORY")}/", + if (options.public) "public/" else "" + ) + + call.respond( + HttpStatusCode.Created, + FileNameWithPath(path = resultPath, fileName = options.fileName) + ) + } } } @@ -127,11 +149,13 @@ fun Route.fileControllerV2() = route("/file") { ?: throw IllegalArgumentException("path query parameter is required") val fileName = call.request.queryParameters["fileName"] val recursive = call.request.queryParameters["recursive"]?.toBoolean() ?: false + val public = call.request.queryParameters["public"]?.toBoolean() ?: false if (fileName == null) { val options = DirectoryDeleteOptions( path = path, - recursive = recursive + recursive = recursive, + public = public ) when (val result = fileLogic.deleteDirectory(options)) { @@ -155,9 +179,9 @@ fun Route.fileControllerV2() = route("/file") { } } else { val options = FileDeleteOptions( - path = call.request.queryParameters["path"] - ?: throw IllegalArgumentException("path query parameter is required"), - fileName = call.request.queryParameters["fileName"] ?: "" + path = path, + fileName = fileName, + public = public, ) when (val result = fileLogic.deleteFile(options)) { @@ -177,7 +201,18 @@ fun Route.fileControllerV2() = route("/file") { } } } + staticFiles("/public", Paths.get(System.getenv("PUBLIC_DIRECTORY")).toFile()) { + cacheControl { file -> + when(file.extension.lowercase()) { + "jpg", "jpeg", "png", "gif" -> listOf(CacheControl.MaxAge(maxAgeSeconds = 30.days.inWholeSeconds.toInt())) + "pdf" -> listOf(CacheControl.MaxAge(maxAgeSeconds = 30.days.inWholeSeconds.toInt())) + else -> emptyList() + } + } + } get("/embed") { + call.caching = CachingOptions(CacheControl.MaxAge(maxAgeSeconds = 30.days.inWholeSeconds.toInt(), visibility = CacheControl.Visibility.Private)) + val path = call.request.queryParameters["path"] ?: "" val fileName = call.request.queryParameters["fileName"] ?: "" val downloadFileName = call.request.queryParameters["download"] ?: "" diff --git a/src/main/kotlin/be/vandeas/dto/Base64FileCreationOptions.kt b/src/main/kotlin/be/vandeas/dto/Base64FileCreationOptions.kt index 428ef79..ad8fc27 100644 --- a/src/main/kotlin/be/vandeas/dto/Base64FileCreationOptions.kt +++ b/src/main/kotlin/be/vandeas/dto/Base64FileCreationOptions.kt @@ -4,7 +4,8 @@ import kotlinx.serialization.Serializable @Serializable data class Base64FileCreationOptions( - val path: String, - val fileName: String, + override val path: String, + override val fileName: String, + override val public: Boolean = false, val content: String, -) +): FileOperationOptions diff --git a/src/main/kotlin/be/vandeas/dto/BytesFileCreationOptions.kt b/src/main/kotlin/be/vandeas/dto/BytesFileCreationOptions.kt index 639fdfb..558a4e1 100644 --- a/src/main/kotlin/be/vandeas/dto/BytesFileCreationOptions.kt +++ b/src/main/kotlin/be/vandeas/dto/BytesFileCreationOptions.kt @@ -4,7 +4,8 @@ import kotlinx.serialization.Serializable @Serializable data class BytesFileCreationOptions( - val path: String, - val fileName: String, + override val path: String, + override val fileName: String, + override val public: Boolean = false, val content: ByteArray, -) +): FileOperationOptions diff --git a/src/main/kotlin/be/vandeas/dto/DirectoryDeleteOptions.kt b/src/main/kotlin/be/vandeas/dto/DirectoryDeleteOptions.kt index 8b30bf3..47efdfe 100644 --- a/src/main/kotlin/be/vandeas/dto/DirectoryDeleteOptions.kt +++ b/src/main/kotlin/be/vandeas/dto/DirectoryDeleteOptions.kt @@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable data class DirectoryDeleteOptions( val path: String, val recursive: Boolean = false, -) + override val public: Boolean, +): FileVisibilityOptions diff --git a/src/main/kotlin/be/vandeas/dto/FileDeleteOptions.kt b/src/main/kotlin/be/vandeas/dto/FileDeleteOptions.kt index f73d8d1..61419ae 100644 --- a/src/main/kotlin/be/vandeas/dto/FileDeleteOptions.kt +++ b/src/main/kotlin/be/vandeas/dto/FileDeleteOptions.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable @Serializable data class FileDeleteOptions( - val path: String, - val fileName: String, -) + override val path: String, + override val fileName: String, + override val public: Boolean, +): FileOperationOptions diff --git a/src/main/kotlin/be/vandeas/dto/FileOperationOptions.kt b/src/main/kotlin/be/vandeas/dto/FileOperationOptions.kt new file mode 100644 index 0000000..8940419 --- /dev/null +++ b/src/main/kotlin/be/vandeas/dto/FileOperationOptions.kt @@ -0,0 +1,6 @@ +package be.vandeas.dto + +interface FileOperationOptions: FileVisibilityOptions { + val path: String + val fileName: String +} diff --git a/src/main/kotlin/be/vandeas/dto/FileVisibilityOptions.kt b/src/main/kotlin/be/vandeas/dto/FileVisibilityOptions.kt new file mode 100644 index 0000000..37c7860 --- /dev/null +++ b/src/main/kotlin/be/vandeas/dto/FileVisibilityOptions.kt @@ -0,0 +1,5 @@ +package be.vandeas.dto + +interface FileVisibilityOptions { + val public: Boolean +} diff --git a/src/main/kotlin/be/vandeas/handler/FileHandler.kt b/src/main/kotlin/be/vandeas/handler/FileHandler.kt index 2d260d9..d9a0e1c 100644 --- a/src/main/kotlin/be/vandeas/handler/FileHandler.kt +++ b/src/main/kotlin/be/vandeas/handler/FileHandler.kt @@ -8,9 +8,11 @@ import java.nio.file.* import kotlin.io.FileAlreadyExistsException import kotlin.io.path.* -object FileHandler { +class FileHandler( + directory: String +) { private val LOGGER = KtorSimpleLogger("be.vandeas.handlers.FileHandler") - private val BASE_DIRECTORY: Path = Path.of(URI.create("file://${System.getenv("BASE_DIRECTORY")}")) + private val BASE_DIRECTORY: Path = Path.of(URI.create("file://$directory")) /** * Writes a file. diff --git a/src/main/kotlin/be/vandeas/logic/impl/FileLogicImpl.kt b/src/main/kotlin/be/vandeas/logic/impl/FileLogicImpl.kt index 9834135..c63e713 100644 --- a/src/main/kotlin/be/vandeas/logic/impl/FileLogicImpl.kt +++ b/src/main/kotlin/be/vandeas/logic/impl/FileLogicImpl.kt @@ -7,42 +7,48 @@ import be.vandeas.logic.FileLogic import io.ktor.util.* import java.nio.file.Paths -class FileLogicImpl : FileLogic { +class FileLogicImpl( + private val privateFileHandler: FileHandler, + private val publicFileHandler: FileHandler +) : FileLogic { + + private fun FileVisibilityOptions.fileHandler() = if (public) publicFileHandler else privateFileHandler + override fun createFile(options: Base64FileCreationOptions): FileCreationResult { - return FileHandler.write( + return options.fileHandler().write( content = options.content.decodeBase64Bytes(), filePath = Paths.get(options.path, options.fileName) ) } override fun createFile(options: BytesFileCreationOptions): FileCreationResult { - return FileHandler.write( + return options.fileHandler().write( content = options.content, filePath = Paths.get(options.path, options.fileName) ) } override fun deleteFile(fileDeleteOptions: FileDeleteOptions): FileDeleteResult { - return FileHandler.deleteFile( + return fileDeleteOptions.fileHandler().deleteFile( path = Paths.get(fileDeleteOptions.path, fileDeleteOptions.fileName) ) } override fun deleteDirectory(directoryDeleteOptions: DirectoryDeleteOptions): DirectoryDeleteResult { - return FileHandler.deleteDirectory( + return directoryDeleteOptions.fileHandler().deleteDirectory( path = Paths.get(directoryDeleteOptions.path), recursive = directoryDeleteOptions.recursive ) } override fun readFile(fileReadOptions: FileReadOptions): FileBytesReadResult { - return FileHandler.read( + return privateFileHandler.read( path = Paths.get(fileReadOptions.path, fileReadOptions.fileName) ) } override fun getFile(fileReadOptions: FileReadOptions): FileReadResult { - return FileHandler.get( + return privateFileHandler.get( path = Paths.get(fileReadOptions.path, fileReadOptions.fileName) ) } diff --git a/src/main/kotlin/be/vandeas/plugins/KoinConfig.kt b/src/main/kotlin/be/vandeas/plugins/KoinConfig.kt index 3d3c8c4..13c9b62 100644 --- a/src/main/kotlin/be/vandeas/plugins/KoinConfig.kt +++ b/src/main/kotlin/be/vandeas/plugins/KoinConfig.kt @@ -1,5 +1,6 @@ package be.vandeas.plugins +import be.vandeas.handler.FileHandler import be.vandeas.logic.AuthLogic import be.vandeas.logic.FileLogic import be.vandeas.logic.impl.AuthLogicImpl @@ -18,7 +19,10 @@ import org.koin.logger.slf4jLogger fun appModule(environment: ApplicationEnvironment) = module { single { - FileLogicImpl() + FileLogicImpl( + privateFileHandler = FileHandler(System.getenv("BASE_DIRECTORY")), + publicFileHandler = FileHandler(System.getenv("PUBLIC_DIRECTORY")), + ) } single { diff --git a/src/test/kotlin/be/vandeas/v1/ApplicationTest.kt b/src/test/kotlin/be/vandeas/v1/ApplicationTest.kt index 23225d8..ca8a66e 100644 --- a/src/test/kotlin/be/vandeas/v1/ApplicationTest.kt +++ b/src/test/kotlin/be/vandeas/v1/ApplicationTest.kt @@ -5,89 +5,56 @@ import be.vandeas.dto.ReadFileBytesResult import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.testing.* import io.ktor.util.* import kotlinx.coroutines.runBlocking import java.util.* import kotlin.io.path.toPath import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.time.Duration import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation class ApplicationTest { val apiKey = System.getenv("API_KEY") ?: throw IllegalStateException("API_KEY is not set") - private val client = HttpClient(CIO) { - install(ClientContentNegotiation) { - json() - } - } - - private suspend fun getToken(dir: String, fileName: String): String?{ - return client.get("http://localhost:8082/v1/auth/token?path=$dir&fileName=$fileName") { + private suspend fun HttpClient.getToken(dir: String, fileName: String): String? { + return this.get("/v1/auth/token?path=$dir&fileName=$fileName") { header("Authorization", apiKey) + accept(ContentType.Application.Json) }.apply { assertEquals(HttpStatusCode.OK, status) }.body>()["token"] } @Test - fun `Should be able to write and read`() { - runBlocking { - val dirName = UUID.randomUUID().toString() - val fileNames = listOf( - "file.txt", - "file.pdf", - "img.webp" - ) - - fileNames.forEach { fileName -> - val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() - - client.post("http://localhost:8082/v1/file") { - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - header("Authorization", getToken(dirName, fileName)!!) - setBody( - Base64FileCreationOptions( - path = dirName, - fileName = fileName, - content = testedFile.readBytes().encodeBase64() - ) - ) - }.apply { - assertEquals(HttpStatusCode.Created, status) - assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) - } - - client.get("http://localhost:8082/v1/file?path=$dirName&fileName=$fileName") { - header("Authorization", getToken(dirName, fileName)!!) - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - }.apply { - assertEquals(HttpStatusCode.OK, status) - assertEquals(testedFile.readBytes().toList(), body().content.decodeBase64Bytes().toList()) - } + fun `Should be able to write and read`() = testApplication { + val httpClient = client.config { + install(ContentNegotiation) { + json() } } - } - @Test - fun `Should not be able to re-use the same token twice`() { - runBlocking { - val dirName = UUID.randomUUID().toString() - val fileName = "file.txt" - val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + val dirName = UUID.randomUUID().toString() + val fileNames = listOf( + "file.txt", + "file.pdf", + "img.webp" + ) - val token = getToken(dirName, fileName) + fileNames.forEach { fileName -> + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() - client.post("http://localhost:8082/v1/file") { + httpClient.post("/v1/file") { contentType(ContentType.Application.Json) - header("Authorization", token!!) + accept(ContentType.Application.Json) + header("Authorization", httpClient.getToken(dirName, fileName)!!) setBody( Base64FileCreationOptions( path = dirName, @@ -100,84 +67,131 @@ class ApplicationTest { assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) } - client.get("http://localhost:8082/v1/file?path=$dirName&fileName=$fileName") { - header("Authorization", token!!) + httpClient.get("/v1/file?path=$dirName&fileName=$fileName") { + header("Authorization", httpClient.getToken(dirName, fileName)!!) + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) }.apply { - assertEquals(HttpStatusCode.Unauthorized, status) + assertEquals(HttpStatusCode.OK, status) + assertEquals(testedFile.readBytes().toList(), body().content.decodeBase64Bytes().toList()) } } } @Test - fun `Should be able to delete a file`() { - runBlocking { - val dirName = UUID.randomUUID().toString() - val fileName = "file.txt" - val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + fun `Should not be able to re-use the same token twice`() = testApplication { + val httpClient = client.config { + install(ContentNegotiation) { + json() + } + } - client.post("http://localhost:8082/v1/file") { - contentType(ContentType.Application.Json) - header("Authorization", getToken(dirName, fileName)!!) - setBody( - Base64FileCreationOptions( - path = dirName, - fileName = fileName, - content = testedFile.readBytes().encodeBase64() - ) + val dirName = UUID.randomUUID().toString() + val fileName = "file.txt" + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + val token = httpClient.getToken(dirName, fileName) + + httpClient.post("/v1/file") { + contentType(ContentType.Application.Json) + header("Authorization", token!!) + setBody( + Base64FileCreationOptions( + path = dirName, + fileName = fileName, + content = testedFile.readBytes().encodeBase64() ) - }.apply { - assertEquals(HttpStatusCode.Created, status) - assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) - } + ) + }.apply { + assertEquals(HttpStatusCode.Created, status) + assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) + } - client.delete("http://localhost:8082/v1/file?path=$dirName&fileName=$fileName") { - header("Authorization", getToken(dirName, fileName)!!) - }.apply { - assertEquals(HttpStatusCode.NoContent, status) - } + httpClient.get("/v1/file?path=$dirName&fileName=$fileName") { + header("Authorization", token!!) + }.apply { + assertEquals(HttpStatusCode.Unauthorized, status) + } + } - client.get("http://localhost:8082/v1/file?path=$dirName&fileName=$fileName") { - header("Authorization", getToken(dirName, fileName)!!) - }.apply { - assertEquals(HttpStatusCode.NotFound, status) + @Test + fun `Should be able to delete a file`() = testApplication { + val httpClient = client.config { + install(ContentNegotiation) { + json() } } + + val dirName = UUID.randomUUID().toString() + val fileName = "file.txt" + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + httpClient.post("/v1/file") { + contentType(ContentType.Application.Json) + header("Authorization", httpClient.getToken(dirName, fileName)!!) + setBody( + Base64FileCreationOptions( + path = dirName, + fileName = fileName, + content = testedFile.readBytes().encodeBase64() + ) + ) + }.apply { + assertEquals(HttpStatusCode.Created, status) + assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) + } + + httpClient.delete("/v1/file?path=$dirName&fileName=$fileName") { + header("Authorization", httpClient.getToken(dirName, fileName)!!) + }.apply { + assertEquals(HttpStatusCode.NoContent, status) + } + + httpClient.get("/v1/file?path=$dirName&fileName=$fileName") { + header("Authorization", httpClient.getToken(dirName, fileName)!!) + }.apply { + assertEquals(HttpStatusCode.NotFound, status) + } } @Test - fun `Should be able to upload in multipart-form data`() { - runBlocking { - val fileNames = mapOf( - "file.txt" to ContentType.Text.Plain, - "file.pdf" to ContentType.Application.Pdf, - "img.webp" to ContentType.Image.Any - ) + fun `Should be able to upload in multipart-form data`() = testApplication { + val httpClient = client.config { + install(ContentNegotiation) { + json() + } + } + + val fileNames = mapOf( + "file.txt" to ContentType.Text.Plain, + "file.pdf" to ContentType.Application.Pdf, + "img.webp" to ContentType.Image.Any + ) - val dirName = "multipart-${UUID.randomUUID()}" - - fileNames.forEach { (fileName, contentType) -> - val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() - - client.submitFormWithBinaryData("http://localhost:8082/v1/file/upload", formData { - append(key = "path", value = dirName) - append(key = "fileName", value = fileName) - append(key = "content", value = testedFile.readBytes(), headers = Headers.build { - append(HttpHeaders.ContentType, contentType.toString()) - append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") - }) - }) { - header("Authorization", getToken(dirName, fileName)!!) - }.apply { - assertEquals(HttpStatusCode.Created, status) - assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) - } - - client.get("http://localhost:8082/v1/file?path=$dirName&fileName=$fileName") { - header("Authorization", getToken(dirName, fileName)!!) - }.apply { - assertEquals(HttpStatusCode.OK, status) - assertEquals(testedFile.readBytes().toList(), body().content.decodeBase64Bytes().toList()) - } + val dirName = "multipart-${UUID.randomUUID()}" + + fileNames.forEach { (fileName, contentType) -> + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + httpClient.submitFormWithBinaryData("/v1/file/upload", formData { + append(key = "path", value = dirName) + append(key = "fileName", value = fileName) + append(key = "content", value = testedFile.readBytes(), headers = Headers.build { + append(HttpHeaders.ContentType, contentType.toString()) + append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") + }) + }) { + header("Authorization", httpClient.getToken(dirName, fileName)!!) + }.apply { + assertEquals(HttpStatusCode.Created, status) + assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) + } + + httpClient.get("/v1/file?path=$dirName&fileName=$fileName") { + header("Authorization", httpClient.getToken(dirName, fileName)!!) + }.apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals(testedFile.readBytes().toList(), body().content.decodeBase64Bytes().toList()) } } } diff --git a/src/test/kotlin/be/vandeas/v2/ApplicationTest.kt b/src/test/kotlin/be/vandeas/v2/ApplicationTest.kt index 81adb84..d3645ce 100644 --- a/src/test/kotlin/be/vandeas/v2/ApplicationTest.kt +++ b/src/test/kotlin/be/vandeas/v2/ApplicationTest.kt @@ -4,100 +4,60 @@ import be.vandeas.dto.Base64FileCreationOptions import be.vandeas.dto.ReadFileBytesResult import io.ktor.client.* import io.ktor.client.call.* -import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.client.request.forms.* +import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.testing.* import io.ktor.util.* +import io.ktor.utils.io.jvm.javaio.* import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking import java.util.* import kotlin.io.path.toPath -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation class ApplicationTest { val apiKey = System.getenv("API_KEY") ?: throw IllegalStateException("API_KEY is not set") - private val client = HttpClient(CIO) { - install(ClientContentNegotiation) { - json() - } - } - - private suspend fun getToken(lifeTime: Duration): String?{ - return client.get("http://localhost:8082/v2/auth/token?lifeTime=${lifeTime.inWholeSeconds}") { + private suspend fun HttpClient.getToken(lifeTime: Duration): String? { + return this.get("/v2/auth/token?lifeTime=${lifeTime.inWholeSeconds}") { header("Authorization", apiKey) + accept(ContentType.Application.Json) }.apply { assertEquals(HttpStatusCode.OK, status) }.body>()["token"] } - @BeforeTest - fun setup() { - // DELETE ALL FILES FROM SCRATCH - } - @Test - fun `Should be able to write and read`() { - runBlocking { - val jwt = getToken(60.seconds)!! - val dirName = UUID.randomUUID().toString() - val fileNames = listOf( - "file.txt", - "file.pdf", - "img.webp" - ) - - fileNames.forEach { fileName -> - val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() - - client.post("http://localhost:8082/v2/file") { - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - bearerAuth(jwt) - setBody( - Base64FileCreationOptions( - path = dirName, - fileName = fileName, - content = testedFile.readBytes().encodeBase64() - ) - ) - }.apply { - assertEquals(HttpStatusCode.Created, status) - assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) - } - - client.get("http://localhost:8082/v2/file?path=$dirName&fileName=$fileName") { - bearerAuth(jwt) - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - }.apply { - assertEquals(HttpStatusCode.OK, status) - assertEquals(testedFile.readBytes().toList(), body().content.decodeBase64Bytes().toList()) - } + fun `Should be able to write and read`() = testApplication { + val httpClient = client.config { + install(ContentNegotiation) { + json() } } - } - @Test - fun `Should not be able to use a token once expired`() { - runBlocking { - val dirName = UUID.randomUUID().toString() - val fileName = "file.txt" - val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() - val token = getToken(5.seconds)!! + val jwt = httpClient.getToken(60.seconds)!! + val dirName = UUID.randomUUID().toString() + val fileNames = listOf( + "file.txt", + "file.pdf", + "img.webp" + ) + + fileNames.forEach { fileName -> + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() - client.post("http://localhost:8082/v2/file") { + httpClient.post("/v2/file") { contentType(ContentType.Application.Json) - bearerAuth(token) + accept(ContentType.Application.Json) + bearerAuth(jwt) setBody( Base64FileCreationOptions( path = dirName, @@ -107,91 +67,259 @@ class ApplicationTest { ) }.apply { assertEquals(HttpStatusCode.Created, status) - assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) + assertEquals(mapOf("path" to dirName, "fileName" to fileName), body()) } - delay(6.seconds) - - client.get("http://localhost:8082/v2/file?path=$dirName&fileName=$fileName") { - bearerAuth(token) + httpClient.get("/v2/file?path=$dirName&fileName=$fileName") { + bearerAuth(jwt) + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) }.apply { - assertEquals(HttpStatusCode.Unauthorized, status) + assertEquals(HttpStatusCode.OK, status) + assertEquals( + testedFile.readBytes().toList(), + body().content.decodeBase64Bytes().toList() + ) } } } @Test - fun `Should be able to delete a file`() { - runBlocking { - val dirName = UUID.randomUUID().toString() - val fileName = "file.txt" - val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + fun `Should not be able to use a token once expired`() = testApplication { + val httpClient = client.config { + install(ContentNegotiation) { + json() + } + } - val jwt = getToken(60.seconds)!! + val dirName = UUID.randomUUID().toString() + val fileName = "file.txt" + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() - client.post("http://localhost:8082/v2/file") { - contentType(ContentType.Application.Json) - bearerAuth(jwt) - setBody( - Base64FileCreationOptions( - path = dirName, - fileName = fileName, - content = testedFile.readBytes().encodeBase64() - ) + val token = httpClient.getToken(5.seconds)!! + + httpClient.post("/v2/file") { + contentType(ContentType.Application.Json) + bearerAuth(token) + setBody( + Base64FileCreationOptions( + path = dirName, + fileName = fileName, + content = testedFile.readBytes().encodeBase64() + ) + ) + }.apply { + assertEquals(HttpStatusCode.Created, status) + assertEquals(mapOf("path" to dirName, "fileName" to fileName), body()) + } + + delay(6.seconds) + + httpClient.get("/v2/file?path=$dirName&fileName=$fileName") { + bearerAuth(token) + }.apply { + assertEquals(HttpStatusCode.Unauthorized, status) + } + } + + @Test + fun `Should be able to delete a file`() = testApplication { + val httpClient = client.config { + install(ContentNegotiation) { + json() + } + } + + val dirName = UUID.randomUUID().toString() + val fileName = "file.txt" + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + val jwt = httpClient.getToken(60.seconds)!! + + httpClient.post("/v2/file") { + contentType(ContentType.Application.Json) + bearerAuth(jwt) + setBody( + Base64FileCreationOptions( + path = dirName, + fileName = fileName, + content = testedFile.readBytes().encodeBase64() ) + ) + }.apply { + assertEquals(HttpStatusCode.Created, status) + assertEquals(mapOf("path" to dirName, "fileName" to fileName), body()) + } + + httpClient.delete("/v2/file?path=$dirName&fileName=$fileName") { + bearerAuth(jwt) + }.apply { + assertEquals(HttpStatusCode.NoContent, status) + } + + httpClient.get("/v2/file?path=$dirName&fileName=$fileName") { + bearerAuth(jwt) + }.apply { + assertEquals(HttpStatusCode.NotFound, status) + } + } + + @Test + fun `Should be able to upload in multipart-form data`() = testApplication { + val httpClient = client.config { + install(ContentNegotiation) { + json() + } + } + + val fileNames = mapOf( + "file.txt" to ContentType.Text.Plain, + "file.pdf" to ContentType.Application.Pdf, + "img.webp" to ContentType.Image.Any + ) + + val jwt = httpClient.getToken(60.seconds)!! + val dirName = "multipart-${UUID.randomUUID()}" + + fileNames.forEach { (fileName, contentType) -> + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + httpClient.submitFormWithBinaryData("/v2/file/upload", formData { + append(key = "path", value = dirName) + append(key = "fileName", value = fileName) + append(key = "content", value = testedFile.readBytes(), headers = Headers.build { + append(HttpHeaders.ContentType, contentType.toString()) + append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") + }) + }) { + bearerAuth(jwt) }.apply { assertEquals(HttpStatusCode.Created, status) - assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) + assertEquals(mapOf("path" to dirName, "fileName" to fileName), body()) } - client.delete("http://localhost:8082/v2/file?path=$dirName&fileName=$fileName") { + httpClient.get("/v2/file?path=$dirName&fileName=$fileName") { bearerAuth(jwt) }.apply { - assertEquals(HttpStatusCode.NoContent, status) + assertEquals(HttpStatusCode.OK, status) + assertEquals( + testedFile.readBytes().toList(), + body().content.decodeBase64Bytes().toList() + ) } + } + } + + @Test + fun `Should be able to upload public file in multipart-form data`() = testApplication { + val httpClient = client.config { + install(ContentNegotiation) { + json() + } + } + + val fileNames = mapOf( + "file.txt" to ContentType.Text.Plain, + "file.pdf" to ContentType.Application.Pdf, + "img.webp" to ContentType.Image.Any + ) - client.get("http://localhost:8082/v2/file?path=$dirName&fileName=$fileName") { + val jwt = httpClient.getToken(60.seconds)!! + val dirName = "multipart-${UUID.randomUUID()}" + + fileNames.forEach { (fileName, contentType) -> + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + httpClient.submitFormWithBinaryData("/v2/file/upload", formData { + append(key = "path", value = dirName) + append(key = "public", value = true) + append(key = "fileName", value = fileName) + append(key = "content", value = testedFile.readBytes(), headers = Headers.build { + append(HttpHeaders.ContentType, contentType.toString()) + append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") + }) + }) { bearerAuth(jwt) }.apply { - assertEquals(HttpStatusCode.NotFound, status) + assertEquals(HttpStatusCode.Created, status) + val response = body>() + + assertEquals(mapOf("path" to listOf("public", dirName).joinToString("/"), "fileName" to fileName), response) + + httpClient.get("/v2/file/${response["path"]}/${response["fileName"]}") { + accept(ContentType.Application.OctetStream) + accept(ContentType.Text.Plain) + accept(ContentType.Application.Pdf) + accept(ContentType.Image.Any) + }.apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals( + testedFile.readBytes().toList(), + bodyAsChannel().toInputStream().readBytes().toList() + ) + } } } } @Test - fun `Should be able to upload in multipart-form data`() { - runBlocking { - val fileNames = mapOf( - "file.txt" to ContentType.Text.Plain, - "file.pdf" to ContentType.Application.Pdf, - "img.webp" to ContentType.Image.Any - ) + fun `Should be able to upload public file in multipart-form data and delete it`() = testApplication { + val httpClient = client.config { + install(ContentNegotiation) { + json() + } + } - val jwt = getToken(60.seconds)!! - val dirName = "multipart-${UUID.randomUUID()}" + val fileNames = mapOf("file.txt" to ContentType.Text.Plain,) - fileNames.forEach { (fileName, contentType) -> - val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + val jwt = httpClient.getToken(60.seconds)!! + val dirName = "multipart-${UUID.randomUUID()}" - client.submitFormWithBinaryData("http://localhost:8082/v2/file/upload", formData { - append(key = "path", value = dirName) - append(key = "fileName", value = fileName) - append(key = "content", value = testedFile.readBytes(), headers = Headers.build { - append(HttpHeaders.ContentType, contentType.toString()) - append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") - }) - }) { - bearerAuth(jwt) + fileNames.forEach { (fileName, contentType) -> + val testedFile = this::class.java.classLoader.getResource("input/$fileName")!!.toURI().toPath().toFile() + + httpClient.submitFormWithBinaryData("/v2/file/upload", formData { + append(key = "path", value = dirName) + append(key = "public", value = true) + append(key = "fileName", value = fileName) + append(key = "content", value = testedFile.readBytes(), headers = Headers.build { + append(HttpHeaders.ContentType, contentType.toString()) + append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") + }) + }) { + bearerAuth(jwt) + }.apply { + assertEquals(HttpStatusCode.Created, status) + val response = body>() + + assertEquals(mapOf("path" to listOf("public", dirName).joinToString("/"), "fileName" to fileName), response) + + httpClient.get("/v2/file/${response["path"]}/${response["fileName"]}") { + accept(ContentType.Application.OctetStream) + accept(ContentType.Text.Plain) + accept(ContentType.Application.Pdf) + accept(ContentType.Image.Any) }.apply { - assertEquals(HttpStatusCode.Created, status) - assertEquals(mapOf("path" to dirName, "fileName" to fileName) , body()) + assertEquals(HttpStatusCode.OK, status) + assertEquals( + testedFile.readBytes().toList(), + bodyAsChannel().toInputStream().readBytes().toList() + ) } - client.get("http://localhost:8082/v2/file?path=$dirName&fileName=$fileName") { + httpClient.delete("/v2/file?path=${dirName}&fileName=${response["fileName"]}&public=true") { bearerAuth(jwt) }.apply { - assertEquals(HttpStatusCode.OK, status) - assertEquals(testedFile.readBytes().toList(), body().content.decodeBase64Bytes().toList()) + assertEquals(HttpStatusCode.NoContent, status) + } + + httpClient.get("/v2/file/${response["path"]}/${response["fileName"]}") { + accept(ContentType.Application.OctetStream) + accept(ContentType.Text.Plain) + accept(ContentType.Application.Pdf) + accept(ContentType.Image.Any) + }.apply { + assertEquals(HttpStatusCode.NotFound, status) } } }