diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt index 8010405d..9c5b46fe 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt @@ -4,9 +4,11 @@ import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments import dev.usbharu.hideout.core.infrastructure.exposedrepository.* import dev.usbharu.hideout.domain.mastodon.model.generated.Account import dev.usbharu.hideout.domain.mastodon.model.generated.Status +import dev.usbharu.hideout.domain.mastodon.model.generated.Status.Visibility.* import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusQuery import dev.usbharu.hideout.mastodon.query.StatusQueryService import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.andWhere import org.jetbrains.exposed.sql.select import org.springframework.stereotype.Repository import java.time.Instant @@ -40,6 +42,62 @@ class StatusQueryServiceImpl : StatusQueryService { } } + override suspend fun accountsStatus( + accountId: Long, + maxId: Long?, + sinceId: Long?, + minId: Long?, + limit: Int, + onlyMedia: Boolean, + excludeReplies: Boolean, + excludeReblogs: Boolean, + pinned: Boolean, + tagged: String?, + includeFollowers: Boolean + ): List { + val query = Posts + .leftJoin(PostsMedia) + .leftJoin(Users) + .leftJoin(Media) + .select { Posts.userId eq accountId }.limit(20) + + if (maxId != null) { + query.andWhere { Posts.id eq maxId } + } + if (sinceId != null) { + query.andWhere { Posts.id eq sinceId } + } + if (minId != null) { + query.andWhere { Posts.id eq minId } + } + if (onlyMedia) { + query.andWhere { PostsMedia.mediaId.isNotNull() } + } + if (excludeReplies) { + query.andWhere { Posts.replyId.isNotNull() } + } + if (excludeReblogs) { + query.andWhere { Posts.repostId.isNotNull() } + } + if (includeFollowers) { + query.andWhere { Posts.visibility inList listOf(public.ordinal, unlisted.ordinal, private.ordinal) } + } else { + query.andWhere { Posts.visibility inList listOf(public.ordinal, unlisted.ordinal) } + } + + val pairs = query.groupBy { it[Posts.id] } + .map { it.value } + .map { + toStatus(it.first()).copy( + mediaAttachments = it.mapNotNull { resultRow -> + resultRow.toMediaOrNull()?.toMediaAttachments() + } + ) to it.first()[Posts.repostId] + } + + return resolveReplyAndRepost(pairs) + } + private fun resolveReplyAndRepost(pairs: List>): List { val statuses = pairs.map { it.first } return pairs @@ -111,11 +169,11 @@ private fun toStatus(it: ResultRow) = Status( ), content = it[Posts.text], visibility = when (it[Posts.visibility]) { - 0 -> Status.Visibility.public - 1 -> Status.Visibility.unlisted - 2 -> Status.Visibility.private - 3 -> Status.Visibility.direct - else -> Status.Visibility.public + 0 -> public + 1 -> unlisted + 2 -> private + 3 -> direct + else -> public }, sensitive = it[Posts.sensitive], spoilerText = it[Posts.overview].orEmpty(), diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiController.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiController.kt index f3c05765..a7f3741c 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiController.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/account/MastodonAccountApiController.kt @@ -3,11 +3,11 @@ package dev.usbharu.hideout.mastodon.interfaces.api.account import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.controller.mastodon.generated.AccountApi import dev.usbharu.hideout.core.service.user.UserCreateDto -import dev.usbharu.hideout.domain.mastodon.model.generated.Account -import dev.usbharu.hideout.domain.mastodon.model.generated.CredentialAccount -import dev.usbharu.hideout.domain.mastodon.model.generated.FollowRequestBody -import dev.usbharu.hideout.domain.mastodon.model.generated.Relationship +import dev.usbharu.hideout.domain.mastodon.model.generated.* import dev.usbharu.hideout.mastodon.service.account.AccountApiService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.runBlocking import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -58,4 +58,49 @@ class MastodonAccountApiController( httpHeaders.location = URI("/users/$username") return ResponseEntity(Unit, httpHeaders, HttpStatus.FOUND) } + + override fun apiV1AccountsIdStatusesGet( + id: String, + maxId: String?, + sinceId: String?, + minId: String?, + limit: Int, + onlyMedia: Boolean, + excludeReplies: Boolean, + excludeReblogs: Boolean, + pinned: Boolean, + tagged: String? + ): ResponseEntity> = runBlocking { + val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt + + val userid = principal.getClaim("uid").toLong() + val statusFlow = accountApiService.accountsStatuses( + id.toLong(), + maxId?.toLongOrNull(), + sinceId?.toLongOrNull(), + minId?.toLongOrNull(), + limit, + onlyMedia, + excludeReplies, + excludeReblogs, + pinned, + tagged, + userid + ).asFlow() + ResponseEntity.ok(statusFlow) + } + + override fun apiV1AccountsRelationshipsGet( + id: List?, + withSuspended: Boolean + ): ResponseEntity> = runBlocking { + val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt + + val userid = principal.getClaim("uid").toLong() + + ResponseEntity.ok( + accountApiService.relationships(userid, id.orEmpty().mapNotNull { it.toLongOrNull() }, withSuspended) + .asFlow() + ) + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt index a5777360..2b4e2a31 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt @@ -6,4 +6,34 @@ import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusQuery interface StatusQueryService { suspend fun findByPostIds(ids: List): List suspend fun findByPostIdsWithMediaIds(statusQueries: List): List + + /** + * アカウントの投稿一覧を取得します + * + * @param accountId 対象アカウントのid + * @param maxId 投稿の最大id + * @param sinceId 投稿の最小id + * @param minId 不明 + * @param limit 投稿の最大件数 + * @param onlyMedia メディア付き投稿のみ + * @param excludeReplies 返信を除外 + * @param excludeReblogs リブログを除外 + * @param pinned ピン止め投稿のみ + * @param tagged タグ付き? + * @param includeFollowers フォロワー限定投稿を含める + */ + @Suppress("LongParameterList") + suspend fun accountsStatus( + accountId: Long, + maxId: Long? = null, + sinceId: Long? = null, + minId: Long? = null, + limit: Int, + onlyMedia: Boolean = false, + excludeReplies: Boolean = false, + excludeReblogs: Boolean = false, + pinned: Boolean = false, + tagged: String? = null, + includeFollowers: Boolean = false + ): List } diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountApiService.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountApiService.kt index b2e2792a..7a3a6820 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountApiService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/account/AccountApiService.kt @@ -6,14 +6,31 @@ import dev.usbharu.hideout.core.query.FollowerQueryService import dev.usbharu.hideout.core.service.user.UserCreateDto import dev.usbharu.hideout.core.service.user.UserService import dev.usbharu.hideout.domain.mastodon.model.generated.* +import dev.usbharu.hideout.mastodon.query.StatusQueryService +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @Service interface AccountApiService { + suspend fun accountsStatuses( + userid: Long, + maxId: Long?, + sinceId: Long?, + minId: Long?, + limit: Int, + onlyMedia: Boolean, + excludeReplies: Boolean, + excludeReblogs: Boolean, + pinned: Boolean, + tagged: String?, + loginUser: Long? + ): List + suspend fun verifyCredentials(userid: Long): CredentialAccount suspend fun registerAccount(userCreateDto: UserCreateDto): Unit suspend fun follow(userid: Long, followeeId: Long): Relationship suspend fun account(id: Long): Account + suspend fun relationships(userid: Long, id: List, withSuspended: Boolean): List } @Service @@ -22,9 +39,48 @@ class AccountApiServiceImpl( private val transaction: Transaction, private val userService: UserService, private val followerQueryService: FollowerQueryService, - private val userRepository: UserRepository + private val userRepository: UserRepository, + private val statusQueryService: StatusQueryService ) : AccountApiService { + override suspend fun accountsStatuses( + userid: Long, + maxId: Long?, + sinceId: Long?, + minId: Long?, + limit: Int, + onlyMedia: Boolean, + excludeReplies: Boolean, + excludeReblogs: Boolean, + pinned: Boolean, + tagged: String?, + loginUser: Long? + ): List { + val canViewFollowers = if (loginUser == null) { + false + } else { + transaction.transaction { + followerQueryService.alreadyFollow(userid, loginUser) + } + } + + return transaction.transaction { + statusQueryService.accountsStatus( + userid, + maxId, + sinceId, + minId, + limit, + onlyMedia, + excludeReplies, + excludeReblogs, + pinned, + tagged, + canViewFollowers + ) + } + } + override suspend fun verifyCredentials(userid: Long): CredentialAccount = transaction.transaction { val account = accountService.findById(userid) from(account) @@ -70,6 +126,42 @@ class AccountApiServiceImpl( return@transaction accountService.findById(id) } + override suspend fun relationships(userid: Long, id: List, withSuspended: Boolean): List { + if (id.isEmpty()) { + return emptyList() + } + + + logger.warn("id is too long! ({}) truncate to 20", id.size) + + val subList = id.subList(0, 20) + + return subList.map { + + val alreadyFollow = followerQueryService.alreadyFollow(userid, it) + + val followed = followerQueryService.alreadyFollow(it, userid) + + val requested = userRepository.findFollowRequestsById(it, userid) + + Relationship( + id = it.toString(), + following = alreadyFollow, + showingReblogs = true, + notifying = false, + followedBy = followed, + blocking = false, + blockedBy = false, + muting = false, + mutingNotifications = false, + requested = requested, + domainBlocking = false, + endorsed = false, + note = "" + ) + } + } + private fun from(account: Account): CredentialAccount { return CredentialAccount( id = account.id, @@ -107,4 +199,8 @@ class AccountApiServiceImpl( role = Role(0, "Admin", "", 32) ) } + + companion object { + private val logger = LoggerFactory.getLogger(AccountApiServiceImpl::class.java) + } } diff --git a/src/main/resources/openapi/mastodon.yaml b/src/main/resources/openapi/mastodon.yaml index 03fa7a69..5d39ad40 100644 --- a/src/main/resources/openapi/mastodon.yaml +++ b/src/main/resources/openapi/mastodon.yaml @@ -206,6 +206,37 @@ paths: 200: description: 成功 + /api/v1/accounts/relationships: + get: + tags: + - account + security: + - OAuth2: + - "read:follows" + parameters: + - in: query + name: id + required: false + schema: + type: array + items: + type: string + - in: query + name: with_suspended + required: false + schema: + type: boolean + default: false + responses: + 200: + description: 成功 + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Relationship" + /api/v1/accounts/{id}: get: tags: @@ -255,6 +286,81 @@ paths: application/json: schema: $ref: "#/components/schemas/Relationship" + + /api/v1/accounts/{id}/statuses: + get: + tags: + - account + security: + - OAuth2: + - "read:statuses" + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: query + name: max_id + required: false + schema: + type: string + - in: query + name: since_id + required: false + schema: + type: string + - in: query + name: min_id + required: false + schema: + type: string + - in: query + name: limit + required: false + schema: + type: integer + default: 20 + - in: query + name: only_media + required: false + schema: + type: boolean + default: false + - in: query + name: exclude_replies + required: false + schema: + type: boolean + default: false + - in: query + name: exclude_reblogs + required: false + schema: + type: boolean + default: false + - in: query + name: pinned + required: false + schema: + type: boolean + default: false + - in: query + required: false + name: tagged + schema: + type: string + + responses: + 200: + description: 成功 + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Status" + /api/v1/timelines/public: get: tags: