Merge pull request #585 from usbharu/ssr-client

noscript用の簡易SSRクライアントを追加
This commit is contained in:
usbharu 2024-09-06 17:50:36 +09:00 committed by GitHub
commit 20a762b8f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 769 additions and 110 deletions

View File

@ -0,0 +1,38 @@
package dev.usbharu.hideout.core.application.actor
import dev.usbharu.hideout.core.domain.model.actor.Actor
import java.net.URI
data class ActorDetail(
val id: Long,
val name: String,
val screenName: String,
val host: String,
val remoteUrl: String?,
val locked: Boolean,
val description: String,
val postsCount: Int,
val iconUrl: URI?,
val bannerURL: URI?,
val followingCount: Int?,
val followersCount: Int?,
) {
companion object {
fun of(actor: Actor, iconUrl: URI?, bannerURL: URI?): ActorDetail {
return ActorDetail(
id = actor.id.id,
name = actor.name.name,
screenName = actor.screenName.screenName,
host = actor.url.host,
remoteUrl = actor.url.toString(),
locked = actor.locked,
description = actor.description.description,
postsCount = actor.postsCount.postsCount,
iconUrl = iconUrl,
bannerURL = bannerURL,
followingCount = actor.followingCount?.relationshipCount,
followersCount = actor.followersCount?.relationshipCount,
)
}
}
}

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.core.application.actor
import dev.usbharu.hideout.core.domain.model.support.acct.Acct
data class GetActorDetail(
val actorName: Acct? = null,
val id: Long? = null
)

View File

@ -0,0 +1,49 @@
package dev.usbharu.hideout.core.application.actor
import dev.usbharu.hideout.core.application.shared.AbstractApplicationService
import dev.usbharu.hideout.core.application.shared.Transaction
import dev.usbharu.hideout.core.config.ApplicationConfig
import dev.usbharu.hideout.core.domain.model.actor.ActorId
import dev.usbharu.hideout.core.domain.model.actor.ActorRepository
import dev.usbharu.hideout.core.domain.model.media.MediaRepository
import dev.usbharu.hideout.core.domain.model.support.principal.Principal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
@Component
class GetActorDetailApplicationService(
private val actorRepository: ActorRepository,
private val mediaRepository: MediaRepository,
private val applicationConfig: ApplicationConfig,
transaction: Transaction
) :
AbstractApplicationService<GetActorDetail, ActorDetail>(
transaction,
logger
) {
override suspend fun internalExecute(command: GetActorDetail, principal: Principal): ActorDetail {
val actor = if (command.id != null) {
actorRepository.findById(ActorId(command.id))
?: throw IllegalArgumentException("Actor ${command.id} not found.")
} else if (command.actorName != null) {
val host = if (command.actorName.host.isEmpty()) {
applicationConfig.url.host
} else {
command.actorName.host
}
actorRepository.findByNameAndDomain(command.actorName.userpart, host)
?: throw IllegalArgumentException("Actor ${command.actorName} not found.")
} else {
throw IllegalArgumentException("id and actorName are null.")
}
val iconUrl = actor.icon?.let { mediaRepository.findById(it)?.url }
val bannerUrl = actor.banner?.let { mediaRepository.findById(it)?.url }
return ActorDetail.of(actor, iconUrl, bannerUrl)
}
companion object {
private val logger = LoggerFactory.getLogger(GetActorDetailApplicationService::class.java)
}
}

View File

