Merge pull request #197 from usbharu/feature/mastodon-follow-api

Feature/mastodon follow api
This commit is contained in:
usbharu 2023-12-07 15:13:27 +09:00 committed by GitHub
commit d05e8cf68b
26 changed files with 1081 additions and 57 deletions

View File

@ -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

View File

@ -1,5 +1,5 @@
hideout:
url: "https://localhost:8080"
url: "https://example.com"
use-mongodb: true
security:
jwt:

View File

@ -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);

View File

@ -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);

View File

@ -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

View File

@ -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()}"
}
}

View File

@ -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)

View File

@ -99,7 +99,6 @@ class InboxJobProcessor(
val verify = signature?.let { verifyHttpSignature(httpRequest, it, transaction) } ?: false
transaction.transaction {
logger.debug("Is verifying success? {}", verify)
val activityPubProcessor =
@ -115,7 +114,6 @@ class InboxJobProcessor(
logger.info("SUCCESS Process inbox. type: {}", param.type)
}
}
override fun job(): InboxJob = InboxJob

View File

@ -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,

View File

@ -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"))

View File

@ -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()
}
}

View File

@ -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 {
try {
MDC.put("x-job-id", this.jobId)
val param = it.convertUnsafe(props)
jobProcessor.process(param)
} finally {
MDC.remove("x-job-id")
}
}
}
}

View File

@ -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()

View File

@ -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 {

View File

@ -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(),

View File

@ -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()
)
}
}

View File

@ -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>
}

View File

@ -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)
}
}

View File

@ -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(),

View File

@ -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
)
}
}

View File

@ -36,7 +36,7 @@ spring:
max-request-size: 40MB
h2:
console:
enabled: false
enabled: true
server:
tomcat:
basedir: tomcat

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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() } }
}
}

View File

@ -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)
}
}