diff --git a/openapi.yaml b/openapi.yaml index 849c1c99..63aec2da 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -45,6 +45,7 @@ paths: /saved-playlists: { $ref: ./openapi/paths/saved-playlists.yaml#/SavedPlaylists } /saved-playlists/{id}: { $ref: ./openapi/paths/saved-playlists.yaml#/SavedPlaylist } /subscriptions: { $ref: ./openapi/paths/subscriptions.yaml#/Subscriptions } + /subscriptions/group-memberships: { $ref: ./openapi/paths/subscriptions.yaml#/SubscriptionGroupMemberships } /subscriptions/groups: { $ref: ./openapi/paths/subscriptions.yaml#/SubscriptionGroups } /subscriptions/groups/{groupId}: { $ref: ./openapi/paths/subscriptions.yaml#/SubscriptionGroup } /subscriptions/groups/{groupId}/channels: { $ref: ./openapi/paths/subscriptions.yaml#/SubscriptionGroupChannels } @@ -160,9 +161,11 @@ components: SavedPlaylistItem: { $ref: ./openapi/components/media.yaml#/SavedPlaylistItem } SavedPlaylistRequest: { $ref: ./openapi/components/media.yaml#/SavedPlaylistRequest } SubscriptionItem: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionItem } + SubscriptionGroupMembershipItem: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionGroupMembershipItem } SubscriptionGroupItem: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionGroupItem } SubscriptionGroupRequest: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionGroupRequest } SubscriptionGroupMembershipRequest: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionGroupMembershipRequest } + SubscriptionGroupMembershipBatchRequest: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionGroupMembershipBatchRequest } SubscriptionFeedResponse: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionFeedResponse } SubscriptionFeedPreparingResponse: { $ref: ./openapi/components/subscriptions.yaml#/SubscriptionFeedPreparingResponse } RssFeedRequest: { $ref: ./openapi/components/rss.yaml#/RssFeedRequest } diff --git a/openapi/components/subscriptions.yaml b/openapi/components/subscriptions.yaml index 5894cc5f..d0af4c01 100644 --- a/openapi/components/subscriptions.yaml +++ b/openapi/components/subscriptions.yaml @@ -6,6 +6,18 @@ SubscriptionItem: name: { type: string } avatarUrl: { type: string } subscribedAt: { type: integer, format: int64 } +SubscriptionGroupMembershipItem: + type: object + required: [channelUrl, name, avatarUrl, subscribedAt, groupIds] + properties: + channelUrl: { type: string, minLength: 1 } + name: { type: string } + avatarUrl: { type: string } + subscribedAt: { type: integer, format: int64 } + groupIds: + type: array + uniqueItems: true + items: { type: string, format: uuid } SubscriptionCreateRequest: type: object required: [channelUrl, name, avatarUrl] @@ -31,7 +43,16 @@ SubscriptionGroupMembershipRequest: type: object required: [channelUrl] properties: - channelUrl: { type: string, minLength: 1 } + channelUrl: { type: string, minLength: 1, maxLength: 2048 } +SubscriptionGroupMembershipBatchRequest: + type: object + required: [channelUrls] + properties: + channelUrls: + type: array + minItems: 1 + maxItems: 500 + items: { type: string, minLength: 1, maxLength: 2048 } SubscriptionFeedResponse: type: object required: [videos, nextpage, generation, generatedAt, refreshing] diff --git a/openapi/paths/subscriptions.yaml b/openapi/paths/subscriptions.yaml index 9f9b4863..0a6244ab 100644 --- a/openapi/paths/subscriptions.yaml +++ b/openapi/paths/subscriptions.yaml @@ -52,6 +52,20 @@ Subscriptions: '400': { $ref: ../components/common.yaml#/JsonError } '401': { $ref: ../components/common.yaml#/JsonError } '404': { $ref: ../components/common.yaml#/JsonError } +SubscriptionGroupMemberships: + get: + tags: [user-data] + summary: List subscriptions with their group memberships + description: Returns every current subscription once, with all account-owned group IDs assigned to that channel. Ungrouped subscriptions have an empty groupIds array. + responses: + '200': + description: Account-scoped subscriptions and their complete group memberships. + content: + application/json: + schema: + type: array + items: { $ref: ../components/subscriptions.yaml#/SubscriptionGroupMembershipItem } + '401': { $ref: ../components/common.yaml#/JsonError } SubscriptionGroups: get: tags: [user-data] @@ -117,29 +131,44 @@ SubscriptionGroupChannels: schema: { type: string, format: uuid } put: tags: [user-data] - summary: Add a subscribed channel to a group + summary: Add subscribed channels to a group + description: Accepts the original singular channelUrl request or up to 500 channelUrls. Batch additions are atomic, canonicalized and deduplicated. Request bodies are limited to 1 MiB. requestBody: required: true content: application/json: - schema: { $ref: ../components/subscriptions.yaml#/SubscriptionGroupMembershipRequest } + schema: + oneOf: + - { $ref: ../components/subscriptions.yaml#/SubscriptionGroupMembershipRequest } + - { $ref: ../components/subscriptions.yaml#/SubscriptionGroupMembershipBatchRequest } responses: '204': { description: Membership exists. } '400': { $ref: ../components/common.yaml#/JsonError } '401': { $ref: ../components/common.yaml#/JsonError } + '413': { $ref: ../components/common.yaml#/JsonError } '404': { $ref: ../components/common.yaml#/JsonError } delete: tags: [user-data] - summary: Remove a subscribed channel from a group + summary: Remove subscribed channels from a group + description: The url query parameter preserves the original singular contract. A JSON request body can remove one channel or up to 500 channelUrls atomically; absent batch memberships are ignored. Supplying both url and any non-empty request body returns 400. Request bodies are limited to 1 MiB. parameters: - name: url in: query - required: true - schema: { type: string, minLength: 1 } + required: false + schema: { type: string, minLength: 1, maxLength: 2048 } + requestBody: + required: false + content: + application/json: + schema: + oneOf: + - { $ref: ../components/subscriptions.yaml#/SubscriptionGroupMembershipRequest } + - { $ref: ../components/subscriptions.yaml#/SubscriptionGroupMembershipBatchRequest } responses: '204': { description: Membership deleted. } '400': { $ref: ../components/common.yaml#/JsonError } '401': { $ref: ../components/common.yaml#/JsonError } + '413': { $ref: ../components/common.yaml#/JsonError } '404': { $ref: ../components/common.yaml#/JsonError } SubscriptionFeed: get: diff --git a/src/main/kotlin/dev/typetype/server/models/SubscriptionGroupMembershipItem.kt b/src/main/kotlin/dev/typetype/server/models/SubscriptionGroupMembershipItem.kt new file mode 100644 index 00000000..b4a6fa60 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/SubscriptionGroupMembershipItem.kt @@ -0,0 +1,12 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class SubscriptionGroupMembershipItem( + val channelUrl: String, + val name: String, + val avatarUrl: String, + val subscribedAt: Long, + val groupIds: List, +) diff --git a/src/main/kotlin/dev/typetype/server/models/SubscriptionGroupMembershipRequest.kt b/src/main/kotlin/dev/typetype/server/models/SubscriptionGroupMembershipRequest.kt index 9a2fcb5f..d6befc62 100644 --- a/src/main/kotlin/dev/typetype/server/models/SubscriptionGroupMembershipRequest.kt +++ b/src/main/kotlin/dev/typetype/server/models/SubscriptionGroupMembershipRequest.kt @@ -3,4 +3,7 @@ package dev.typetype.server.models import kotlinx.serialization.Serializable @Serializable -data class SubscriptionGroupMembershipRequest(val channelUrl: String) +data class SubscriptionGroupMembershipRequest( + val channelUrl: String? = null, + val channelUrls: List? = null, +) diff --git a/src/main/kotlin/dev/typetype/server/routes/SubscriptionGroupMembershipRequestBody.kt b/src/main/kotlin/dev/typetype/server/routes/SubscriptionGroupMembershipRequestBody.kt new file mode 100644 index 00000000..022aeaa6 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/routes/SubscriptionGroupMembershipRequestBody.kt @@ -0,0 +1,85 @@ +package dev.typetype.server.routes + +import dev.typetype.server.models.ErrorResponse +import dev.typetype.server.models.SubscriptionGroupMembershipRequest +import dev.typetype.server.services.SubscriptionGroupsService +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.contentType +import io.ktor.server.request.receiveChannel +import io.ktor.server.response.respond +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.jvm.javaio.toInputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import java.io.ByteArrayOutputStream + +private const val MAX_MEMBERSHIP_REQUEST_BODY_BYTES = 1024 * 1024 +private const val MAX_MEMBERSHIP_CHANNEL_URL_LENGTH = 2048 +private val membershipRequestJson = Json { ignoreUnknownKeys = true } + +internal sealed interface MembershipChannels { + data class Single(val channelUrl: String) : MembershipChannels + data class Batch(val channelUrls: List) : MembershipChannels +} + +internal suspend fun ApplicationCall.receiveMembershipChannels(body: ByteArray): MembershipChannels? { + val request = if (request.contentType().match(ContentType.Application.Json)) { + try { + membershipRequestJson.decodeFromString(body.decodeToString()) + } catch (_: SerializationException) { + null + } + } else { + null + } + val channelUrl = request?.channelUrl?.takeIf(String::isValidMembershipChannelUrl) + val channelUrls = request?.channelUrls + val parsed = when { + channelUrl != null && channelUrls == null -> MembershipChannels.Single(channelUrl) + request?.channelUrl == null && channelUrls != null && + channelUrls.size in 1..SubscriptionGroupsService.MAX_MEMBERSHIP_CHANNELS && + channelUrls.all(String::isValidMembershipChannelUrl) -> MembershipChannels.Batch(channelUrls) + else -> null + } + if (parsed == null) respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body")) + return parsed +} + +internal suspend fun ApplicationCall.receiveMembershipBody(): ByteArray? { + val contentLength = request.headers[HttpHeaders.ContentLength]?.toLongOrNull() + if (contentLength != null && contentLength > MAX_MEMBERSHIP_REQUEST_BODY_BYTES) { + respondMembershipBodyTooLarge() + return null + } + val body = receiveChannel().readUpTo(MAX_MEMBERSHIP_REQUEST_BODY_BYTES) + if (body == null) respondMembershipBodyTooLarge() + return body +} + +private suspend fun ByteReadChannel.readUpTo(maxBytes: Int): ByteArray? = withContext(Dispatchers.IO) { + toInputStream().use { input -> + ByteArrayOutputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read <= 0) break + if (output.size() + read > maxBytes) return@withContext null + output.write(buffer, 0, read) + } + output.toByteArray() + } + } +} + +internal fun String.isValidMembershipChannelUrl(): Boolean = + isNotBlank() && length <= MAX_MEMBERSHIP_CHANNEL_URL_LENGTH + +private suspend fun ApplicationCall.respondMembershipBodyTooLarge() = respond( + HttpStatusCode.PayloadTooLarge, + ErrorResponse("Request body exceeds 1 MiB", "request_body_too_large"), +) diff --git a/src/main/kotlin/dev/typetype/server/routes/SubscriptionGroupsRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/SubscriptionGroupsRoutes.kt index c80acb3d..2109f4b7 100644 --- a/src/main/kotlin/dev/typetype/server/routes/SubscriptionGroupsRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/SubscriptionGroupsRoutes.kt @@ -1,7 +1,6 @@ package dev.typetype.server.routes import dev.typetype.server.models.ErrorResponse -import dev.typetype.server.models.SubscriptionGroupMembershipRequest import dev.typetype.server.models.SubscriptionGroupRequest import dev.typetype.server.services.AuthService import dev.typetype.server.services.SubscriptionGroupMembershipResult @@ -45,21 +44,44 @@ fun Route.subscriptionGroupsRoutes(groupsService: SubscriptionGroupsService, aut put("/subscriptions/groups/{groupId}/channels") { call.withJwtAuth(authService) { userId -> val groupId = call.groupId() ?: return@withJwtAuth call.respondMissingGroupId() - val request = runCatching { call.receive() }.getOrElse { - return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body")) + val body = call.receiveMembershipBody() ?: return@withJwtAuth + when (val request = call.receiveMembershipChannels(body) ?: return@withJwtAuth) { + is MembershipChannels.Single -> call.respondMembership( + groupsService.addSubscription(userId, groupId, request.channelUrl), + ) + is MembershipChannels.Batch -> call.respondMembership( + groupsService.addSubscriptions(userId, groupId, request.channelUrls), + ) } - if (request.channelUrl.isBlank()) { - return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("channelUrl must not be blank")) - } - call.respondMembership(groupsService.addSubscription(userId, groupId, request.channelUrl)) } } delete("/subscriptions/groups/{groupId}/channels") { call.withJwtAuth(authService) { userId -> val groupId = call.groupId() ?: return@withJwtAuth call.respondMissingGroupId() - val channelUrl = call.request.queryParameters["url"]?.takeIf(String::isNotBlank) - ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing channelUrl")) - call.respondMembership(groupsService.removeSubscription(userId, groupId, channelUrl)) + val queryChannelUrl = call.request.queryParameters["url"] + if (queryChannelUrl != null && !queryChannelUrl.isValidMembershipChannelUrl()) { + return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid channel URL")) + } + val body = call.receiveMembershipBody() ?: return@withJwtAuth + if (queryChannelUrl != null) { + if (body.isNotEmpty()) { + return@withJwtAuth call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("Specify either url or a request body, not both"), + ) + } + return@withJwtAuth call.respondMembership( + groupsService.removeSubscription(userId, groupId, queryChannelUrl), + ) + } + when (val request = call.receiveMembershipChannels(body) ?: return@withJwtAuth) { + is MembershipChannels.Single -> call.respondMembership( + groupsService.removeSubscription(userId, groupId, request.channelUrl), + ) + is MembershipChannels.Batch -> call.respondMembership( + groupsService.removeSubscriptions(userId, groupId, request.channelUrls), + ) + } } } } diff --git a/src/main/kotlin/dev/typetype/server/routes/SubscriptionsRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/SubscriptionsRoutes.kt index becd5d9b..33aac871 100644 --- a/src/main/kotlin/dev/typetype/server/routes/SubscriptionsRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/SubscriptionsRoutes.kt @@ -27,6 +27,11 @@ fun Route.subscriptionsRoutes( warmupService: HomeRecommendationWarmup = NoopHomeRecommendationWarmup, groupsService: SubscriptionGroupsService = SubscriptionGroupsService(), ) { + get("/subscriptions/group-memberships") { + call.withJwtAuth(authService) { userId -> + call.respond(subscriptionsService.getAllWithGroupMemberships(userId)) + } + } get("/subscriptions") { call.withJwtAuth(authService) { userId -> val parsed = call.parseSubscriptionSelection() diff --git a/src/main/kotlin/dev/typetype/server/services/SubscriptionGroupsService.kt b/src/main/kotlin/dev/typetype/server/services/SubscriptionGroupsService.kt index 9529ad45..5c92769b 100644 --- a/src/main/kotlin/dev/typetype/server/services/SubscriptionGroupsService.kt +++ b/src/main/kotlin/dev/typetype/server/services/SubscriptionGroupsService.kt @@ -9,6 +9,8 @@ import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.jdbc.batchInsert import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.insertIgnore import org.jetbrains.exposed.v1.jdbc.selectAll @@ -103,19 +105,31 @@ class SubscriptionGroupsService { userId: String, groupId: String, rawChannelUrl: String, + ): SubscriptionGroupMembershipResult = addSubscriptions(userId, groupId, listOf(rawChannelUrl)) + + suspend fun addSubscriptions( + userId: String, + groupId: String, + rawChannelUrls: List, ): SubscriptionGroupMembershipResult = DatabaseFactory.query { SubscriptionMutationLock.acquire(userId) if (!groupExists(userId, groupId)) return@query SubscriptionGroupMembershipResult.GroupNotFound - val channelUrl = ChannelUrlCanonicalizer.canonicalize(rawChannelUrl) - val subscriptionExists = SubscriptionsTable.selectAll().where { - (SubscriptionsTable.userId eq userId) and (SubscriptionsTable.channelUrl eq channelUrl) - }.any() - if (!subscriptionExists) return@query SubscriptionGroupMembershipResult.SubscriptionNotFound - SubscriptionGroupMembershipsTable.insertIgnore { - it[SubscriptionGroupMembershipsTable.groupId] = groupId - it[SubscriptionGroupMembershipsTable.userId] = userId - it[SubscriptionGroupMembershipsTable.channelUrl] = channelUrl - it[addedAt] = System.currentTimeMillis() + val channelUrls = rawChannelUrls.mapTo(linkedSetOf(), ChannelUrlCanonicalizer::canonicalize) + val subscribed = SubscriptionsTable.selectAll().where { + (SubscriptionsTable.userId eq userId) and (SubscriptionsTable.channelUrl inList channelUrls) + }.mapTo(hashSetOf()) { it[SubscriptionsTable.channelUrl] } + if (subscribed.size != channelUrls.size) return@query SubscriptionGroupMembershipResult.SubscriptionNotFound + val existing = SubscriptionGroupMembershipsTable.selectAll().where { + (SubscriptionGroupMembershipsTable.groupId eq groupId) and + (SubscriptionGroupMembershipsTable.userId eq userId) and + (SubscriptionGroupMembershipsTable.channelUrl inList channelUrls) + }.mapTo(hashSetOf()) { it[SubscriptionGroupMembershipsTable.channelUrl] } + val addedAt = System.currentTimeMillis() + SubscriptionGroupMembershipsTable.batchInsert(channelUrls - existing, shouldReturnGeneratedValues = false) { url -> + this[SubscriptionGroupMembershipsTable.groupId] = groupId + this[SubscriptionGroupMembershipsTable.userId] = userId + this[SubscriptionGroupMembershipsTable.channelUrl] = url + this[SubscriptionGroupMembershipsTable.addedAt] = addedAt } SubscriptionGroupMembershipResult.Success } @@ -138,6 +152,22 @@ class SubscriptionGroupsService { } } + suspend fun removeSubscriptions( + userId: String, + groupId: String, + rawChannelUrls: List, + ): SubscriptionGroupMembershipResult = DatabaseFactory.query { + SubscriptionMutationLock.acquire(userId) + if (!groupExists(userId, groupId)) return@query SubscriptionGroupMembershipResult.GroupNotFound + val channelUrls = rawChannelUrls.mapTo(hashSetOf(), ChannelUrlCanonicalizer::canonicalize) + SubscriptionGroupMembershipsTable.deleteWhere { + (SubscriptionGroupMembershipsTable.groupId eq groupId) and + (SubscriptionGroupMembershipsTable.userId eq userId) and + (SubscriptionGroupMembershipsTable.channelUrl inList channelUrls) + } + SubscriptionGroupMembershipResult.Success + } + suspend fun getChannelUrls(userId: String, groupId: String): List = DatabaseFactory.query { SubscriptionGroupMembershipsTable.selectAll().where { (SubscriptionGroupMembershipsTable.groupId eq groupId) and @@ -182,6 +212,7 @@ class SubscriptionGroupsService { companion object { const val MAX_GROUP_NAME_LENGTH = 100 + const val MAX_MEMBERSHIP_CHANNELS = 500 private const val UNIQUE_VIOLATION_SQL_STATE = "23505" } } diff --git a/src/main/kotlin/dev/typetype/server/services/SubscriptionsService.kt b/src/main/kotlin/dev/typetype/server/services/SubscriptionsService.kt index 6e90ab8f..70da17df 100644 --- a/src/main/kotlin/dev/typetype/server/services/SubscriptionsService.kt +++ b/src/main/kotlin/dev/typetype/server/services/SubscriptionsService.kt @@ -2,7 +2,9 @@ package dev.typetype.server.services import dev.typetype.server.db.DatabaseFactory import dev.typetype.server.db.tables.SubscriptionGroupMembershipsTable +import dev.typetype.server.db.tables.SubscriptionGroupsTable import dev.typetype.server.db.tables.SubscriptionsTable +import dev.typetype.server.models.SubscriptionGroupMembershipItem import dev.typetype.server.models.SubscriptionItem import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.SortOrder @@ -19,14 +21,37 @@ class SubscriptionsService { selection: SubscriptionSelection = SubscriptionSelection.All, ): List = DatabaseFactory.query { val selectedUrls = selectedChannelUrls(userId, selection) - val items = SubscriptionsTable.selectAll() - .where { SubscriptionsTable.userId eq userId } - .orderBy(SubscriptionsTable.subscribedAt to SortOrder.DESC) - .map { it.toItem() } + subscriptionItems(userId) .filter { selection == SubscriptionSelection.All || it.channelUrl in selectedUrls } - SubscriptionAvatarRepairer.repair(userId = userId, items = items) } + suspend fun getAllWithGroupMemberships(userId: String): List = + DatabaseFactory.query { + val groupIdsByChannel = SubscriptionGroupMembershipsTable + .innerJoin(SubscriptionGroupsTable) + .selectAll() + .where { + (SubscriptionGroupMembershipsTable.userId eq userId) and + (SubscriptionGroupsTable.userId eq userId) + } + .groupBy( + keySelector = { + ChannelUrlCanonicalizer.canonicalize(it[SubscriptionGroupMembershipsTable.channelUrl]) + }, + valueTransform = { it[SubscriptionGroupMembershipsTable.groupId] }, + ) + .mapValues { (_, groupIds) -> groupIds.sorted() } + subscriptionItems(userId).map { item -> + SubscriptionGroupMembershipItem( + channelUrl = item.channelUrl, + name = item.name, + avatarUrl = item.avatarUrl, + subscribedAt = item.subscribedAt, + groupIds = groupIdsByChannel[item.channelUrl].orEmpty(), + ) + } + } + suspend fun getChannelUrls(userId: String, selection: SubscriptionSelection): Set = DatabaseFactory.query { selectedChannelUrls(userId, selection) } @@ -55,6 +80,14 @@ class SubscriptionsService { SubscriptionsTable.deleteWhere { SubscriptionsTable.channelUrl eq canonicalUrl and (SubscriptionsTable.userId eq userId) } > 0 } + private fun subscriptionItems(userId: String): List { + val items = SubscriptionsTable.selectAll() + .where { SubscriptionsTable.userId eq userId } + .orderBy(SubscriptionsTable.subscribedAt to SortOrder.DESC) + .map { it.toItem() } + return SubscriptionAvatarRepairer.repair(userId = userId, items = items) + } + private fun selectedChannelUrls(userId: String, selection: SubscriptionSelection): Set { val all = SubscriptionsTable.selectAll() .where { SubscriptionsTable.userId eq userId } diff --git a/src/test/kotlin/dev/typetype/server/SubscriptionGroupMembershipRequestLimitsRoutesTest.kt b/src/test/kotlin/dev/typetype/server/SubscriptionGroupMembershipRequestLimitsRoutesTest.kt new file mode 100644 index 00000000..1b599dec --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/SubscriptionGroupMembershipRequestLimitsRoutesTest.kt @@ -0,0 +1,132 @@ +package dev.typetype.server + +import dev.typetype.server.models.SubscriptionItem +import dev.typetype.server.routes.subscriptionGroupsRoutes +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.SubscriptionGroupsService +import dev.typetype.server.services.SubscriptionGroupWriteResult +import dev.typetype.server.services.SubscriptionsService +import io.ktor.client.request.delete +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.OutgoingContent +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 io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.writeByteArray +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class SubscriptionGroupMembershipRequestLimitsRoutesTest { + private val groups = SubscriptionGroupsService() + private val subscriptions = SubscriptionsService() + private val auth = AuthService.fixed(TEST_USER_ID) + + companion object { + private const val EXPECTED_BODY_LIMIT_BYTES = 1024 * 1024 + private const val EXPECTED_CHANNEL_URL_LIMIT = 2048 + + @BeforeAll + @JvmStatic + fun initDb() = TestDatabase.setup() + } + + @BeforeEach + fun clean() = TestDatabase.truncateAll() + + @Test + fun `membership mutation rejects declared body over one mebibyte`() = withApp { + val group = createGroup() + + val response = client.put("/subscriptions/groups/${group.id}/channels") { + authorizeJson() + setBody("x".repeat(EXPECTED_BODY_LIMIT_BYTES + 1)) + } + + assertEquals(HttpStatusCode.PayloadTooLarge, response.status) + } + + @Test + fun `membership mutation rejects streamed body over one mebibyte`() = withApp { + val group = createGroup() + + val response = client.put("/subscriptions/groups/${group.id}/channels") { + authorize() + setBody(oversizedStreamingBody()) + } + + assertEquals(HttpStatusCode.PayloadTooLarge, response.status) + } + + @Test + fun `membership mutation rejects channel urls over 2048 characters`() = withApp { + val group = createGroup() + val channelUrl = "https://example.com/" + "a".repeat(EXPECTED_CHANNEL_URL_LIMIT) + + val response = client.put("/subscriptions/groups/${group.id}/channels") { + authorizeJson() + setBody("""{"channelUrl":"$channelUrl"}""") + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun `membership deletion rejects query and whitespace body together`() = withApp { + val channelUrl = "https://example.com/channel" + subscriptions.add(TEST_USER_ID, SubscriptionItem(channelUrl, "Channel", "")) + val group = createGroup() + groups.addSubscription(TEST_USER_ID, group.id, channelUrl) + + val response = client.delete("/subscriptions/groups/${group.id}/channels") { + authorize() + parameter("url", channelUrl) + header(HttpHeaders.ContentType, ContentType.Text.Plain.toString()) + setBody(" ") + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertEquals(1, groups.getAll(TEST_USER_ID).single().channelCount) + } + + private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + application { + install(ContentNegotiation) { json() } + routing { subscriptionGroupsRoutes(groups, auth) } + } + block() + } + + private suspend fun createGroup() = requireNotNull( + (groups.create(TEST_USER_ID, "Work") as? SubscriptionGroupWriteResult.Success)?.group, + ) + + private fun oversizedStreamingBody() = object : OutgoingContent.WriteChannelContent() { + override val contentType = ContentType.Application.Json + + override suspend fun writeTo(channel: ByteWriteChannel) { + val chunk = ByteArray(64 * 1024) { 'x'.code.toByte() } + repeat(EXPECTED_BODY_LIMIT_BYTES / chunk.size + 1) { channel.writeByteArray(chunk) } + } + } + + private fun io.ktor.client.request.HttpRequestBuilder.authorize() { + header(HttpHeaders.Authorization, "Bearer test-jwt") + } + + private fun io.ktor.client.request.HttpRequestBuilder.authorizeJson() { + authorize() + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + } +} diff --git a/src/test/kotlin/dev/typetype/server/SubscriptionGroupsRoutesTest.kt b/src/test/kotlin/dev/typetype/server/SubscriptionGroupsRoutesTest.kt index 70dd04b3..8ba7517c 100644 --- a/src/test/kotlin/dev/typetype/server/SubscriptionGroupsRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/SubscriptionGroupsRoutesTest.kt @@ -6,6 +6,7 @@ import dev.typetype.server.routes.subscriptionGroupsRoutes import dev.typetype.server.routes.subscriptionsRoutes import dev.typetype.server.services.AuthService import dev.typetype.server.services.SubscriptionGroupsService +import dev.typetype.server.services.SubscriptionGroupWriteResult import dev.typetype.server.services.SubscriptionsService import io.ktor.client.request.delete import io.ktor.client.request.get @@ -25,6 +26,9 @@ import io.ktor.server.routing.routing import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.server.testing.testApplication import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll @@ -37,6 +41,8 @@ class SubscriptionGroupsRoutesTest { private val auth = AuthService.fixed(TEST_USER_ID) companion object { + private const val FOREIGN_USER_ID = "foreign-user" + @BeforeAll @JvmStatic fun initDb() = TestDatabase.setup() @@ -61,6 +67,11 @@ class SubscriptionGroupsRoutesTest { assertEquals(HttpStatusCode.Unauthorized, client.get("/subscriptions/groups").status) } + @Test + fun `group membership projection requires authentication`() = withApp { + assertEquals(HttpStatusCode.Unauthorized, client.get("/subscriptions/group-memberships").status) + } + @Test fun `groups can be created listed renamed and deleted`() = withApp { val create = client.post("/subscriptions/groups") { @@ -121,6 +132,108 @@ class SubscriptionGroupsRoutesTest { assertTrue(authorizedGet("/subscriptions") { parameter("ungrouped", true) }.bodyAsText().contains(channel("one"))) } + @Test + fun `membership routes add and remove multiple channels atomically`() = withApp { + val first = channel("one") + val second = channel("two") + val neverAdded = channel("three") + listOf(first, second, neverAdded).forEach { url -> + subscriptions.add(TEST_USER_ID, SubscriptionItem(url, url.substringAfterLast('/'), "")) + } + val group = createGroup("Work") + + assertEquals(HttpStatusCode.NoContent, client.put("/subscriptions/groups/${group.id}/channels") { + authorizeJson() + setBody("""{"channelUrls":["$first","$second","$first"]}""") + }.status) + assertEquals(HttpStatusCode.NoContent, client.put("/subscriptions/groups/${group.id}/channels") { + authorizeJson() + setBody("""{"channelUrls":["$first","$second"]}""") + }.status) + val grouped = authorizedGet("/subscriptions") { parameter("groupId", group.id) }.bodyAsText() + assertTrue(grouped.contains(first)) + assertTrue(grouped.contains(second)) + assertTrue(!grouped.contains(neverAdded)) + + assertEquals(HttpStatusCode.NoContent, client.delete("/subscriptions/groups/${group.id}/channels") { + authorizeJson() + setBody("""{"channelUrls":["$first","$second","$neverAdded"]}""") + }.status) + assertEquals("[]", authorizedGet("/subscriptions") { parameter("groupId", group.id) }.bodyAsText()) + } + + @Test + fun `membership deletion rejects query and body together`() = withApp { + val first = channel("one") + val second = channel("two") + listOf(first, second).forEach { url -> + subscriptions.add(TEST_USER_ID, SubscriptionItem(url, url.substringAfterLast('/'), "")) + } + val group = createGroup("Work") + listOf(first, second).forEach { url -> groups.addSubscription(TEST_USER_ID, group.id, url) } + + assertEquals(HttpStatusCode.BadRequest, client.delete("/subscriptions/groups/${group.id}/channels") { + authorizeJson() + parameter("url", first) + setBody("""{"channelUrls":["$second"]}""") + }.status) + val grouped = authorizedGet("/subscriptions") { parameter("groupId", group.id) }.bodyAsText() + assertTrue(grouped.contains(first)) + assertTrue(grouped.contains(second)) + } + + @Test + fun `bulk membership addition changes nothing when a subscription is missing`() = withApp { + val subscribed = channel("subscribed") + subscriptions.add(TEST_USER_ID, SubscriptionItem(subscribed, "Subscribed", "")) + val group = createGroup("Work") + + assertEquals(HttpStatusCode.NotFound, client.put("/subscriptions/groups/${group.id}/channels") { + authorizeJson() + setBody("""{"channelUrls":["$subscribed","${channel("missing")}"]}""") + }.status) + assertEquals("[]", authorizedGet("/subscriptions") { parameter("groupId", group.id) }.bodyAsText()) + } + + @Test + fun `group membership projection returns account scoped memberships with subscription data`() = withApp { + val sharedChannel = channel("shared") + subscriptions.add(TEST_USER_ID, SubscriptionItem(sharedChannel, "Shared", "avatar")) + subscriptions.add(TEST_USER_ID, SubscriptionItem(channel("ungrouped"), "Ungrouped", "")) + subscriptions.add(FOREIGN_USER_ID, SubscriptionItem(sharedChannel, "Foreign shared", "")) + + val ownGroups = listOf(createGroup("Own"), createGroup("Another")) + ownGroups.forEach { group -> + assertEquals(HttpStatusCode.NoContent, client.put("/subscriptions/groups/${group.id}/channels") { + authorizeJson() + setBody("""{"channelUrl":"$sharedChannel"}""") + }.status) + } + val foreignGroup = requireNotNull( + (groups.create(FOREIGN_USER_ID, "Foreign") as? SubscriptionGroupWriteResult.Success)?.group, + ) + groups.addSubscription(FOREIGN_USER_ID, foreignGroup.id, sharedChannel) + + val response = authorizedGet("/subscriptions/group-memberships") + assertEquals(HttpStatusCode.OK, response.status) + val items = Json.parseToJsonElement(response.bodyAsText()).jsonArray.map { it.jsonObject } + assertEquals(2, items.size) + + val shared = items.single { it.getValue("channelUrl").jsonPrimitive.content == sharedChannel } + assertEquals("Shared", shared.getValue("name").jsonPrimitive.content) + assertEquals("avatar", shared.getValue("avatarUrl").jsonPrimitive.content) + assertTrue(shared.getValue("subscribedAt").jsonPrimitive.content.toLong() > 0) + assertEquals( + ownGroups.map { it.id }.sorted(), + shared.getValue("groupIds").jsonArray.map { it.jsonPrimitive.content }, + ) + + val ungrouped = items.single { + it.getValue("channelUrl").jsonPrimitive.content == channel("ungrouped") + } + assertEquals(emptyList(), ungrouped.getValue("groupIds").jsonArray.map { it.jsonPrimitive.content }) + } + @Test fun `invalid or inaccessible filters fail explicitly`() = withApp { assertEquals(HttpStatusCode.BadRequest, authorizedGet("/subscriptions") {