@ -19,10 +19,11 @@ class GetLocalInstanceApplicationService(
transaction,
logger
) {
var cachedInstance: Instance? = null
private var cachedInstance: Instance? = null
override suspend fun internalExecute(command: Unit, principal: Principal): Instance {
if (cachedInstance != null) {
logger.trace("Use cache {}", cachedInstance)
@Suppress("UnsafeCallOnNullableType")
return cachedInstance!!
}

View File

@ -0,0 +1,5 @@
package dev.usbharu.hideout.core.application.timeline
import dev.usbharu.hideout.core.domain.model.support.page.Page
data class GetUserTimeline(val id: Long, val page: Page)

View File

@ -0,0 +1,53 @@
package dev.usbharu.hideout.core.application.timeline
import dev.usbharu.hideout.core.application.post.PostDetail
import dev.usbharu.hideout.core.application.shared.AbstractApplicationService
import dev.usbharu.hideout.core.application.shared.Transaction
import dev.usbharu.hideout.core.domain.model.actor.ActorId
import dev.usbharu.hideout.core.domain.model.post.PostId
import dev.usbharu.hideout.core.domain.model.post.PostRepository
import dev.usbharu.hideout.core.domain.model.post.Visibility
import dev.usbharu.hideout.core.domain.model.support.page.PaginationList
import dev.usbharu.hideout.core.domain.model.support.principal.Principal
import dev.usbharu.hideout.core.query.usertimeline.UserTimelineQueryService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class GetUserTimelineApplicationService(
private val userTimelineQueryService: UserTimelineQueryService,
private val postRepository: PostRepository,
transaction: Transaction
) :
AbstractApplicationService<GetUserTimeline, PaginationList<PostDetail, PostId>>(transaction, logger) {
override suspend fun internalExecute(
command: GetUserTimeline,
principal: Principal
): PaginationList<PostDetail, PostId> {
val postList = postRepository.findByActorIdAndVisibilityInList(
ActorId(command.id),
listOf(Visibility.PUBLIC, Visibility.UNLISTED, Visibility.FOLLOWERS),
command.page
)
val postIdList =
postList.mapNotNull { it.repostId } + postList.mapNotNull { it.replyId } + postList.map { it.id }
val postDetailMap = userTimelineQueryService.findByIdAll(postIdList, principal).associateBy { it.id }
return PaginationList(
postList.mapNotNull {
postDetailMap[it.id.id]?.copy(
repost = postDetailMap[it.repostId?.id],
reply = postDetailMap[it.replyId?.id]
)
},
postList.next,
postList.prev
)
}
companion object {
private val logger = LoggerFactory.getLogger(GetUserTimelineApplicationService::class.java)
}
}

View File

@ -0,0 +1,26 @@
package dev.usbharu.hideout.core.config
import org.springframework.boot.autoconfigure.context.MessageSourceProperties
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.MessageSource
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import org.springframework.context.support.ReloadableResourceBundleMessageSource
@Configuration
@Profile("dev")
class MessageSourceConfig {
@Bean
fun messageSource(messageSourceProperties: MessageSourceProperties): MessageSource {
val reloadableResourceBundleMessageSource = ReloadableResourceBundleMessageSource()
reloadableResourceBundleMessageSource.setBasename("classpath:" + messageSourceProperties.basename)
reloadableResourceBundleMessageSource.setCacheSeconds(0)
return reloadableResourceBundleMessageSource
}
@Bean
@Profile("dev")
@ConfigurationProperties(prefix = "spring.messages")
fun messageSourceProperties(): MessageSourceProperties = MessageSourceProperties()
}

View File

@ -68,7 +68,7 @@ class SecurityConfig {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)
http {
exceptionHandling {
authenticationEntryPoint = LoginUrlAuthenticationEntryPoint("/login")
authenticationEntryPoint = LoginUrlAuthenticationEntryPoint("/auth/sign_in")
}
}
return http.build()
@ -80,20 +80,29 @@ class SecurityConfig {
http {
authorizeHttpRequests {
authorize("/error", permitAll)
authorize("/login", permitAll)
authorize("/auth/sign_in", permitAll)
authorize(GET, "/.well-known/**", permitAll)
authorize(GET, "/nodeinfo/2.0", permitAll)
authorize(GET, "/auth/sign_up", hasRole("ANONYMOUS"))
authorize(POST, "/auth/sign_up", permitAll)
authorize(GET, "/users/{username}/posts/{postId}", permitAll)
authorize(GET, "/users/{userid}", permitAll)
authorize(GET, "/files/*", permitAll)
authorize(POST, "/publish", authenticated)
authorize(GET, "/publish", authenticated)
authorize(GET, "/", permitAll)
authorize(anyRequest, authenticated)
}
formLogin {
loginPage = "/auth/sign_in"
loginProcessingUrl = "/login"
defaultSuccessUrl("/home", false)
}
logout {
logoutUrl = "/auth/sign_out"
logoutSuccessUrl = "/auth/sign_in"
}
}
return http.build()
@ -131,6 +140,7 @@ class SecurityConfig {
}
@Bean
@Suppress("UnsafeCallOnNullableType")
fun loadJwkSource(jwkConfig: JwkConfig, applicationConfig: ApplicationConfig): JWKSource<SecurityContext> {
if (jwkConfig.keyId == null) {
logger.error("hideout.security.jwt.keyId is null.")

View File

@ -16,20 +16,29 @@
package dev.usbharu.hideout.core.config
import dev.usbharu.hideout.core.infrastructure.springframework.SPAInterceptor
import dev.usbharu.hideout.generate.JsonOrFormModelMethodProcessor
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.converter.HttpMessageConverter
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor
import org.springframework.web.servlet.mvc.method.annotation.ServletModelAttributeMethodProcessor
@Configuration
class MvcConfigurer(private val jsonOrFormModelMethodProcessor: JsonOrFormModelMethodProcessor) : WebMvcConfigurer {
class MvcConfigurer(
private val jsonOrFormModelMethodProcessor: JsonOrFormModelMethodProcessor,
private val spaInterceptor: SPAInterceptor
) : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(jsonOrFormModelMethodProcessor)
}
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(spaInterceptor)
}
}
@Configuration

View File

@ -5,4 +5,14 @@ data class Acct(
val host: String
) {
override fun toString(): String = "acct:$userpart@$host"
companion object {
fun of(acct: String): Acct {
return Acct(
acct.substringBeforeLast('@'),
acct.substringAfterLast('@', "")
)
}
}
}

View File

@ -18,7 +18,5 @@ package dev.usbharu.hideout.core.domain.model.userdetails
@JvmInline
value class UserDetailHashedPassword(val password: String) {
override fun toString(): String {
return "[MASKED]"
}
override fun toString(): String = "[MASKED]"
}

View File

@ -18,7 +18,5 @@ package dev.usbharu.hideout.core.domain.model.userdetails
@JvmInline
value class UserDetailId(val id: Long) {
override fun toString(): String {
return "UserDetailId(id=$id)"
}
override fun toString(): String = "UserDetailId(id=$id)"
}

View File

@ -0,0 +1,110 @@
package dev.usbharu.hideout.core.infrastructure.exposedquery
import dev.usbharu.hideout.core.application.post.ActorDetail
import dev.usbharu.hideout.core.application.post.MediaDetail
import dev.usbharu.hideout.core.application.post.PostDetail
import dev.usbharu.hideout.core.domain.model.post.PostId
import dev.usbharu.hideout.core.domain.model.post.Visibility
import dev.usbharu.hideout.core.domain.model.support.principal.Principal
import dev.usbharu.hideout.core.infrastructure.exposedrepository.*
import dev.usbharu.hideout.core.query.usertimeline.UserTimelineQueryService
import org.jetbrains.exposed.sql.*
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Repository
import java.net.URI
@Repository
class ExposedUserTimelineQueryService : UserTimelineQueryService, AbstractRepository() {
override val logger: Logger
get() = Companion.logger
protected fun authorizedQuery(principal: Principal? = null): QueryAlias {
if (principal == null) {
return Posts
.selectAll()
.where {
Posts.visibility eq Visibility.PUBLIC.name or (Posts.visibility eq Visibility.UNLISTED.name)
}.alias("authorized_table")
}
val relationshipsAlias = Relationships.alias("inverse_relationships")
return Posts
.leftJoin(PostsVisibleActors)
.leftJoin(Relationships, onColumn = { Posts.actorId }, otherColumn = { actorId })
.leftJoin(
relationshipsAlias,
onColumn = { Posts.actorId },
otherColumn = { relationshipsAlias[Relationships.targetActorId] }
)
.select(Posts.columns)
.where {
Posts.visibility eq Visibility.PUBLIC.name or
(Posts.visibility eq Visibility.UNLISTED.name) or
(Posts.visibility eq Visibility.DIRECT.name and (PostsVisibleActors.actorId eq principal.actorId.id)) or
(Posts.visibility eq Visibility.FOLLOWERS.name and (Relationships.blocking eq false and (relationshipsAlias[Relationships.following] eq true))) or
(Posts.actorId eq principal.actorId.id)
}
.alias("authorized_table")
}
override suspend fun findByIdAll(idList: List<PostId>, principal: Principal): List<PostDetail> {
val authorizedQuery = authorizedQuery(principal)
val iconMedia = Media.alias("ICON_MEDIA")
return authorizedQuery
.leftJoin(PostsVisibleActors, { authorizedQuery[Posts.id] }, { PostsVisibleActors.postId })
.leftJoin(Actors, { authorizedQuery[Posts.actorId] }, { Actors.id })
.leftJoin(iconMedia, { Actors.icon }, { iconMedia[Media.id] })
.leftJoin(PostsMedia, { authorizedQuery[Posts.id] }, { PostsMedia.postId })
.leftJoin(Media, { PostsMedia.mediaId }, { Media.id })
.selectAll()
.where { authorizedQuery[Posts.id] inList idList.map { it.id } }
.groupBy { it[authorizedQuery[Posts.id]] }
.map { it.value }
.map {
toPostDetail(it.first(), authorizedQuery, iconMedia).copy(
mediaDetailList = it.mapNotNull { resultRow ->
resultRow.toMediaOrNull()?.let { it1 -> MediaDetail.of(it1) }
}
)
}
}
private fun toPostDetail(it: ResultRow, authorizedQuery: QueryAlias, iconMedia: Alias<Media>): PostDetail {
return PostDetail(
id = it[authorizedQuery[Posts.id]],
actor = ActorDetail(
actorId = it[authorizedQuery[Posts.actorId]],
instanceId = it[Actors.instance],
name = it[Actors.name],
domain = it[Actors.domain],
screenName = it[Actors.screenName],
url = URI.create(it[Actors.url]),
locked = it[Actors.locked],
icon = it.getOrNull(iconMedia[Media.url])?.let { URI.create(it) }
),
overview = it[authorizedQuery[Posts.overview]],
text = it[authorizedQuery[Posts.text]],
content = it[authorizedQuery[Posts.content]],
createdAt = it[authorizedQuery[Posts.createdAt]],
visibility = Visibility.valueOf(it[authorizedQuery[Posts.visibility]]),
pureRepost = false,
url = URI.create(it[authorizedQuery[Posts.url]]),
apId = URI.create(it[authorizedQuery[Posts.apId]]),
repost = null,
reply = null,
sensitive = it[authorizedQuery[Posts.sensitive]],
deleted = it[authorizedQuery[Posts.deleted]],
mediaDetailList = emptyList(),
moveTo = null
)
}
companion object {
private val logger = LoggerFactory.getLogger(ExposedUserTimelineQueryService::class.java)
}
}

View File

@ -211,17 +211,38 @@ class ExposedPostRepository(
visibilityList: List<Visibility>,
of: Page?
): PaginationList<Post, PostId> {
val postList = query {
val query = Posts
.selectAll()
.where {
Posts.actorId eq actorId.id and (visibility inList visibilityList.map { it.name })
}
if (of?.minId != null) {
query.orderBy(Posts.createdAt, SortOrder.ASC)
of.minId?.let { query.andWhere { Posts.id greater it } }
of.maxId?.let { query.andWhere { Posts.id less it } }
} else {
query.orderBy(Posts.createdAt, SortOrder.DESC)
of?.sinceId?.let { query.andWhere { Posts.id greater it } }
of?.maxId?.let { query.andWhere { Posts.id less it } }
}
of?.limit?.let { query.limit(it) }
query.let(postQueryMapper::map)
}
val posts = if (of?.minId != null) {
postList.reversed()
} else {
postList
}
return PaginationList(
query {
Posts
.selectAll()
.where {
Posts.actorId eq actorId.id and (visibility inList visibilityList.map { it.name })
}
.let(postQueryMapper::map)
},
null,
null
posts,
posts.lastOrNull()?.id,
posts.firstOrNull()?.id
)
}

View File

@ -108,6 +108,23 @@ fun ResultRow.toMedia(): EntityMedia {
)
}
fun ResultRow.toMediaOrNull(): EntityMedia? {
val fileType = FileType.valueOf(this.getOrNull(Media.type) ?: return null)
val mimeType = this.getOrNull(Media.mimeType) ?: return null
return EntityMedia(
id = MediaId(this.getOrNull(Media.id) ?: return null),
name = MediaName(this.getOrNull(Media.name) ?: return null),
url = URI.create(this.getOrNull(Media.url) ?: return null),
remoteUrl = this[Media.remoteUrl]?.let { URI.create(it) },
thumbnailUrl = this[Media.thumbnailUrl]?.let { URI.create(it) },
type = FileType.valueOf(this[Media.type]),
blurHash = this[Media.blurhash]?.let { MediaBlurHash(it) },
mimeType = MimeType(mimeType.substringBefore("/"), mimeType.substringAfter("/"), fileType),
description = this[Media.description]?.let { MediaDescription(it) },
actorId = ActorId(this[Media.actorId])
)
}
object Media : Table("media") {
val id = long("id")
val name = varchar("name", 255)

View File

@ -0,0 +1,43 @@
package dev.usbharu.hideout.core.infrastructure.springframework
import dev.usbharu.hideout.core.interfaces.web.common.OGP
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import org.springframework.web.servlet.ModelAndView
@Component
class SPAInterceptor : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
if (request.getParameter("s") == "f") {
request.session.setAttribute("s", "f")
} else if (request.getParameter("s") == "t") {
request.session.setAttribute("s", "t")
}
return true
}
override fun postHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
modelAndView: ModelAndView?
) {
if (modelAndView?.viewName == "error") {
return
}
if (request.session.getAttribute("s") == "f") {
return
}
val ogp = modelAndView?.modelMap?.get("ogp") as? OGP
modelAndView?.clear()
modelAndView?.addObject("nsUrl", request.requestURI + "?s=f" + request.queryString?.let { "&$it" }.orEmpty())
modelAndView?.addObject("ogp", ogp)
modelAndView?.viewName = "index"
}
}

