Compare commits

...

15 Commits

Author SHA1 Message Date
usbharu b1c2b554b0
Merge branch 'develop' into feature/instance 2023-11-18 13:41:35 +09:00
usbharu 8bb6b3d017
Apply suggestions from code review
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-11-18 13:41:13 +09:00
usbharu 3bddfa854e
feat: userにinstanceのidを追加 2023-11-18 12:29:20 +09:00
usbharu 1323815abd
feat: Instanceの重複チェックを追加 2023-11-18 12:20:29 +09:00
usbharu e0b1cf6bc0
feat: Nodeinfoのデシリアライズ用クラスを別に準備 2023-11-18 12:02:40 +09:00
usbharu 84356161a8
feat: 新規ユーザー作成時にInstanceを取得するように 2023-11-18 11:37:51 +09:00
usbharu b8756b40a6
feat: 汎用ResourceResolverを追加 2023-11-18 11:05:02 +09:00
usbharu 4aa1970c76
feat: InstanceServiceを追加 2023-11-18 10:39:37 +09:00
usbharu 02abbab7e1
Merge pull request #153 from usbharu/feature/oauth-scopes
feat: OAuth2のスコープの処理方法を変更
2023-11-18 00:31:59 +09:00
usbharu f8619a36ba
Merge branch 'develop' into feature/oauth-scopes 2023-11-18 00:28:26 +09:00
usbharu 25169671b6
Update src/main/kotlin/dev/usbharu/hideout/mastodon/service/app/AppApiService.kt
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-11-18 00:28:20 +09:00
usbharu d4548fe095
feat: OAuth2のスコープの処理方法を変更 2023-11-18 00:06:49 +09:00
usbharu 9b3b662812
Merge pull request #149 from usbharu/feature/jdbc-timeline
feat: タイムラインのJDBC実装
2023-11-16 23:43:35 +09:00
usbharu 621a8084fb
Apply suggestions from code review
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-11-16 23:38:24 +09:00
usbharu 22b6dacb5d
feat: タイムラインのJDBC実装 2023-11-16 23:33:10 +09:00
36 changed files with 647 additions and 70 deletions

View File

@ -3,7 +3,10 @@ package dev.usbharu.hideout.activitypub.service.common
import dev.usbharu.hideout.activitypub.domain.model.objects.Object
import dev.usbharu.hideout.core.domain.model.user.User
import dev.usbharu.hideout.core.domain.model.user.UserRepository
import dev.usbharu.hideout.core.service.resource.CacheManager
import dev.usbharu.hideout.core.service.resource.ResolveResponse
import org.springframework.stereotype.Service
import java.io.InputStream
@Service
class APResourceResolveServiceImpl(
@ -25,7 +28,7 @@ class APResourceResolveServiceImpl(
cacheManager.putCache(key) {
runResolve(url, singerId?.let { userRepository.findById(it) }, clazz)
}
return cacheManager.getOrWait(key) as T
return (cacheManager.getOrWait(key) as APResolveResponse<T>).objects
}
private suspend fun <T : Object> internalResolve(url: String, singer: User?, clazz: Class<T>): T {
@ -33,11 +36,12 @@ class APResourceResolveServiceImpl(
cacheManager.putCache(key) {
runResolve(url, singer, clazz)
}
return cacheManager.getOrWait(key) as T
return (cacheManager.getOrWait(key) as APResolveResponse<T>).objects
}
private suspend fun <T : Object> runResolve(url: String, singer: User?, clazz: Class<T>): Object =
apRequestService.apGet(url, singer, clazz)
private suspend fun <T : Object> runResolve(url: String, singer: User?, clazz: Class<T>): ResolveResponse {
return APResolveResponse(apRequestService.apGet(url, singer, clazz))
}
private fun genCacheKey(url: String, singerId: Long?): String {
if (singerId != null) {
@ -45,4 +49,30 @@ class APResourceResolveServiceImpl(
}
return url
}
private class APResolveResponse<T : Object>(val objects: T) : ResolveResponse {
override suspend fun body(): InputStream {
TODO("Not yet implemented")
}
override suspend fun bodyAsText(): String {
TODO("Not yet implemented")
}
override suspend fun bodyAsBytes(): ByteArray {
TODO("Not yet implemented")
}
override suspend fun header(): Map<String, List<String>> {
TODO("Not yet implemented")
}
override suspend fun status(): Int {
TODO("Not yet implemented")
}
override suspend fun statusMessage(): String {
TODO("Not yet implemented")
}
}
}

View File

@ -1,9 +0,0 @@
package dev.usbharu.hideout.activitypub.service.common
import dev.usbharu.hideout.activitypub.domain.model.objects.Object
interface CacheManager {
suspend fun putCache(key: String, block: suspend () -> Object)
suspend fun getOrWait(key: String): Object
}

View File

@ -124,7 +124,8 @@ class APUserServiceImpl(
?: throw IllegalActivityPubObjectException("publicKey is null"),
keyId = person.publicKey?.id ?: throw IllegalActivityPubObjectException("publicKey keyId is null"),
following = person.following,
followers = person.followers
followers = person.followers,
sharedInbox = person.endpoints["sharedInbox"]
)
)
}

