mirror of https://github.com/usbharu/Hideout.git
Merge pull request #197 from usbharu/feature/mastodon-follow-api
Feature/mastodon follow api
This commit is contained in:
commit
d05e8cf68b
|
@ -1,8 +1,10 @@
|
|||
package mastodon.account
|
||||
|
||||
import dev.usbharu.hideout.SpringApplication
|
||||
import dev.usbharu.hideout.core.infrastructure.exposedquery.FollowerQueryServiceImpl
|
||||
import dev.usbharu.hideout.core.infrastructure.exposedquery.UserQueryServiceImpl
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
|
@ -14,6 +16,7 @@ import org.springframework.http.MediaType
|
|||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.test.context.support.WithAnonymousUser
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
|
||||
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
|
||||
import org.springframework.test.context.jdbc.Sql
|
||||
|
@ -29,11 +32,16 @@ import org.springframework.web.context.WebApplicationContext
|
|||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
@Sql("/sql/test-user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
|
||||
@Sql("/sql/test-user2.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
|
||||
class AccountApiTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var followerQueryServiceImpl: FollowerQueryServiceImpl
|
||||
|
||||
@Autowired
|
||||
private lateinit var userQueryServiceImpl: UserQueryServiceImpl
|
||||
|
||||
|
||||
@Autowired
|
||||
private lateinit var context: WebApplicationContext
|
||||
|
||||
|
@ -92,7 +100,7 @@ class AccountApiTest {
|
|||
.asyncDispatch()
|
||||
.andExpect { status { isFound() } }
|
||||
|
||||
userQueryServiceImpl.findByNameAndDomain("api-test-user-1", "localhost")
|
||||
userQueryServiceImpl.findByNameAndDomain("api-test-user-1", "example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -108,7 +116,7 @@ class AccountApiTest {
|
|||
.asyncDispatch()
|
||||
.andExpect { status { isFound() } }
|
||||
|
||||
userQueryServiceImpl.findByNameAndDomain("api-test-user-2", "localhost")
|
||||
userQueryServiceImpl.findByNameAndDomain("api-test-user-2", "example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -159,6 +167,120 @@ class AccountApiTest {
|
|||
.andExpect { status { isForbidden() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithAnonymousUser
|
||||
fun `apiV1AccountsIdGet 匿名でアカウント情報を取得できる`() {
|
||||
mockMvc
|
||||
.get("/api/v1/accounts/1")
|
||||
.asyncDispatch()
|
||||
.andExpect { status { isOk() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apiV1AccountsIdFollowPost write_follows権限でPOSTでフォローできる`() {
|
||||
mockMvc
|
||||
.post("/api/v1/accounts/2/follow") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:follows")))
|
||||
}
|
||||
.asyncDispatch()
|
||||
.andExpect { status { isOk() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apiV1AccountsIdFollowPost write権限でPOSTでフォローできる`() {
|
||||
mockMvc
|
||||
.post("/api/v1/accounts/2/follow") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
|
||||
}
|
||||
.asyncDispatch()
|
||||
.andExpect { status { isOk() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apiV1AccountsIdFollowPost read権限でだと403`() {
|
||||
mockMvc
|
||||
.post("/api/v1/accounts/2/follow") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read")))
|
||||
}
|
||||
.andExpect { status { isForbidden() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithAnonymousUser
|
||||
fun `apiV1AAccountsIdFollowPost 匿名だと401`() {
|
||||
mockMvc
|
||||
.post("/api/v1/accounts/2/follow") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
with(csrf())
|
||||
}
|
||||
.andExpect { status { isUnauthorized() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithAnonymousUser
|
||||
fun `apiV1AAccountsIdFollowPost 匿名の場合通常csrfトークンは持ってないので403`() {
|
||||
mockMvc
|
||||
.post("/api/v1/accounts/2/follow") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
}
|
||||
.andExpect { status { isForbidden() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apiV1AccountsRelationshipsGet 匿名だと401`() {
|
||||
mockMvc
|
||||
.get("/api/v1/accounts/relationships")
|
||||
.andExpect { status { isUnauthorized() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apiV1AccountsRelationshipsGet read_follows権限を持っていたら取得できる`() {
|
||||
mockMvc
|
||||
.get("/api/v1/accounts/relationships") {
|
||||
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:follows")))
|
||||
}
|
||||
.asyncDispatch()
|
||||
.andExpect { status { isOk() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apiV1AccountsRelationshipsGet read権限を持っていたら取得できる`() {
|
||||
mockMvc
|
||||
.get("/api/v1/accounts/relationships") {
|
||||
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read")))
|
||||
}
|
||||
.asyncDispatch()
|
||||
.andExpect { status { isOk() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apiV1AccountsRelationshipsGet write権限だと403`() {
|
||||
mockMvc
|
||||
.get("/api/v1/accounts/relationships") {
|
||||
with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
|
||||
}
|
||||
.andExpect { status { isForbidden() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql("/sql/accounts/apiV1AccountsIdFollowPost フォローできる.sql")
|
||||
fun `apiV1AccountsIdFollowPost フォローできる`() = runTest {
|
||||
mockMvc
|
||||
.post("/api/v1/accounts/3733363/follow") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
with(jwt().jwt { it.claim("uid", "37335363") }.authorities(SimpleGrantedAuthority("SCOPE_write")))
|
||||
}
|
||||
.asyncDispatch()
|
||||
.andExpect { status { isOk() } }
|
||||
|
||||
val alreadyFollow = followerQueryServiceImpl.alreadyFollow(3733363, 37335363)
|
||||
|
||||
assertThat(alreadyFollow).isTrue()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@AfterAll
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
hideout:
|
||||
url: "https://localhost:8080"
|
||||
url: "https://example.com"
|
||||
use-mongodb: true
|
||||
security:
|
||||
jwt:
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
insert into "USERS" (id, name, domain, screen_name, description, password, inbox, outbox, url, public_key, private_key,
|
||||
created_at, key_id, following, followers, instance)
|
||||
VALUES (3733363, 'follow-test-user-1', 'example.com', 'follow-test-user-1-name', '',
|
||||
'5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8',
|
||||
'https://example.com/users/follow-test-user-1/inbox',
|
||||
'https://example.com/users/follow-test-user-1/outbox', 'https://example.com/users/follow-test-user-1',
|
||||
'-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----',
|
||||
'-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----', 12345678,
|
||||
'https://example.com/users/follow-test-user-1#pubkey', 'https://example.com/users/follow-test-user-1/following',
|
||||
'https://example.com/users/follow-test-user-1/followers', null),
|
||||
(37335363, 'follow-test-user-2', 'example.com', 'follow-test-user-2-name', '',
|
||||
'5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8',
|
||||
'https://example.com/users/follow-test-user-2/inbox',
|
||||
'https://example.com/users/follow-test-user-2/outbox', 'https://example.com/users/follow-test-user-2',
|
||||
'-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----',
|
||||
'-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----', 12345678,
|
||||
'https://example.com/users/follow-test-user-2#pubkey', 'https://example.com/users/follow-test-user-2/following',
|
||||
'https://example.com/users/follow-test-user-2/followers', null);
|
|
@ -0,0 +1,10 @@
|
|||
insert into "USERS" (ID, NAME, DOMAIN, SCREEN_NAME, DESCRIPTION, PASSWORD, INBOX, OUTBOX, URL, PUBLIC_KEY, PRIVATE_KEY,
|
||||
CREATED_AT, KEY_ID, FOLLOWING, FOLLOWERS, INSTANCE)
|
||||
VALUES (2, 'test-user2', 'example.com', 'Im test user.', 'THis account is test user.',
|
||||
'5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8',
|
||||
'https://example.com/users/test-user2/inbox',
|
||||
'https://example.com/users/test-user2/outbox', 'https://example.com/users/test-user2',
|
||||
'-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----',
|
||||
'-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----', 12345678,
|
||||
'https://example.com/users/test-user2#pubkey', 'https://example.com/users/test-user2/following',
|
||||
'https://example.com/users/test-user2s/followers', null);
|
|
@ -1,9 +1,12 @@
|
|||
package dev.usbharu.hideout.activitypub.domain.model
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonSetter
|
||||
import com.fasterxml.jackson.annotation.Nulls
|
||||
import dev.usbharu.hideout.activitypub.domain.model.objects.Object
|
||||
|
||||
open class Document(
|
||||
type: List<String> = emptyList(),
|
||||
@JsonSetter(nulls = Nulls.AS_EMPTY)
|
||||
override val name: String = "",
|
||||
val mediaType: String,
|
||||
val url: String
|
||||
|
|
|
@ -4,12 +4,11 @@ import dev.usbharu.hideout.activitypub.domain.model.objects.Object
|
|||
|
||||
open class Image(
|
||||
type: List<String> = emptyList(),
|
||||
val mediaType: String,
|
||||
val mediaType: String? = null,
|
||||
val url: String
|
||||
) : Object(
|
||||
add(type, "Image")
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
@ -25,10 +24,16 @@ open class Image(
|
|||
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result = 31 * result + mediaType.hashCode()
|
||||
result = 31 * result + (mediaType?.hashCode() ?: 0)
|
||||
result = 31 * result + url.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String = "Image(mediaType=$mediaType, url=$url) ${super.toString()}"
|
||||
override fun toString(): String {
|
||||
return "Image(" +
|
||||
"mediaType=$mediaType, " +
|
||||
"url='$url'" +
|
||||
")" +
|
||||
" ${super.toString()}"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -84,7 +84,7 @@ class APRequestServiceImpl(
|
|||
headers {
|
||||
appendAll(headers)
|
||||
append("Signature", sign.signatureHeader)
|
||||
remove("Host")
|
||||
// remove("Host")
|
||||
}
|
||||
}
|
||||
contentType(Activity)
|
||||
|
@ -173,7 +173,7 @@ class APRequestServiceImpl(
|
|||
append("Accept", Activity)
|
||||
append("Date", date)
|
||||
append("Host", u.host)
|
||||
append("Digest", "sha-256=$digest")
|
||||
append("Digest", "SHA-256=$digest")
|
||||
}
|
||||
|
||||
val sign = httpSignatureSigner.sign(
|
||||
|
@ -193,7 +193,7 @@ class APRequestServiceImpl(
|
|||
headers {
|
||||
appendAll(headers)
|
||||
append("Signature", sign.signatureHeader)
|
||||
remove("Host")
|
||||
// remove("Host")
|
||||
}
|
||||
setBody(requestBody)
|
||||
contentType(Activity)
|
||||
|
|
|
@ -99,22 +99,20 @@ class InboxJobProcessor(
|
|||
|
||||
val verify = signature?.let { verifyHttpSignature(httpRequest, it, transaction) } ?: false
|
||||
|
||||
transaction.transaction {
|
||||
logger.debug("Is verifying success? {}", verify)
|
||||
logger.debug("Is verifying success? {}", verify)
|
||||
|
||||
val activityPubProcessor =
|
||||
activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as ActivityPubProcessor<Object>?
|
||||
val activityPubProcessor =
|
||||
activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as ActivityPubProcessor<Object>?
|
||||
|
||||
if (activityPubProcessor == null) {
|
||||
logger.warn("ActivityType {} is not support.", param.type)
|
||||
throw IllegalStateException("ActivityPubProcessor not found.")
|
||||
}
|
||||
|
||||
val value = objectMapper.treeToValue(jsonNode, activityPubProcessor.type())
|
||||
activityPubProcessor.process(ActivityPubProcessContext(value, jsonNode, httpRequest, signature, verify))
|
||||
|
||||
logger.info("SUCCESS Process inbox. type: {}", param.type)
|
||||
if (activityPubProcessor == null) {
|
||||
logger.warn("ActivityType {} is not support.", param.type)
|
||||
throw IllegalStateException("ActivityPubProcessor not found.")
|
||||
}
|
||||
|
||||
val value = objectMapper.treeToValue(jsonNode, activityPubProcessor.type())
|
||||
activityPubProcessor.process(ActivityPubProcessContext(value, jsonNode, httpRequest, signature, verify))
|
||||
|
||||
logger.info("SUCCESS Process inbox. type: {}", param.type)
|
||||
}
|
||||
|
||||
override fun job(): InboxJob = InboxJob
|
||||
|
|
|
@ -58,8 +58,8 @@ class APUserServiceImpl(
|
|||
url = userUrl,
|
||||
icon = Image(
|
||||
type = emptyList(),
|
||||
mediaType = "image/png",
|
||||
url = "$userUrl/icon.png"
|
||||
mediaType = "image/jpeg",
|
||||
url = "$userUrl/icon.jpg"
|
||||
),
|
||||
publicKey = Key(
|
||||
id = userEntity.keyId,
|
||||
|
@ -124,8 +124,8 @@ class APUserServiceImpl(
|
|||
url = id,
|
||||
icon = Image(
|
||||
type = emptyList(),
|
||||
mediaType = "image/png",
|
||||
url = "$id/icon.png"
|
||||
mediaType = "image/jpeg",
|
||||
url = "$id/icon.jpg"
|
||||
),
|
||||
publicKey = Key(
|
||||
id = userEntity.keyId,
|
||||
|
|
|
@ -180,17 +180,23 @@ class SecurityConfig {
|
|||
|
||||
authorize(POST, "/inbox", permitAll)
|
||||
authorize(POST, "/users/*/inbox", permitAll)
|
||||
authorize(GET, "/users/*", permitAll)
|
||||
authorize(GET, "/users/*/posts/*", permitAll)
|
||||
|
||||
authorize(POST, "/api/v1/apps", permitAll)
|
||||
authorize(GET, "/api/v1/instance/**", permitAll)
|
||||
authorize(POST, "/api/v1/accounts", permitAll)
|
||||
|
||||
authorize("/auth/sign_up", hasRole("ANONYMOUS"))
|
||||
authorize(GET, "/files", permitAll)
|
||||
authorize(GET, "/files/*", permitAll)
|
||||
authorize(GET, "/users/*/icon.jpg", permitAll)
|
||||
authorize(GET, "/users/*/header.jpg", permitAll)
|
||||
|
||||
authorize(GET, "/api/v1/accounts/verify_credentials", hasAnyScope("read", "read:accounts"))
|
||||
authorize(GET, "/api/v1/accounts/relationships", hasAnyScope("read", "read:follows"))
|
||||
authorize(GET, "/api/v1/accounts/*", permitAll)
|
||||
authorize(GET, "/api/v1/accounts/*/statuses", permitAll)
|
||||
authorize(POST, "/api/v1/accounts/*/follow", hasAnyScope("write", "write:follows"))
|
||||
|
||||
authorize(POST, "/api/v1/media", hasAnyScope("write", "write:media"))
|
||||
authorize(POST, "/api/v1/statuses", hasAnyScope("write", "write:statuses"))
|
||||
|
|
|
@ -2,19 +2,24 @@ package dev.usbharu.hideout.application.infrastructure.exposed
|
|||
|
||||
import dev.usbharu.hideout.application.external.Transaction
|
||||
import kotlinx.coroutines.slf4j.MDCContext
|
||||
import org.jetbrains.exposed.sql.StdOutSqlLogger
|
||||
import org.jetbrains.exposed.sql.addLogger
|
||||
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
|
||||
import org.springframework.stereotype.Service
|
||||
import java.sql.Connection
|
||||
|
||||
@Service
|
||||
class ExposedTransaction : Transaction {
|
||||
override suspend fun <T> transaction(block: suspend () -> T): T {
|
||||
return newSuspendedTransaction(MDCContext()) {
|
||||
return newSuspendedTransaction(MDCContext(), transactionIsolation = Connection.TRANSACTION_SERIALIZABLE) {
|
||||
addLogger(StdOutSqlLogger)
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <T> transaction(transactionLevel: Int, block: suspend () -> T): T {
|
||||
return newSuspendedTransaction(MDCContext(), transactionIsolation = transactionLevel) {
|
||||
addLogger(StdOutSqlLogger)
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import kjob.core.dsl.JobRegisterContext
|
|||
import kjob.core.dsl.KJobFunctions
|
||||
import kjob.core.kjob
|
||||
import org.jetbrains.exposed.sql.transactions.TransactionManager
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
|
@ -35,8 +36,13 @@ class KJobJobQueueWorkerService(private val jobQueueProcessorList: List<JobProce
|
|||
for (jobProcessor in jobQueueProcessorList) {
|
||||
kjob.register(jobProcessor.job()) {
|
||||
execute {
|
||||
val param = it.convertUnsafe(props)
|
||||
jobProcessor.process(param)
|
||||
try {
|
||||
MDC.put("x-job-id", this.jobId)
|
||||
val param = it.convertUnsafe(props)
|
||||
jobProcessor.process(param)
|
||||
} finally {
|
||||
MDC.remove("x-job-id")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,9 +8,7 @@ import jakarta.servlet.http.HttpServletRequest
|
|||
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter
|
||||
import java.net.URL
|
||||
|
||||
class HttpSignatureFilter(
|
||||
private val httpSignatureHeaderParser: SignatureHeaderParser
|
||||
) :
|
||||
class HttpSignatureFilter(private val httpSignatureHeaderParser: SignatureHeaderParser) :
|
||||
AbstractPreAuthenticatedProcessingFilter() {
|
||||
override fun getPreAuthenticatedPrincipal(request: HttpServletRequest?): Any? {
|
||||
val headersList = request?.headerNames?.toList().orEmpty()
|
||||
|
|
|
@ -114,10 +114,13 @@ class UserServiceImpl(
|
|||
}
|
||||
|
||||
override suspend fun follow(id: Long, followerId: Long) {
|
||||
logger.debug("START Follow id: {} → target: {}", followerId, id)
|
||||
followerQueryService.appendFollower(id, followerId)
|
||||
if (userRepository.findFollowRequestsById(id, followerId)) {
|
||||
logger.debug("Follow request is accepted! ")
|
||||
userRepository.deleteFollowRequest(id, followerId)
|
||||
}
|
||||
logger.debug("SUCCESS Follow id: {} → target: {}", followerId, id)
|
||||
}
|
||||
|
||||
override suspend fun unfollow(id: Long, followerId: Long): Boolean {
|
||||
|
|
|
@ -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<Status> {
|
||||
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<Pair<Status, Long?>>): List<Status> {
|
||||
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(),
|
||||
|
|
|
@ -3,8 +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.CredentialAccount
|
||||
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
|
||||
|
@ -18,6 +21,19 @@ class MastodonAccountApiController(
|
|||
private val accountApiService: AccountApiService,
|
||||
private val transaction: Transaction
|
||||
) : AccountApi {
|
||||
|
||||
override suspend fun apiV1AccountsIdFollowPost(
|
||||
id: String,
|
||||
followRequestBody: FollowRequestBody?
|
||||
): ResponseEntity<Relationship> {
|
||||
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
|
||||
|
||||
return ResponseEntity.ok(accountApiService.follow(principal.getClaim<String>("uid").toLong(), id.toLong()))
|
||||
}
|
||||
|
||||
override suspend fun apiV1AccountsIdGet(id: String): ResponseEntity<Account> =
|
||||
ResponseEntity.ok(accountApiService.account(id.toLong()))
|
||||
|
||||
override suspend fun apiV1AccountsVerifyCredentialsGet(): ResponseEntity<CredentialAccount> {
|
||||
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
|
||||
|
||||
|
@ -42,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<Flow<Status>> = runBlocking {
|
||||
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
|
||||
|
||||
val userid = principal.getClaim<String>("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<String>?,
|
||||
withSuspended: Boolean
|
||||
): ResponseEntity<Flow<Relationship>> = runBlocking {
|
||||
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
|
||||
|
||||
val userid = principal.getClaim<String>("uid").toLong()
|
||||
|
||||
ResponseEntity.ok(
|
||||
accountApiService.relationships(userid, id.orEmpty().mapNotNull { it.toLongOrNull() }, withSuspended)
|
||||
.asFlow()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,4 +6,34 @@ import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusQuery
|
|||
interface StatusQueryService {
|
||||
suspend fun findByPostIds(ids: List<Long>): List<Status>
|
||||
suspend fun findByPostIdsWithMediaIds(statusQueries: List<StatusQuery>): List<Status>
|
||||
|
||||
/**
|
||||
* アカウントの投稿一覧を取得します
|
||||
*
|
||||
* @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<Status>
|
||||
}
|
||||
|
|
|
@ -1,27 +1,87 @@
|
|||
package dev.usbharu.hideout.mastodon.service.account
|
||||
|
||||
import dev.usbharu.hideout.application.external.Transaction
|
||||
import dev.usbharu.hideout.core.domain.model.user.UserRepository
|
||||
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.Account
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.CredentialAccount
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.CredentialAccountSource
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.Role
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.*
|
||||
import dev.usbharu.hideout.mastodon.query.StatusQueryService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import kotlin.math.min
|
||||
|
||||
@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<Status>
|
||||
|
||||
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<Long>, withSuspended: Boolean): List<Relationship>
|
||||
}
|
||||
|
||||
@Service
|
||||
class AccountApiServiceImpl(
|
||||
private val accountService: AccountService,
|
||||
private val transaction: Transaction,
|
||||
private val userService: UserService
|
||||
private val userService: UserService,
|
||||
private val followerQueryService: FollowerQueryService,
|
||||
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<Status> {
|
||||
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)
|
||||
|
@ -31,6 +91,75 @@ class AccountApiServiceImpl(
|
|||
userService.createLocalUser(UserCreateDto(userCreateDto.name, userCreateDto.name, "", userCreateDto.password))
|
||||
}
|
||||
|
||||
override suspend fun follow(userid: Long, followeeId: Long): Relationship = transaction.transaction {
|
||||
val alreadyFollow = followerQueryService.alreadyFollow(followeeId, userid)
|
||||
|
||||
val followRequest = if (alreadyFollow) {
|
||||
true
|
||||
} else {
|
||||
userService.followRequest(followeeId, userid)
|
||||
}
|
||||
|
||||
val alreadyFollow1 = followerQueryService.alreadyFollow(userid, followeeId)
|
||||
|
||||
val followRequestsById = userRepository.findFollowRequestsById(followeeId, userid)
|
||||
|
||||
return@transaction Relationship(
|
||||
followeeId.toString(),
|
||||
followRequest,
|
||||
true,
|
||||
false,
|
||||
alreadyFollow1,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
followRequestsById,
|
||||
false,
|
||||
false,
|
||||
""
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun account(id: Long): Account = transaction.transaction {
|
||||
return@transaction accountService.findById(id)
|
||||
}
|
||||
|
||||
override suspend fun relationships(userid: Long, id: List<Long>, withSuspended: Boolean): List<Relationship> =
|
||||
transaction.transaction {
|
||||
if (id.isEmpty()) {
|
||||
return@transaction emptyList()
|
||||
}
|
||||
|
||||
logger.warn("id is too long! ({}) truncate to 20", id.size)
|
||||
|
||||
val subList = id.subList(0, min(id.size, 20))
|
||||
|
||||
return@transaction 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,
|
||||
|
@ -68,4 +197,8 @@ class AccountApiServiceImpl(
|
|||
role = Role(0, "Admin", "", 32)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val logger = LoggerFactory.getLogger(AccountApiServiceImpl::class.java)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package dev.usbharu.hideout.mastodon.service.account
|
||||
|
||||
import dev.usbharu.hideout.application.config.ApplicationConfig
|
||||
import dev.usbharu.hideout.core.query.UserQueryService
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.Account
|
||||
import org.springframework.stereotype.Service
|
||||
|
@ -10,9 +11,14 @@ interface AccountService {
|
|||
}
|
||||
|
||||
@Service
|
||||
class AccountServiceImpl(private val userQueryService: UserQueryService) : AccountService {
|
||||
class AccountServiceImpl(
|
||||
private val userQueryService: UserQueryService,
|
||||
private val applicationConfig: ApplicationConfig
|
||||
) : AccountService {
|
||||
override suspend fun findById(id: Long): Account {
|
||||
val findById = userQueryService.findById(id)
|
||||
val userUrl = applicationConfig.url.toString() + "/users/" + findById.id.toString()
|
||||
|
||||
return Account(
|
||||
id = findById.id.toString(),
|
||||
username = findById.name,
|
||||
|
@ -20,10 +26,10 @@ class AccountServiceImpl(private val userQueryService: UserQueryService) : Accou
|
|||
url = findById.url,
|
||||
displayName = findById.screenName,
|
||||
note = findById.description,
|
||||
avatar = findById.url + "/icon.jpg",
|
||||
avatarStatic = findById.url + "/icon.jpg",
|
||||
header = findById.url + "/header.jpg",
|
||||
headerStatic = findById.url + "/header.jpg",
|
||||
avatar = "$userUrl/icon.jpg",
|
||||
avatarStatic = "$userUrl/icon.jpg",
|
||||
header = "$userUrl/header.jpg",
|
||||
headerStatic = "$userUrl/header.jpg",
|
||||
locked = false,
|
||||
fields = emptyList(),
|
||||
emojis = emptyList(),
|
||||
|
|
|
@ -13,7 +13,6 @@ import org.springframework.security.oauth2.server.authorization.settings.ClientS
|
|||
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
@Service
|
||||
|
@ -46,7 +45,7 @@ class AppApiServiceImpl(
|
|||
.tokenSettings(
|
||||
TokenSettings.builder()
|
||||
.accessTokenTimeToLive(
|
||||
Duration.ofSeconds((Instant.MAX.epochSecond - Instant.now().epochSecond - 10000) / 1000)
|
||||
Duration.ofSeconds(31536000000)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
@ -60,7 +59,8 @@ class AppApiServiceImpl(
|
|||
"invalid-vapid-key",
|
||||
appsRequest.website,
|
||||
id,
|
||||
clientSecret
|
||||
clientSecret,
|
||||
appsRequest.redirectUris
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ spring:
|
|||
max-request-size: 40MB
|
||||
h2:
|
||||
console:
|
||||
enabled: false
|
||||
enabled: true
|
||||
server:
|
||||
tomcat:
|
||||
basedir: tomcat
|
||||
|
|
|
@ -31,7 +31,7 @@ create table if not exists users
|
|||
"following" varchar(1000) null,
|
||||
followers varchar(1000) null,
|
||||
"instance" bigint null,
|
||||
unique (name, domain),
|
||||
unique ("name", "domain"),
|
||||
constraint fk_users_instance__id foreign key ("instance") references instance (id) on delete restrict on update restrict
|
||||
);
|
||||
create table if not exists follow_requests
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{x-request-id}] %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="INFO">
|
||||
<root level="DEBUG">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
<logger name="org.eclipse.jetty" level="INFO"/>
|
||||
|
@ -12,7 +12,7 @@
|
|||
<logger name="kjob.core.internal.scheduler.JobServiceImpl" level="INFO"/>
|
||||
<logger name="Exposed" level="INFO"/>
|
||||
<logger name="io.ktor.server.plugins.contentnegotiation" level="INFO"/>
|
||||
<logger name="org.springframework.web.filter.CommonsRequestLoggingFilter" level="INFO"/>
|
||||
<logger name="org.springframework.web.filter.CommonsRequestLoggingFilter" level="DEBUG"/>
|
||||
<logger name="org.mongodb.driver.protocol.command" level="INFO"/>
|
||||
<logger name="dev.usbharu" level="TRACE"/>
|
||||
</configuration>
|
||||
|
|
|
@ -206,6 +206,161 @@ 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:
|
||||
- account
|
||||
security:
|
||||
- { }
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: 成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Account"
|
||||
|
||||
/api/v1/accounts/{id}/follow:
|
||||
post:
|
||||
tags:
|
||||
- account
|
||||
security:
|
||||
- OAuth2:
|
||||
- "write:follows"
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/FollowRequestBody"
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: "#/components/schemas/FollowRequestBody"
|
||||
responses:
|
||||
200:
|
||||
description: 成功
|
||||
content:
|
||||
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:
|
||||
|
@ -1314,6 +1469,8 @@ components:
|
|||
type: string
|
||||
client_secret:
|
||||
type: string
|
||||
redirect_uri:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- vapid_key
|
||||
|
@ -1333,6 +1490,65 @@ components:
|
|||
- client_name
|
||||
- redirect_uris
|
||||
|
||||
Relationship:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
following:
|
||||
type: boolean
|
||||
showing_reblogs:
|
||||
type: boolean
|
||||
notifying:
|
||||
type: boolean
|
||||
followed_by:
|
||||
type: boolean
|
||||
blocking:
|
||||
type: boolean
|
||||
blocked_by:
|
||||
type: boolean
|
||||
muting:
|
||||
type: boolean
|
||||
muting_notifications:
|
||||
type: boolean
|
||||
requested:
|
||||
type: boolean
|
||||
domain_blocking:
|
||||
type: boolean
|
||||
endorsed:
|
||||
type: boolean
|
||||
note:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- following
|
||||
- showing_reblogs
|
||||
- notifying
|
||||
- followed_by
|
||||
- blocking
|
||||
- blocked_by
|
||||
- muting
|
||||
- muting_notifications
|
||||
- requested
|
||||
- domain_blocking
|
||||
- endorsed
|
||||
- note
|
||||
|
||||
|
||||
FollowRequestBody:
|
||||
type: object
|
||||
properties:
|
||||
reblogs:
|
||||
type: boolean
|
||||
default: true
|
||||
notify:
|
||||
type: boolean
|
||||
default: false
|
||||
languages:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
securitySchemes:
|
||||
OAuth2:
|
||||
type: oauth2
|
||||
|
|
|
@ -125,4 +125,20 @@ class MastodonAccountApiControllerTest {
|
|||
.andExpect { header { string("location", "/users/hoge") } }
|
||||
.andExpect { status { isFound() } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apiV1AccountsIdFollowPost フォロー成功時は200が返ってくる`() {
|
||||
val createEmptyContext = SecurityContextHolder.createEmptyContext()
|
||||
createEmptyContext.authentication = JwtAuthenticationToken(
|
||||
Jwt.withTokenValue("a").header("alg", "RS236").claim("uid", "1234").build()
|
||||
)
|
||||
SecurityContextHolder.setContext(createEmptyContext)
|
||||
mockMvc
|
||||
.post("/api/v1/accounts/1/follow") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
}
|
||||
.asyncDispatch()
|
||||
.andExpect { status { isOk() } }
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,330 @@
|
|||
package dev.usbharu.hideout.mastodon.service.account
|
||||
|
||||
import dev.usbharu.hideout.application.external.Transaction
|
||||
import dev.usbharu.hideout.core.domain.model.user.UserRepository
|
||||
import dev.usbharu.hideout.core.query.FollowerQueryService
|
||||
import dev.usbharu.hideout.core.service.user.UserService
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.Account
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.Relationship
|
||||
import dev.usbharu.hideout.domain.mastodon.model.generated.Status
|
||||
import dev.usbharu.hideout.mastodon.query.StatusQueryService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.InjectMocks
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Spy
|
||||
import org.mockito.junit.jupiter.MockitoExtension
|
||||
import org.mockito.kotlin.*
|
||||
import utils.TestTransaction
|
||||
|
||||
@ExtendWith(MockitoExtension::class)
|
||||
class AccountApiServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private lateinit var accountService: AccountService
|
||||
|
||||
@Mock
|
||||
private lateinit var userService: UserService
|
||||
|
||||
@Mock
|
||||
private lateinit var userRepository: UserRepository
|
||||
|
||||
@Mock
|
||||
private lateinit var followerQueryService: FollowerQueryService
|
||||
|
||||
@Mock
|
||||
private lateinit var statusQueryService: StatusQueryService
|
||||
|
||||
@Spy
|
||||
private val transaction: Transaction = TestTransaction
|
||||
|
||||
@InjectMocks
|
||||
private lateinit var accountApiServiceImpl: AccountApiServiceImpl
|
||||
|
||||
private val statusList = listOf(
|
||||
Status(
|
||||
id = "",
|
||||
uri = "",
|
||||
createdAt = "",
|
||||
account = Account(
|
||||
id = "",
|
||||
username = "",
|
||||
acct = "",
|
||||
url = "",
|
||||
displayName = "",
|
||||
note = "",
|
||||
avatar = "",
|
||||
avatarStatic = "",
|
||||
header = "",
|
||||
headerStatic = "",
|
||||
locked = false,
|
||||
fields = emptyList(),
|
||||
emojis = emptyList(),
|
||||
bot = false,
|
||||
group = false,
|
||||
discoverable = true,
|
||||
createdAt = "",
|
||||
lastStatusAt = "",
|
||||
statusesCount = 0,
|
||||
followersCount = 0,
|
||||
noindex = false,
|
||||
moved = false,
|
||||
suspendex = false,
|
||||
limited = false,
|
||||
followingCount = 0
|
||||
),
|
||||
content = "",
|
||||
visibility = Status.Visibility.public,
|
||||
sensitive = false,
|
||||
spoilerText = "",
|
||||
mediaAttachments = emptyList(),
|
||||
mentions = emptyList(),
|
||||
tags = emptyList(),
|
||||
emojis = emptyList(),
|
||||
reblogsCount = 0,
|
||||
favouritesCount = 0,
|
||||
repliesCount = 0,
|
||||
url = "https://example.com",
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
language = "ja_JP",
|
||||
text = "Test",
|
||||
editedAt = null
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `accountsStatuses 非ログイン時は非公開投稿を見れない`() = runTest {
|
||||
val userId = 1234L
|
||||
|
||||
whenever(
|
||||
statusQueryService.accountsStatus(
|
||||
accountId = eq(userId),
|
||||
maxId = isNull(),
|
||||
sinceId = isNull(),
|
||||
minId = isNull(),
|
||||
limit = eq(20),
|
||||
onlyMedia = eq(false),
|
||||
excludeReplies = eq(false),
|
||||
excludeReblogs = eq(false),
|
||||
pinned = eq(false),
|
||||
tagged = isNull(),
|
||||
includeFollowers = eq(false)
|
||||
)
|
||||
).doReturn(
|
||||
statusList
|
||||
)
|
||||
|
||||
|
||||
val accountsStatuses = accountApiServiceImpl.accountsStatuses(
|
||||
userid = userId,
|
||||
maxId = null,
|
||||
sinceId = null,
|
||||
minId = null,
|
||||
limit = 20,
|
||||
onlyMedia = false,
|
||||
excludeReplies = false,
|
||||
excludeReblogs = false,
|
||||
pinned = false,
|
||||
tagged = null,
|
||||
loginUser = null
|
||||
)
|
||||
|
||||
assertThat(accountsStatuses).hasSize(1)
|
||||
|
||||
verify(followerQueryService, never()).alreadyFollow(any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accountsStatuses ログイン時フォロワーじゃない場合は非公開投稿を見れない`() = runTest {
|
||||
val userId = 1234L
|
||||
val loginUser = 1L
|
||||
whenever(
|
||||
statusQueryService.accountsStatus(
|
||||
accountId = eq(userId),
|
||||
maxId = isNull(),
|
||||
sinceId = isNull(),
|
||||
minId = isNull(),
|
||||
limit = eq(20),
|
||||
onlyMedia = eq(false),
|
||||
excludeReplies = eq(false),
|
||||
excludeReblogs = eq(false),
|
||||
pinned = eq(false),
|
||||
tagged = isNull(),
|
||||
includeFollowers = eq(false)
|
||||
)
|
||||
).doReturn(statusList)
|
||||
|
||||
whenever(followerQueryService.alreadyFollow(eq(userId), eq(loginUser))).doReturn(false)
|
||||
|
||||
|
||||
val accountsStatuses = accountApiServiceImpl.accountsStatuses(
|
||||
userid = userId,
|
||||
maxId = null,
|
||||
sinceId = null,
|
||||
minId = null,
|
||||
limit = 20,
|
||||
onlyMedia = false,
|
||||
excludeReplies = false,
|
||||
excludeReblogs = false,
|
||||
pinned = false,
|
||||
tagged = null,
|
||||
loginUser = loginUser
|
||||
)
|
||||
|
||||
assertThat(accountsStatuses).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accountsStatuses ログイン時フォロワーの場合は非公開投稿を見れる`() = runTest {
|
||||
val userId = 1234L
|
||||
val loginUser = 2L
|
||||
whenever(
|
||||
statusQueryService.accountsStatus(
|
||||
accountId = eq(userId),
|
||||
maxId = isNull(),
|
||||
sinceId = isNull(),
|
||||
minId = isNull(),
|
||||
limit = eq(20),
|
||||
onlyMedia = eq(false),
|
||||
excludeReplies = eq(false),
|
||||
excludeReblogs = eq(false),
|
||||
pinned = eq(false),
|
||||
tagged = isNull(),
|
||||
includeFollowers = eq(true)
|
||||
)
|
||||
).doReturn(statusList)
|
||||
|
||||
whenever(followerQueryService.alreadyFollow(eq(userId), eq(loginUser))).doReturn(true)
|
||||
|
||||
|
||||
val accountsStatuses = accountApiServiceImpl.accountsStatuses(
|
||||
userid = userId,
|
||||
maxId = null,
|
||||
sinceId = null,
|
||||
minId = null,
|
||||
limit = 20,
|
||||
onlyMedia = false,
|
||||
excludeReplies = false,
|
||||
excludeReblogs = false,
|
||||
pinned = false,
|
||||
tagged = null,
|
||||
loginUser = loginUser
|
||||
)
|
||||
|
||||
assertThat(accountsStatuses).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `follow 既にフォローしている場合は何もしない`() = runTest {
|
||||
val userId = 1234L
|
||||
val followeeId = 1L
|
||||
|
||||
whenever(followerQueryService.alreadyFollow(eq(followeeId), eq(userId))).doReturn(true)
|
||||
|
||||
whenever(followerQueryService.alreadyFollow(eq(userId), eq(followeeId))).doReturn(true)
|
||||
|
||||
whenever(userRepository.findFollowRequestsById(eq(followeeId), eq(userId))).doReturn(false)
|
||||
|
||||
val follow = accountApiServiceImpl.follow(userId, followeeId)
|
||||
|
||||
val expected = Relationship(
|
||||
id = followeeId.toString(),
|
||||
following = true,
|
||||
showingReblogs = true,
|
||||
notifying = false,
|
||||
followedBy = true,
|
||||
blocking = false,
|
||||
blockedBy = false,
|
||||
muting = false,
|
||||
mutingNotifications = false,
|
||||
requested = false,
|
||||
domainBlocking = false,
|
||||
endorsed = false,
|
||||
note = ""
|
||||
)
|
||||
assertThat(follow).isEqualTo(expected)
|
||||
|
||||
verify(userService, never()).followRequest(any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `follow 未フォローの場合フォローリクエストが発生する`() = runTest {
|
||||
val userId = 1234L
|
||||
val followeeId = 1L
|
||||
|
||||
whenever(followerQueryService.alreadyFollow(eq(followeeId), eq(userId))).doReturn(false)
|
||||
|
||||
whenever(userService.followRequest(eq(followeeId), eq(userId))).doReturn(true)
|
||||
|
||||
whenever(followerQueryService.alreadyFollow(eq(userId), eq(followeeId))).doReturn(true)
|
||||
|
||||
whenever(userRepository.findFollowRequestsById(eq(followeeId), eq(userId))).doReturn(false)
|
||||
|
||||
val follow = accountApiServiceImpl.follow(userId, followeeId)
|
||||
|
||||
val expected = Relationship(
|
||||
id = followeeId.toString(),
|
||||
following = true,
|
||||
showingReblogs = true,
|
||||
notifying = false,
|
||||
followedBy = true,
|
||||
blocking = false,
|
||||
blockedBy = false,
|
||||
muting = false,
|
||||
mutingNotifications = false,
|
||||
requested = false,
|
||||
domainBlocking = false,
|
||||
endorsed = false,
|
||||
note = ""
|
||||
)
|
||||
assertThat(follow).isEqualTo(expected)
|
||||
|
||||
verify(userService, times(1)).followRequest(eq(followeeId), eq(userId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relationships idが長すぎたら省略する`() = runTest {
|
||||
whenever(followerQueryService.alreadyFollow(any(), any())).doReturn(true)
|
||||
|
||||
whenever(userRepository.findFollowRequestsById(any(), any())).doReturn(true)
|
||||
|
||||
val relationships = accountApiServiceImpl.relationships(
|
||||
userid = 1234L,
|
||||
id = (1..30L).toList(),
|
||||
withSuspended = false
|
||||
)
|
||||
|
||||
assertThat(relationships).hasSizeLessThanOrEqualTo(20)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relationships id0の場合即時return`() = runTest {
|
||||
val relationships = accountApiServiceImpl.relationships(
|
||||
userid = 1234L,
|
||||
id = emptyList(),
|
||||
withSuspended = false
|
||||
)
|
||||
|
||||
assertThat(relationships).hasSize(0)
|
||||
verify(followerQueryService, never()).alreadyFollow(any(), any())
|
||||
verify(userRepository, never()).findFollowRequestsById(any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relationships idに指定されたアカウントの関係を取得する`() = runTest {
|
||||
whenever(followerQueryService.alreadyFollow(any(), any())).doReturn(true)
|
||||
|
||||
whenever(userRepository.findFollowRequestsById(any(), any())).doReturn(true)
|
||||
|
||||
val relationships = accountApiServiceImpl.relationships(
|
||||
userid = 1234L,
|
||||
id = (1..15L).toList(),
|
||||
withSuspended = false
|
||||
)
|
||||
|
||||
assertThat(relationships).hasSize(15)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue