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.util.*
@EnableWebSecurity(debug = true)
@EnableWebSecurity(debug = false)
@Configuration
class SecurityConfig {

View File

@ -2,9 +2,12 @@ package dev.usbharu.hideout.config
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.filter.CommonsRequestLoggingFilter
import java.net.URL
@Configuration
class SpringConfig {
@ -13,6 +16,17 @@ class SpringConfig {
@Autowired
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")

View File

@ -8,5 +8,6 @@ data class PostCreateDto(
val visibility: Visibility = Visibility.PUBLIC,
val repostId: 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 replyId: Long? = null,
val sensitive: Boolean = false,
val apId: String = url
val apId: String = url,
val mediaIds: List<Long> = emptyList()
) {
companion object {
@Suppress("FunctionMinLength", "LongParameterList")
@ -28,7 +29,8 @@ data class Post private constructor(
repostId: Long? = null,
replyId: Long? = null,
sensitive: Boolean = false,
apId: String = url
apId: String = url,
mediaIds: List<Long> = emptyList()
): Post {
val characterLimit = Config.configData.characterLimit
@ -67,7 +69,8 @@ data class Post private constructor(
repostId = repostId,
replyId = replyId,
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.exception.FailedToGetResourcesException
import dev.usbharu.hideout.repository.Posts
import dev.usbharu.hideout.repository.PostsMedia
import dev.usbharu.hideout.repository.toPost
import dev.usbharu.hideout.util.singleOr
import org.jetbrains.exposed.sql.innerJoin
import org.jetbrains.exposed.sql.select
import org.springframework.stereotype.Repository
@Repository
class PostQueryServiceImpl : PostQueryService {
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()
override suspend fun findByUrl(url: String): Post = Posts.select { Posts.url eq url }
.singleOr { FailedToGetResourcesException("url: $url is duplicate or does not exist.", it) }.toPost()
override suspend fun findByUrl(url: String): Post =
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 }
.singleOr { FailedToGetResourcesException("apId: $string is duplicate or does not exist.", it) }.toPost()
override suspend fun findByApId(string: String): Post =
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
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.repository.Posts
import dev.usbharu.hideout.repository.Users
import dev.usbharu.hideout.domain.model.hideout.dto.FileType
import dev.usbharu.hideout.repository.*
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.innerJoin
import org.jetbrains.exposed.sql.select
import org.springframework.stereotype.Repository
@ -12,67 +14,21 @@ import java.time.Instant
@Repository
class StatusQueryServiceImpl : StatusQueryService {
@Suppress("LongMethod")
override suspend fun findByPostIds(ids: List<Long>): List<Status> {
val pairs = Posts.innerJoin(Users, onColumn = { userId }, otherColumn = { id })
override suspend fun findByPostIds(ids: List<Long>): List<Status> = findByPostIdsWithMediaAttachments(ids)
private suspend fun internalFindByPostIds(ids: List<Long>): List<Status> {
val pairs = Posts
.innerJoin(Users, onColumn = { Posts.userId }, otherColumn = { id })
.select { Posts.id inList ids }
.map {
Status(
id = it[Posts.id].toString(),
uri = it[Posts.apId],
createdAt = Instant.ofEpochMilli(it[Posts.createdAt]).toString(),
account = Account(
id = it[Users.id].toString(),
username = it[Users.name],
acct = "${it[Users.name]}@${it[Users.domain]}",
url = it[Users.url],
displayName = it[Users.screenName],
note = it[Users.description],
avatar = it[Users.url] + "/icon.jpg",
avatarStatic = it[Users.url] + "/icon.jpg",
header = it[Users.url] + "/header.jpg",
headerStatic = it[Users.url] + "/header.jpg",
locked = false,
fields = emptyList(),
emojis = emptyList(),
bot = false,
group = false,
discoverable = true,
createdAt = Instant.ofEpochMilli(it[Users.createdAt]).toString(),
lastStatusAt = Instant.ofEpochMilli(it[Users.createdAt]).toString(),
statusesCount = 0,
followersCount = 0,
followingCount = 0,
noindex = false,
moved = false,
suspendex = false,
limited = false
),
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
},
sensitive = it[Posts.sensitive],
spoilerText = it[Posts.overview].orEmpty(),
mediaAttachments = emptyList(),
mentions = emptyList(),
tags = emptyList(),
emojis = emptyList(),
reblogsCount = 0,
favouritesCount = 0,
repliesCount = 0,
url = it[Posts.apId],
inReplyToId = it[Posts.replyId].toString(),
inReplyToAccountId = null,
language = null,
text = it[Posts.text],
editedAt = null
) to it[Posts.repostId]
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 {
@ -90,4 +46,92 @@ class StatusQueryServiceImpl : StatusQueryService {
}
}
}
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(),
uri = it[Posts.apId],
createdAt = Instant.ofEpochMilli(it[Posts.createdAt]).toString(),
account = Account(
id = it[Users.id].toString(),
username = it[Users.name],
acct = "${it[Users.name]}@${it[Users.domain]}",
url = it[Users.url],
displayName = it[Users.screenName],
note = it[Users.description],
avatar = it[Users.url] + "/icon.jpg",
avatarStatic = it[Users.url] + "/icon.jpg",
header = it[Users.url] + "/header.jpg",
headerStatic = it[Users.url] + "/header.jpg",
locked = false,
fields = emptyList(),
emojis = emptyList(),
bot = false,
group = false,
discoverable = true,
createdAt = Instant.ofEpochMilli(it[Users.createdAt]).toString(),
lastStatusAt = Instant.ofEpochMilli(it[Users.createdAt]).toString(),
statusesCount = 0,
followersCount = 0,
followingCount = 0,
noindex = false,
moved = false,
suspendex = false,
limited = false
),
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
},
sensitive = it[Posts.sensitive],
spoilerText = it[Posts.overview].orEmpty(),
mediaAttachments = emptyList(),
mentions = emptyList(),
tags = emptyList(),
emojis = emptyList(),
reblogsCount = 0,
favouritesCount = 0,
repliesCount = 0,
url = it[Posts.apId],
inReplyToId = it[Posts.replyId].toString(),
inReplyToAccountId = null,
language = null,
text = it[Posts.text],
editedAt = null
)

View File

@ -55,18 +55,18 @@ class MediaRepositoryImpl(private val idGenerateService: IdGenerateService) : Me
Media.id eq id
}
}
}
fun ResultRow.toMedia(): EntityMedia {
return EntityMedia(
id = this[Media.id],
name = this[Media.name],
url = this[Media.url],
remoteUrl = this[Media.remoteUrl],
thumbnailUrl = this[Media.thumbnailUrl],
type = FileType.values().first { it.ordinal == this[Media.type] },
blurHash = this[Media.blurhash],
)
}
fun ResultRow.toMedia(): EntityMedia {
return EntityMedia(
id = this[Media.id],
name = this[Media.name],
url = this[Media.url],
remoteUrl = this[Media.remoteUrl],
thumbnailUrl = this[Media.thumbnailUrl],
type = FileType.values().first { it.ordinal == this[Media.type] },
blurHash = this[Media.blurhash],
)
}
object Media : Table("media") {
@ -77,4 +77,5 @@ object Media : Table("media") {
val thumbnailUrl = varchar("thumbnail_url", 255).nullable()
val type = integer("type")
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[apId] = post.apId
}
PostsMedia.batchInsert(post.mediaIds) {
this[PostsMedia.postId] = post.id
this[PostsMedia.mediaId] = it
}
} 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 }) {
it[userId] = post.userId
it[overview] = post.overview
@ -46,8 +57,12 @@ class PostRepositoryImpl(private val idGenerateService: IdGenerateService) : Pos
return post
}
override suspend fun findById(id: Long): Post = Posts.select { Posts.id eq id }.singleOrNull()?.toPost()
?: throw FailedToGetResourcesException("id: $id was not found.")
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.")
override suspend fun delete(id: Long) {
Posts.deleteWhere { Posts.id eq id }
@ -69,6 +84,13 @@ object Posts : Table() {
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 {
return Post.of(
id = this[Posts.id],
@ -81,6 +103,12 @@ fun ResultRow.toPost(): Post {
repostId = this[Posts.repostId],
replyId = this[Posts.replyId],
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)
}
// TODO: リモートのメディア処理を追加
postService.createRemote(
Post.of(
id = postRepository.generateId(),

View File

@ -1,12 +1,15 @@
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.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.entity.Visibility
import dev.usbharu.hideout.exception.FailedToGetResourcesException
import dev.usbharu.hideout.query.PostQueryService
import dev.usbharu.hideout.query.UserQueryService
import dev.usbharu.hideout.repository.MediaRepository
import dev.usbharu.hideout.service.core.Transaction
import dev.usbharu.hideout.service.mastodon.AccountService
import dev.usbharu.hideout.service.post.PostService
@ -24,11 +27,13 @@ class StatsesApiServiceImpl(
private val accountService: AccountService,
private val postQueryService: PostQueryService,
private val userQueryService: UserQueryService,
private val mediaRepository: MediaRepository,
private val transaction: Transaction
) :
StatusesApiService {
@Suppress("LongMethod")
override suspend fun postStatus(statusesRequest: StatusesRequest, userId: Long): Status = transaction.transaction {
println("Post status media ids " + statusesRequest.mediaIds)
val visibility = when (statusesRequest.visibility) {
StatusesRequest.Visibility.public -> Visibility.PUBLIC
StatusesRequest.Visibility.unlisted -> Visibility.UNLISTED
@ -43,7 +48,8 @@ class StatsesApiServiceImpl(
overview = statusesRequest.spoilerText,
visibility = visibility,
repolyId = statusesRequest.inReplyToId?.toLongOrNull(),
userId = userId
userId = userId,
mediaIds = statusesRequest.mediaIds.orEmpty().map { it.toLong() }
)
)
val account = accountService.findById(userId)
@ -66,6 +72,27 @@ class StatsesApiServiceImpl(
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(
id = post.id.toString(),
uri = post.apId,
@ -75,7 +102,7 @@ class StatsesApiServiceImpl(
visibility = postVisibility,
sensitive = post.sensitive,
spoilerText = post.overview.orEmpty(),
mediaAttachments = emptyList(),
mediaAttachments = mediaAttachment,
mentions = emptyList(),
tags = emptyList(),
emojis = emptyList(),
@ -87,7 +114,7 @@ class StatsesApiServiceImpl(
inReplyToAccountId = replyUser?.toString(),
language = null,
text = post.text,
editedAt = null
editedAt = null,
)
}
}

View File

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

View File

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