diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt index f35b0c4f..5867cd4c 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt @@ -42,7 +42,7 @@ class ObjectDeserializer : JsonDeserializer() { ExtendedActivityVocabulary.OrderedCollectionPage -> null ExtendedActivityVocabulary.Accept -> p.codec.treeToValue(treeNode, Accept::class.java) ExtendedActivityVocabulary.Add -> null - ExtendedActivityVocabulary.Announce -> null + ExtendedActivityVocabulary.Announce -> p.codec.treeToValue(treeNode,Announce::class.java) ExtendedActivityVocabulary.Arrive -> null ExtendedActivityVocabulary.Block -> p.codec.treeToValue(treeNode, Block::class.java) ExtendedActivityVocabulary.Create -> p.codec.treeToValue(treeNode, Create::class.java) diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/infrastructure/exposedquery/ExposedAnnounceQueryService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/infrastructure/exposedquery/ExposedAnnounceQueryService.kt new file mode 100644 index 00000000..1921be1b --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/infrastructure/exposedquery/ExposedAnnounceQueryService.kt @@ -0,0 +1,67 @@ +package dev.usbharu.hideout.activitypub.infrastructure.exposedquery + +import dev.usbharu.hideout.activitypub.domain.model.Announce +import dev.usbharu.hideout.activitypub.query.AnnounceQueryService +import dev.usbharu.hideout.activitypub.service.objects.note.APNoteServiceImpl +import dev.usbharu.hideout.application.infrastructure.exposed.ResultRowMapper +import dev.usbharu.hideout.core.domain.model.post.Post +import dev.usbharu.hideout.core.domain.model.post.PostRepository +import dev.usbharu.hideout.core.domain.model.post.Visibility +import dev.usbharu.hideout.core.infrastructure.exposedrepository.Actors +import dev.usbharu.hideout.core.infrastructure.exposedrepository.Posts +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.select +import org.springframework.stereotype.Repository +import java.time.Instant + +@Repository +class ExposedAnnounceQueryService( + private val postRepository: PostRepository, + private val postResultRowMapper: ResultRowMapper +) : AnnounceQueryService { + override suspend fun findById(id: Long): Pair? { + return Posts + .leftJoin(Actors) + .select { Posts.id eq id } + .singleOrNull() + ?.let { (it.toAnnounce() ?: return null) to (postResultRowMapper.map(it)) } + } + + override suspend fun findByApId(apId: String): Pair? { + return Posts + .leftJoin(Actors) + .select { Posts.apId eq apId } + .singleOrNull() + ?.let { (it.toAnnounce() ?: return null) to (postResultRowMapper.map(it)) } + } + + + private suspend fun ResultRow.toAnnounce(): Announce? { + val repostId = this[Posts.repostId] ?: return null + val repost = postRepository.findById(repostId)?.url ?: return null + + val (to, cc) = visibility( + Visibility.values().first { visibility -> visibility.ordinal == this[Posts.visibility] }, + this[Actors.followers] + ) + + return Announce( + emptyList(), + id = this[Posts.apId], + apObject = repost, + actor = this[Actors.url], + published = Instant.ofEpochMilli(this[Posts.createdAt]).toString(), + to = to, + cc = cc + ) + } + + private fun visibility(visibility: Visibility, followers: String?): Pair, List> { + return when (visibility) { + Visibility.PUBLIC -> listOf(APNoteServiceImpl.public) to listOf(APNoteServiceImpl.public) + Visibility.UNLISTED -> listOfNotNull(followers) to listOf(APNoteServiceImpl.public) + Visibility.FOLLOWERS -> listOfNotNull(followers) to listOfNotNull(followers) + Visibility.DIRECT -> TODO() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/query/AnnounceQueryService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/query/AnnounceQueryService.kt new file mode 100644 index 00000000..ed56ca6c --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/query/AnnounceQueryService.kt @@ -0,0 +1,11 @@ +package dev.usbharu.hideout.activitypub.query + +import dev.usbharu.hideout.activitypub.domain.model.Announce +import dev.usbharu.hideout.core.domain.model.post.Post +import org.springframework.stereotype.Repository + +@Repository +interface AnnounceQueryService { + suspend fun findById(id: Long): Pair? + suspend fun findByApId(apId: String): Pair? +} \ No newline at end of file diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt index 7c788ae0..94f21c36 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt @@ -1,8 +1,10 @@ package dev.usbharu.hideout.activitypub.service.objects.note import dev.usbharu.hideout.activitypub.domain.exception.FailedToGetActivityPubResourceException +import dev.usbharu.hideout.activitypub.domain.model.Announce import dev.usbharu.hideout.activitypub.domain.model.Emoji import dev.usbharu.hideout.activitypub.domain.model.Note +import dev.usbharu.hideout.activitypub.query.AnnounceQueryService import dev.usbharu.hideout.activitypub.query.NoteQueryService import dev.usbharu.hideout.activitypub.service.common.APResourceResolveService import dev.usbharu.hideout.activitypub.service.common.resolve @@ -23,6 +25,9 @@ interface APNoteService { suspend fun fetchNote(url: String, targetActor: String? = null): Note = fetchNoteWithEntity(url, targetActor).first suspend fun fetchNote(note: Note, targetActor: String? = null): Note suspend fun fetchNoteWithEntity(url: String, targetActor: String? = null): Pair + + suspend fun fetchAnnounce(url: String, signerId: Long? = null): Pair + suspend fun fetchAnnounce(announce: Announce, signerId: Long? = null): Pair } @Service @@ -35,7 +40,8 @@ class APNoteServiceImpl( private val postBuilder: Post.PostBuilder, private val noteQueryService: NoteQueryService, private val mediaService: MediaService, - private val emojiService: EmojiService + private val emojiService: EmojiService, + private val announceQueryService: AnnounceQueryService ) : APNoteService { @@ -67,6 +73,68 @@ class APNoteServiceImpl( return savedNote } + override suspend fun fetchAnnounce(url: String, signerId: Long?): Pair { + logger.debug("START Fetch Announce url: {}", url) + + val post: Pair? = announceQueryService.findByApId(url) + + if (post != null) { + logger.debug("SUCCESS Found in local url: {}", url) + return post + } + + logger.info("AP GET url: {}", url) + + val announce = try { + apResourceResolveService.resolve(url, signerId) + } catch (e: ClientRequestException) { + logger.warn( + "FAILED Failed to retrieve ActivityPub resource. HTTP Status Code: {} url: {}", + e.response.status, + url + ) + throw FailedToGetActivityPubResourceException("Could not retrieve $url.", e) + } + + return fetchAnnounce(announce,signerId) + } + + override suspend fun fetchAnnounce(announce: Announce, signerId: Long?): Pair { + + val findByApId = announceQueryService.findByApId(announce.id) + + if (findByApId != null) { + return findByApId + } + + val (_, actor) = apUserService.fetchPersonWithEntity(announce.actor, null) + + val (_, post) = fetchNoteWithEntity(announce.apObject, null) + + val visibility = if (announce.to.contains(public)) { + Visibility.PUBLIC + } else if (announce.to.contains(actor.followers) && announce.cc.contains(public)) { + Visibility.UNLISTED + } else if (announce.to.contains(actor.followers)) { + Visibility.FOLLOWERS + } else { + Visibility.DIRECT + } + + val createRemote = postService.createRemote( + postBuilder.pureRepostOf( + id = postRepository.generateId(), + actorId = actor.id, + visibility = visibility, + createdAt = Instant.parse(announce.published), + url = announce.id, + repost = post, + apId = announce.id + ) + ) + return announce to createRemote + } + private suspend fun saveIfMissing( note: Note, targetActor: String?, diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt index 5e8b4264..e488d86b 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt @@ -5,6 +5,7 @@ import dev.usbharu.hideout.application.config.CharacterLimit import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import java.time.Instant +import kotlin.math.max data class Actor private constructor( val id: Long, @@ -159,18 +160,6 @@ data class Actor private constructor( "keyId must contain non-blank characters." } - require(postsCount >= 0) { - "postsCount must be greater than or equal to 0" - } - - require(followersCount >= 0) { - "followersCount must be greater than or equal to 0" - } - - require(followingCount >= 0) { - "followingCount must be greater than or equal to 0" - } - return Actor( id = id, name = limitedName, @@ -188,9 +177,9 @@ data class Actor private constructor( following = following, instance = instance, locked = locked, - followersCount = followersCount, - followingCount = followingCount, - postsCount = postsCount, + followersCount = max(0,followersCount), + followingCount = max(0,followingCount), + postsCount = max(0, postsCount), lastPostDate = lastPostDate, emojis = emojis ) @@ -212,27 +201,27 @@ data class Actor private constructor( fun withLastPostAt(lastPostDate: Instant): Actor = this.copy(lastPostDate = lastPostDate) override fun toString(): String { return "Actor(" + - "id=$id, " + - "name='$name', " + - "domain='$domain', " + - "screenName='$screenName', " + - "description='$description', " + - "inbox='$inbox', " + - "outbox='$outbox', " + - "url='$url', " + - "publicKey='$publicKey', " + - "privateKey=$privateKey, " + - "createdAt=$createdAt, " + - "keyId='$keyId', " + - "followers=$followers, " + - "following=$following, " + - "instance=$instance, " + - "locked=$locked, " + - "followersCount=$followersCount, " + - "followingCount=$followingCount, " + - "postsCount=$postsCount, " + - "lastPostDate=$lastPostDate, " + - "emojis=$emojis" + - ")" + "id=$id, " + + "name='$name', " + + "domain='$domain', " + + "screenName='$screenName', " + + "description='$description', " + + "inbox='$inbox', " + + "outbox='$outbox', " + + "url='$url', " + + "publicKey='$publicKey', " + + "privateKey=$privateKey, " + + "createdAt=$createdAt, " + + "keyId='$keyId', " + + "followers=$followers, " + + "following=$following, " + + "instance=$instance, " + + "locked=$locked, " + + "followersCount=$followersCount, " + + "followingCount=$followingCount, " + + "postsCount=$postsCount, " + + "lastPostDate=$lastPostDate, " + + "emojis=$emojis" + + ")" } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt index 0eabb4aa..f50e879b 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt @@ -89,6 +89,114 @@ data class Post private constructor( ) } + fun pureRepostOf( + id: Long, + actorId: Long, + visibility: Visibility, + createdAt: Instant, + url: String, + repost: Post, + apId: String + ): Post { + + // リポストの公開範囲は元のポストより広くてはいけない + val fixedVisibility = if (visibility.ordinal <= repost.visibility.ordinal) { + repost.visibility + } else { + visibility + } + + require(id >= 0) { "id must be greater than or equal to 0." } + + require(actorId >= 0) { "actorId must be greater than or equal to 0." } + + + return Post( + id, + actorId, + null, + "", + "", + createdAt.toEpochMilli(), + fixedVisibility, + url, + repost.id, + null, + false, + apId, + emptyList(), + false, + emptyList() + ) + } + + fun quoteRepostOf( + id: Long, + actorId: Long, + overview: String? = null, + content: String, + createdAt: Instant, + visibility: Visibility, + url: String, + repost:Post, + replyId: Long? = null, + sensitive: Boolean = false, + apId: String = url, + mediaIds: List = emptyList(), + emojiIds: List = emptyList() + ): Post { + + // リポストの公開範囲は元のポストより広くてはいけない + val fixedVisibility = if (visibility.ordinal <= repost.visibility.ordinal) { + repost.visibility + } else { + visibility + } + + require(id >= 0) { "id must be greater than or equal to 0." } + + require(actorId >= 0) { "actorId must be greater than or equal to 0." } + + val limitedOverview = if ((overview?.length ?: 0) >= characterLimit.post.overview) { + overview?.substring(0, characterLimit.post.overview) + } else { + overview + } + + val limitedText = if (content.length >= characterLimit.post.text) { + content.substring(0, characterLimit.post.text) + } else { + content + } + + val (html, content1) = postContentFormatter.format(limitedText) + + require(url.isNotBlank()) { "url must contain non-blank characters" } + require(url.length <= characterLimit.general.url) { + "url must not exceed ${characterLimit.general.url} characters." + } + + require((replyId ?: 0) >= 0) { "replyId must be greater then or equal to 0." } + + return Post( + id = id, + actorId = actorId, + overview = limitedOverview, + content = html, + text = content1, + createdAt = createdAt.toEpochMilli(), + visibility = fixedVisibility, + url = url, + repostId = repost.id, + replyId = replyId, + sensitive = sensitive, + apId = apId, + mediaIds = mediaIds, + delted = false, + emojiIds = emojiIds + ) + } + @Suppress("LongParameterList") fun deleteOf( id: Long, @@ -117,6 +225,9 @@ data class Post private constructor( } } + fun isPureRepost():Boolean = + this.text.isEmpty() && this.content.isEmpty() && this.overview == null && this.replyId == null && this.repostId != null + fun delete(): Post { return Post( id = this.id, diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt index 8109bb2d..97f34d09 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt @@ -79,6 +79,7 @@ class APNoteServiceImplTest { ), noteQueryService = noteQueryService, mock(), + mock(), mock() ) @@ -152,7 +153,8 @@ class APNoteServiceImplTest { ), noteQueryService = noteQueryService, mock(), - mock { } + mock { }, + mock() ) val actual = apNoteServiceImpl.fetchNote(url) @@ -204,7 +206,8 @@ class APNoteServiceImplTest { ), noteQueryService = noteQueryService, mock(), - mock() + mock(), + mock { } ) assertThrows { apNoteServiceImpl.fetchNote(url) } @@ -255,6 +258,7 @@ class APNoteServiceImplTest { postBuilder = postBuilder, noteQueryService = noteQueryService, mock(), + mock(), mock() ) @@ -308,6 +312,7 @@ class APNoteServiceImplTest { postBuilder = postBuilder, noteQueryService = noteQueryService, mock(), + mock(), mock() )