diff --git a/src/main/kotlin/dev/typetype/server/routes/SubscriptionsRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/SubscriptionsRoutes.kt index ee1865e..646a30e 100644 --- a/src/main/kotlin/dev/typetype/server/routes/SubscriptionsRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/SubscriptionsRoutes.kt @@ -5,12 +5,16 @@ import dev.typetype.server.models.SubscriptionItem import dev.typetype.server.services.AuthService import dev.typetype.server.services.SubscriptionsService import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall import io.ktor.server.request.receive +import io.ktor.server.request.path import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.delete import io.ktor.server.routing.get import io.ktor.server.routing.post +import java.net.URLDecoder +import java.nio.charset.StandardCharsets fun Route.subscriptionsRoutes(subscriptionsService: SubscriptionsService, authService: AuthService) { get("/subscriptions") { @@ -24,11 +28,41 @@ fun Route.subscriptionsRoutes(subscriptionsService: SubscriptionsService, authSe call.respond(HttpStatusCode.Created, subscriptionsService.add(userId, item)) } } + delete("/subscriptions") { + call.withJwtAuth(authService) { userId -> + val channelUrl = call.request.queryParameters["url"]?.takeIf { it.isNotBlank() } + ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing channelUrl")) + call.respondDeleteResult(subscriptionsService, userId, channelUrl) + } + } delete("/subscriptions/{channelUrl...}") { call.withJwtAuth(authService) { userId -> - val channelUrl = call.parameters.getAll("channelUrl")?.joinToString("/") ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing channelUrl")) - val deleted = subscriptionsService.delete(userId, channelUrl) - if (deleted) call.respond(HttpStatusCode.NoContent) else call.respond(HttpStatusCode.NotFound, ErrorResponse("Not found")) + val channelUrl = call.extractDeleteChannelUrl() + ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing channelUrl")) + call.respondDeleteResult(subscriptionsService, userId, channelUrl) } } } + +private suspend fun ApplicationCall.respondDeleteResult( + subscriptionsService: SubscriptionsService, + userId: String, + channelUrl: String, +) { + val deleted = subscriptionsService.delete(userId, channelUrl) + if (deleted) respond(HttpStatusCode.NoContent) else respond(HttpStatusCode.NotFound, ErrorResponse("Not found")) +} + +private fun ApplicationCall.extractDeleteChannelUrl(): String? { + val queryUrl = request.queryParameters["url"]?.takeIf { it.isNotBlank() } + if (queryUrl != null) return queryUrl + val rawPath = request.path() + val marker = "/subscriptions/" + val index = rawPath.indexOf(marker) + if (index == -1) return null + val rawTail = rawPath.substring(index + marker.length) + if (rawTail.isBlank()) return null + return runCatching { URLDecoder.decode(rawTail, StandardCharsets.UTF_8) } + .getOrDefault(rawTail) + .takeIf { it.isNotBlank() } +} diff --git a/src/test/kotlin/dev/typetype/server/SubscriptionsDeleteProxyRoutesTest.kt b/src/test/kotlin/dev/typetype/server/SubscriptionsDeleteProxyRoutesTest.kt new file mode 100644 index 0000000..7b56ffc --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/SubscriptionsDeleteProxyRoutesTest.kt @@ -0,0 +1,75 @@ +package dev.typetype.server + +import dev.typetype.server.models.SubscriptionItem +import dev.typetype.server.routes.subscriptionsRoutes +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.SubscriptionsService +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.statement.bodyAsText +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.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class SubscriptionsDeleteProxyRoutesTest { + private val service = SubscriptionsService() + private val auth = AuthService.fixed(TEST_USER_ID) + + companion object { + @BeforeAll + @JvmStatic + fun initDb() { + TestDatabase.setup() + } + } + + @BeforeEach + fun clean() { + TestDatabase.truncateAll() + } + + private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + application { + install(ContentNegotiation) { json() } + routing { subscriptionsRoutes(service, auth) } + } + block() + } + + @Test + fun `DELETE subscriptions supports query url`() = withApp { + service.add(TEST_USER_ID, SubscriptionItem(channelUrl = "https://www.youtube.com/channel/UC123", name = "T", avatarUrl = "")) + val response = client.delete("/subscriptions?url=https%3A%2F%2Fwww.youtube.com%2Fchannel%2FUC123") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + } + assertEquals(HttpStatusCode.NoContent, response.status) + val body = client.get("/subscriptions") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + }.bodyAsText() + assertFalse(body.contains("UC123")) + } + + @Test + fun `DELETE subscriptions uses query url when path is proxy-decoded`() = withApp { + service.add(TEST_USER_ID, SubscriptionItem(channelUrl = "https://www.youtube.com/channel/UC123", name = "T", avatarUrl = "")) + val response = client.delete("/subscriptions/https:/www.youtube.com/channel/UC123?url=https%3A%2F%2Fwww.youtube.com%2Fchannel%2FUC123") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + } + assertEquals(HttpStatusCode.NoContent, response.status) + val body = client.get("/subscriptions") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + }.bodyAsText() + assertFalse(body.contains("UC123")) + } +}