mirror of https://github.com/usbharu/Hideout.git
feat: メディア付き投稿をできるように
This commit is contained in:
parent
82d51e2660
commit
6b01927133
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -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]) })
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,11 +14,73 @@ 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(
|
||||
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(),
|
||||
uri = it[Posts.apId],
|
||||
createdAt = Instant.ofEpochMilli(it[Posts.createdAt]).toString(),
|
||||
|
@ -70,24 +134,4 @@ class StatusQueryServiceImpl : StatusQueryService {
|
|||
language = null,
|
||||
text = it[Posts.text],
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -55,6 +55,7 @@ class MediaRepositoryImpl(private val idGenerateService: IdGenerateService) : Me
|
|||
Media.id eq id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ResultRow.toMedia(): EntityMedia {
|
||||
return EntityMedia(
|
||||
|
@ -67,7 +68,6 @@ class MediaRepositoryImpl(private val idGenerateService: IdGenerateService) : Me
|
|||
blurHash = this[Media.blurhash],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object Media : Table("media") {
|
||||
val id = long("id")
|
||||
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,7 +57,11 @@ class PostRepositoryImpl(private val idGenerateService: IdGenerateService) : Pos
|
|||
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.")
|
||||
|
||||
override suspend fun delete(id: Long) {
|
||||
|
@ -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] }) }
|
||||
}
|
||||
|
|
|
@ -168,6 +168,7 @@ class APNoteServiceImpl(
|
|||
postQueryService.findByUrl(it)
|
||||
}
|
||||
|
||||
// TODO: リモートのメディア処理を追加
|
||||
postService.createRemote(
|
||||
Post.of(
|
||||
id = postRepository.generateId(),
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue