feat: Undoを実装

This commit is contained in:
usbharu 2023-05-21 17:52:36 +09:00
parent fbff97c247
commit cb2ee6ca3f
18 changed files with 329 additions and 21 deletions

View File

@ -6,6 +6,6 @@ exposed_version=0.41.1
h2_version=2.1.214 h2_version=2.1.214
koin_version=3.3.1 koin_version=3.3.1
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.configureondemand=true #org.gradle.configureondemand=true
org.gradle.caching=true org.gradle.caching=true
org.gradle.jvmargs=-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC org.gradle.jvmargs=-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC

View File

@ -1,33 +1,43 @@
package dev.usbharu.hideout.domain.model.ap package dev.usbharu.hideout.domain.model.ap
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
open class Accept : Object { open class Accept : Object {
@JsonDeserialize(using = ObjectDeserializer::class)
var `object`: Object? = null var `object`: Object? = null
protected constructor() : super() protected constructor()
constructor( constructor(
type: List<String> = emptyList(), type: List<String> = emptyList(),
name: String, name: String,
`object`: Object?, `object`: Object?,
actor: String? actor: String?
) : super(add(type, "Accept"), name, actor) { ) : super(
type = add(type, "Accept"),
name = name,
actor = actor
) {
this.`object` = `object` this.`object` = `object`
} }
override fun toString(): String {
return "Accept(`object`=$`object`) ${super.toString()}"
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other !is Accept) return false if (other !is Accept) return false
if (!super.equals(other)) return false if (!super.equals(other)) return false
if (`object` != other.`object`) return false return `object` == other.`object`
return actor == other.actor
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = super.hashCode() var result = super.hashCode()
result = 31 * result + (`object`?.hashCode() ?: 0) result = 31 * result + (`object`?.hashCode() ?: 0)
result = 31 * result + (actor?.hashCode() ?: 0)
return result return result
} }
override fun toString(): String = "Accept(`object`=$`object`, actor=$actor) ${super.toString()}"
} }

View File

