Merge pull request #254 from usbharu/feature/mute

feat: ユーザーのミュートのMastodon互換APIを実装
This commit is contained in:
usbharu 2024-01-26 11:56:49 +09:00 committed by GitHub
commit a60b58daef
7 changed files with 304 additions and 0 deletions

View File

@ -281,6 +281,149 @@ class AccountApiTest {
assertThat(alreadyFollow).isTrue() assertThat(alreadyFollow).isTrue()
} }
@Test
fun `apiV1AccountsIdMutePost write権限でミュートできる`() {
mockMvc
.post("/api/v1/accounts/2/mute") {
contentType = MediaType.APPLICATION_JSON
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1AccountsIdMutePost write_mutes権限でミュートできる`() {
mockMvc
.post("/api/v1/accounts/2/mute") {
contentType = MediaType.APPLICATION_JSON
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:mutes")))
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1AccountsIdMutePost read権限だと403`() = runTest {
mockMvc
.post("/api/v1/accounts/2/mute") {
contentType = MediaType.APPLICATION_JSON
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read")))
}
.andExpect { status { isForbidden() } }
}
@Test
@WithAnonymousUser
fun `apiV1AccountsIdMutePost 匿名だと401`() = runTest {
mockMvc
.post("/api/v1/accounts/2/mute") {
contentType = MediaType.APPLICATION_JSON
with(csrf())
}
.andExpect { status { isUnauthorized() } }
}
@Test
@WithAnonymousUser
fun `apiV1AccountsIdMutePost csrfトークンがないと403`() = runTest {
mockMvc
.post("/api/v1/accounts/2/mute") {
contentType = MediaType.APPLICATION_JSON
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV1AccountsIdUnmutePost write権限でアンミュートできる`() {
mockMvc
.post("/api/v1/accounts/2/unmute") {
contentType = MediaType.APPLICATION_JSON
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1AccountsIdUnmutePost write_mutes権限でアンミュートできる`() {
mockMvc
.post("/api/v1/accounts/2/unmute") {
contentType = MediaType.APPLICATION_JSON
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:mutes")))
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1AccountsIdUnmutePost read権限だと403`() = runTest {
mockMvc
.post("/api/v1/accounts/2/unmute") {
contentType = MediaType.APPLICATION_JSON
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read")))
}
.andExpect { status { isForbidden() } }
}
@Test
@WithAnonymousUser
fun `apiV1AccountsIdUnmutePost 匿名だと401`() = runTest {
mockMvc
.post("/api/v1/accounts/2/unmute") {
contentType = MediaType.APPLICATION_JSON
with(csrf())
}
.andExpect { status { isUnauthorized() } }
}
@Test
@WithAnonymousUser
fun `apiV1AccountsIdUnmutePost csrfトークンがないと403`() = runTest {
mockMvc
.post("/api/v1/accounts/2/unmute") {
contentType = MediaType.APPLICATION_JSON
}
.andExpect { status { isForbidden() } }
}
@Test
fun `apiV1MutesGet read権限でミュートしているアカウント一覧を取得できる`() {
mockMvc
.get("/api/v1/mutes") {
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read")))
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1MutesGet read_mutes権限でミュートしているアカウント一覧を取得できる`() {
mockMvc
.get("/api/v1/mutes") {
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:mutes")))
}
.asyncDispatch()
.andExpect { status { isOk() } }
}
@Test
fun `apiV1MutesGet write権限だと403`() {
mockMvc
.get("/api/v1/mutes") {
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
}
.andExpect { status { isForbidden() } }
}
@Test
@WithAnonymousUser
fun `apiV1MutesGet 匿名だと401`() {
mockMvc
.get("/api/v1/mutes")
.andExpect { status { isUnauthorized() } }
}
companion object { companion object {
@JvmStatic @JvmStatic
@AfterAll @AfterAll

View File

@ -200,6 +200,9 @@ class SecurityConfig {
authorize(POST, "/api/v1/accounts/*/unfollow", hasAnyScope("write", "write:follows")) authorize(POST, "/api/v1/accounts/*/unfollow", hasAnyScope("write", "write:follows"))
authorize(POST, "/api/v1/accounts/*/block", hasAnyScope("write", "write:blocks")) authorize(POST, "/api/v1/accounts/*/block", hasAnyScope("write", "write:blocks"))
authorize(POST, "/api/v1/accounts/*/unblock", hasAnyScope("write", "write:blocks")) authorize(POST, "/api/v1/accounts/*/unblock", hasAnyScope("write", "write:blocks"))
authorize(POST, "/api/v1/accounts/*/mute", hasAnyScope("write", "write:mutes"))
authorize(POST, "/api/v1/accounts/*/unmute", hasAnyScope("write", "write:mutes"))
authorize(GET, "/api/v1/mutes", hasAnyScope("read", "read:mutes"))
authorize(POST, "/api/v1/media", hasAnyScope("write", "write:media")) authorize(POST, "/api/v1/media", hasAnyScope("write", "write:media"))
authorize(POST, "/api/v1/statuses", hasAnyScope("write", "write:statuses")) authorize(POST, "/api/v1/statuses", hasAnyScope("write", "write:statuses"))

View File

@ -42,4 +42,12 @@ interface RelationshipRepository {
followRequest: Boolean, followRequest: Boolean,
ignoreFollowRequest: Boolean ignoreFollowRequest: Boolean
): List<Relationship> ): List<Relationship>
suspend fun findByActorIdAntMutingAndMaxIdAndSinceId(
actorId: Long,
muting: Boolean,
maxId: Long?,
sinceId: Long?,
limit: Int
): List<Relationship>
} }

View File

@ -97,6 +97,28 @@ class RelationshipRepositoryImpl : RelationshipRepository, AbstractRepository()
return@query query.map { it.toRelationships() } return@query query.map { it.toRelationships() }
} }
override suspend fun findByActorIdAntMutingAndMaxIdAndSinceId(
actorId: Long,
muting: Boolean,
maxId: Long?,
sinceId: Long?,
limit: Int
): List<Relationship> = query {
val query = Relationships.select {
Relationships.actorId.eq(actorId).and(Relationships.muting.eq(muting))
}.limit(limit)
if (maxId != null) {
query.andWhere { Relationships.id lessEq maxId }
}
if (sinceId != null) {
query.andWhere { Relationships.id greaterEq sinceId }
}
return@query query.map { it.toRelationships() }
}
companion object { companion object {
private val logger = LoggerFactory.getLogger(RelationshipRepositoryImpl::class.java) private val logger = LoggerFactory.getLogger(RelationshipRepositoryImpl::class.java)
} }

View File

@ -186,4 +186,36 @@ class MastodonAccountApiController(
.asFlow() .asFlow()
ResponseEntity.ok(accountFlow) ResponseEntity.ok(accountFlow)
} }
override suspend fun apiV1AccountsIdMutePost(id: String): ResponseEntity<Relationship> {
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
val userid = principal.getClaim<String>("uid").toLong()
val mute = accountApiService.mute(userid, id.toLong())
return ResponseEntity.ok(mute)
}
override suspend fun apiV1AccountsIdUnmutePost(id: String): ResponseEntity<Relationship> {
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
val userid = principal.getClaim<String>("uid").toLong()
val unmute = accountApiService.unmute(userid, id.toLong())
return ResponseEntity.ok(unmute)
}
override fun apiV1MutesGet(maxId: String?, sinceId: String?, limit: Int?): ResponseEntity<Flow<Account>> =
runBlocking {
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
val userid = principal.getClaim<String>("uid").toLong()
val unmute =
accountApiService.mutesAccount(userid, maxId?.toLong(), sinceId?.toLong(), limit ?: 20).asFlow()
return@runBlocking ResponseEntity.ok(unmute)
}
} }

View File

@ -60,6 +60,9 @@ interface AccountApiService {
suspend fun acceptFollowRequest(loginUser: Long, target: Long): Relationship suspend fun acceptFollowRequest(loginUser: Long, target: Long): Relationship
suspend fun rejectFollowRequest(loginUser: Long, target: Long): Relationship suspend fun rejectFollowRequest(loginUser: Long, target: Long): Relationship
suspend fun mute(userid: Long, target: Long): Relationship
suspend fun unmute(userid: Long, target: Long): Relationship
suspend fun mutesAccount(userid: Long, maxId: Long?, sinceId: Long?, limit: Int): List<Account>
} }
@Service @Service
@ -245,6 +248,25 @@ class AccountApiServiceImpl(
return@transaction fetchRelationship(loginUser, target) return@transaction fetchRelationship(loginUser, target)
} }
override suspend fun mute(userid: Long, target: Long): Relationship = transaction.transaction {
relationshipService.mute(userid, target)
return@transaction fetchRelationship(userid, target)
}
override suspend fun unmute(userid: Long, target: Long): Relationship = transaction.transaction {
relationshipService.mute(userid, target)
return@transaction fetchRelationship(userid, target)
}
override suspend fun mutesAccount(userid: Long, maxId: Long?, sinceId: Long?, limit: Int): List<Account> {
val mutedAccounts =
relationshipRepository.findByActorIdAntMutingAndMaxIdAndSinceId(userid, true, maxId, sinceId, limit)
return accountService.findByIds(mutedAccounts.map { it.targetActorId })
}
private fun from(account: Account): CredentialAccount { private fun from(account: Account): CredentialAccount {
return CredentialAccount( return CredentialAccount(
id = account.id, id = account.id,

View File

@ -464,6 +464,80 @@ paths:
schema: schema:
$ref: "#/components/schemas/Relationship" $ref: "#/components/schemas/Relationship"
/api/v1/accounts/{id}/mute:
post:
tags:
- account
security:
- OAuth2:
- "write:mutes"
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/Relationship"
/api/v1/accounts/{id}/unmute:
post:
tags:
- account
security:
- OAuth2:
- "write:mutes"
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/Relationship"
/api/v1/mutes:
get:
tags:
- account
security:
- OAuth2:
- "read:mutes"
parameters:
- in: query
name: max_id
required: false
schema:
type: string
- in: query
name: since_id
required: false
schema:
type: string
- in: query
name: limit
schema:
type: integer
required: false
responses:
200:
description: 成功
content:
application/json:
schema:
items:
$ref: "#/components/schemas/Account"
/api/v1/accounts/{id}/statuses: /api/v1/accounts/{id}/statuses:
get: get:
tags: tags: