diff --git a/src/main/kotlin/dev/typetype/server/routes/BlockedRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/BlockedRoutes.kt index f4dc71e..ced0d07 100644 --- a/src/main/kotlin/dev/typetype/server/routes/BlockedRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/BlockedRoutes.kt @@ -49,7 +49,7 @@ fun Route.blockedRoutes(blockedService: BlockedService, authService: AuthService } delete("/blocked/videos/{videoUrl...}") { call.withJwtAuth(authService) { userId -> - val videoUrl = call.parameters.getAll("videoUrl")?.joinToString("/") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) + val videoUrl = call.urlTailParameter("videoUrl") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) val role = authService.getUserRole(userId) ?: "user" val deleted = blockedService.deleteVideo(userId, videoUrl, role) if (deleted) call.respond(HttpStatusCode.NoContent) else call.respond(HttpStatusCode.NotFound, ErrorResponse("Not found")) diff --git a/src/main/kotlin/dev/typetype/server/routes/FavoritesRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/FavoritesRoutes.kt index c36e663..7d89191 100644 --- a/src/main/kotlin/dev/typetype/server/routes/FavoritesRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/FavoritesRoutes.kt @@ -16,13 +16,13 @@ fun Route.favoritesRoutes(favoritesService: FavoritesService, authService: AuthS } post("/favorites/{videoUrl...}") { call.withJwtAuth(authService) { userId -> - val videoUrl = call.parameters.getAll("videoUrl")?.joinToString("/") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) + val videoUrl = call.urlTailParameter("videoUrl") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) call.respond(HttpStatusCode.Created, favoritesService.add(userId, videoUrl)) } } delete("/favorites/{videoUrl...}") { call.withJwtAuth(authService) { userId -> - val videoUrl = call.parameters.getAll("videoUrl")?.joinToString("/") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) + val videoUrl = call.urlTailParameter("videoUrl") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) val deleted = favoritesService.delete(userId, videoUrl) if (deleted) call.respond(HttpStatusCode.NoContent) else call.respond(HttpStatusCode.NotFound, ErrorResponse("Not found")) } diff --git a/src/main/kotlin/dev/typetype/server/routes/PlaylistRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/PlaylistRoutes.kt index 52c82bb..03512ec 100644 --- a/src/main/kotlin/dev/typetype/server/routes/PlaylistRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/PlaylistRoutes.kt @@ -62,7 +62,7 @@ fun Route.playlistRoutes(playlistService: PlaylistService, authService: AuthServ delete("/playlists/{id}/videos/{videoUrl...}") { call.withJwtAuth(authService) { userId -> val id = call.parameters["id"] ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id")) - val videoUrl = call.parameters.getAll("videoUrl")?.joinToString("/") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) + val videoUrl = call.urlTailParameter("videoUrl") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) val deleted = playlistService.removeVideo(userId, id, videoUrl) if (deleted) call.respond(HttpStatusCode.NoContent) else call.respond(HttpStatusCode.NotFound, ErrorResponse("Not found")) } diff --git a/src/main/kotlin/dev/typetype/server/routes/ProgressRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/ProgressRoutes.kt index 87e6fcb..06220d4 100644 --- a/src/main/kotlin/dev/typetype/server/routes/ProgressRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/ProgressRoutes.kt @@ -17,7 +17,7 @@ internal data class ProgressBody(val position: Long) fun Route.progressRoutes(progressService: ProgressService, authService: AuthService) { get("/progress/{videoUrl...}") { call.withJwtAuth(authService) { userId -> - val videoUrl = call.parameters.getAll("videoUrl")?.joinToString("/") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) + val videoUrl = call.urlTailParameter("videoUrl") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) val item = progressService.get(userId, videoUrl) ?: return@withJwtAuth call.respond(HttpStatusCode.NotFound, ErrorResponse("Not found")) call.respond(item) } @@ -32,7 +32,7 @@ fun Route.progressRoutes(progressService: ProgressService, authService: AuthServ } put("/progress/{videoUrl...}") { call.withJwtAuth(authService) { userId -> - val videoUrl = call.parameters.getAll("videoUrl")?.joinToString("/") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) + val videoUrl = call.urlTailParameter("videoUrl") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) val body = runCatching { call.receive() }.getOrElse { return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body")) } diff --git a/src/main/kotlin/dev/typetype/server/routes/RouteUrlParameter.kt b/src/main/kotlin/dev/typetype/server/routes/RouteUrlParameter.kt new file mode 100644 index 0000000..92c4dee --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/routes/RouteUrlParameter.kt @@ -0,0 +1,21 @@ +package dev.typetype.server.routes + +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.queryString + +internal fun ApplicationCall.urlTailParameter(name: String): String? { + val pathUrl = parameters.getAll(name)?.joinToString("/") ?: return null + val normalizedPathUrl = pathUrl.withUrlSchemeSlashes() + val queryString = request.queryString() + return if (queryString.isBlank() || "?" in normalizedPathUrl) { + normalizedPathUrl + } else { + "$normalizedPathUrl?$queryString" + } +} + +private fun String.withUrlSchemeSlashes(): String = when { + startsWith("https:/") && !startsWith("https://") -> "https://" + removePrefix("https:/") + startsWith("http:/") && !startsWith("http://") -> "http://" + removePrefix("http:/") + else -> this +} diff --git a/src/main/kotlin/dev/typetype/server/routes/WatchLaterRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/WatchLaterRoutes.kt index 0515649..ee55833 100644 --- a/src/main/kotlin/dev/typetype/server/routes/WatchLaterRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/WatchLaterRoutes.kt @@ -5,7 +5,6 @@ import dev.typetype.server.models.WatchLaterItem import dev.typetype.server.services.AuthService import dev.typetype.server.services.WatchLaterService import io.ktor.http.HttpStatusCode -import io.ktor.server.request.queryString import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.routing.Route @@ -27,15 +26,8 @@ fun Route.watchLaterRoutes(watchLaterService: WatchLaterService, authService: Au } delete("/watch-later/{videoUrl...}") { call.withJwtAuth(authService) { userId -> - val pathVideoUrl = call.parameters.getAll("videoUrl")?.joinToString("/") + val videoUrl = call.urlTailParameter("videoUrl") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing videoUrl")) - val decodedPathVideoUrl = pathVideoUrl.withUrlSchemeSlashes() - val queryString = call.request.queryString() - val videoUrl = if (queryString.isBlank() || "?" in decodedPathVideoUrl) { - decodedPathVideoUrl - } else { - "$decodedPathVideoUrl?$queryString" - } val deleted = watchLaterService.delete(userId, videoUrl) if (deleted) { call.respond(HttpStatusCode.NoContent) @@ -45,9 +37,3 @@ fun Route.watchLaterRoutes(watchLaterService: WatchLaterService, authService: Au } } } - -private fun String.withUrlSchemeSlashes(): String = when { - startsWith("https:/") && !startsWith("https://") -> "https://" + removePrefix("https:/") - startsWith("http:/") && !startsWith("http://") -> "http://" + removePrefix("http:/") - else -> this -} diff --git a/src/test/kotlin/dev/typetype/server/EncodedVideoUrlDeleteRoutesTest.kt b/src/test/kotlin/dev/typetype/server/EncodedVideoUrlDeleteRoutesTest.kt new file mode 100644 index 0000000..f1b1dbc --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/EncodedVideoUrlDeleteRoutesTest.kt @@ -0,0 +1,94 @@ +package dev.typetype.server + +import dev.typetype.server.models.PlaylistItem +import dev.typetype.server.routes.favoritesRoutes +import dev.typetype.server.routes.playlistRoutes +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.FavoritesService +import dev.typetype.server.services.PlaylistService +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.routing.routing +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class EncodedVideoUrlDeleteRoutesTest { + + private val auth = AuthService.fixed(TEST_USER_ID) + private val favoritesService = FavoritesService() + private val playlistService = PlaylistService() + + companion object { + private const val VIDEO_URL = "https://www.youtube.com/watch?v=ZZZZZZZZZZZ" + private const val ENCODED_VIDEO_URL = "https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DZZZZZZZZZZZ" + + @BeforeAll + @JvmStatic + fun initDb() { TestDatabase.setup() } + } + + @BeforeEach + fun clean() { TestDatabase.truncateAll() } + + private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + application { + install(ContentNegotiation) { json() } + routing { + favoritesRoutes(favoritesService, auth) + playlistRoutes(playlistService, auth) + } + } + block() + } + + @Test + fun `DELETE favorites removes encoded youtube watch url added by POST`() = withApp { + val post = client.post("/favorites/$ENCODED_VIDEO_URL") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + } + assertEquals(HttpStatusCode.Created, post.status) + assertTrue(post.bodyAsText().contains("\"videoUrl\":\"$VIDEO_URL\"")) + + val delete = client.delete("/favorites/$ENCODED_VIDEO_URL") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + } + assertEquals(HttpStatusCode.NoContent, delete.status) + } + + @Test + fun `DELETE playlists video removes encoded youtube watch url added by POST`() = withApp { + val playlist = playlistService.create(TEST_USER_ID, PlaylistItem(name = "Favorites")) + val body = """{"url":"$VIDEO_URL","title":"test","thumbnail":"","duration":0}""" + val post = client.post("/playlists/${playlist.id}/videos") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(body) + } + assertEquals(HttpStatusCode.Created, post.status) + + val delete = client.delete("/playlists/${playlist.id}/videos/$ENCODED_VIDEO_URL") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + } + assertEquals(HttpStatusCode.NoContent, delete.status) + val afterDelete = client.get("/playlists/${playlist.id}") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + }.bodyAsText() + assertFalse(afterDelete.contains(VIDEO_URL)) + } +}