@ -6,7 +6,7 @@ open class Follow : Object {
protected constructor() : super() protected constructor() : super()
constructor( constructor(
type: List<String> = emptyList(), type: List<String> = emptyList(),
name: String, name: String?,
`object`: String?, `object`: String?,
actor: String? actor: String?
) : super( ) : super(
@ -16,4 +16,24 @@ open class Follow : Object {
) { ) {
this.`object` = `object` this.`object` = `object`
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Follow) return false
if (!super.equals(other)) return false
return `object` == other.`object`
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + (`object`?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "Follow(`object`=$`object`) ${super.toString()}"
}
} }

View File

@ -7,7 +7,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize
open class Object : JsonLd { open class Object : JsonLd {
@JsonSerialize(using = TypeSerializer::class) @JsonSerialize(using = TypeSerializer::class)
private var type: List<String> = emptyList() var type: List<String> = emptyList()
var name: String? = null var name: String? = null
var actor: String? = null var actor: String? = null
var id: String? = null var id: String? = null

View File

@ -0,0 +1,49 @@
package dev.usbharu.hideout.domain.model.ap
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import dev.usbharu.hideout.service.activitypub.ActivityType
class ObjectDeserializer : JsonDeserializer<Object>() {
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Object {
requireNotNull(p)
val treeNode: JsonNode = requireNotNull(p.codec?.readTree(p))
if (treeNode.isValueNode) {
return ObjectValue(
emptyList(),
null,
null,
null,
treeNode.asText()
)
} else if (treeNode.isObject) {
val type = treeNode["type"]
val activityType = if (type.isArray) {
type.firstNotNullOf { jsonNode: JsonNode ->
ActivityType.values().firstOrNull { it.name.equals(jsonNode.asText(), true) }
}
} else if (type.isValueNode) {
ActivityType.values().first { it.name.equals(type.asText(), true) }
} else {
TODO()
}
return when (activityType) {
ActivityType.Follow -> {
val readValue = p.codec.treeToValue(treeNode, Follow::class.java)
println(readValue)
readValue
}
else -> {
TODO()
}
}
} else {
TODO()
}
}
}

View File

@ -0,0 +1,36 @@
package dev.usbharu.hideout.domain.model.ap
class ObjectValue : Object {
var `object`: String? = null
protected constructor()
constructor(type: List<String>, name: String?, actor: String?, id: String?, `object`: String?) : super(
type,
name,
actor,
id
) {
this.`object` = `object`
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ObjectValue) return false
if (!super.equals(other)) return false
return `object` == other.`object`
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + (`object`?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "ObjectValue(`object`=$`object`) ${super.toString()}"
}
}

View File

@ -0,0 +1,46 @@
package dev.usbharu.hideout.domain.model.ap
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import java.time.Instant
class Undo : Object {
@JsonDeserialize(using = ObjectDeserializer::class)
var `object`: Object? = null
var published: String? = null
protected constructor()
constructor(
type: List<String> = emptyList(),
name: String,
actor: String,
id: String?,
`object`: Object,
published: Instant
) : super(add(type, "Undo"), name, actor, id) {
this.`object` = `object`
this.published = published.toString()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Undo) return false
if (!super.equals(other)) return false
if (`object` != other.`object`) return false
return published == other.published
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + (`object`?.hashCode() ?: 0)
result = 31 * result + (published?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "Undo(`object`=$`object`, published=$published) ${super.toString()}"
}
}

View File

@ -71,7 +71,7 @@ fun Route.users(userService: IUserService, userApiService: IUserApiService) {
val userParameter = call.parameters["name"] val userParameter = call.parameters["name"]
?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.") ?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.")
if (userParameter.toLongOrNull() != null) { if (userParameter.toLongOrNull() != null) {
if (userService.addFollowers(userParameter.toLong(), userId)) { if (userService.follow(userParameter.toLong(), userId)) {
return@post call.respond(HttpStatusCode.OK) return@post call.respond(HttpStatusCode.OK)
} else { } else {
return@post call.respond(HttpStatusCode.Accepted) return@post call.respond(HttpStatusCode.Accepted)
@ -79,7 +79,7 @@ fun Route.users(userService: IUserService, userApiService: IUserApiService) {
} }
val acct = AcctUtil.parse(userParameter) val acct = AcctUtil.parse(userParameter)
val targetUser = userApiService.findByAcct(acct) val targetUser = userApiService.findByAcct(acct)
if (userService.addFollowers(targetUser.id, userId)) { if (userService.follow(targetUser.id, userId)) {
return@post call.respond(HttpStatusCode.OK) return@post call.respond(HttpStatusCode.OK)
} else { } else {
return@post call.respond(HttpStatusCode.Accepted) return@post call.respond(HttpStatusCode.Accepted)

View File

@ -49,6 +49,6 @@ class ActivityPubFollowServiceImpl(
val users = val users =
userService.findByUrls(listOf(targetActor, follow.actor ?: throw IllegalArgumentException("actor is null"))) userService.findByUrls(listOf(targetActor, follow.actor ?: throw IllegalArgumentException("actor is null")))
userService.addFollowers(users.first { it.url == targetActor }.id, users.first { it.url == follow.actor }.id) userService.follow(users.first { it.url == targetActor }.id, users.first { it.url == follow.actor }.id)
} }
} }

View File

@ -1,6 +1,7 @@
package dev.usbharu.hideout.service.activitypub package dev.usbharu.hideout.service.activitypub
import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.readValue
import dev.usbharu.hideout.config.Config import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.domain.model.ActivityPubResponse import dev.usbharu.hideout.domain.model.ActivityPubResponse
import dev.usbharu.hideout.domain.model.ap.Follow import dev.usbharu.hideout.domain.model.ap.Follow
@ -17,7 +18,8 @@ import org.slf4j.LoggerFactory
@Single @Single
class ActivityPubServiceImpl( class ActivityPubServiceImpl(
private val activityPubFollowService: ActivityPubFollowService, private val activityPubFollowService: ActivityPubFollowService,
private val activityPubNoteService: ActivityPubNoteService private val activityPubNoteService: ActivityPubNoteService,
private val activityPubUndoService: ActivityPubUndoService
) : ActivityPubService { ) : ActivityPubService {
val logger: Logger = LoggerFactory.getLogger(this::class.java) val logger: Logger = LoggerFactory.getLogger(this::class.java)
@ -70,7 +72,7 @@ class ActivityPubServiceImpl(
ActivityType.TentativeReject -> TODO() ActivityType.TentativeReject -> TODO()
ActivityType.TentativeAccept -> TODO() ActivityType.TentativeAccept -> TODO()
ActivityType.Travel -> TODO() ActivityType.Travel -> TODO()
ActivityType.Undo -> TODO() ActivityType.Undo -> activityPubUndoService.receiveUndo(Config.configData.objectMapper.readValue(json))
ActivityType.Update -> TODO() ActivityType.Update -> TODO()
ActivityType.View -> TODO() ActivityType.View -> TODO()
ActivityType.Other -> TODO() ActivityType.Other -> TODO()

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.service.activitypub
import dev.usbharu.hideout.domain.model.ActivityPubResponse
import dev.usbharu.hideout.domain.model.ap.Undo
interface ActivityPubUndoService {
suspend fun receiveUndo(undo: Undo): ActivityPubResponse
}

View File

@ -0,0 +1,45 @@
package dev.usbharu.hideout.service.activitypub
import dev.usbharu.hideout.domain.model.ActivityPubResponse
import dev.usbharu.hideout.domain.model.ActivityPubStringResponse
import dev.usbharu.hideout.domain.model.ap.Follow
import dev.usbharu.hideout.domain.model.ap.Undo
import dev.usbharu.hideout.service.impl.IUserService
import io.ktor.http.*
import org.koin.core.annotation.Single
@Single
class ActivityPubUndoServiceImpl(
private val userService: IUserService,
private val activityPubUserService: ActivityPubUserService
) : ActivityPubUndoService {
override suspend fun receiveUndo(undo: Undo): ActivityPubResponse {
if (undo.actor == null) {
return ActivityPubStringResponse(HttpStatusCode.BadRequest, "actor is null")
}
val type =
undo.`object`?.type.orEmpty()
.firstOrNull { it == "Block" || it == "Follow" || it == "Like" || it == "Announce" || it == "Accept" }
?: return ActivityPubStringResponse(HttpStatusCode.BadRequest, "unknown type ${undo.`object`?.type}")
when (type) {
"Follow" -> {
val follow = undo.`object` as Follow
if (follow.`object` == null) {
return ActivityPubStringResponse(HttpStatusCode.BadRequest, "object.object is null")
}
activityPubUserService.fetchPerson(undo.actor!!, follow.`object`)
val follower = userService.findByUrl(undo.actor!!)
val target = userService.findByUrl(follow.`object`!!)
userService.unfollow(target.id, follower.id)
}
else -> {}
}
TODO()
}
}

View File

@ -5,5 +5,12 @@ import dev.usbharu.hideout.domain.model.ap.Person
interface ActivityPubUserService { interface ActivityPubUserService {
suspend fun getPersonByName(name: String): Person suspend fun getPersonByName(name: String): Person
/**
* Fetch person
*
* @param url
* @param targetActor 署名するユーザー
* @return
*/
suspend fun fetchPerson(url: String, targetActor: String? = null): Person suspend fun fetchPerson(url: String, targetActor: String? = null): Person
} }

View File

@ -45,5 +45,7 @@ interface IUserService {
* @param follower * @param follower
* @return リクエストが成功したか * @return リクエストが成功したか
*/ */
suspend fun addFollowers(id: Long, follower: Long): Boolean suspend fun follow(id: Long, follower: Long): Boolean
suspend fun unfollow(id: Long, follower: Long): Boolean
} }

View File

@ -105,8 +105,13 @@ class UserService(private val userRepository: IUserRepository, private val userA
} }
// TODO APのフォロー処理を作る // TODO APのフォロー処理を作る
override suspend fun addFollowers(id: Long, follower: Long): Boolean { override suspend fun follow(id: Long, follower: Long): Boolean {
userRepository.createFollower(id, follower) userRepository.createFollower(id, follower)
return false return false
} }
override suspend fun unfollow(id: Long, follower: Long): Boolean {
userRepository.deleteFollower(id, follower)
return false
}
} }

View File

@ -0,0 +1,76 @@
package dev.usbharu.hideout.domain.model.ap
import org.intellij.lang.annotations.Language
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import utils.JsonObjectMapper
import java.time.Clock
import java.time.Instant
import java.time.ZoneId
class UndoTest {
@Test
fun Undoのシリアライズができる() {
val undo = Undo(
emptyList(),
"Undo Follow",
"https://follower.example.com/",
"https://follower.example.com/undo/1",
Follow(
emptyList(),
null,
"https://follower.example.com/users/",
actor = "https://follower.exaple.com/users/1"
),
Instant.now(Clock.tickMillis(ZoneId.systemDefault()))
)
val writeValueAsString = JsonObjectMapper.objectMapper.writeValueAsString(undo)
println(writeValueAsString)
}
@Test
fun Undoをデシリアライズ出来る() {
@Language("JSON") val json = """
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"sensitive": "as:sensitive",
"Hashtag": "as:Hashtag",
"quoteUrl": "as:quoteUrl",
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji",
"featured": "toot:featured",
"discoverable": "toot:discoverable",
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
"misskey": "https://misskey-hub.net/ns#",
"_misskey_content": "misskey:_misskey_content",
"_misskey_quote": "misskey:_misskey_quote",
"_misskey_reaction": "misskey:_misskey_reaction",
"_misskey_votes": "misskey:_misskey_votes",
"isCat": "misskey:isCat",
"vcard": "http://www.w3.org/2006/vcard/ns#"
}
],
"type": "Undo",
"id": "https://misskey.usbharu.dev/follows/97ws8y3rj6/9ezbh8qrh0/undo",
"actor": "https://misskey.usbharu.dev/users/97ws8y3rj6",
"object": {
"id": "https://misskey.usbharu.dev/follows/97ws8y3rj6/9ezbh8qrh0",
"type": "Follow",
"actor": "https://misskey.usbharu.dev/users/97ws8y3rj6",
"object": "https://test-hideout.usbharu.dev/users/test"
},
"published": "2023-05-20T10:28:17.308Z"
}
""".trimIndent()
val undo = JsonObjectMapper.objectMapper.readValue(json, Undo::class.java)
println(undo)
}
}

View File

@ -432,7 +432,7 @@ class UsersTest {
) )
} }
val userService = mock<IUserService> { val userService = mock<IUserService> {
onBlocking { addFollowers(eq(1235), eq(1234)) } doReturn true onBlocking { follow(eq(1235), eq(1234)) } doReturn true
} }
application { application {
configureSerialization() configureSerialization()
@ -482,7 +482,7 @@ class UsersTest {
) )
} }
val userService = mock<IUserService> { val userService = mock<IUserService> {
onBlocking { addFollowers(eq(1235), eq(1234)) } doReturn false onBlocking { follow(eq(1235), eq(1234)) } doReturn false
} }
application { application {
configureSerialization() configureSerialization()
@ -532,7 +532,7 @@ class UsersTest {
) )
} }
val userService = mock<IUserService> { val userService = mock<IUserService> {
onBlocking { addFollowers(eq(1235), eq(1234)) } doReturn false onBlocking { follow(eq(1235), eq(1234)) } doReturn false
} }
application { application {
configureSerialization() configureSerialization()

View File

@ -115,7 +115,7 @@ class ActivityPubFollowServiceImplTest {
createdAt = Instant.now() createdAt = Instant.now()
) )
) )
onBlocking { addFollowers(any(), any()) } doReturn false onBlocking { follow(any(), any()) } doReturn false
} }
val activityPubFollowService = val activityPubFollowService =
ActivityPubFollowServiceImpl( ActivityPubFollowServiceImpl(
@ -137,10 +137,12 @@ class ActivityPubFollowServiceImplTest {
actor = "https://example.com" actor = "https://example.com"
) )
accept.context += "https://www.w3.org/ns/activitystreams" accept.context += "https://www.w3.org/ns/activitystreams"
val content = httpRequestData.body.toByteArray().decodeToString()
println(content)
assertEquals( assertEquals(
accept, accept,
Config.configData.objectMapper.readValue<Accept>( Config.configData.objectMapper.readValue<Accept>(
httpRequestData.body.toByteArray().decodeToString() content
) )
) )
respondOk() respondOk()