View File

@ -182,7 +182,7 @@ class SecurityConfig {
).anonymous()
it.requestMatchers(builder.pattern("/change-password")).authenticated()
it.requestMatchers(builder.pattern("/api/v1/accounts/verify_credentials"))
.hasAnyAuthority("SCOPE_read", "SCOPE_read:accounts")
.hasAnyAuthority("SCOPE_read:accounts")
it.anyRequest().permitAll()
}
http.oauth2ResourceServer {

View File

@ -4,11 +4,12 @@ import dev.usbharu.hideout.application.external.Transaction
import kotlinx.coroutines.slf4j.MDCContext
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.springframework.stereotype.Service
import java.sql.Connection
@Service
class ExposedTransaction : Transaction {
override suspend fun <T> transaction(block: suspend () -> T): T {
return newSuspendedTransaction(MDCContext()) {
return newSuspendedTransaction(MDCContext(), transactionIsolation = Connection.TRANSACTION_SERIALIZABLE) {
block()
}
}

View File

@ -8,7 +8,7 @@ data class Instance(
val description: String,
val url: String,
val iconUrl: String,
val sharedInbox: String,
val sharedInbox: String?,
val software: String,
val version: String,
val isBlocked: Boolean,

View File

@ -1,6 +1,7 @@
package dev.usbharu.hideout.core.domain.model.instance
interface InstanceRepository {
suspend fun generateId(): Long
suspend fun save(instance: Instance): Instance
suspend fun findById(id: Long): Instance
suspend fun delete(instance: Instance)

View File

@ -0,0 +1,15 @@
package dev.usbharu.hideout.core.domain.model.instance
class Nodeinfo {
var links: List<Links> = emptyList()
protected constructor()
}
class Links {
var rel: String? = null
var href: String? = null
protected constructor()
}

View File

@ -0,0 +1,22 @@
package dev.usbharu.hideout.core.domain.model.instance
class Nodeinfo2_0 {
var metadata: Metadata? = null
var software: Software? = null
protected constructor()
}
class Metadata {
var nodeName: String? = null
var nodeDescription: String? = null
protected constructor()
}
class Software {
var name: String? = null
var version: String? = null
protected constructor()
}

View File

@ -21,16 +21,15 @@ data class User private constructor(
val createdAt: Instant,
val keyId: String,
val followers: String? = null,
val following: String? = null
val following: String? = null,
val instance: Long? = null
) {
override fun toString(): String =
"User(id=$id, name='$name', domain='$domain', screenName='$screenName', description='$description'," +
" password=$password, inbox='$inbox', outbox='$outbox', url='$url', publicKey='$publicKey'," +
" privateKey=$privateKey, createdAt=$createdAt, keyId='$keyId', followers=$followers," +
" following=$following)"
"User(id=$id, name='$name', domain='$domain', screenName='$screenName', description='$description', password=$password, inbox='$inbox', outbox='$outbox', url='$url', publicKey='$publicKey', privateKey=$privateKey, createdAt=$createdAt, keyId='$keyId', followers=$followers, following=$following, instance=$instance)"
@Component
class UserBuilder(private val characterLimit: CharacterLimit, private val applicationConfig: ApplicationConfig) {
private val logger = LoggerFactory.getLogger(UserBuilder::class.java)
@Suppress("LongParameterList", "FunctionMinLength", "LongMethod")
@ -49,7 +48,8 @@ data class User private constructor(
createdAt: Instant,
keyId: String,
following: String? = null,
followers: String? = null
followers: String? = null,
instance: Long? = null
): User {
// idは0未満ではいけない
require(id >= 0) { "id must be greater than or equal to 0." }
@ -141,7 +141,8 @@ data class User private constructor(
createdAt = createdAt,
keyId = keyId,
followers = followers,
following = following
following = following,
instance = instance
)
}
}

View File

@ -25,7 +25,8 @@ class UserResultRowMapper(private val userBuilder: User.UserBuilder) : ResultRow
createdAt = Instant.ofEpochMilli((resultRow[Users.createdAt])),
keyId = resultRow[Users.keyId],
followers = resultRow[Users.followers],
following = resultRow[Users.following]
following = resultRow[Users.following],
instance = resultRow[Users.instance]
)
}
}

View File

@ -38,7 +38,8 @@ class FollowerQueryServiceImpl(private val userBuilder: User.UserBuilder) : Foll
followers[Users.createdAt],
followers[Users.keyId],
followers[Users.following],
followers[Users.followers]
followers[Users.followers],
followers[Users.instance]
)
.select { Users.id eq id }
.map {
@ -57,7 +58,8 @@ class FollowerQueryServiceImpl(private val userBuilder: User.UserBuilder) : Foll
createdAt = Instant.ofEpochMilli(it[followers[Users.createdAt]]),
keyId = it[followers[Users.keyId]],
followers = it[followers[Users.followers]],
following = it[followers[Users.following]]
following = it[followers[Users.following]],
instance = it[followers[Users.instance]]
)
}
}
@ -89,7 +91,8 @@ class FollowerQueryServiceImpl(private val userBuilder: User.UserBuilder) : Foll
followers[Users.createdAt],
followers[Users.keyId],
followers[Users.following],
followers[Users.followers]
followers[Users.followers],
followers[Users.instance]
)
.select { Users.name eq name and (Users.domain eq domain) }
.map {
@ -108,7 +111,8 @@ class FollowerQueryServiceImpl(private val userBuilder: User.UserBuilder) : Foll
createdAt = Instant.ofEpochMilli(it[followers[Users.createdAt]]),
keyId = it[followers[Users.keyId]],
followers = it[followers[Users.followers]],
following = it[followers[Users.following]]
following = it[followers[Users.following]],
instance = it[followers[Users.instance]]
)
}
}
@ -140,7 +144,8 @@ class FollowerQueryServiceImpl(private val userBuilder: User.UserBuilder) : Foll
followers[Users.createdAt],
followers[Users.keyId],
followers[Users.following],
followers[Users.followers]
followers[Users.followers],
followers[Users.instance]
)
.select { followers[Users.id] eq id }
.map {
@ -159,7 +164,8 @@ class FollowerQueryServiceImpl(private val userBuilder: User.UserBuilder) : Foll
createdAt = Instant.ofEpochMilli(it[followers[Users.createdAt]]),
keyId = it[followers[Users.keyId]],
followers = it[followers[Users.followers]],
following = it[followers[Users.following]]
following = it[followers[Users.following]],
instance = it[followers[Users.instance]]
)
}
}
@ -191,7 +197,8 @@ class FollowerQueryServiceImpl(private val userBuilder: User.UserBuilder) : Foll
followers[Users.createdAt],
followers[Users.keyId],
followers[Users.following],
followers[Users.followers]
followers[Users.followers],
followers[Users.instance]
)
.select { followers[Users.name] eq name and (followers[Users.domain] eq domain) }
.map {
@ -210,7 +217,8 @@ class FollowerQueryServiceImpl(private val userBuilder: User.UserBuilder) : Foll
createdAt = Instant.ofEpochMilli(it[followers[Users.createdAt]]),
keyId = it[followers[Users.keyId]],
followers = it[followers[Users.followers]],
following = it[followers[Users.following]]
following = it[followers[Users.following]],
instance = it[followers[Users.instance]]
)
}
}