View File

@ -0,0 +1,28 @@
package dev.usbharu.hideout.core.interfaces.web
import dev.usbharu.hideout.core.application.instance.GetLocalInstanceApplicationService
import dev.usbharu.hideout.core.config.ApplicationConfig
import dev.usbharu.hideout.core.domain.model.support.principal.Anonymous
import dev.usbharu.hideout.core.infrastructure.springframework.SpringSecurityFormLoginPrincipalContextHolder
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
@Controller
class IndexController(
private val applicationConfig: ApplicationConfig,
private val springSecurityFormLoginPrincipalContextHolder: SpringSecurityFormLoginPrincipalContextHolder,
private val getLocalInstanceApplicationService: GetLocalInstanceApplicationService
) {
@GetMapping("/")
suspend fun index(model: Model): String {
if (springSecurityFormLoginPrincipalContextHolder.getPrincipal().userDetailId != null) {
return "redirect:/home"
}
val instance = getLocalInstanceApplicationService.execute(Unit, Anonymous)
model.addAttribute("instance", instance)
model.addAttribute("applicationConfig", applicationConfig)
return "top"
}
}

View File

@ -14,13 +14,16 @@
* limitations under the License.
*/
package dev.usbharu.hideout.core.interfaces.api.auth
package dev.usbharu.hideout.core.interfaces.web.auth
import dev.usbharu.hideout.core.application.actor.RegisterLocalActor
import dev.usbharu.hideout.core.application.actor.RegisterLocalActorApplicationService
import dev.usbharu.hideout.core.application.instance.GetLocalInstanceApplicationService
import dev.usbharu.hideout.core.config.ApplicationConfig
import dev.usbharu.hideout.core.domain.model.support.principal.Anonymous
import jakarta.servlet.http.HttpServletRequest
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
@ -28,11 +31,16 @@ import org.springframework.web.bind.annotation.PostMapping
@Controller
class AuthController(
private val applicationConfig: ApplicationConfig,
private val registerLocalActorApplicationService: RegisterLocalActorApplicationService,
private val getLocalInstanceApplicationService: GetLocalInstanceApplicationService,
) {
@GetMapping("/auth/sign_up")
@Suppress("FunctionOnlyReturningConstant")
fun signUp(): String = "sign_up"
suspend fun signUp(model: Model): String {
model.addAttribute("instance", getLocalInstanceApplicationService.execute(Unit, Anonymous))
return "sign_up"
}
@PostMapping("/auth/sign_up")
suspend fun signUp(@Validated @ModelAttribute signUpForm: SignUpForm, request: HttpServletRequest): String {
@ -44,4 +52,14 @@ class AuthController(
request.login(signUpForm.username, signUpForm.password)
return "redirect:$uri"
}
@GetMapping("/auth/sign_in")
suspend fun signIn(model: Model): String {
model.addAttribute("applicationConfig", applicationConfig)
return "sign_in"
}
@GetMapping("/auth/sign_out")
@Suppress("FunctionOnlyReturningConstant")
fun signOut(): String = "sign_out"
}

View File

@ -1,4 +1,4 @@
package dev.usbharu.hideout.core.interfaces.api.auth
package dev.usbharu.hideout.core.interfaces.web.auth
data class SignUpForm(
val username: String,

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.core.interfaces.web.common
data class OGP(
val title: String,
val url: String,
val description: String,
val image: String?
)

View File

@ -13,6 +13,7 @@ import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
@Controller
class PublishController(
@ -22,7 +23,7 @@ class PublishController(
private val userRegisterLocalPostApplicationService: RegisterLocalPostApplicationService
) {
@GetMapping("/publish")
suspend fun publish(model: Model): String {
suspend fun publish(model: Model, @RequestParam("reply_to") replyTo: Long?, @RequestParam repost: Long?): String {
val principal = springSecurityFormLoginPrincipalContextHolder.getPrincipal()
if (principal.userDetailId == null) {
@ -35,7 +36,7 @@ class PublishController(
val userDetail = getUserDetailApplicationService.execute(GetUserDetail(principal.userDetailId!!.id), principal)
model.addAttribute("instance", instance)
model.addAttribute("user", userDetail)
model.addAttribute("form", PublishPost())
model.addAttribute("form", PublishPost(reply_to = replyTo, repost = repost))
return "post-postForm"
}
@ -50,8 +51,8 @@ class PublishController(
content = publishPost.status.orEmpty(),
overview = publishPost.overview,
visibility = Visibility.valueOf(publishPost.visibility.uppercase()),
repostId = null,
replyId = null,
repostId = publishPost.repost,
replyId = publishPost.reply_to,
sensitive = false,
mediaIds = emptyList()
)

View File

@ -1,3 +1,10 @@
package dev.usbharu.hideout.core.interfaces.web.posts
data class PublishPost(var status: String? = null, var overview: String? = null, var visibility: String = "PUBLIC")
@Suppress("ConstructorParameterNaming")
data class PublishPost(
var status: String? = null,
var overview: String? = null,
var visibility: String = "PUBLIC",
var reply_to: Long? = null,
var repost: Long? = null
)

View File

@ -22,9 +22,9 @@ class TimelineController(
@GetMapping("/home")
suspend fun homeTimeline(
model: Model,
@RequestParam sinceId: String?,
@RequestParam maxId: String?,
@RequestParam minId: String?
@RequestParam("since_id") sinceId: String?,
@RequestParam("max_id") maxId: String?,
@RequestParam("min_id") minId: String?
): String {
val principal = springSecurityFormLoginPrincipalContextHolder.getPrincipal()
val userDetail = transaction.transaction {

View File

@ -0,0 +1,53 @@
package dev.usbharu.hideout.core.interfaces.web.user
import dev.usbharu.hideout.core.application.actor.GetActorDetail
import dev.usbharu.hideout.core.application.actor.GetActorDetailApplicationService
import dev.usbharu.hideout.core.application.instance.GetLocalInstanceApplicationService
import dev.usbharu.hideout.core.application.timeline.GetUserTimeline
import dev.usbharu.hideout.core.application.timeline.GetUserTimelineApplicationService
import dev.usbharu.hideout.core.domain.model.support.acct.Acct
import dev.usbharu.hideout.core.domain.model.support.page.Page
import dev.usbharu.hideout.core.domain.model.support.principal.Anonymous
import dev.usbharu.hideout.core.infrastructure.springframework.SpringSecurityFormLoginPrincipalContextHolder
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestParam
@Controller
class UserController(
private val getLocalInstanceApplicationService: GetLocalInstanceApplicationService,
private val getUserDetailApplicationService: GetActorDetailApplicationService,
private val springSecurityFormLoginPrincipalContextHolder: SpringSecurityFormLoginPrincipalContextHolder,
private val getUserTimelineApplicationService: GetUserTimelineApplicationService
) {
@GetMapping("/users/{name}")
suspend fun userById(
@PathVariable name: String,
@RequestParam("min_id") minId: Long?,
@RequestParam("max_id") maxId: Long?,
@RequestParam("since_id") sinceId: Long?,
model: Model
): String {
val principal = springSecurityFormLoginPrincipalContextHolder.getPrincipal()
model.addAttribute("instance", getLocalInstanceApplicationService.execute(Unit, Anonymous))
val actorDetail = getUserDetailApplicationService.execute(GetActorDetail(Acct.of(name)), principal)
model.addAttribute(
"user",
actorDetail
)
model.addAttribute(
"userTimeline",
getUserTimelineApplicationService.execute(
GetUserTimeline(
actorDetail.id,
Page.of(maxId, sinceId, minId, 20)
),
principal
)
)
return "userById"
}
}

View File

@ -0,0 +1,12 @@
package dev.usbharu.hideout.core.query.usertimeline
import dev.usbharu.hideout.core.application.post.PostDetail
import dev.usbharu.hideout.core.domain.model.post.PostId
import dev.usbharu.hideout.core.domain.model.support.principal.Principal
interface UserTimelineQueryService {
/**
* replyやrepost等はnullになります
*/
suspend fun findByIdAll(idList: List<PostId>, principal: Principal): List<PostDetail>
}

View File

@ -45,11 +45,7 @@ object RsaUtil {
fun decodeRsaPrivateKey(encoded: String): RSAPrivateKey = decodeRsaPrivateKey(Base64Util.decode(encoded))
fun encodeRsaPublicKey(publicKey: RSAPublicKey): String {
return Base64Util.encode(publicKey.encoded)
}
fun encodeRsaPublicKey(publicKey: RSAPublicKey): String = Base64Util.encode(publicKey.encoded)
fun encodeRsaPrivateKey(privateKey: RSAPrivateKey): String {
return Base64Util.encode(privateKey.encoded)
}
fun encodeRsaPrivateKey(privateKey: RSAPrivateKey): String = Base64Util.encode(privateKey.encoded)
}

View File

@ -29,7 +29,8 @@ spring:
virtual:
enabled: true
messages:
basename: messages.hideout-web-messages
basename: messages/hideout-web-messages
cache-duration: -1
thymeleaf:
cache: false
server:

View File

@ -1,3 +1,7 @@
auth-signIn.title=\u30ED\u30B0\u30A4\u30F3 - {0}
auth-signUp.password=\u30D1\u30B9\u30EF\u30FC\u30C9
auth-signUp.register=\u767B\u9332\u3059\u308B
auth-signUp.username=\u30E6\u30FC\u30B6\u30FC\u540D
common.audio=\u30AA\u30FC\u30C7\u30A3\u30AA
common.audio-download-link=\u97F3\u58F0\u30D5\u30A1\u30A4\u30EB\u3092\u30C0\u30A6\u30F3\u30ED\u30FC\u30C9
common.empty=\u8868\u793A\u3059\u308B\u3082\u306E\u304C\u3042\u308A\u307E\u305B\u3093
@ -16,6 +20,12 @@ post-form.new-posts=\u65B0\u3057\u3044\u6295\u7A3F!
post-form.new-posts-cw=CW
post-form.new-posts-cw-title=\u30B3\u30F3\u30C6\u30F3\u30C4\u306B\u95B2\u89A7\u6CE8\u610F\u3092\u3064\u3051\u308B
post-form.new-posts-form-label=\u4ECA\u306A\u306B\u3057\u3066\u308B?
post-form.new-posts-replyTo=\u8FD4\u4FE1\u5148
post-form.new-posts-submit=\u6295\u7A3F\u3059\u308B
post-form.new-posts.repost=\u30EA\u30DD\u30B9\u30C8
post.repost=\u30EA\u30DD\u30B9\u30C8
post.repost-by={0}\u304C\u30EA\u30DD\u30B9\u30C8
user-by-id.followersCount={0} \u30D5\u30A9\u30ED\u30EF\u30FC
user-by-id.followingCount={0} \u30D5\u30A9\u30ED\u30FC\u4E2D
user-by-id.postsCount={0} \u6295\u7A3F
user-by-id.title={0} \u3055\u3093 - {1}

View File

@ -1,3 +1,7 @@
auth-signIn.title=Sign in - {0}
auth-signUp.password=Password
auth-signUp.register=Register Account
auth-signUp.username=Username
common.audio=Audio
common.audio-download-link=Download the audio.
common.empty=Empty
@ -15,6 +19,11 @@ post-form.new-posts=New Posts!
post-form.new-posts-cw=CW
post-form.new-posts-cw-title=Add content warning
post-form.new-posts-form-label=What's on your mind?
post-form.new-posts-replyTo=Reply to
post-form.new-posts-submit=Submit!
post-form.new-posts.repost=Repost
post.repost=Repost
post.repost-by=Repost by {0}
user-by-id.followersCount={0} Followers
user-by-id.followingCount={0} Following
user-by-id.postsCount={0} Posts

View File

@ -1,3 +1,7 @@
auth-signIn.title=\u30ED\u30B0\u30A4\u30F3 - {0}
auth-signUp.password=\u30D1\u30B9\u30EF\u30FC\u30C9
auth-signUp.register=\u767B\u9332\u3059\u308B
auth-signUp.username=\u30E6\u30FC\u30B6\u30FC\u540D
common.audio=\u30AA\u30FC\u30C7\u30A3\u30AA
common.audio-download-link=\u97F3\u58F0\u30D5\u30A1\u30A4\u30EB\u3092\u30C0\u30A6\u30F3\u30ED\u30FC\u30C9
common.empty=\u8868\u793A\u3059\u308B\u3082\u306E\u304C\u3042\u308A\u307E\u305B\u3093
@ -16,6 +20,12 @@ post-form.new-posts=\u65B0\u3057\u3044\u6295\u7A3F!
post-form.new-posts-cw=CW
post-form.new-posts-cw-title=\u30B3\u30F3\u30C6\u30F3\u30C4\u306B\u95B2\u89A7\u6CE8\u610F\u3092\u3064\u3051\u308B
post-form.new-posts-form-label=\u4ECA\u306A\u306B\u3057\u3066\u308B?
post-form.new-posts-replyTo=\u8FD4\u4FE1\u5148
post-form.new-posts-submit=\u6295\u7A3F\u3059\u308B
post-form.new-posts.repost=\u30EA\u30DD\u30B9\u30C8
post.repost=\u30EA\u30DD\u30B9\u30C8
post.repost-by={0}\u304C\u30EA\u30DD\u30B9\u30C8
user-by-id.followersCount={0} \u30D5\u30A9\u30ED\u30EF\u30FC
user-by-id.followingCount={0} \u30D5\u30A9\u30ED\u30FC\u4E2D
user-by-id.postsCount={0} \u6295\u7A3F
user-by-id.title={0} \u3055\u3093 - {1}

View File

@ -11,7 +11,8 @@
<!--/*@thymesVar id="post" type="dev.usbharu.hideout.core.application.post.PostDetail"*/-->
<img alt="" height="80px" src="" th:src="${post.actor.icon}" width="80px">
<div style="display: inline-block">
<p th:text="${post.actor.screenName}+'('+${post.actor.name}+'@'+${post.actor.domain}+')'"></p>
<a th:href="${post.actor.url}"
th:text="${post.actor.screenName}+'(@'+${post.actor.name}+'@'+${post.actor.domain}+')'"></a>
</div>
<div th:utext="${post.content}">
@ -43,6 +44,7 @@
<div class="post-controller" th:fragment="single-post-controller(post)">
<!--/*@thymesVar id="post" type="dev.usbharu.hideout.core.application.post.PostDetail"*/-->
<a th:href="${'/publish?reply_to=' + post.id}">Reply</a>
<a th:href="${post.apId}">
<time th:datetime="${post.createdAt}" th:text="${#temporals.format(post.createdAt, 'yyyy-MM-dd HH:mm')}"></time>
</a>

View File

@ -8,7 +8,7 @@
<th:block th:fragment="simple-timline(timelineObject,href)">
<!--/*@thymesVar id="timelineObject" type="dev.usbharu.hideout.core.domain.model.support.page.PaginationList<dev.usbharu.hideout.core.application.post.PostDetail,dev.usbharu.hideout.core.domain.model.post.PostId>"*/-->
<div th:if="${timelineObject.prev != null}">
<a th:href="${href + '?minId=' + timelineObject.prev.id}" th:text="#{common.paging-load}">Show more</a>
<a th:href="${href + '?min_id=' + timelineObject.prev.id}" th:text="#{common.paging-load}">Show more</a>
</div>
<div th:if="${timelineObject.isEmpty()}" th:text="#{common.empty}"></div>
<div th:each="postDetail : ${timelineObject}">
@ -16,7 +16,7 @@
<th:block th:replace="~{fragments-post :: single-post-controller(${postDetail})}"></th:block>
</div>
<div th:if="${timelineObject.next != null}">
<a th:href="${href + '?maxId=' + timelineObject.next.id}" th:text="#{common.paging-load}">Show more</a>
<a th:href="${href + '?max_id=' + timelineObject.next.id}" th:text="#{common.paging-load}">Show more</a>
</div>
</th:block>
</body>

View File

@ -1,12 +1,10 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<html lang="en" th:replace="~{layout::layout(${ogp}, ~{::#content})}" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<noscript>
<div th:replace="~{fragments-timeline :: simple-timline(${timeline},'/home')}"></div>
</noscript>
<div id="content" th:replace="~{fragments-timeline :: simple-timline(${timeline},'/home')}"></div>
</body>
</html>

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head prefix="og: http://ogp.me/ns#">
<meta charset="UTF-8">
<title th:title="${ogp?.title}">Index</title>
<meta property="og:url" th:content="${ogp?.url}">
<meta property="og:title" th:content="${ogp?.title}">
<meta property="og:description" th:content="${ogp?.description}">
<th:block th:if="${ogp?.image != null}">
<meta property="og:image" th:content="${ogp.image}">
</th:block>
<noscript>
<meta http-equiv="refresh" th:content="${'1; url='+nsUrl}">
</noscript>
</head>
<body>
<noscript>
<a th:href="${nsUrl}">No Script</a>
</noscript>
</body>
</html>

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en" th:fragment="layout(ogp, content)" xmlns:th="http://www.thymeleaf.org">
<head prefix="og: http://ogp.me/ns#">
<meta charset="UTF-8">
<!--/*@thymesVar id="ogp" type="dev.usbharu.hideout.core.interfaces.web.common.OGP"*/-->
<title th:title="${ogp?.title}">Index</title>
<meta property="og:url" th:content="${ogp?.url}">
<meta property="og:title" th:content="${ogp?.title}">
<meta property="og:description" th:content="${ogp?.description}">
<th:block th:if="${ogp?.image != null}">
<meta property="og:image" th:content="${ogp.image}">
</th:block>
</head>
<body>
<header>
<a href="/home">Hideout</a><a href="/publish">New Post</a><a href="/users/1"><img height="80px"
src="/users/i/icon.jpg" width="80px"></a>
</header>
<hr>
<main>
<div th:replace="${content}"></div>
</main>
<footer>
</footer>
</body>
</html>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<html lang="ja" th:replace="~{layout::layout(${ogp}, ~{::#content})}" xmlns:th="http://www.thymeleaf.org">
<head prefix="og: http://ogp.me/ns#">
<meta charset="UTF-8">
<title th:text="#{post-form.new-posts}">New Posts!</title>
@ -7,8 +7,8 @@
<meta property="og:title" th:content="#{post-form.new-posts}">
</head>
<body>
<div id="content">
<noscript>
<div>
<img alt="" height="80px" src="" th:src="${user.iconUrl}" width="80px">
<div style="display: inline-block">
@ -16,6 +16,14 @@
</div>
</div>
<form action="/publish" method="post" th:action="@{/publish}" th:object="${form}">
<div>
<label for="status-form-replyto" th:text="#{post-form.new-posts-replyTo}">Reply to</label>
<input id="status-form-replyto" name="reply_to" th:value="${form.reply_to}" type="number">
</div>
<div>
<label for="status-form-repost" th:text="#{post-form.new-posts.repost}">Repost</label>
<input id="status-form-repost" name="repost" type="number" th:value="${form.repost}">
</div>
<div>
<label for="stats-form-overview" th:text="#{post-form.new-posts-cw}"
th:title="#{post-form.new-posts-cw-title}"
@ -31,13 +39,16 @@
<fieldset>
<legend th:text="#{common.visibility}">Visibility</legend>
<label for="status-form-visibility-public" th:text="#{common.visibility-public}">Public</label>
<input id="status-form-visibility-public" name="visibility" th:checked="${form.visibility == 'PUBLIC'}" type="radio"
<input id="status-form-visibility-public" name="visibility" th:checked="${form.visibility == 'PUBLIC'}"
type="radio"
value="PUBLIC">
<label for="status-form-visibility-unlisted" th:text="#{common.visibility-unlisted}">Unlisted</label>
<input id="status-form-visibility-unlisted" name="visibility" th:checked="${form.visibility == 'UNLISTED'}" type="radio"
<input id="status-form-visibility-unlisted" name="visibility"
th:checked="${form.visibility == 'UNLISTED'}" type="radio"
value="UNLISTED">
<label for="status-form-visibility-followers" th:text="#{common.visibility-followers}">Followers</label>
<input id="status-form-visibility-followers" name="visibility" th:checked="${form.visibility == 'FOLLOWERS'}" type="radio"
<input id="status-form-visibility-followers" name="visibility"
th:checked="${form.visibility == 'FOLLOWERS'}" type="radio"
value="FOLLOWERS">
</fieldset>
</div>
@ -45,7 +56,7 @@
<input th:value="#{post-form.new-posts-submit}" type="submit" value="Publish!">
</div>
</form>
</noscript>
</div>
</body>
</html>

View File

@ -1,24 +1,13 @@
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<html lang="ja" th:replace="~{layout::layout(${ogp},~{::#content})}" xmlns:th="http://www.thymeleaf.org">
<head prefix="og: http://ogp.me/ns#">
<meta charset="UTF-8">
<title th:text="#{post-by-id.title(${post.actor.screenName},${instance.name})}">Posts - hideout</title>
<meta property="og:url" th:content="${post.url}">
<meta property="og:title" th:content="#{post-by-id.title(${post.actor.screenName},${instance.name})}">
<meta property="og:description" th:content="${post.text}">
<th:block
th:if="${post.mediaDetailList.isEmpty() || (post.mediaDetailList.get(0).thumbnailUrl == null && (post.mediaDetailList.get(0).type != 'Image' || post.mediaDetailList.get(0).type != 'Video'))}">
<meta property="og:image" th:content="${post.actor.icon}">
</th:block>
<th:block
th:unless="${post.mediaDetailList.isEmpty() || (post.mediaDetailList.get(0).thumbnailUrl == null && (post.mediaDetailList.get(0).type != 'Image' || post.mediaDetailList.get(0).type != 'Video'))}">
<meta property="og:image" th:content="${post.mediaDetailList.get(0).thumbnailUrl}">
</th:block>
<title>Posts - hideout</title>
</head>
<body>
<noscript>
<div id="content">
<th:block th:if=" ${post.reply != null}">
<th:block th:replace="~{fragments-post :: single-simple-post(${post.reply})}"></th:block>
<hr>
@ -40,6 +29,7 @@
<th:block th:replace="={fragments-post :: single-simple-post(${post.repost})}"></th:block>
<cite th:text="${post.repost.apId}"></cite>
</th:block>
</noscript>
</div>
</body>
</html>

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="#{auth-signIn.title(${applicationConfig.url})}">Sign In - Hideout</title>
</head>
<body>
<div th:if="${param.error}">
Invalid username and password.
</div>
<div th:if="${param.logout}">
You have been logged out.
</div>
<form method="post" th:action="@{/login}">
<div>
<input name="username" placeholder="Username" type="text"/>
</div>
<div>
<input name="password" placeholder="Password" type="password"/>
</div>
<input type="submit" value="Log in"/>
</form>
</body>
</html>

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form method="post" th:action="@{/auth/sign_out}">
<input name="logout" type="submit" value="Sign out">
</form>
</body>
</html>

View File

@ -2,13 +2,21 @@
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>SignUp</title>
<title th:text="#{auth-signUp.register}">Register Account</title>
<meta property="og:url" th:content="${instance.url + '/auth/sign_up'}">
<meta property="og:title" th:content="#{auth-signUp.register}">
</head>
<body>
<form method='post' th:action="@{/auth/sign_up}">
<input name='username' type='text' value=''>
<input name='password' type='password'>
<div>
<label for="signUp-form-username" th:text="#{auth-signUp.username}">Username</label>
<input id="signUp-form-username" name='username' type='text' value=''>
</div>
<div>
<label for="signUp-form-password" th:text="#{auth-signUp.password}">Password</label>
<input id="signUp-form-password" name='password' type='password'>
</div>
<input type="submit">
</form>
</body>

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:title="${title}">Index</title>
<meta property="og:url" th:content="${url}">
<meta property="og:title" th:content="${title}">
<meta property="og:description" th:content="${description}">
<meta property="og:image" th:content="${image}">
</head>
<body>
<h1 th:text="${instance.name + ' - Hideout'}">Hideout</h1>
<div><a href="/auth/sign_up" th:unless="${applicationConfig.private}">Sign up</a> <a href="/auth/sign_in">Sign in</a>
</div>
</body>
</html>

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en" th:replace="~{layout::layout(${ogp}, ~{::#content})}" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>User - hideout</title>
</head>
<body>
<div id="content">
<div>
<img alt="" height="150px" th:src="${user.iconUrl}" width="150px">
<img alt="" height="150px" th:src="${user.bannerURL}" width="600px">
</div>
<div>
<th:block th:if="${user.locked}">
<h2 th:text="${user.screenName} + '(private)'"></h2>
</th:block>
<th:block th:if="!${user.locked}">
<h2 th:text="${user.screenName}"></h2>
</th:block>
<p th:text="'@'+${user.name} + '@' + ${user.host}"></p>
</div>
<div>
<p th:text="${user.description}"></p>
</div>
<div>
<p th:if="user.postsCount != null" th:text="#{user-by-id.postsCount(${user.postsCount})}">0 Posts</p>
<p th:if="user.followingCount != null" th:text="#{user-by-id.followingCount(${user.followingCount})}">0
Following</p>
<p th:if="user.followersCount != null" th:text="#{user-by-id.followersCount(${user.followersCount})}">0
Followers</p>
</div>
<div th:replace="~{fragments-timeline :: simple-timline(${userTimeline},'/users/'+${user.name}+'@'+${user.host})}"></div>
</div>
</body>
</html>

View File

@ -16,9 +16,8 @@
package dev.usbharu.hideout.mastodon.infrastructure.exposedquery
import dev.usbharu.hideout.core.domain.model.actor.ActorId
import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji
import dev.usbharu.hideout.core.domain.model.media.*
import dev.usbharu.hideout.core.domain.model.media.FileType
import dev.usbharu.hideout.core.domain.model.post.Visibility
import dev.usbharu.hideout.core.domain.model.support.principal.Principal
import dev.usbharu.hideout.core.infrastructure.exposedrepository.*
@ -30,7 +29,6 @@ import dev.usbharu.hideout.mastodon.query.StatusQuery
import dev.usbharu.hideout.mastodon.query.StatusQueryService
import org.jetbrains.exposed.sql.*
import org.springframework.stereotype.Repository
import java.net.URI
import dev.usbharu.hideout.core.domain.model.media.Media as EntityMedia
import dev.usbharu.hideout.mastodon.interfaces.api.generated.model.CustomEmoji as MastodonEmoji
@ -274,40 +272,6 @@ private fun toStatus(it: ResultRow, queryAlias: QueryAlias, inReplyToAlias: Alia
editedAt = null
)
fun ResultRow.toMedia(): EntityMedia {
val fileType = FileType.valueOf(this[Media.type])
val mimeType = this[Media.mimeType]
return EntityMedia(
id = MediaId(this[Media.id]),
name = MediaName(this[Media.name]),
url = URI.create(this[Media.url]),
remoteUrl = this[Media.remoteUrl]?.let { URI.create(it) },
thumbnailUrl = this[Media.thumbnailUrl]?.let { URI.create(it) },
type = fileType,
blurHash = this[Media.blurhash]?.let { MediaBlurHash(it) },
mimeType = MimeType(mimeType.substringBefore("/"), mimeType.substringAfter("/"), fileType),
description = this[Media.description]?.let { MediaDescription(it) },
actorId = ActorId(this[Media.actorId])
)
}
fun ResultRow.toMediaOrNull(): EntityMedia? {
val fileType = FileType.valueOf(this.getOrNull(Media.type) ?: return null)
val mimeType = this.getOrNull(Media.mimeType) ?: return null
return EntityMedia(
id = MediaId(this.getOrNull(Media.id) ?: return null),
name = MediaName(this.getOrNull(Media.name) ?: return null),
url = URI.create(this.getOrNull(Media.url) ?: return null),
remoteUrl = this[Media.remoteUrl]?.let { URI.create(it) },
thumbnailUrl = this[Media.thumbnailUrl]?.let { URI.create(it) },
type = FileType.valueOf(this[Media.type]),
blurHash = this[Media.blurhash]?.let { MediaBlurHash(it) },
mimeType = MimeType(mimeType.substringBefore("/"), mimeType.substringAfter("/"), fileType),
description = this[Media.description]?.let { MediaDescription(it) },
actorId = ActorId(this[Media.actorId])
)
}
fun EntityMedia.toMediaAttachments(): MediaAttachment = MediaAttachment(
id = id.id.toString(),
type = when (type) {