feat: メディア付き投稿をできるように

This commit is contained in:
usbharu 2023-10-05 19:26:10 +09:00
parent 82d51e2660
commit 6b01927133
13 changed files with 278 additions and 87 deletions

View File

@ -35,7 +35,7 @@ import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey import java.security.interfaces.RSAPublicKey
import java.util.* import java.util.*
@EnableWebSecurity(debug = true) @EnableWebSecurity(debug = false)
@Configuration @Configuration
class SecurityConfig { class SecurityConfig {

View File

@ -2,9 +2,12 @@ package dev.usbharu.hideout.config
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.web.filter.CommonsRequestLoggingFilter
import java.net.URL import java.net.URL
@Configuration @Configuration
class SpringConfig { class SpringConfig {
@ -13,6 +16,17 @@ class SpringConfig {
@Autowired @Autowired
lateinit var storageConfig: StorageConfig lateinit var storageConfig: StorageConfig
@Bean
fun requestLoggingFilter(): CommonsRequestLoggingFilter {
val loggingFilter = CommonsRequestLoggingFilter()
loggingFilter.setIncludeHeaders(true)
loggingFilter.setIncludeClientInfo(true)
loggingFilter.setIncludeQueryString(true)
loggingFilter.setIncludePayload(true)
loggingFilter.setMaxPayloadLength(64000)
return loggingFilter
}
} }
@ConfigurationProperties("hideout") @ConfigurationProperties("hideout")

View File

@ -8,5 +8,6 @@ data class PostCreateDto(
val visibility: Visibility = Visibility.PUBLIC, val visibility: Visibility = Visibility.PUBLIC,
val repostId: Long? = null, val repostId: Long? = null,
val repolyId: Long? = null, val repolyId: Long? = null,
val userId: Long val userId: Long,
val mediaIds: List<Long> = emptyList()
) )

View File

@ -13,7 +13,8 @@ data class Post private constructor(
val repostId: Long? = null, val repostId: Long? = null,
val replyId: Long? = null, val replyId: Long? = null,
val sensitive: Boolean = false, val sensitive: Boolean = false,
val apId: String = url val apId: String = url,
val mediaIds: List<Long> = emptyList()
) { ) {
companion object { companion object {
@Suppress("FunctionMinLength", "LongParameterList") @Suppress("FunctionMinLength", "LongParameterList")
@ -28,7 +29,8 @@ data class Post private constructor(
repostId: Long? = null, repostId: Long? = null,
replyId: Long? = null, replyId: Long? = null,
sensitive: Boolean = false, sensitive: Boolean = false,
apId: String = url apId: String = url,
mediaIds: List<Long> = emptyList()
): Post { ): Post {
val characterLimit = Config.configData.characterLimit val characterLimit = Config.configData.characterLimit
@ -67,7 +69,8 @@ data class Post private constructor(
repostId = repostId, repostId = repostId,
replyId = replyId, replyId = replyId,
sensitive = sensitive, sensitive = sensitive,
apId = apId apId = apId,
mediaIds = mediaIds
) )
} }
} }

View File

@ -3,20 +3,29 @@ package dev.usbharu.hideout.query
import dev.usbharu.hideout.domain.model.hideout.entity.Post import dev.usbharu.hideout.domain.model.hideout.entity.Post
import dev.usbharu.hideout.exception.FailedToGetResourcesException import dev.usbharu.hideout.exception.FailedToGetResourcesException
import dev.usbharu.hideout.repository.Posts import dev.usbharu.hideout.repository.Posts
import dev.usbharu.hideout.repository.PostsMedia
import dev.usbharu.hideout.repository.toPost import dev.usbharu.hideout.repository.toPost
import dev.usbharu.hideout.util.singleOr import dev.usbharu.hideout.util.singleOr
import org.jetbrains.exposed.sql.innerJoin
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@Repository @Repository
class PostQueryServiceImpl : PostQueryService { class PostQueryServiceImpl : PostQueryService {
override suspend fun findById(id: Long): Post = override suspend fun findById(id: Long): Post =
Posts.select { Posts.id eq id } Posts.innerJoin(PostsMedia, onColumn = { Posts.id }, otherColumn = { PostsMedia.postId })
.select { Posts.id eq id }
.singleOr { FailedToGetResourcesException("id: $id is duplicate or does not exist.", it) }.toPost() .singleOr { FailedToGetResourcesException("id: $id is duplicate or does not exist.", it) }.toPost()
override suspend fun findByUrl(url: String): Post = Posts.select { Posts.url eq url } override suspend fun findByUrl(url: String): Post =
.singleOr { FailedToGetResourcesException("url: $url is duplicate or does not exist.", it) }.toPost() Posts.innerJoin(PostsMedia, onColumn = { Posts.id }, otherColumn = { PostsMedia.postId })
.select { Posts.url eq url }
.toPost()
.singleOr { FailedToGetResourcesException("url: $url is duplicate or does not exist.", it) }
override suspend fun findByApId(string: String): Post = Posts.select { Posts.apId eq string } override suspend fun findByApId(string: String): Post =
.singleOr { FailedToGetResourcesException("apId: $string is duplicate or does not exist.", it) }.toPost() Posts.innerJoin(PostsMedia, onColumn = { Posts.id }, otherColumn = { PostsMedia.postId })
.select { Posts.apId eq string }
.toPost()
.singleOr { FailedToGetResourcesException("apId: $string is duplicate or does not exist.", it) }
} }

View File

@ -0,0 +1,61 @@
package dev.usbharu.hideout.query
import dev.usbharu.hideout.domain.model.hideout.dto.Account
import dev.usbharu.hideout.domain.model.hideout.dto.ReactionResponse
import dev.usbharu.hideout.domain.model.hideout.entity.Reaction
import dev.usbharu.hideout.exception.FailedToGetResourcesException
import dev.usbharu.hideout.repository.Reactions
import dev.usbharu.hideout.repository.Users
import dev.usbharu.hideout.repository.toReaction
import dev.usbharu.hideout.util.singleOr
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.springframework.stereotype.Repository
@Repository
class ReactionQueryServiceImpl : ReactionQueryService {
override suspend fun findByPostId(postId: Long, userId: Long?): List<Reaction> {
return Reactions.select {
Reactions.postId.eq(postId)
}.map { it.toReaction() }
}
@Suppress("FunctionMaxLength")
override suspend fun findByPostIdAndUserIdAndEmojiId(postId: Long, userId: Long, emojiId: Long): Reaction {
return Reactions
.select {
Reactions.postId.eq(postId).and(Reactions.userId.eq(userId)).and(
Reactions.emojiId.eq(emojiId)
)
}
.singleOr {
FailedToGetResourcesException(
"postId: $postId,userId: $userId,emojiId: $emojiId is duplicate or does not exist.",
it
)
}
.toReaction()
}
override suspend fun reactionAlreadyExist(postId: Long, userId: Long, emojiId: Long): Boolean {
return Reactions.select {
Reactions.postId.eq(postId).and(Reactions.userId.eq(userId)).and(
Reactions.emojiId.eq(emojiId)
)
}.empty().not()
}
override suspend fun deleteByPostIdAndUserId(postId: Long, userId: Long) {
Reactions.deleteWhere { Reactions.postId.eq(postId).and(Reactions.userId.eq(userId)) }
}
override suspend fun findByPostIdWithUsers(postId: Long, userId: Long?): List<ReactionResponse> {
return Reactions
.leftJoin(Users, onColumn = { Reactions.userId }, otherColumn = { id })
.select { Reactions.postId.eq(postId) }
.groupBy { _: ResultRow -> ReactionResponse("", true, "", emptyList()) }
.map { entry: Map.Entry<ReactionResponse, List<ResultRow>> ->
entry.key.copy(accounts = entry.value.map { Account(it[Users.screenName], "", it[Users.url]) })
}
}
}

View File

@ -1,9 +1,11 @@
package dev.usbharu.hideout.query.mastodon package dev.usbharu.hideout.query.mastodon
import dev.usbharu.hideout.domain.mastodon.model.generated.Account import dev.usbharu.hideout.domain.mastodon.model.generated.Account
import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment
import dev.usbharu.hideout.domain.mastodon.model.generated.Status import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import dev.usbharu.hideout.repository.Posts import dev.usbharu.hideout.domain.model.hideout.dto.FileType
import dev.usbharu.hideout.repository.Users import dev.usbharu.hideout.repository.*
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.innerJoin import org.jetbrains.exposed.sql.innerJoin
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@ -12,11 +14,73 @@ import java.time.Instant
@Repository @Repository
class StatusQueryServiceImpl : StatusQueryService { class StatusQueryServiceImpl : StatusQueryService {
@Suppress("LongMethod") @Suppress("LongMethod")
override suspend fun findByPostIds(ids: List<Long>): List<Status> { override suspend fun findByPostIds(ids: List<Long>): List<Status> = findByPostIdsWithMediaAttachments(ids)
val pairs = Posts.innerJoin(Users, onColumn = { userId }, otherColumn = { id })
private suspend fun internalFindByPostIds(ids: List<Long>): List<Status> {
val pairs = Posts
.innerJoin(Users, onColumn = { Posts.userId }, otherColumn = { id })
.select { Posts.id inList ids } .select { Posts.id inList ids }
.map { .map {
Status( toStatus(it) to it[Posts.repostId]
}
return resolveReplyAndRepost(pairs)
}
private fun resolveReplyAndRepost(pairs: List<Pair<Status, Long?>>): List<Status> {
val statuses = pairs.map { it.first }
return pairs
.map {
if (it.second != null) {
it.first.copy(reblog = statuses.find { (id) -> id == it.second.toString() })
} else {
it.first
}
}
.map {
if (it.inReplyToId != null) {
it.copy(inReplyToAccountId = statuses.find { (id) -> id == it.inReplyToId }?.id)
} else {
it
}
}
}
private suspend fun findByPostIdsWithMediaAttachments(ids: List<Long>): List<Status> {
val pairs = Posts
.innerJoin(PostsMedia, onColumn = { Posts.id }, otherColumn = { PostsMedia.postId })
.innerJoin(Users, onColumn = { Posts.userId }, otherColumn = { id })
.innerJoin(Media, onColumn = { PostsMedia.mediaId }, otherColumn = { id })
.select { Posts.id inList ids }
.groupBy { it[Posts.id] }
.map { it.value }
.map {
toStatus(it.first()).copy(mediaAttachments = it.map {
it.toMedia().let {
MediaAttachment(
it.id.toString(),
when (it.type) {
FileType.Image -> MediaAttachment.Type.image
FileType.Video -> MediaAttachment.Type.video
FileType.Audio -> MediaAttachment.Type.audio
FileType.Unknown -> MediaAttachment.Type.unknown
},
it.url,
it.thumbnailUrl,
it.remoteUrl,
"",
it.blurHash,
it.url
)
}
}) to it.first()[Posts.repostId]
}
return resolveReplyAndRepost(pairs)
}
}
private fun toStatus(it: ResultRow) = Status(
id = it[Posts.id].toString(), id = it[Posts.id].toString(),
uri = it[Posts.apId], uri = it[Posts.apId],
createdAt = Instant.ofEpochMilli(it[Posts.createdAt]).toString(), createdAt = Instant.ofEpochMilli(it[Posts.createdAt]).toString(),
@ -70,24 +134,4 @@ class StatusQueryServiceImpl : StatusQueryService {
language = null, language = null,
text = it[Posts.text], text = it[Posts.text],
editedAt = null editedAt = null
) to it[Posts.repostId] )
}
val statuses = pairs.map { it.first }
return pairs
.map {
if (it.second != null) {
it.first.copy(reblog = statuses.find { (id) -> id == it.second.toString() })
} else {
it.first
}
}
.map {
if (it.inReplyToId != null) {
it.copy(inReplyToAccountId = statuses.find { (id) -> id == it.inReplyToId }?.id)
} else {
it
}
}
}
}

View File

@ -55,6 +55,7 @@ class MediaRepositoryImpl(private val idGenerateService: IdGenerateService) : Me
Media.id eq id Media.id eq id
} }
} }
}
fun ResultRow.toMedia(): EntityMedia { fun ResultRow.toMedia(): EntityMedia {
return EntityMedia( return EntityMedia(
@ -67,7 +68,6 @@ class MediaRepositoryImpl(private val idGenerateService: IdGenerateService) : Me
blurHash = this[Media.blurhash], blurHash = this[Media.blurhash],
) )
} }
}
object Media : Table("media") { object Media : Table("media") {
val id = long("id") val id = long("id")
@ -77,4 +77,5 @@ object Media : Table("media") {
val thumbnailUrl = varchar("thumbnail_url", 255).nullable() val thumbnailUrl = varchar("thumbnail_url", 255).nullable()
val type = integer("type") val type = integer("type")
val blurhash = varchar("blurhash", 255).nullable() val blurhash = varchar("blurhash", 255).nullable()
override val primaryKey = PrimaryKey(id)
} }

View File

@ -29,7 +29,18 @@ class PostRepositoryImpl(private val idGenerateService: IdGenerateService) : Pos
it[sensitive] = post.sensitive it[sensitive] = post.sensitive
it[apId] = post.apId it[apId] = post.apId
} }
PostsMedia.batchInsert(post.mediaIds) {
this[PostsMedia.postId] = post.id
this[PostsMedia.mediaId] = it
}
} else { } else {
PostsMedia.deleteWhere {
PostsMedia.postId eq post.id
}
PostsMedia.batchInsert(post.mediaIds) {
this[PostsMedia.postId] = post.id
this[PostsMedia.mediaId] = it
}
Posts.update({ Posts.id eq post.id }) { Posts.update({ Posts.id eq post.id }) {
it[userId] = post.userId it[userId] = post.userId
it[overview] = post.overview it[overview] = post.overview
@ -46,7 +57,11 @@ class PostRepositoryImpl(private val idGenerateService: IdGenerateService) : Pos
return post return post
} }
override suspend fun findById(id: Long): Post = Posts.select { Posts.id eq id }.singleOrNull()?.toPost() override suspend fun findById(id: Long): Post =
Posts.innerJoin(PostsMedia, onColumn = { Posts.id }, otherColumn = { PostsMedia.postId })
.select { Posts.id eq id }
.toPost()
.singleOrNull()
?: throw FailedToGetResourcesException("id: $id was not found.") ?: throw FailedToGetResourcesException("id: $id was not found.")
override suspend fun delete(id: Long) { override suspend fun delete(id: Long) {
@ -69,6 +84,13 @@ object Posts : Table() {
override val primaryKey: PrimaryKey = PrimaryKey(id) override val primaryKey: PrimaryKey = PrimaryKey(id)
} }
object PostsMedia : Table() {
val postId = long("post_id").references(Posts.id, ReferenceOption.CASCADE, ReferenceOption.CASCADE)
val mediaId = long("media_id").references(Media.id, ReferenceOption.CASCADE, ReferenceOption.CASCADE)
override val primaryKey = PrimaryKey(postId, mediaId)
}
fun ResultRow.toPost(): Post { fun ResultRow.toPost(): Post {
return Post.of( return Post.of(
id = this[Posts.id], id = this[Posts.id],
@ -81,6 +103,12 @@ fun ResultRow.toPost(): Post {
repostId = this[Posts.repostId], repostId = this[Posts.repostId],
replyId = this[Posts.replyId], replyId = this[Posts.replyId],
sensitive = this[Posts.sensitive], sensitive = this[Posts.sensitive],
apId = this[Posts.apId] apId = this[Posts.apId],
) )
} }
fun Query.toPost(): List<Post> {
return this.groupBy { it[Posts.id] }
.map { it.value }
.map { it.first().toPost().copy(mediaIds = it.map { it[PostsMedia.mediaId] }) }
}

View File

@ -168,6 +168,7 @@ class APNoteServiceImpl(
postQueryService.findByUrl(it) postQueryService.findByUrl(it)
} }
// TODO: リモートのメディア処理を追加
postService.createRemote( postService.createRemote(
Post.of( Post.of(
id = postRepository.generateId(), id = postRepository.generateId(),

View File

@ -1,12 +1,15 @@
package dev.usbharu.hideout.service.api.mastodon package dev.usbharu.hideout.service.api.mastodon
import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment
import dev.usbharu.hideout.domain.mastodon.model.generated.Status import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import dev.usbharu.hideout.domain.mastodon.model.generated.StatusesRequest import dev.usbharu.hideout.domain.mastodon.model.generated.StatusesRequest
import dev.usbharu.hideout.domain.model.hideout.dto.FileType
import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto
import dev.usbharu.hideout.domain.model.hideout.entity.Visibility import dev.usbharu.hideout.domain.model.hideout.entity.Visibility
import dev.usbharu.hideout.exception.FailedToGetResourcesException import dev.usbharu.hideout.exception.FailedToGetResourcesException
import dev.usbharu.hideout.query.PostQueryService import dev.usbharu.hideout.query.PostQueryService
import dev.usbharu.hideout.query.UserQueryService import dev.usbharu.hideout.query.UserQueryService
import dev.usbharu.hideout.repository.MediaRepository
import dev.usbharu.hideout.service.core.Transaction import dev.usbharu.hideout.service.core.Transaction
import dev.usbharu.hideout.service.mastodon.AccountService import dev.usbharu.hideout.service.mastodon.AccountService
import dev.usbharu.hideout.service.post.PostService import dev.usbharu.hideout.service.post.PostService
@ -24,11 +27,13 @@ class StatsesApiServiceImpl(
private val accountService: AccountService, private val accountService: AccountService,
private val postQueryService: PostQueryService, private val postQueryService: PostQueryService,
private val userQueryService: UserQueryService, private val userQueryService: UserQueryService,
private val mediaRepository: MediaRepository,
private val transaction: Transaction private val transaction: Transaction
) : ) :
StatusesApiService { StatusesApiService {
@Suppress("LongMethod") @Suppress("LongMethod")
override suspend fun postStatus(statusesRequest: StatusesRequest, userId: Long): Status = transaction.transaction { override suspend fun postStatus(statusesRequest: StatusesRequest, userId: Long): Status = transaction.transaction {
println("Post status media ids " + statusesRequest.mediaIds)
val visibility = when (statusesRequest.visibility) { val visibility = when (statusesRequest.visibility) {
StatusesRequest.Visibility.public -> Visibility.PUBLIC StatusesRequest.Visibility.public -> Visibility.PUBLIC
StatusesRequest.Visibility.unlisted -> Visibility.UNLISTED StatusesRequest.Visibility.unlisted -> Visibility.UNLISTED
@ -43,7 +48,8 @@ class StatsesApiServiceImpl(
overview = statusesRequest.spoilerText, overview = statusesRequest.spoilerText,
visibility = visibility, visibility = visibility,
repolyId = statusesRequest.inReplyToId?.toLongOrNull(), repolyId = statusesRequest.inReplyToId?.toLongOrNull(),
userId = userId userId = userId,
mediaIds = statusesRequest.mediaIds.orEmpty().map { it.toLong() }
) )
) )
val account = accountService.findById(userId) val account = accountService.findById(userId)
@ -66,6 +72,27 @@ class StatsesApiServiceImpl(
null null
} }
// TODO: n+1解消
val mediaAttachment = post.mediaIds.map { mediaId ->
mediaRepository.findById(mediaId)
}.map {
MediaAttachment(
it.id.toString(),
when (it.type) {
FileType.Image -> MediaAttachment.Type.image
FileType.Video -> MediaAttachment.Type.video
FileType.Audio -> MediaAttachment.Type.audio
FileType.Unknown -> MediaAttachment.Type.unknown
},
it.url,
it.thumbnailUrl,
it.remoteUrl,
"",
it.blurHash,
it.url
)
}
Status( Status(
id = post.id.toString(), id = post.id.toString(),
uri = post.apId, uri = post.apId,
@ -75,7 +102,7 @@ class StatsesApiServiceImpl(
visibility = postVisibility, visibility = postVisibility,
sensitive = post.sensitive, sensitive = post.sensitive,
spoilerText = post.overview.orEmpty(), spoilerText = post.overview.orEmpty(),
mediaAttachments = emptyList(), mediaAttachments = mediaAttachment,
mentions = emptyList(), mentions = emptyList(),
tags = emptyList(), tags = emptyList(),
emojis = emptyList(), emojis = emptyList(),
@ -87,7 +114,7 @@ class StatsesApiServiceImpl(
inReplyToAccountId = replyUser?.toString(), inReplyToAccountId = replyUser?.toString(),
language = null, language = null,
text = post.text, text = post.text,
editedAt = null editedAt = null,
) )
} }
} }

View File

@ -44,7 +44,8 @@ class PostServiceImpl(
text = post.text, text = post.text,
createdAt = Instant.now().toEpochMilli(), createdAt = Instant.now().toEpochMilli(),
visibility = post.visibility, visibility = post.visibility,
url = "${user.url}/posts/$id" url = "${user.url}/posts/$id",
mediaIds = post.mediaIds
) )
return internalCreate(createPost, isLocal) return internalCreate(createPost, isLocal)
} }

View File

@ -13,4 +13,5 @@
<logger name="Exposed" level="INFO"/> <logger name="Exposed" level="INFO"/>
<logger name="io.ktor.server.plugins.contentnegotiation" level="INFO"/> <logger name="io.ktor.server.plugins.contentnegotiation" level="INFO"/>
<logger name="org.springframework.security" level="DEBUG"/> <logger name="org.springframework.security" level="DEBUG"/>
<logger name="org.springframework.web.filter.CommonsRequestLoggingFilter" level="DEBUG"/>
</configuration> </configuration>