View File

@ -0,0 +1,16 @@
package dev.usbharu.hideout.core.infrastructure.exposedquery
import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException
import dev.usbharu.hideout.core.infrastructure.exposedrepository.Instance
import dev.usbharu.hideout.core.infrastructure.exposedrepository.toInstance
import dev.usbharu.hideout.core.query.InstanceQueryService
import dev.usbharu.hideout.util.singleOr
import org.jetbrains.exposed.sql.select
import org.springframework.stereotype.Repository
import dev.usbharu.hideout.core.domain.model.instance.Instance as InstanceEntity
@Repository
class InstanceQueryServiceImpl : InstanceQueryService {
override suspend fun findByUrl(url: String): InstanceEntity = Instance.select { Instance.url eq url }
.singleOr { FailedToGetResourcesException("url is doesn't exist") }.toInstance()
}

View File

@ -0,0 +1,119 @@
package dev.usbharu.hideout.core.infrastructure.exposedrepository
import dev.usbharu.hideout.application.service.id.IdGenerateService
import dev.usbharu.hideout.core.domain.model.post.Visibility
import dev.usbharu.hideout.core.domain.model.timeline.Timeline
import dev.usbharu.hideout.core.domain.model.timeline.TimelineRepository
import org.jetbrains.exposed.sql.*
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Repository
@Repository
@Qualifier("jdbc")
@ConditionalOnProperty("hideout.use-mongodb", havingValue = "false", matchIfMissing = true)
class ExposedTimelineRepository(private val idGenerateService: IdGenerateService) : TimelineRepository {
override suspend fun generateId(): Long = idGenerateService.generateId()
override suspend fun save(timeline: Timeline): Timeline {
if (Timelines.select { Timelines.id eq timeline.id }.singleOrNull() == null) {
Timelines.insert {
it[id] = timeline.id
it[userId] = timeline.userId
it[timelineId] = timeline.timelineId
it[postId] = timeline.postId
it[postUserId] = timeline.postUserId
it[createdAt] = timeline.createdAt
it[replyId] = timeline.replyId
it[repostId] = timeline.repostId
it[visibility] = timeline.visibility.ordinal
it[sensitive] = timeline.sensitive
it[isLocal] = timeline.isLocal
it[isPureRepost] = timeline.isPureRepost
it[mediaIds] = timeline.mediaIds.joinToString(",")
}
} else {
Timelines.update({ Timelines.id eq timeline.id }) {
it[userId] = timeline.userId
it[timelineId] = timeline.timelineId
it[postId] = timeline.postId
it[postUserId] = timeline.postUserId
it[createdAt] = timeline.createdAt
it[replyId] = timeline.replyId
it[repostId] = timeline.repostId
it[visibility] = timeline.visibility.ordinal
it[sensitive] = timeline.sensitive
it[isLocal] = timeline.isLocal
it[isPureRepost] = timeline.isPureRepost
it[mediaIds] = timeline.mediaIds.joinToString(",")
}
}
return timeline
}
override suspend fun saveAll(timelines: List<Timeline>): List<Timeline> {
Timelines.batchInsert(timelines, true, false) {
this[Timelines.id] = it.id
this[Timelines.userId] = it.userId
this[Timelines.timelineId] = it.timelineId
this[Timelines.postId] = it.postId
this[Timelines.postUserId] = it.postUserId
this[Timelines.createdAt] = it.createdAt
this[Timelines.replyId] = it.replyId
this[Timelines.repostId] = it.repostId
this[Timelines.visibility] = it.visibility.ordinal
this[Timelines.sensitive] = it.sensitive
this[Timelines.isLocal] = it.isLocal
this[Timelines.isPureRepost] = it.isPureRepost
this[Timelines.mediaIds] = it.mediaIds.joinToString(",")
}
return timelines
}
override suspend fun findByUserId(id: Long): List<Timeline> =
Timelines.select { Timelines.userId eq id }.map { it.toTimeline() }
override suspend fun findByUserIdAndTimelineId(userId: Long, timelineId: Long): List<Timeline> =
Timelines.select { Timelines.userId eq userId and (Timelines.timelineId eq timelineId) }
.map { it.toTimeline() }
}
fun ResultRow.toTimeline(): Timeline {
return Timeline(
id = this[Timelines.id],
userId = this[Timelines.userId],
timelineId = this[Timelines.timelineId],
postId = this[Timelines.postId],
postUserId = this[Timelines.postUserId],
createdAt = this[Timelines.createdAt],
replyId = this[Timelines.replyId],
repostId = this[Timelines.repostId],
visibility = Visibility.values().first { it.ordinal == this[Timelines.visibility] },
sensitive = this[Timelines.sensitive],
isLocal = this[Timelines.isLocal],
isPureRepost = this[Timelines.isPureRepost],
mediaIds = this[Timelines.mediaIds].split(",").mapNotNull { it.toLongOrNull() }
)
}
object Timelines : Table("timelines") {
val id = long("id")
val userId = long("user_id")
val timelineId = long("timeline_id")
val postId = long("post_id")
val postUserId = long("post_user_id")
val createdAt = long("created_at")
val replyId = long("reply_id").nullable()
val repostId = long("repost_id").nullable()
val visibility = integer("visibility")
val sensitive = bool("sensitive")
val isLocal = bool("is_local")
val isPureRepost = bool("is_pure_repost")
val mediaIds = varchar("media_ids", 255)
override val primaryKey: PrimaryKey = PrimaryKey(id)
init {
uniqueIndex(userId, timelineId, postId)
}
}

View File

@ -1,5 +1,6 @@
package dev.usbharu.hideout.core.infrastructure.exposedrepository
import dev.usbharu.hideout.application.service.id.IdGenerateService
import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException
import dev.usbharu.hideout.core.domain.model.instance.InstanceRepository
import dev.usbharu.hideout.util.singleOr
@ -10,7 +11,9 @@ import org.springframework.stereotype.Repository
import dev.usbharu.hideout.core.domain.model.instance.Instance as InstanceEntity
@Repository
class InstanceRepositoryImpl : InstanceRepository {
class InstanceRepositoryImpl(private val idGenerateService: IdGenerateService) : InstanceRepository {
override suspend fun generateId(): Long = idGenerateService.generateId()
override suspend fun save(instance: InstanceEntity): InstanceEntity {
if (Instance.select { Instance.id.eq(instance.id) }.firstOrNull() == null) {
Instance.insert {
@ -78,7 +81,7 @@ object Instance : Table("instance") {
val description = varchar("description", 5000)
val url = varchar("url", 255)
val iconUrl = varchar("icon_url", 255)
val sharedInbox = varchar("shared_inbox", 255)
val sharedInbox = varchar("shared_inbox", 255).nullable()
val software = varchar("software", 255)
val version = varchar("version", 255)
val isBlocked = bool("is_blocked")

View File

@ -59,11 +59,6 @@ class PostRepositoryImpl(
it[apId] = post.apId
}
}
assert(Posts.select { Posts.id eq post.id }.singleOrNull() != null) {
"Faild to insert"
}
return singleOrNull == null
}

View File

@ -35,6 +35,7 @@ class UserRepositoryImpl(
it[keyId] = user.keyId
it[following] = user.following
it[followers] = user.followers
it[instance] = user.instance
}
} else {
Users.update({ Users.id eq user.id }) {
@ -52,6 +53,7 @@ class UserRepositoryImpl(
it[keyId] = user.keyId
it[following] = user.following
it[followers] = user.followers
it[instance] = user.instance
}
}
return user
@ -98,6 +100,7 @@ object Users : Table("users") {
val keyId = varchar("key_id", length = 1000)
val following = varchar("following", length = 1000).nullable()
val followers = varchar("followers", length = 1000).nullable()
val instance = long("instance").references(Instance.id).nullable()
override val primaryKey: PrimaryKey = PrimaryKey(id)

View File

@ -5,21 +5,23 @@ import kjob.core.Job
import kjob.core.KJob
import kjob.core.dsl.ScheduleContext
import kjob.core.kjob
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Service
@Service
@ConditionalOnProperty(name = ["hideout.use-mongodb"], havingValue = "false", matchIfMissing = true)
class KJobJobQueueParentService(private val database: Database) : JobQueueParentService {
class KJobJobQueueParentService() : JobQueueParentService {
private val logger = LoggerFactory.getLogger(this::class.java)
val kjob: KJob = kjob(ExposedKJob) {
connectionDatabase = database
isWorker = false
}.start()
val kjob: KJob by lazy {
kjob(ExposedKJob) {
connectionDatabase = TransactionManager.defaultDatabase
isWorker = false
}.start()
}
override fun init(jobDefines: List<Job>) = Unit

View File

@ -4,7 +4,7 @@ import dev.usbharu.hideout.core.service.job.JobQueueWorkerService
import kjob.core.dsl.JobRegisterContext
import kjob.core.dsl.KJobFunctions
import kjob.core.kjob
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Service
import dev.usbharu.hideout.core.external.job.HideoutJob as HJ
@ -12,11 +12,11 @@ import kjob.core.dsl.JobContextWithProps as JCWP
@Service
@ConditionalOnProperty(name = ["hideout.use-mongodb"], havingValue = "false", matchIfMissing = true)
class KJobJobQueueWorkerService(private val database: Database) : JobQueueWorkerService {
class KJobJobQueueWorkerService() : JobQueueWorkerService {
val kjob by lazy {
kjob(ExposedKJob) {
connectionDatabase = database
connectionDatabase = TransactionManager.defaultDatabase
nonBlockingMaxJobs = 10
blockingMaxJobs = 10
jobExecutionPeriodInSeconds = 1

View File

@ -10,7 +10,7 @@ import org.springframework.stereotype.Repository
@Repository
@Suppress("InjectDispatcher")
@ConditionalOnProperty("hideout.use-mongodb", havingValue = "", matchIfMissing = false)
@ConditionalOnProperty("hideout.use-mongodb", havingValue = "true", matchIfMissing = false)
class MongoTimelineRepositoryWrapper(
private val mongoTimelineRepository: MongoTimelineRepository,
private val idGenerateService: IdGenerateService

View File

@ -0,0 +1,7 @@
package dev.usbharu.hideout.core.query
import dev.usbharu.hideout.core.domain.model.instance.Instance
interface InstanceQueryService {
suspend fun findByUrl(url: String): Instance
}

View File

@ -0,0 +1,11 @@
package dev.usbharu.hideout.core.service.instance
data class InstanceCreateDto(
val name: String?,
val description: String?,
val url: String,
val iconUrl: String,
val sharedInbox: String?,
val software: String?,
val version: String?,
)

View File

@ -0,0 +1,114 @@
package dev.usbharu.hideout.core.service.instance
import com.fasterxml.jackson.databind.ObjectMapper
import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException
import dev.usbharu.hideout.core.domain.model.instance.Instance
import dev.usbharu.hideout.core.domain.model.instance.InstanceRepository
import dev.usbharu.hideout.core.domain.model.instance.Nodeinfo
import dev.usbharu.hideout.core.domain.model.instance.Nodeinfo2_0
import dev.usbharu.hideout.core.query.InstanceQueryService
import dev.usbharu.hideout.core.service.resource.ResourceResolveService
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service
import java.net.URL
import java.time.Instant
interface InstanceService {
suspend fun fetchInstance(url: String, sharedInbox: String? = null): Instance
suspend fun createNewInstance(instanceCreateDto: InstanceCreateDto): Instance
}
@Service
class InstanceServiceImpl(
private val instanceRepository: InstanceRepository,
private val resourceResolveService: ResourceResolveService,
@Qualifier("activitypub") private val objectMapper: ObjectMapper,
private val instanceQueryService: InstanceQueryService
) : InstanceService {
override suspend fun fetchInstance(url: String, sharedInbox: String?): Instance {
val u = URL(url)
val resolveInstanceUrl = u.protocol + "://" + u.host
try {
return instanceQueryService.findByUrl(url)
} catch (e: FailedToGetResourcesException) {
logger.info("Instance not found. try fetch instance info. url: {}", resolveInstanceUrl)
logger.debug("Failed to get resources. url: {}", resolveInstanceUrl, e)
}
val nodeinfoJson = resourceResolveService.resolve("$resolveInstanceUrl/.well-known/nodeinfo").bodyAsText()
val nodeinfo = objectMapper.readValue(nodeinfoJson, Nodeinfo::class.java)
val nodeinfoPathMap = nodeinfo.links.associate { it.rel to it.href }
for ((key, value) in nodeinfoPathMap) {
when (key) {
"http://nodeinfo.diaspora.software/ns/schema/2.0" -> {
val nodeinfo20 = objectMapper.readValue(
resourceResolveService.resolve(value!!).bodyAsText(),
Nodeinfo2_0::class.java
)
val instanceCreateDto = InstanceCreateDto(
nodeinfo20.metadata?.nodeName,
nodeinfo20.metadata?.nodeDescription,
resolveInstanceUrl,
resolveInstanceUrl + "/favicon.ico",
sharedInbox,
nodeinfo20.software?.name,
nodeinfo20.software?.version
)
return createNewInstance(instanceCreateDto)
}
// TODO: 多分2.0と2.1で互換性有るのでそのまま使うけどなおす
"http://nodeinfo.diaspora.software/ns/schema/2.1" -> {
val nodeinfo20 = objectMapper.readValue(
resourceResolveService.resolve(value!!).bodyAsText(),
Nodeinfo2_0::class.java
)
val instanceCreateDto = InstanceCreateDto(
nodeinfo20.metadata?.nodeName,
nodeinfo20.metadata?.nodeDescription,
resolveInstanceUrl,
resolveInstanceUrl + "/favicon.ico",
sharedInbox,
nodeinfo20.software?.name,
nodeinfo20.software?.version
)
return createNewInstance(instanceCreateDto)
}
else -> {
TODO()
}
}
}
throw IllegalStateException("Nodeinfo aren't found.")
}
override suspend fun createNewInstance(instanceCreateDto: InstanceCreateDto): Instance {
val instance = Instance(
instanceRepository.generateId(),
instanceCreateDto.name ?: instanceCreateDto.url,
instanceCreateDto.description ?: "",
instanceCreateDto.url,
instanceCreateDto.iconUrl,
instanceCreateDto.sharedInbox,
instanceCreateDto.software ?: "unknown",
instanceCreateDto.version ?: "unknown",
false,
false,
"",
Instant.now()
)
instanceRepository.save(instance)
return instance
}
companion object {
private val logger = LoggerFactory.getLogger(InstanceServiceImpl::class.java)
}
}

View File

@ -43,11 +43,13 @@ class PostServiceImpl(
if (postRepository.save(post)) {
try {
timelineService.publishTimeline(post, isLocal)
} catch (_: DuplicateKeyException) {
} catch (e: DuplicateKeyException) {
logger.trace("Timeline already exists.", e)
}
}
post
} catch (_: ExposedSQLException) {
} catch (e: ExposedSQLException) {
logger.warn("FAILED Save to post. url: ${post.apId}", e)
postQueryService.findByApId(post.apId)
}
}

View File

@ -0,0 +1,6 @@
package dev.usbharu.hideout.core.service.resource
interface CacheManager {
suspend fun putCache(key: String, block: suspend () -> ResolveResponse)
suspend fun getOrWait(key: String): ResolveResponse
}

View File

@ -1,6 +1,5 @@
package dev.usbharu.hideout.activitypub.service.common
package dev.usbharu.hideout.core.service.resource
import dev.usbharu.hideout.activitypub.domain.model.objects.Object
import dev.usbharu.hideout.util.LruCache
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
@ -11,10 +10,10 @@ import java.time.Instant
@Service
class InMemoryCacheManager : CacheManager {
private val cacheKey = LruCache<String, Long>(15)
private val valueStore = mutableMapOf<String, Object>()
private val valueStore = mutableMapOf<String, ResolveResponse>()
private val keyMutex = Mutex()
override suspend fun putCache(key: String, block: suspend () -> Object) {
override suspend fun putCache(key: String, block: suspend () -> ResolveResponse) {
val needRunBlock: Boolean
keyMutex.withLock {
cacheKey.filter { Instant.ofEpochMilli(it.value).plusSeconds(300) <= Instant.now() }
@ -38,7 +37,7 @@ class InMemoryCacheManager : CacheManager {
}
}
override suspend fun getOrWait(key: String): Object {
override suspend fun getOrWait(key: String): ResolveResponse {
while (valueStore.contains(key).not()) {
if (cacheKey.containsKey(key).not()) {
throw IllegalStateException("Invalid cache key.")

View File

@ -0,0 +1,31 @@
package dev.usbharu.hideout.core.service.resource
import io.ktor.client.statement.*
import io.ktor.util.*
import io.ktor.utils.io.jvm.javaio.*
import java.io.InputStream
class KtorResolveResponse(val ktorHttpResponse: HttpResponse) : ResolveResponse {
private lateinit var _bodyAsText: String
private lateinit var _bodyAsBytes: ByteArray
override suspend fun body(): InputStream = ktorHttpResponse.bodyAsChannel().toInputStream()
override suspend fun bodyAsText(): String {
if (!this::_bodyAsText.isInitialized) {
_bodyAsText = ktorHttpResponse.bodyAsText()
}
return _bodyAsText
}
override suspend fun bodyAsBytes(): ByteArray {
if (!this::_bodyAsBytes.isInitialized) {
_bodyAsBytes = ktorHttpResponse.readBytes()
}
return _bodyAsBytes
}
override suspend fun header(): Map<String, List<String>> = ktorHttpResponse.headers.toMap()
override suspend fun status(): Int = ktorHttpResponse.status.value
override suspend fun statusMessage(): String = ktorHttpResponse.status.description
}

View File

@ -0,0 +1,24 @@
package dev.usbharu.hideout.core.service.resource
import io.ktor.client.*
import io.ktor.client.request.*
import org.springframework.stereotype.Service
@Service
open class KtorResourceResolveService(private val httpClient: HttpClient, private val cacheManager: CacheManager) :
ResourceResolveService {
override suspend fun resolve(url: String): ResolveResponse {
cacheManager.putCache(getCacheKey(url)) {
runResolve(url)
}
return cacheManager.getOrWait(getCacheKey(url))
}
protected suspend fun runResolve(url: String): ResolveResponse {
val httpResponse = httpClient.get(url)
return KtorResolveResponse(httpResponse)
}
protected suspend fun getCacheKey(url: String) = url
}

View File

@ -0,0 +1,12 @@
package dev.usbharu.hideout.core.service.resource
import java.io.InputStream
interface ResolveResponse {
suspend fun body(): InputStream
suspend fun bodyAsText(): String
suspend fun bodyAsBytes(): ByteArray
suspend fun header(): Map<String, List<String>>
suspend fun status(): Int
suspend fun statusMessage(): String
}

View File

@ -0,0 +1,5 @@
package dev.usbharu.hideout.core.service.resource
interface ResourceResolveService {
suspend fun resolve(url: String): ResolveResponse
}

View File

@ -0,0 +1,54 @@
package dev.usbharu.hideout.core.service.timeline
import dev.usbharu.hideout.core.infrastructure.exposedrepository.Timelines
import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusQuery
import dev.usbharu.hideout.mastodon.query.StatusQueryService
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.selectAll
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Service
@Service
@ConditionalOnProperty("hideout.use-mongodb", havingValue = "false", matchIfMissing = true)
class ExposedGenerateTimelineService(private val statusQueryService: StatusQueryService) : GenerateTimelineService {
override suspend fun getTimeline(
forUserId: Long?,
localOnly: Boolean,
mediaOnly: Boolean,
maxId: Long?,
minId: Long?,
sinceId: Long?,
limit: Int
): List<Status> {
val query = Timelines.selectAll()
if (forUserId != null) {
query.andWhere { Timelines.userId eq forUserId }
}
if (localOnly) {
query.andWhere { Timelines.isLocal eq true }
}
if (maxId != null) {
query.andWhere { Timelines.id lessEq maxId }
}
if (minId != null) {
query.andWhere { Timelines.id greaterEq minId }
}
val result = query
.limit(limit)
.orderBy(Timelines.createdAt, SortOrder.DESC)
val statusQueries = result.map {
StatusQuery(
it[Timelines.postId],
it[Timelines.replyId],
it[Timelines.repostId],
it[Timelines.mediaIds].split(",").mapNotNull { it.toLongOrNull() }
)
}
return statusQueryService.findByPostIdsWithMediaIds(statusQueries)
}
}

View File

@ -6,6 +6,7 @@ import dev.usbharu.hideout.core.domain.model.timeline.Timeline
import dev.usbharu.hideout.core.domain.model.timeline.TimelineRepository
import dev.usbharu.hideout.core.query.FollowerQueryService
import dev.usbharu.hideout.core.query.UserQueryService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
@ -58,5 +59,10 @@ class TimelineService(
)
}
timelineRepository.saveAll(timelines)
logger.debug("SUCCESS Timeline published. {}", timelines.size)
}
companion object {
private val logger = LoggerFactory.getLogger(TimelineService::class.java)
}
}

View File

@ -11,5 +11,6 @@ data class RemoteUserCreateDto(
val publicKey: String,
val keyId: String,
val followers: String?,
val following: String?
val following: String?,
val sharedInbox: String?
)

View File

@ -8,7 +8,9 @@ import dev.usbharu.hideout.core.domain.model.user.UserRepository
import dev.usbharu.hideout.core.query.FollowerQueryService
import dev.usbharu.hideout.core.query.UserQueryService
import dev.usbharu.hideout.core.service.follow.SendFollowDto
import dev.usbharu.hideout.core.service.instance.InstanceService
import org.jetbrains.exposed.exceptions.ExposedSQLException
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.Instant
@ -20,7 +22,8 @@ class UserServiceImpl(
private val userQueryService: UserQueryService,
private val followerQueryService: FollowerQueryService,
private val userBuilder: User.UserBuilder,
private val applicationConfig: ApplicationConfig
private val applicationConfig: ApplicationConfig,
private val instanceService: InstanceService
) :
UserService {
@ -49,12 +52,20 @@ class UserServiceImpl(
createdAt = Instant.now(),
following = "$userUrl/following",
followers = "$userUrl/followers",
keyId = "$userUrl#pubkey"
keyId = "$userUrl#pubkey",
instance = null
)
return userRepository.save(userEntity)
}
override suspend fun createRemoteUser(user: RemoteUserCreateDto): User {
val instance = try {
instanceService.fetchInstance(user.url, user.sharedInbox)
} catch (e: Exception) {
logger.warn("FAILED to fetch instance. url: {}", user.url, e)
null
}
val nextId = userRepository.nextId()
val userEntity = userBuilder.of(
id = nextId,
@ -69,7 +80,8 @@ class UserServiceImpl(
createdAt = Instant.now(),
followers = user.followers,
following = user.following,
keyId = user.keyId
keyId = user.keyId,
instance = instance?.id
)
return try {
userRepository.save(userEntity)
@ -106,4 +118,8 @@ class UserServiceImpl(
followerQueryService.removeFollower(id, followerId)
return false
}
companion object {
private val logger = LoggerFactory.getLogger(UserServiceImpl::class.java)
}
}

View File

@ -28,6 +28,7 @@ class AppApiServiceImpl(
private val passwordEncoder: PasswordEncoder,
private val transaction: Transaction
) : AppApiService {
override suspend fun createApp(appsRequest: AppsRequest): Application {
return transaction.transaction {
val id = UUID.randomUUID().toString()
@ -65,5 +66,84 @@ class AppApiServiceImpl(
}
}
private fun parseScope(string: String): Set<String> = string.split(" ").toSet()
private fun parseScope(string: String): Set<String> {
return string.split(" ")
.flatMap {
when (it) {
"read" -> READ_SCOPES
"write" -> WRITE_SCOPES
"follow" -> FOLLOW_SCOPES
"admin" -> ADMIN_SCOPES
"admin:write" -> ADMIN_WRITE_SCOPES
"admin:read" -> ADMIN_READ_SCOPES
else -> listOfNotNull(it.takeIf { ALL_SCOPES.contains(it) })
}
}
.toSet()
}
companion object {
private val READ_SCOPES = listOf(
"read:accounts",
"read:blocks",
"read:bookmarks",
"read:favourites",
"read:filters",
"read:follows",
"read:lists",
"read:mutes",
"read:notifications",
"read:search",
"read:statuses"
)
private val WRITE_SCOPES = listOf(
"write:accounts",
"write:blocks",
"write:bookmarks",
"write:conversations",
"write:favourites",
"write:filters",
"write:follows",
"write:lists",
"write:media",
"write:mutes",
"write:notifications",
"write:reports",
"write:statuses"
)
private val FOLLOW_SCOPES = listOf(
"read:blocks",
"write:blocks",
"read:follows",
"write:follows",
"read:mutes",
"write:mutes"
)
private val ADMIN_READ_SCOPES = listOf(
"admin:read:accounts",
"admin:read:reports",
"admin:read:domain_allows",
"admin:read:domain_blocks",
"admin:read:ip_blocks",
"admin:read:email_domain_blocks",
"admin:read:canonical_email_blocks"
)
private val ADMIN_WRITE_SCOPES = listOf(
"admin:write:accounts",
"admin:write:reports",
"admin:write:domain_allows",
"admin:write:domain_blocks",
"admin:write:ip_blocks",
"admin:write:email_domain_blocks",
"admin:write:canonical_email_blocks"
)
private val ADMIN_SCOPES = ADMIN_READ_SCOPES + ADMIN_WRITE_SCOPES
private val ALL_SCOPES = READ_SCOPES + WRITE_SCOPES + FOLLOW_SCOPES + ADMIN_SCOPES
}
}

View File

@ -1,6 +1,6 @@
hideout:
url: "https://test-hideout.usbharu.dev"
use-mongodb: true
use-mongodb: false
security:
jwt:
generate: true
@ -19,15 +19,15 @@ spring:
default-property-inclusion: always
datasource:
driver-class-name: org.h2.Driver
url: "jdbc:h2:./test-dev3;MODE=POSTGRESQL"
url: "jdbc:h2:./test-dev3;MODE=POSTGRESQL;TRACE_LEVEL_FILE=4"
username: ""
password: ""
data:
mongodb:
auto-index-creation: true
host: localhost
port: 27017
database: hideout
# data:
# mongodb:
# auto-index-creation: true
# host: localhost
# port: 27017
# database: hideout
# username: hideoutuser
# password: hideoutpass
servlet: