diff --git a/build.gradle.kts b/build.gradle.kts index 54780dc5..0dd14e61 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,14 @@ repositories { mavenCentral() } +kotlin { + target { + compilations.all { + kotlinOptions.jvmTarget = JavaVersion.VERSION_11.toString() + } + } +} + dependencies { implementation("io.ktor:ktor-server-core-jvm:$ktor_version") implementation("io.ktor:ktor-server-auth-jvm:$ktor_version") @@ -51,18 +59,30 @@ dependencies { implementation("io.insert-koin:koin-ktor:$koin_version") implementation("io.insert-koin:koin-logger-slf4j:$koin_version") implementation("io.ktor:ktor-client-logging-jvm:2.2.4") + implementation("io.ktor:ktor-server-host-common-jvm:2.2.4") + implementation("io.ktor:ktor-server-status-pages-jvm:2.2.4") testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") + testImplementation ("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") + testImplementation("io.ktor:ktor-client-mock:$ktor_version") implementation("io.ktor:ktor-client-core:$ktor_version") implementation("io.ktor:ktor-client-cio:$ktor_version") implementation("io.ktor:ktor-client-content-negotiation:$ktor_version") testImplementation("io.ktor:ktor-client-mock:$ktor_version") - implementation("tech.barbero.http-messages-signing:http-messages-signing-core:1.0.0") testImplementation("org.junit.jupiter:junit-jupiter:5.8.1") + testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0") + testImplementation("org.mockito:mockito-inline:5.2.0") + + + implementation("org.drewcarlson:kjob-core:0.6.0") + testImplementation("io.ktor:ktor-server-test-host-jvm:2.2.4") + + testImplementation("org.slf4j:slf4j-simple:2.0.7") + } jib { diff --git a/gradle.properties b/gradle.properties index 23ea2ded..73c5f36a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ ktor_version=2.2.4 kotlin_version=1.8.10 -logback_version=1.2.11 +logback_version=1.4.6 kotlin.code.style=official exposed_version=0.41.1 h2_version=2.1.214 diff --git a/src/main/kotlin/dev/usbharu/hideout/Application.kt b/src/main/kotlin/dev/usbharu/hideout/Application.kt index 56f3f1d8..320ec162 100644 --- a/src/main/kotlin/dev/usbharu/hideout/Application.kt +++ b/src/main/kotlin/dev/usbharu/hideout/Application.kt @@ -5,54 +5,73 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import dev.usbharu.hideout.config.Config import dev.usbharu.hideout.config.ConfigData +import dev.usbharu.hideout.domain.model.job.ReceiveFollowJob import dev.usbharu.hideout.plugins.* import dev.usbharu.hideout.repository.IUserAuthRepository import dev.usbharu.hideout.repository.IUserRepository import dev.usbharu.hideout.repository.UserAuthRepository import dev.usbharu.hideout.repository.UserRepository -import dev.usbharu.hideout.routing.* -import dev.usbharu.hideout.service.* +import dev.usbharu.hideout.routing.register +import dev.usbharu.hideout.service.IUserAuthService +import dev.usbharu.hideout.service.activitypub.* +import dev.usbharu.hideout.service.impl.UserAuthService +import dev.usbharu.hideout.service.impl.UserService +import dev.usbharu.hideout.service.job.JobQueueParentService +import dev.usbharu.hideout.service.job.KJobJobQueueParentService +import dev.usbharu.hideout.service.signature.HttpSignatureVerifyService +import dev.usbharu.hideout.service.signature.HttpSignatureVerifyServiceImpl +import dev.usbharu.kjob.exposed.ExposedKJob import io.ktor.client.* import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* -import io.ktor.serialization.jackson.* import io.ktor.server.application.* +import kjob.core.Job +import kjob.core.KJob +import kjob.core.dsl.JobContextWithProps +import kjob.core.dsl.JobRegisterContext +import kjob.core.dsl.KJobFunctions +import kjob.core.kjob import org.jetbrains.exposed.sql.Database import org.koin.ktor.ext.inject -import java.util.* fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args) -@Suppress("unused") // application.conf references the main function. This annotation prevents the IDE from marking it as unused. -fun Application.module() { - val module = org.koin.dsl.module { +val Application.property: Application.(propertyName: String) -> String + get() = { + environment.config.property(it).getString() + } +@Suppress("unused") // application.conf references the main function. This annotation prevents the IDE from marking it as unused. +fun Application.parent() { + + Config.configData = ConfigData( + url = property("hideout.url"), + objectMapper = jacksonObjectMapper().enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + .setSerializationInclusion(JsonInclude.Include.NON_EMPTY) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + ) + + val module = org.koin.dsl.module { single { Database.connect( - url = environment.config.property("hideout.database.url").getString(), - driver = environment.config.property("hideout.database.driver").getString(), + url = property("hideout.database.url"), + driver = property("hideout.database.driver"), + user = property("hideout.database.username"), + password = property("hideout.database.password") ) } - single { - ConfigData( - url = environment.config.propertyOrNull("hideout.url")?.getString() - ?: environment.config.property("hideout.hostname").getString(), - objectMapper = jacksonObjectMapper().enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) - .setSerializationInclusion(JsonInclude.Include.NON_EMPTY) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - ) + + single { UserRepository(get()) } + single { UserAuthRepository(get()) } + single { UserAuthService(get(), get()) } + single { HttpSignatureVerifyServiceImpl(get()) } + single { + val kJobJobQueueService = KJobJobQueueParentService(get()) + kJobJobQueueService.init(listOf()) + kJobJobQueueService } single { - HttpClient(CIO) { - install(ContentNegotiation) { - - jackson { - enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) - setSerializationInclusion(JsonInclude.Include.NON_EMPTY) - configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - } - } + HttpClient(CIO).config { install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL @@ -62,33 +81,37 @@ fun Application.module() { } } } - single { UserRepository(get()) } - single { UserAuthRepository(get()) } - single { UserAuthService(get(), get()) } + single { ActivityPubFollowServiceImpl(get(), get(), get(),get()) } + single { ActivityPubServiceImpl(get()) } single { UserService(get()) } - single { ActivityPubService() } - single { ActivityPubUserService(get(), get(), get(), get()) } - single { WebFingerService(get(), get()) } + single { ActivityPubUserServiceImpl(get(), get(), get()) } } - configureKoin(module) - val configData by inject() - Config.configData = configData - val decode = Base64.getDecoder().decode("76pc9N9hspQqapj30kCaLJA14O/50ptCg50zCA1oxjA=") - val pair = "admin" to decode - println(pair) - val userAuthService by inject() - val userService by inject() - configureSecurity(userAuthService) + + configureKoin(module) configureHTTP() + configureSockets() configureMonitoring() configureSerialization() - configureSockets() - val activityPubUserService by inject() - user(userService, activityPubUserService) - login() - register(userAuthService) - wellKnown(userService) - val activityPubService by inject() - userActivityPubRouting(activityPubService, activityPubUserService) + register(inject().value) + configureRouting( + inject().value, + inject().value, + inject().value, + inject().value + ) +} +@Suppress("unused") +fun Application.worker() { + val kJob = kjob(ExposedKJob) { + connectionDatabase = inject().value + }.start() + + val activityPubService = inject().value + + kJob.register(ReceiveFollowJob){ + execute { + activityPubService.processActivity(this,it) + } + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/ap/Accept.kt b/src/main/kotlin/dev/usbharu/hideout/ap/Accept.kt index 15014b52..35b822c8 100644 --- a/src/main/kotlin/dev/usbharu/hideout/ap/Accept.kt +++ b/src/main/kotlin/dev/usbharu/hideout/ap/Accept.kt @@ -13,4 +13,26 @@ open class Accept : Object { this.`object` = `object` this.actor = actor } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Accept) return false + if (!super.equals(other)) return false + + if (`object` != other.`object`) return false + return actor == other.actor + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + (`object`?.hashCode() ?: 0) + result = 31 * result + (actor?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "Accept(`object`=$`object`, actor=$actor) ${super.toString()}" + } + + } diff --git a/src/main/kotlin/dev/usbharu/hideout/ap/Image.kt b/src/main/kotlin/dev/usbharu/hideout/ap/Image.kt index 38d3cbbc..29639e20 100644 --- a/src/main/kotlin/dev/usbharu/hideout/ap/Image.kt +++ b/src/main/kotlin/dev/usbharu/hideout/ap/Image.kt @@ -13,4 +13,21 @@ open class Image : Object { this.url = url } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Image) return false + if (!super.equals(other)) return false + + if (mediaType != other.mediaType) return false + return url == other.url + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + (mediaType?.hashCode() ?: 0) + result = 31 * result + (url?.hashCode() ?: 0) + return result + } + + } diff --git a/src/main/kotlin/dev/usbharu/hideout/ap/JsonLd.kt b/src/main/kotlin/dev/usbharu/hideout/ap/JsonLd.kt index 51985b34..285319bd 100644 --- a/src/main/kotlin/dev/usbharu/hideout/ap/JsonLd.kt +++ b/src/main/kotlin/dev/usbharu/hideout/ap/JsonLd.kt @@ -25,6 +25,23 @@ open class JsonLd { } protected constructor() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is JsonLd) return false + + return context == other.context + } + + override fun hashCode(): Int { + return context.hashCode() + } + + override fun toString(): String { + return "JsonLd(context=$context)" + } + + } public class ContextDeserializer : JsonDeserializer() { diff --git a/src/main/kotlin/dev/usbharu/hideout/ap/Key.kt b/src/main/kotlin/dev/usbharu/hideout/ap/Key.kt index 0c7470da..b3dccdfd 100644 --- a/src/main/kotlin/dev/usbharu/hideout/ap/Key.kt +++ b/src/main/kotlin/dev/usbharu/hideout/ap/Key.kt @@ -1,9 +1,9 @@ package dev.usbharu.hideout.ap open class Key : Object{ - private var id:String? = null - private var owner:String? = null - private var publicKeyPem:String? = null + var id:String? = null + var owner:String? = null + var publicKeyPem:String? = null protected constructor() : super() constructor( type: List, @@ -17,5 +17,23 @@ open class Key : Object{ this.publicKeyPem = publicKeyPem } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Key) return false + if (!super.equals(other)) return false + + if (id != other.id) return false + if (owner != other.owner) return false + return publicKeyPem == other.publicKeyPem + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + (id?.hashCode() ?: 0) + result = 31 * result + (owner?.hashCode() ?: 0) + result = 31 * result + (publicKeyPem?.hashCode() ?: 0) + return result + } + } diff --git a/src/main/kotlin/dev/usbharu/hideout/ap/Object.kt b/src/main/kotlin/dev/usbharu/hideout/ap/Object.kt index 76d609e5..27362b30 100644 --- a/src/main/kotlin/dev/usbharu/hideout/ap/Object.kt +++ b/src/main/kotlin/dev/usbharu/hideout/ap/Object.kt @@ -24,6 +24,26 @@ open class Object : JsonLd { return toMutableList.distinct() } } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Object) return false + + if (type != other.type) return false + return name == other.name + } + + override fun hashCode(): Int { + var result = type.hashCode() + result = 31 * result + (name?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "Object(type=$type, name=$name) ${super.toString()}" + } + + } public class TypeSerializer : JsonSerializer>() { diff --git a/src/main/kotlin/dev/usbharu/hideout/ap/Person.kt b/src/main/kotlin/dev/usbharu/hideout/ap/Person.kt index 2a1f29f7..148892b0 100644 --- a/src/main/kotlin/dev/usbharu/hideout/ap/Person.kt +++ b/src/main/kotlin/dev/usbharu/hideout/ap/Person.kt @@ -5,10 +5,10 @@ open class Person : Object { var preferredUsername:String? = null var summary:String? = null var inbox:String? = null - private var outbox:String? = null + var outbox:String? = null private var url:String? = null private var icon:Image? = null - private var publicKey:Key? = null + var publicKey:Key? = null protected constructor() : super() constructor( type: List = emptyList(), @@ -32,4 +32,31 @@ open class Person : Object { this.publicKey = publicKey } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Person) return false + + if (id != other.id) return false + if (preferredUsername != other.preferredUsername) return false + if (summary != other.summary) return false + if (inbox != other.inbox) return false + if (outbox != other.outbox) return false + if (url != other.url) return false + if (icon != other.icon) return false + return publicKey == other.publicKey + } + + override fun hashCode(): Int { + var result = id?.hashCode() ?: 0 + result = 31 * result + (preferredUsername?.hashCode() ?: 0) + result = 31 * result + (summary?.hashCode() ?: 0) + result = 31 * result + (inbox?.hashCode() ?: 0) + result = 31 * result + (outbox?.hashCode() ?: 0) + result = 31 * result + (url?.hashCode() ?: 0) + result = 31 * result + (icon?.hashCode() ?: 0) + result = 31 * result + (publicKey?.hashCode() ?: 0) + return result + } + + } diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/ActivityPubStringResponse.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/ActivityPubStringResponse.kt new file mode 100644 index 00000000..2e4b8b4d --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/ActivityPubStringResponse.kt @@ -0,0 +1,24 @@ +package dev.usbharu.hideout.domain.model + +import dev.usbharu.hideout.ap.JsonLd +import dev.usbharu.hideout.util.HttpUtil.Activity +import io.ktor.http.* + +sealed class ActivityPubResponse( + val httpStatusCode: HttpStatusCode, + val contentType: ContentType = ContentType.Application.Activity +) + +class ActivityPubStringResponse( + httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + val message: String, + contentType: ContentType = ContentType.Application.Activity +) : + ActivityPubResponse(httpStatusCode, contentType) + +class ActivityPubObjectResponse( + httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + val message: JsonLd, + contentType: ContentType = ContentType.Application.Activity +) : + ActivityPubResponse(httpStatusCode, contentType) diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/User.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/User.kt index 19893891..28e57f99 100644 --- a/src/main/kotlin/dev/usbharu/hideout/domain/model/User.kt +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/User.kt @@ -2,16 +2,36 @@ package dev.usbharu.hideout.domain.model import org.jetbrains.exposed.dao.id.LongIdTable -data class User(val name: String,val domain: String, val screenName: String, val description: String) +data class User( + val name: String, + val domain: String, + val screenName: String, + val description: String, + val inbox: String, + val outbox: String, + val url: String +) data class UserEntity( val id: Long, val name: String, - val domain:String, + val domain: String, val screenName: String, - val description: String + val description: String, + val inbox: String, + val outbox: String, + val url: String ) { - constructor(id: Long, user: User) : this(id, user.name,user.domain, user.screenName, user.description) + constructor(id: Long, user: User) : this( + id, + user.name, + user.domain, + user.screenName, + user.description, + user.inbox, + user.outbox, + user.url + ) } object Users : LongIdTable("users") { @@ -19,6 +39,10 @@ object Users : LongIdTable("users") { val domain = varchar("domain", length = 255) val screenName = varchar("screen_name", length = 64) val description = varchar("description", length = 600) + val inbox = varchar("inbox", length = 255).uniqueIndex() + val outbox = varchar("outbox", length = 255).uniqueIndex() + val url = varchar("url", length = 255).uniqueIndex() + init { uniqueIndex(name, domain) } diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/job/HideoutJob.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/job/HideoutJob.kt new file mode 100644 index 00000000..c499807c --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/job/HideoutJob.kt @@ -0,0 +1,11 @@ +package dev.usbharu.hideout.domain.model.job + +import kjob.core.Job + +sealed class HideoutJob(name: String = "") : Job(name) + +object ReceiveFollowJob : HideoutJob("ReceiveFollowJob"){ + val actor = string("actor") + val follow = string("follow") + val targetActor = string("targetActor") +} diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/wellknown/WebFinger.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/wellknown/WebFinger.kt new file mode 100644 index 00000000..b8542f23 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/wellknown/WebFinger.kt @@ -0,0 +1,5 @@ +package dev.usbharu.hideout.domain.model.wellknown + +data class WebFinger(val subject:String,val links:List){ + data class Link(val rel:String,val type:String,val href:String) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/HttpSignatureVerifyException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/HttpSignatureVerifyException.kt new file mode 100644 index 00000000..d123025f --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/HttpSignatureVerifyException.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.exception + +class HttpSignatureVerifyException : IllegalArgumentException { + constructor() : super() + constructor(s: String?) : super(s) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/IllegalParameterException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/IllegalParameterException.kt new file mode 100644 index 00000000..dd94d127 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/IllegalParameterException.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.exception + +class IllegalParameterException : IllegalArgumentException { + constructor() : super() + constructor(s: String?) : super(s) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/JsonParseException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/JsonParseException.kt new file mode 100644 index 00000000..d5749f75 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/JsonParseException.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.exception + +class JsonParseException : IllegalArgumentException { + constructor() : super() + constructor(s: String?) : super(s) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/ParameterNotExistException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/ParameterNotExistException.kt new file mode 100644 index 00000000..d3ca6693 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/ParameterNotExistException.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.exception + +class ParameterNotExistException : IllegalArgumentException { + constructor() : super() + constructor(s: String?) : super(s) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/exception/ap/IllegalActivityPubObjectException.kt b/src/main/kotlin/dev/usbharu/hideout/exception/ap/IllegalActivityPubObjectException.kt new file mode 100644 index 00000000..12965d62 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/exception/ap/IllegalActivityPubObjectException.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.exception.ap + +class IllegalActivityPubObjectException : IllegalArgumentException { + constructor() : super() + constructor(s: String?) : super(s) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/ActivityPub.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/ActivityPub.kt index 7d0a41d2..2c8d1d52 100644 --- a/src/main/kotlin/dev/usbharu/hideout/plugins/ActivityPub.kt +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/ActivityPub.kt @@ -3,7 +3,7 @@ package dev.usbharu.hideout.plugins import dev.usbharu.hideout.ap.JsonLd import dev.usbharu.hideout.config.Config import dev.usbharu.hideout.service.IUserAuthService -import dev.usbharu.hideout.service.UserAuthService +import dev.usbharu.hideout.service.impl.UserAuthService import dev.usbharu.hideout.util.HttpUtil.Activity import io.ktor.client.* import io.ktor.client.plugins.api.* @@ -12,7 +12,6 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.response.* -import io.ktor.util.* import kotlinx.coroutines.runBlocking import tech.barbero.http.message.signing.HttpMessage import tech.barbero.http.message.signing.HttpMessageSigner diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/Monitoring.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/Monitoring.kt index 861880ee..3ce065b7 100644 --- a/src/main/kotlin/dev/usbharu/hideout/plugins/Monitoring.kt +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/Monitoring.kt @@ -8,6 +8,5 @@ import io.ktor.server.application.* fun Application.configureMonitoring() { install(CallLogging) { level = Level.INFO - filter { call -> call.request.path().startsWith("/") } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt index 44f097ad..6b44c0fd 100644 --- a/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/Routing.kt @@ -1,15 +1,28 @@ package dev.usbharu.hideout.plugins -import io.ktor.server.routing.* -import io.ktor.server.response.* -import io.ktor.server.plugins.autohead.* +import dev.usbharu.hideout.routing.activitypub.inbox +import dev.usbharu.hideout.routing.activitypub.outbox +import dev.usbharu.hideout.routing.activitypub.usersAP +import dev.usbharu.hideout.routing.wellknown.webfinger +import dev.usbharu.hideout.service.activitypub.ActivityPubService +import dev.usbharu.hideout.service.activitypub.ActivityPubUserService +import dev.usbharu.hideout.service.impl.UserService +import dev.usbharu.hideout.service.signature.HttpSignatureVerifyService import io.ktor.server.application.* +import io.ktor.server.plugins.autohead.* +import io.ktor.server.routing.* -fun Application.configureRouting() { +fun Application.configureRouting( + httpSignatureVerifyService: HttpSignatureVerifyService, + activityPubService: ActivityPubService, + userService:UserService, + activityPubUserService: ActivityPubUserService +) { install(AutoHeadResponse) routing { - get("/") { - call.respondText("Hello World!") - } + inbox(httpSignatureVerifyService, activityPubService) + outbox() + usersAP(activityPubUserService) + webfinger(userService) } } diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/Serialization.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/Serialization.kt index de3c0a54..c8b71a7d 100644 --- a/src/main/kotlin/dev/usbharu/hideout/plugins/Serialization.kt +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/Serialization.kt @@ -1,5 +1,11 @@ package dev.usbharu.hideout.plugins +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import com.fasterxml.jackson.databind.DeserializationFeature +import dev.usbharu.hideout.util.HttpUtil.Activity +import io.ktor.http.* import io.ktor.serialization.jackson.* import io.ktor.server.application.* import io.ktor.server.plugins.contentnegotiation.* @@ -8,11 +14,11 @@ import io.ktor.server.routing.* fun Application.configureSerialization() { install(ContentNegotiation) { - jackson() - } - routing { - get("/json/kotlinx-serialization") { - call.respond(mapOf("hello" to "world")) + jackson { + enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + setSerializationInclusion(JsonInclude.Include.NON_EMPTY) + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + configOverride(List::class.java).setSetterInfo(JsonSetter.Value.forContentNulls(Nulls.AS_EMPTY)) } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/plugins/StatusPages.kt b/src/main/kotlin/dev/usbharu/hideout/plugins/StatusPages.kt new file mode 100644 index 00000000..cd078e28 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/plugins/StatusPages.kt @@ -0,0 +1,17 @@ +package dev.usbharu.hideout.plugins + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.response.* + +fun Application.configureStatusPages() { + install(StatusPages) { + exception { call, cause -> + call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError) + } + exception { call, cause -> + call.respondText(text = "400: $cause", status = HttpStatusCode.BadRequest) + } + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/IUserRepository.kt b/src/main/kotlin/dev/usbharu/hideout/repository/IUserRepository.kt index 8da272fc..dd89dbd9 100644 --- a/src/main/kotlin/dev/usbharu/hideout/repository/IUserRepository.kt +++ b/src/main/kotlin/dev/usbharu/hideout/repository/IUserRepository.kt @@ -8,8 +8,16 @@ interface IUserRepository { suspend fun findById(id: Long): UserEntity? + suspend fun findByIds(ids: List): List + suspend fun findByName(name: String): UserEntity? + suspend fun findByNameAndDomains(names: List>): List + + suspend fun findByUrl(url:String):UserEntity? + + suspend fun findByUrls(urls: List): List + suspend fun update(userEntity: UserEntity) suspend fun delete(id: Long) diff --git a/src/main/kotlin/dev/usbharu/hideout/repository/UserRepository.kt b/src/main/kotlin/dev/usbharu/hideout/repository/UserRepository.kt index c945aab8..f51240f0 100644 --- a/src/main/kotlin/dev/usbharu/hideout/repository/UserRepository.kt +++ b/src/main/kotlin/dev/usbharu/hideout/repository/UserRepository.kt @@ -25,7 +25,10 @@ class UserRepository(private val database: Database) : IUserRepository { this[Users.name], this[Users.domain], this[Users.screenName], - this[Users.description] + this[Users.description], + this[Users.inbox], + this[Users.outbox], + this[Users.url] ) } @@ -35,7 +38,10 @@ class UserRepository(private val database: Database) : IUserRepository { this[Users.name], this[Users.domain], this[Users.screenName], - this[Users.description] + this[Users.description], + this[Users.inbox], + this[Users.outbox], + this[Users.url], ) } @@ -49,6 +55,9 @@ class UserRepository(private val database: Database) : IUserRepository { it[domain] = user.domain it[screenName] = user.screenName it[description] = user.description + it[inbox] = user.inbox + it[outbox] = user.outbox + it[url] = user.url }[Users.id].value, user) } } @@ -70,6 +79,14 @@ class UserRepository(private val database: Database) : IUserRepository { } } + override suspend fun findByIds(ids: List): List { + return query { + Users.select { Users.id inList ids }.map { + it.toUserEntity() + } + } + } + override suspend fun findByName(name: String): UserEntity? { return query { Users.select { Users.name eq name }.map { @@ -78,6 +95,26 @@ class UserRepository(private val database: Database) : IUserRepository { } } + override suspend fun findByNameAndDomains(names: List>): List { + return query { + val selectAll = Users.selectAll() + names.forEach { (name, domain) -> + selectAll.orWhere { Users.name eq name and (Users.domain eq domain) } + } + selectAll.map { it.toUserEntity() } + } + } + + override suspend fun findByUrl(url: String): UserEntity? { + return query { + Users.select { Users.url eq url }.singleOrNull()?.toUserEntity() + } + } + + override suspend fun findByUrls(urls: List): List { + TODO("Not yet implemented") + } + override suspend fun findFollowersById(id: Long): List { return query { val followers = Users.alias("FOLLOWERS") @@ -91,7 +128,16 @@ class UserRepository(private val database: Database) : IUserRepository { onColumn = { UsersFollowers.followerId }, otherColumn = { followers[Users.id] }) - .slice(followers.get(Users.id), followers.get(Users.name), followers.get(Users.domain), followers.get(Users.screenName), followers.get(Users.description)) + .slice( + followers.get(Users.id), + followers.get(Users.name), + followers.get(Users.domain), + followers.get(Users.screenName), + followers.get(Users.description), + followers.get(Users.inbox), + followers.get(Users.outbox), + followers.get(Users.url) + ) .select { Users.id eq id } .map { UserEntity( @@ -100,6 +146,9 @@ class UserRepository(private val database: Database) : IUserRepository { domain = it[followers[Users.domain]], screenName = it[followers[Users.screenName]], description = it[followers[Users.description]], + inbox = it[followers[Users.inbox]], + outbox = it[followers[Users.outbox]], + url = it[followers[Users.url]], ) } } @@ -113,6 +162,9 @@ class UserRepository(private val database: Database) : IUserRepository { it[domain] = userEntity.domain it[screenName] = userEntity.screenName it[description] = userEntity.description + it[inbox] = userEntity.inbox + it[outbox] = userEntity.outbox + it[url] = userEntity.url } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/UserRouting.kt b/src/main/kotlin/dev/usbharu/hideout/routing/UserRouting.kt index d73c8ad0..d43b070d 100644 --- a/src/main/kotlin/dev/usbharu/hideout/routing/UserRouting.kt +++ b/src/main/kotlin/dev/usbharu/hideout/routing/UserRouting.kt @@ -4,8 +4,8 @@ import dev.usbharu.hideout.domain.model.User import dev.usbharu.hideout.plugins.UserSession import dev.usbharu.hideout.plugins.respondAp import dev.usbharu.hideout.plugins.tokenAuth -import dev.usbharu.hideout.service.ActivityPubUserService -import dev.usbharu.hideout.service.UserService +import dev.usbharu.hideout.service.impl.ActivityPubUserService +import dev.usbharu.hideout.service.impl.UserService import dev.usbharu.hideout.util.HttpUtil import io.ktor.http.* import io.ktor.server.application.* @@ -13,7 +13,6 @@ import io.ktor.server.auth.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import io.ktor.server.sessions.* @Suppress("unused") fun Application.user(userService: UserService, activityPubUserService: ActivityPubUserService) { diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/WellKnownRouting.kt b/src/main/kotlin/dev/usbharu/hideout/routing/WellKnownRouting.kt index 41fc6fd1..f1e6a7a6 100644 --- a/src/main/kotlin/dev/usbharu/hideout/routing/WellKnownRouting.kt +++ b/src/main/kotlin/dev/usbharu/hideout/routing/WellKnownRouting.kt @@ -1,7 +1,7 @@ package dev.usbharu.hideout.routing import dev.usbharu.hideout.config.Config -import dev.usbharu.hideout.service.UserService +import dev.usbharu.hideout.service.impl.UserService import dev.usbharu.hideout.util.HttpUtil.Activity import io.ktor.http.* import io.ktor.server.application.* diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/InboxRouting.kt b/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/InboxRouting.kt new file mode 100644 index 00000000..9ab1d098 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/InboxRouting.kt @@ -0,0 +1,72 @@ +package dev.usbharu.hideout.routing.activitypub + +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.ActivityPubObjectResponse +import dev.usbharu.hideout.domain.model.ActivityPubStringResponse +import dev.usbharu.hideout.exception.HttpSignatureVerifyException +import dev.usbharu.hideout.service.signature.HttpSignatureVerifyService +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Routing.inbox( + httpSignatureVerifyService: HttpSignatureVerifyService, + activityPubService: dev.usbharu.hideout.service.activitypub.ActivityPubService +){ + + route("/inbox") { + get { + call.respond(HttpStatusCode.MethodNotAllowed) + } + post { + if (httpSignatureVerifyService.verify(call.request.headers).not()) { + throw HttpSignatureVerifyException() + } + val json = call.receiveText() + call.application.log.trace("Received: $json") + val activityTypes = activityPubService.parseActivity(json) + call.application.log.debug("ActivityTypes: ${activityTypes.name}") + val response = activityPubService.processActivity(json, activityTypes) + when (response) { + is ActivityPubObjectResponse -> call.respond( + response.httpStatusCode, + Config.configData.objectMapper.writeValueAsString(response.message.apply { + context = + listOf("https://www.w3.org/ns/activitystreams") + }) + ) + is ActivityPubStringResponse -> call.respond(response.httpStatusCode, response.message) + null -> call.respond(HttpStatusCode.NotImplemented) + } + } + } + route("/users/{name}/inbox"){ + get { + call.respond(HttpStatusCode.MethodNotAllowed) + } + post { + if (httpSignatureVerifyService.verify(call.request.headers).not()) { + throw HttpSignatureVerifyException() + } + val json = call.receiveText() + call.application.log.trace("Received: $json") + val activityTypes = activityPubService.parseActivity(json) + call.application.log.debug("ActivityTypes: ${activityTypes.name}") + val response = activityPubService.processActivity(json, activityTypes) + when (response) { + is ActivityPubObjectResponse -> call.respond( + response.httpStatusCode, + Config.configData.objectMapper.writeValueAsString(response.message.apply { + context = + listOf("https://www.w3.org/ns/activitystreams") + }) + ) + is ActivityPubStringResponse -> call.respond(response.httpStatusCode, response.message) + null -> call.respond(HttpStatusCode.NotImplemented) + } + } + } + +} diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/OutboxRouting.kt b/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/OutboxRouting.kt new file mode 100644 index 00000000..7bfced91 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/OutboxRouting.kt @@ -0,0 +1,27 @@ +package dev.usbharu.hideout.routing.activitypub + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Routing.outbox() { + + route("/outbox") { + get { + call.respond(HttpStatusCode.NotImplemented) + } + post { + call.respond(HttpStatusCode.NotImplemented) + } + } + route("/users/{name}/outbox"){ + get { + call.respond(HttpStatusCode.NotImplemented) + } + post { + call.respond(HttpStatusCode.NotImplemented) + } + } + +} diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/UserRouting.kt b/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/UserRouting.kt new file mode 100644 index 00000000..36b4d80d --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/routing/activitypub/UserRouting.kt @@ -0,0 +1,39 @@ +package dev.usbharu.hideout.routing.activitypub + +import dev.usbharu.hideout.exception.ParameterNotExistException +import dev.usbharu.hideout.plugins.respondAp +import dev.usbharu.hideout.service.activitypub.ActivityPubUserService +import dev.usbharu.hideout.util.HttpUtil.Activity +import dev.usbharu.hideout.util.HttpUtil.JsonLd +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.routing.* + +fun Routing.usersAP(activityPubUserService: ActivityPubUserService) { + route("/users/{name}") { + createChild(ContentTypeRouteSelector(ContentType.Application.Activity, ContentType.Application.JsonLd)).handle { + val name = + call.parameters["name"] ?: throw ParameterNotExistException("Parameter(name='name') does not exist.") + val person = activityPubUserService.getPersonByName(name) + return@handle call.respondAp( + person, + HttpStatusCode.OK + ) + } + } +} + +class ContentTypeRouteSelector(private vararg val contentType: ContentType) : RouteSelector() { + override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation { + + val requestContentType = + ContentType.parse(context.call.request.accept() ?: return RouteSelectorEvaluation.FailedParameter) + return if (contentType.any { contentType -> contentType.match(requestContentType) }) { + RouteSelectorEvaluation.Constant + } else { + RouteSelectorEvaluation.FailedParameter + } + } + +} diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/userActivityPubRouting.kt b/src/main/kotlin/dev/usbharu/hideout/routing/userActivityPubRouting.kt deleted file mode 100644 index 8993ecda..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/routing/userActivityPubRouting.kt +++ /dev/null @@ -1,54 +0,0 @@ -package dev.usbharu.hideout.routing - -import com.fasterxml.jackson.module.kotlin.readValue -import dev.usbharu.hideout.ap.Follow -import dev.usbharu.hideout.config.Config -import dev.usbharu.hideout.service.ActivityPubService -import dev.usbharu.hideout.service.ActivityPubUserService -import dev.usbharu.hideout.util.HttpUtil -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* - -fun Application.userActivityPubRouting(activityPubService: ActivityPubService, activityPubUserService: ActivityPubUserService) { - routing { - route("/users/{name}") { - route("/inbox") { - get { - call.respond(HttpStatusCode.MethodNotAllowed) - } - post { - if (!HttpUtil.isContentTypeOfActivityPub(call.request.contentType())) { - return@post call.respond(HttpStatusCode.BadRequest) - } - val bodyText = call.receiveText() - println(bodyText) - when (activityPubService.switchApType(bodyText)) { - ActivityPubService.ActivityType.Follow -> { - val readValue = Config.configData.objectMapper.readValue(bodyText) - activityPubUserService.receiveFollow(readValue) - return@post call.respond(HttpStatusCode.Accepted) - } - - ActivityPubService.ActivityType.Undo -> { - return@post call.respond(HttpStatusCode.Accepted) - } - } - - } - } - route("/outbox") { - get { - call.respond(HttpStatusCode.MethodNotAllowed) - - } - post { - - call.respond(HttpStatusCode.MethodNotAllowed) - } - } - } - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/routing/wellknown/WebfingerRouting.kt b/src/main/kotlin/dev/usbharu/hideout/routing/wellknown/WebfingerRouting.kt new file mode 100644 index 00000000..f862f745 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/routing/wellknown/WebfingerRouting.kt @@ -0,0 +1,44 @@ +package dev.usbharu.hideout.routing.wellknown + +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.wellknown.WebFinger +import dev.usbharu.hideout.exception.IllegalParameterException +import dev.usbharu.hideout.exception.ParameterNotExistException +import dev.usbharu.hideout.service.impl.UserService +import dev.usbharu.hideout.util.HttpUtil.Activity +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Routing.webfinger(userService:UserService){ + route("/.well-known/webfinger"){ + get { + val acct = call.request.queryParameters["resource"]?.decodeURLPart() + ?: throw ParameterNotExistException("Parameter(name='resource') does not exist.") + + if (acct.startsWith("acct:").not()) { + throw IllegalParameterException("Parameter(name='resource') is not start with 'acct:'.") + } + + val accountName = acct.substringBeforeLast("@") + .substringAfter("acct:") + .trimStart('@') + + val userEntity = userService.findByName(accountName) + + val webFinger = WebFinger( + subject = acct, + links = listOf( + WebFinger.Link( + rel = "self", + type = ContentType.Application.Activity.toString(), + href = "${Config.configData.url}/users/${userEntity.name}" + ) + ) + ) + + return@get call.respond(webFinger) + } + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/IUserAuthService.kt b/src/main/kotlin/dev/usbharu/hideout/service/IUserAuthService.kt index e949d4d4..d2096d37 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/IUserAuthService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/IUserAuthService.kt @@ -1,16 +1,18 @@ package dev.usbharu.hideout.service +import dev.usbharu.hideout.domain.model.UserAuthentication import dev.usbharu.hideout.domain.model.UserAuthenticationEntity interface IUserAuthService { - fun hash(password:String): String + fun hash(password: String): String - suspend fun usernameAlreadyUse(username: String):Boolean + suspend fun usernameAlreadyUse(username: String): Boolean suspend fun registerAccount(username: String, hash: String) - suspend fun verifyAccount(username: String,password: String): Boolean + suspend fun verifyAccount(username: String, password: String): Boolean - suspend fun findByUserId(userId: Long):UserAuthenticationEntity + suspend fun findByUserId(userId: Long): UserAuthenticationEntity - suspend fun findByUsername(username: String):UserAuthenticationEntity + suspend fun findByUsername(username: String): UserAuthenticationEntity + suspend fun createAccount(userEntity: UserAuthentication): UserAuthenticationEntity } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowService.kt b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowService.kt new file mode 100644 index 00000000..4fed9bb6 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowService.kt @@ -0,0 +1,11 @@ +package dev.usbharu.hideout.service.activitypub + +import dev.usbharu.hideout.ap.Follow +import dev.usbharu.hideout.domain.model.ActivityPubResponse +import dev.usbharu.hideout.domain.model.job.ReceiveFollowJob +import kjob.core.job.JobProps + +interface ActivityPubFollowService { + suspend fun receiveFollow(follow:Follow):ActivityPubResponse + suspend fun receiveFollowJob(props: JobProps) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowServiceImpl.kt new file mode 100644 index 00000000..0a606f4a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowServiceImpl.kt @@ -0,0 +1,52 @@ +package dev.usbharu.hideout.service.activitypub + +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.ap.Accept +import dev.usbharu.hideout.ap.Follow +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.ActivityPubResponse +import dev.usbharu.hideout.domain.model.ActivityPubStringResponse +import dev.usbharu.hideout.domain.model.job.ReceiveFollowJob +import dev.usbharu.hideout.plugins.postAp +import dev.usbharu.hideout.service.impl.UserService +import dev.usbharu.hideout.service.job.JobQueueParentService +import io.ktor.client.* +import io.ktor.http.* +import kjob.core.job.JobProps + +class ActivityPubFollowServiceImpl( + private val jobQueueParentService: JobQueueParentService, + private val activityPubUserService: ActivityPubUserService, + private val userService: UserService, + private val httpClient: HttpClient +) : ActivityPubFollowService { + override suspend fun receiveFollow(follow: Follow): ActivityPubResponse { + // TODO: Verify HTTP Signature + jobQueueParentService.schedule(ReceiveFollowJob) { + props[it.actor] = follow.actor + props[it.follow] = Config.configData.objectMapper.writeValueAsString(follow) + props[it.targetActor] = follow.`object` + } + return ActivityPubStringResponse(HttpStatusCode.OK, "{}", ContentType.Application.Json) + } + + override suspend fun receiveFollowJob(props: JobProps) { + val actor = props[ReceiveFollowJob.actor] + val person = activityPubUserService.fetchPerson(actor) + val follow = Config.configData.objectMapper.readValue(props[ReceiveFollowJob.follow]) + val targetActor = props[ReceiveFollowJob.targetActor] + httpClient.postAp( + urlString = person.inbox ?: throw IllegalArgumentException("inbox is not found"), + username = "$targetActor#pubkey", + jsonLd = Accept( + name = "Follow", + `object` = follow, + actor = targetActor + ) + ) + val users = + userService.findByUrls(listOf(targetActor, follow.actor ?: throw IllegalArgumentException("actor is null"))) + + userService.addFollowers(users.first { it.url == targetActor }.id, users.first { it.url == follow.actor }.id) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubService.kt b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubService.kt new file mode 100644 index 00000000..939d3d3b --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubService.kt @@ -0,0 +1,45 @@ +package dev.usbharu.hideout.service.activitypub + +import dev.usbharu.hideout.domain.model.ActivityPubResponse +import dev.usbharu.hideout.domain.model.job.HideoutJob +import kjob.core.dsl.JobContextWithProps + +interface ActivityPubService { + fun parseActivity(json: String): ActivityType + + suspend fun processActivity(json: String, type: ActivityType): ActivityPubResponse? + + suspend fun processActivity(job: JobContextWithProps,hideoutJob: HideoutJob) +} + +enum class ActivityType { + Accept, + Add, + Announce, + Arrive, + Block, + Create, + Delete, + Dislike, + Flag, + Follow, + Ignore, + Invite, + Join, + Leave, + Like, + Listen, + Move, + Offer, + Question, + Reject, + Read, + Remove, + TentativeReject, + TentativeAccept, + Travel, + Undo, + Update, + View, + Other +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubServiceImpl.kt new file mode 100644 index 00000000..06224a67 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubServiceImpl.kt @@ -0,0 +1,82 @@ +package dev.usbharu.hideout.service.activitypub + +import com.fasterxml.jackson.databind.JsonNode +import dev.usbharu.hideout.ap.Follow +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.ActivityPubResponse +import dev.usbharu.hideout.domain.model.job.HideoutJob +import dev.usbharu.hideout.domain.model.job.ReceiveFollowJob +import dev.usbharu.hideout.exception.JsonParseException +import kjob.core.Job +import kjob.core.dsl.JobContextWithProps +import kjob.core.job.JobProps +import org.slf4j.LoggerFactory +import kotlin.reflect.full.createInstance +import kotlin.reflect.full.primaryConstructor + +class ActivityPubServiceImpl(private val activityPubFollowService: ActivityPubFollowService) : ActivityPubService { + + val logger = LoggerFactory.getLogger(this::class.java) + override fun parseActivity(json: String): ActivityType { + val readTree = Config.configData.objectMapper.readTree(json) + logger.debug("readTree: {}", readTree) + if (readTree.isObject.not()) { + throw JsonParseException("Json is not object.") + } + val type = readTree["type"] + if (type.isArray) { + return type.mapNotNull { jsonNode: JsonNode -> + ActivityType.values().firstOrNull { it.name.equals(jsonNode.asText(), true) } + }.first() + } + return ActivityType.values().first { it.name.equals(type.asText(), true) } + } + + override suspend fun processActivity(json: String, type: ActivityType): ActivityPubResponse? { + return when (type) { + ActivityType.Accept -> TODO() + ActivityType.Add -> TODO() + ActivityType.Announce -> TODO() + ActivityType.Arrive -> TODO() + ActivityType.Block -> TODO() + ActivityType.Create -> TODO() + ActivityType.Delete -> TODO() + ActivityType.Dislike -> TODO() + ActivityType.Flag -> TODO() + ActivityType.Follow -> activityPubFollowService.receiveFollow( + Config.configData.objectMapper.readValue( + json, + Follow::class.java + ) + ) + + ActivityType.Ignore -> TODO() + ActivityType.Invite -> TODO() + ActivityType.Join -> TODO() + ActivityType.Leave -> TODO() + ActivityType.Like -> TODO() + ActivityType.Listen -> TODO() + ActivityType.Move -> TODO() + ActivityType.Offer -> TODO() + ActivityType.Question -> TODO() + ActivityType.Reject -> TODO() + ActivityType.Read -> TODO() + ActivityType.Remove -> TODO() + ActivityType.TentativeReject -> TODO() + ActivityType.TentativeAccept -> TODO() + ActivityType.Travel -> TODO() + ActivityType.Undo -> TODO() + ActivityType.Update -> TODO() + ActivityType.View -> TODO() + ActivityType.Other -> TODO() + } + } + + override suspend fun processActivity(job: JobContextWithProps, hideoutJob: HideoutJob) { + when (hideoutJob) { + ReceiveFollowJob -> activityPubFollowService.receiveFollowJob(job.props as JobProps) + } + } + + +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubUserService.kt b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubUserService.kt new file mode 100644 index 00000000..0b67e383 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubUserService.kt @@ -0,0 +1,9 @@ +package dev.usbharu.hideout.service.activitypub + +import dev.usbharu.hideout.ap.Person + +interface ActivityPubUserService { + suspend fun getPersonByName(name:String):Person + + suspend fun fetchPerson(url:String):Person +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubUserServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubUserServiceImpl.kt new file mode 100644 index 00000000..2c5c5002 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubUserServiceImpl.kt @@ -0,0 +1,113 @@ +package dev.usbharu.hideout.service.activitypub + +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.ap.Image +import dev.usbharu.hideout.ap.Key +import dev.usbharu.hideout.ap.Person +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.domain.model.User +import dev.usbharu.hideout.domain.model.UserAuthentication +import dev.usbharu.hideout.exception.UserNotFoundException +import dev.usbharu.hideout.exception.ap.IllegalActivityPubObjectException +import dev.usbharu.hideout.service.IUserAuthService +import dev.usbharu.hideout.service.impl.UserService +import dev.usbharu.hideout.util.HttpUtil.Activity +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* + +class ActivityPubUserServiceImpl( + private val userService: UserService, + private val userAuthService: IUserAuthService, + private val httpClient: HttpClient +) : + ActivityPubUserService { + override suspend fun getPersonByName(name: String): Person { + // TODO: JOINで書き直し + val userEntity = userService.findByName(name) + val userAuthEntity = userAuthService.findByUserId(userEntity.id) + val userUrl = "${Config.configData.url}/users/$name" + return Person( + type = emptyList(), + name = userEntity.name, + id = userUrl, + preferredUsername = name, + summary = userEntity.description, + inbox = "$userUrl/inbox", + outbox = "$userUrl/outbox", + url = userUrl, + icon = Image( + type = emptyList(), + name = "$userUrl/icon.png", + mediaType = "image/png", + url = "$userUrl/icon.png" + ), + publicKey = Key( + type = emptyList(), + name = "Public Key", + id = "$userUrl#pubkey", + owner = userUrl, + publicKeyPem = userAuthEntity.publicKey + ) + ) + } + + override suspend fun fetchPerson(url: String): Person { + return try { + val userEntity = userService.findByUrl(url) + val userAuthEntity = userAuthService.findByUsername(userEntity.name) + return Person( + type = emptyList(), + name = userEntity.name, + id = url, + preferredUsername = userEntity.name, + summary = userEntity.description, + inbox = "$url/inbox", + outbox = "$url/outbox", + url = url, + icon = Image( + type = emptyList(), + name = "$url/icon.png", + mediaType = "image/png", + url = "$url/icon.png" + ), + publicKey = Key( + type = emptyList(), + name = "Public Key", + id = "$url#pubkey", + owner = url, + publicKeyPem = userAuthEntity.publicKey + ) + ) + + } catch (e: UserNotFoundException) { + val httpResponse = httpClient.get(url) { + accept(ContentType.Application.Activity) + } + val person = Config.configData.objectMapper.readValue(httpResponse.bodyAsText()) + val userEntity = userService.create( + User( + name = person.preferredUsername + ?: throw IllegalActivityPubObjectException("preferredUsername is null"), + domain = url.substringAfter(":").substringBeforeLast("/"), + screenName = person.name ?: throw IllegalActivityPubObjectException("name is null"), + description = person.summary ?: throw IllegalActivityPubObjectException("summary is null"), + inbox = person.inbox ?: throw IllegalActivityPubObjectException("inbox is null"), + outbox = person.outbox ?: throw IllegalActivityPubObjectException("outbox is null"), + url = url + ) + ) + userAuthService.createAccount( + UserAuthentication( + userEntity.id, + null, + person.publicKey?.publicKeyPem ?: throw IllegalActivityPubObjectException("publicKey is null"), + null + ) + ) + person + } + + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/ActivityPubService.kt b/src/main/kotlin/dev/usbharu/hideout/service/impl/ActivityPubService.kt similarity index 74% rename from src/main/kotlin/dev/usbharu/hideout/service/ActivityPubService.kt rename to src/main/kotlin/dev/usbharu/hideout/service/impl/ActivityPubService.kt index bc6c9f17..e693bd57 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/ActivityPubService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/impl/ActivityPubService.kt @@ -1,6 +1,5 @@ -package dev.usbharu.hideout.service +package dev.usbharu.hideout.service.impl -import com.fasterxml.jackson.databind.ObjectMapper import dev.usbharu.hideout.config.Config class ActivityPubService() { @@ -10,7 +9,7 @@ class ActivityPubService() { Undo } - fun switchApType(json:String):ActivityType{ + fun switchApType(json:String): ActivityType { val typeAsText = Config.configData.objectMapper.readTree(json).get("type").asText() return when(typeAsText){ "Follow" -> ActivityType.Follow diff --git a/src/main/kotlin/dev/usbharu/hideout/service/ActivityPubUserService.kt b/src/main/kotlin/dev/usbharu/hideout/service/impl/ActivityPubUserService.kt similarity index 93% rename from src/main/kotlin/dev/usbharu/hideout/service/ActivityPubUserService.kt rename to src/main/kotlin/dev/usbharu/hideout/service/impl/ActivityPubUserService.kt index 4c2b0f82..fd26123c 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/ActivityPubUserService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/impl/ActivityPubUserService.kt @@ -1,8 +1,10 @@ -package dev.usbharu.hideout.service +package dev.usbharu.hideout.service.impl import dev.usbharu.hideout.ap.* import dev.usbharu.hideout.config.Config import dev.usbharu.hideout.plugins.postAp +import dev.usbharu.hideout.service.IUserAuthService +import dev.usbharu.hideout.service.IWebFingerService import io.ktor.client.* class ActivityPubUserService( diff --git a/src/main/kotlin/dev/usbharu/hideout/service/HttpSignService.kt b/src/main/kotlin/dev/usbharu/hideout/service/impl/HttpSignService.kt similarity index 68% rename from src/main/kotlin/dev/usbharu/hideout/service/HttpSignService.kt rename to src/main/kotlin/dev/usbharu/hideout/service/impl/HttpSignService.kt index 440ee426..07cf8941 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/HttpSignService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/impl/HttpSignService.kt @@ -1,4 +1,4 @@ -package dev.usbharu.hideout.service +package dev.usbharu.hideout.service.impl import java.security.PrivateKey diff --git a/src/main/kotlin/dev/usbharu/hideout/service/UserAuthService.kt b/src/main/kotlin/dev/usbharu/hideout/service/impl/UserAuthService.kt similarity index 86% rename from src/main/kotlin/dev/usbharu/hideout/service/UserAuthService.kt rename to src/main/kotlin/dev/usbharu/hideout/service/impl/UserAuthService.kt index ac60a847..5542d1ee 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/UserAuthService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/impl/UserAuthService.kt @@ -1,19 +1,16 @@ -package dev.usbharu.hideout.service +package dev.usbharu.hideout.service.impl import dev.usbharu.hideout.config.Config import dev.usbharu.hideout.domain.model.User import dev.usbharu.hideout.domain.model.UserAuthentication import dev.usbharu.hideout.domain.model.UserAuthenticationEntity -import dev.usbharu.hideout.domain.model.Users.screenName +import dev.usbharu.hideout.domain.model.UserEntity import dev.usbharu.hideout.exception.UserNotFoundException import dev.usbharu.hideout.repository.IUserAuthRepository import dev.usbharu.hideout.repository.IUserRepository +import dev.usbharu.hideout.service.IUserAuthService import io.ktor.util.* -import java.security.KeyPair -import java.security.KeyPairGenerator -import java.security.MessageDigest -import java.security.PrivateKey -import java.security.PublicKey +import java.security.* import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPublicKey import java.util.* @@ -35,11 +32,15 @@ class UserAuthService( } override suspend fun registerAccount(username: String, hash: String) { + val url = "${Config.configData.url}/users/$username" val registerUser = User( name = username, domain = Config.configData.domain, screenName = username, - description = "" + description = "", + inbox = "$url/inbox", + outbox = "$url/outbox", + url = url ) val createdUser = userRepository.create(registerUser) @@ -76,6 +77,10 @@ class UserAuthService( ?: throw UserNotFoundException("$username auth data was not found") } + override suspend fun createAccount(userEntity: UserAuthentication): UserAuthenticationEntity { + return userAuthRepository.create(userEntity) + } + private fun generateKeyPair(): KeyPair { val keyPairGenerator = KeyPairGenerator.getInstance("RSA") keyPairGenerator.initialize(1024) @@ -83,8 +88,6 @@ class UserAuthService( } - - companion object { val sha256: MessageDigest = MessageDigest.getInstance("SHA-256") } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/UserService.kt b/src/main/kotlin/dev/usbharu/hideout/service/impl/UserService.kt similarity index 67% rename from src/main/kotlin/dev/usbharu/hideout/service/UserService.kt rename to src/main/kotlin/dev/usbharu/hideout/service/impl/UserService.kt index 3183b212..a64ccd04 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/UserService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/impl/UserService.kt @@ -1,4 +1,4 @@ -package dev.usbharu.hideout.service +package dev.usbharu.hideout.service.impl import dev.usbharu.hideout.domain.model.User import dev.usbharu.hideout.domain.model.UserEntity @@ -21,11 +21,27 @@ class UserService(private val userRepository: IUserRepository) { return userRepository.findById(id) ?: throw UserNotFoundException("$id was not found.") } + suspend fun findByIds(ids: List): List { + return userRepository.findByIds(ids) + } + suspend fun findByName(name: String): UserEntity { return userRepository.findByName(name) ?: throw UserNotFoundException("$name was not found.") } + suspend fun findByNameAndDomains(names: List>): List { + return userRepository.findByNameAndDomains(names) + } + + suspend fun findByUrl(url: String): UserEntity { + return userRepository.findByUrl(url) ?: throw UserNotFoundException("$url was not found.") + } + + suspend fun findByUrls(urls: List): List { + return userRepository.findByUrls(urls) + } + suspend fun create(user: User): UserEntity { return userRepository.create(user) } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/WebFingerService.kt b/src/main/kotlin/dev/usbharu/hideout/service/impl/WebFingerService.kt similarity index 91% rename from src/main/kotlin/dev/usbharu/hideout/service/WebFingerService.kt rename to src/main/kotlin/dev/usbharu/hideout/service/impl/WebFingerService.kt index 9cc2cf52..fe928bf6 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/WebFingerService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/impl/WebFingerService.kt @@ -1,8 +1,9 @@ -package dev.usbharu.hideout.service +package dev.usbharu.hideout.service.impl import dev.usbharu.hideout.ap.Person import dev.usbharu.hideout.domain.model.User import dev.usbharu.hideout.domain.model.UserEntity +import dev.usbharu.hideout.service.IWebFingerService import dev.usbharu.hideout.util.HttpUtil import dev.usbharu.hideout.webfinger.WebFinger import io.ktor.client.* @@ -65,8 +66,12 @@ class WebFingerService( userModel.preferredUsername ?: throw IllegalStateException(), domain, userName, - userModel.summary.orEmpty() + userModel.summary.orEmpty(), + "", + "", + "" ) + TODO() return userService.create(user) } } diff --git a/src/main/kotlin/dev/usbharu/hideout/service/job/JobQueueParentService.kt b/src/main/kotlin/dev/usbharu/hideout/service/job/JobQueueParentService.kt new file mode 100644 index 00000000..4029514b --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/job/JobQueueParentService.kt @@ -0,0 +1,10 @@ +package dev.usbharu.hideout.service.job + +import kjob.core.Job +import kjob.core.dsl.ScheduleContext + +interface JobQueueParentService { + + fun init(jobDefines:List) + suspend fun schedule(job: J, block: ScheduleContext.(J) -> Unit = {}) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/job/JobQueueWorkerService.kt b/src/main/kotlin/dev/usbharu/hideout/service/job/JobQueueWorkerService.kt new file mode 100644 index 00000000..0d15caae --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/job/JobQueueWorkerService.kt @@ -0,0 +1,10 @@ +package dev.usbharu.hideout.service.job + +import kjob.core.Job +import kjob.core.dsl.JobContextWithProps +import kjob.core.dsl.JobRegisterContext +import kjob.core.dsl.KJobFunctions + +interface JobQueueWorkerService { + fun init(defines: List>.(Job) -> KJobFunctions>>>) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/job/KJobJobQueueParentService.kt b/src/main/kotlin/dev/usbharu/hideout/service/job/KJobJobQueueParentService.kt new file mode 100644 index 00000000..1f4178b5 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/job/KJobJobQueueParentService.kt @@ -0,0 +1,24 @@ +package dev.usbharu.hideout.service.job + +import dev.usbharu.kjob.exposed.ExposedKJob +import kjob.core.Job +import kjob.core.KJob +import kjob.core.dsl.ScheduleContext +import kjob.core.kjob +import org.jetbrains.exposed.sql.Database + +class KJobJobQueueParentService(private val database: Database) : JobQueueParentService { + + val kjob: KJob = kjob(ExposedKJob) { + connectionDatabase = database + isWorker = false + }.start() + + override fun init(jobDefines: List) { + + } + + override suspend fun schedule(job: J,block:ScheduleContext.(J)->Unit) { + kjob.schedule(job,block) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/job/KJobJobQueueWorkerService.kt b/src/main/kotlin/dev/usbharu/hideout/service/job/KJobJobQueueWorkerService.kt new file mode 100644 index 00000000..cab3dcc8 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/job/KJobJobQueueWorkerService.kt @@ -0,0 +1,28 @@ +package dev.usbharu.hideout.service.job + +import dev.usbharu.kjob.exposed.ExposedKJob +import kjob.core.Job +import kjob.core.dsl.JobContextWithProps +import kjob.core.dsl.JobRegisterContext +import kjob.core.dsl.KJobFunctions +import kjob.core.kjob +import org.jetbrains.exposed.sql.Database + +class KJobJobQueueWorkerService(private val database: Database) : JobQueueWorkerService { + + val kjob by lazy { + kjob(ExposedKJob) { + connectionDatabase = database + nonBlockingMaxJobs = 10 + blockingMaxJobs = 10 + jobExecutionPeriodInSeconds = 10 + }.start() + } + + override fun init(defines: List>.(Job) -> KJobFunctions>>>) { + defines.forEach { job -> + kjob.register(job.first, job.second) + } + } + +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureVerifyService.kt b/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureVerifyService.kt new file mode 100644 index 00000000..34706206 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureVerifyService.kt @@ -0,0 +1,7 @@ +package dev.usbharu.hideout.service.signature + +import io.ktor.http.* + +interface HttpSignatureVerifyService { + fun verify(headers:Headers):Boolean +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureVerifyServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureVerifyServiceImpl.kt new file mode 100644 index 00000000..74525981 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/signature/HttpSignatureVerifyServiceImpl.kt @@ -0,0 +1,24 @@ +package dev.usbharu.hideout.service.signature + +import dev.usbharu.hideout.plugins.KtorKeyMap +import dev.usbharu.hideout.service.IUserAuthService +import io.ktor.http.* +import tech.barbero.http.message.signing.HttpMessage +import tech.barbero.http.message.signing.SignatureHeaderVerifier + +class HttpSignatureVerifyServiceImpl(private val userAuthService: IUserAuthService) : HttpSignatureVerifyService { + override fun verify(headers: Headers): Boolean { + val build = SignatureHeaderVerifier.builder().keyMap(KtorKeyMap(userAuthService)).build() + return true; + build.verify(object : HttpMessage { + override fun headerValues(name: String?): MutableList { + return name?.let { headers.getAll(it) }?.toMutableList() ?: mutableListOf() + } + + override fun addHeader(name: String?, value: String?) { + TODO() + } + + }) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/util/HttpUtil.kt b/src/main/kotlin/dev/usbharu/hideout/util/HttpUtil.kt index 05c827d3..979b8302 100644 --- a/src/main/kotlin/dev/usbharu/hideout/util/HttpUtil.kt +++ b/src/main/kotlin/dev/usbharu/hideout/util/HttpUtil.kt @@ -28,5 +28,8 @@ object HttpUtil { val ContentType.Application.Activity: ContentType get() = ContentType("application", "activity+json") + + val ContentType.Application.JsonLd: ContentType + get() = ContentType("application", "ld+json", listOf(HeaderValueParam("profile", "https://www.w3.org/ns/activitystreams"))) // fun } diff --git a/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedJobRepository.kt b/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedJobRepository.kt new file mode 100644 index 00000000..6533358a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedJobRepository.kt @@ -0,0 +1,290 @@ +package dev.usbharu.kjob.exposed + +import kjob.core.job.JobProgress +import kjob.core.job.JobSettings +import kjob.core.job.JobStatus +import kjob.core.job.ScheduledJob +import kjob.core.repository.JobRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.* +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList +import org.jetbrains.exposed.sql.SqlExpressionBuilder.isNull +import org.jetbrains.exposed.sql.SqlExpressionBuilder.plus +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import org.jetbrains.exposed.sql.transactions.transaction +import java.time.Clock +import java.time.Instant +import java.util.* + +class ExposedJobRepository( + private val database: Database, + private val tableName: String, + private val clock: Clock, + private val json: Json +) : + JobRepository { + + class Jobs(tableName: String) : LongIdTable(tableName) { + val status = text("status") + val runAt = long("runAt").nullable() + val statusMessage = text("statusMessage").nullable() + val retries = integer("retries") + val kjobId = char("kjobId", 36).nullable() + val createdAt = long("createdAt") + val updatedAt = long("updatedAt") + val jobId = text("jobId") + val name = text("name") + val properties = text("properties").nullable() + val step = integer("step") + val max = integer("max").nullable() + val startedAt = long("startedAt").nullable() + val completedAt = long("completedAt").nullable() + } + + val jobs: Jobs = Jobs(tableName) + + fun createTable() { + transaction(database) { + SchemaUtils.create(jobs) + } + } + + suspend fun query(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() } + + + override suspend fun completeProgress(id: String): Boolean { + val now = Instant.now(clock).toEpochMilli() + return query { + jobs.update({ jobs.id eq id.toLong() }) { + it[jobs.completedAt] = now + it[jobs.updatedAt] = now + } == 1 + } + } + + override suspend fun exist(jobId: String): Boolean { + return query { + jobs.select(jobs.jobId eq jobId).empty().not() + } + } + + override suspend fun findNext(names: Set, status: Set, limit: Int): Flow { + return query { + jobs.select( + jobs.status.inList(list = status.map { it.name }) + .and(if (names.isEmpty()) Op.TRUE else jobs.name.inList(names)) + ).limit(limit) + .map { it.toScheduledJob() }.asFlow() + } + } + + override suspend fun get(id: String): ScheduledJob? { + val single = query { jobs.select(jobs.id eq id.toLong()).singleOrNull() } ?: return null + return single.toScheduledJob() + + } + + override suspend fun reset(id: String, oldKjobId: UUID?): Boolean { + return query { + jobs.update({ jobs.id eq id.toLong() and if (oldKjobId == null) jobs.kjobId.isNull() else jobs.kjobId eq oldKjobId.toString() }) { + it[jobs.status] = JobStatus.CREATED.name + it[jobs.statusMessage] = null + it[jobs.kjobId] = null + it[jobs.step] = 0 + it[jobs.max] = null + it[jobs.startedAt] = null + it[jobs.completedAt] = null + it[jobs.updatedAt] = Instant.now(clock).toEpochMilli() + } == 1 + } + } + + override suspend fun save(jobSettings: JobSettings, runAt: Instant?): ScheduledJob { + val now = Instant.now(clock) + val scheduledJob = + ScheduledJob("", JobStatus.CREATED, runAt, null, 0, null, now, now, jobSettings, JobProgress(0)) + val id = query { + jobs.insert { + it[jobs.status] = scheduledJob.status.name + it[jobs.createdAt] = scheduledJob.createdAt.toEpochMilli() + it[jobs.updatedAt] = scheduledJob.updatedAt.toEpochMilli() + it[jobs.jobId] = scheduledJob.settings.id + it[jobs.name] = scheduledJob.settings.name + it[jobs.properties] = scheduledJob.settings.properties.stringify() + it[jobs.runAt] = scheduledJob.runAt?.toEpochMilli() + it[jobs.statusMessage] = null + it[jobs.retries] = 0 + it[jobs.kjobId] = null + it[jobs.step] = 0 + it[jobs.max] = null + it[jobs.startedAt] = null + it[jobs.completedAt] = null + }[jobs.id].value + } + return scheduledJob.copy(id = id.toString()) + } + + override suspend fun setProgressMax(id: String, max: Long): Boolean { + val now = Instant.now(clock).toEpochMilli() + return query { + jobs.update({ jobs.id eq id.toLong() }) { + it[jobs.max] = max.toInt() + it[jobs.updatedAt] = now + } == 1 + } + } + + override suspend fun startProgress(id: String): Boolean { + val now = Instant.now(clock).toEpochMilli() + return query { + jobs.update({ jobs.id eq id.toLong() }) { + it[jobs.startedAt] = now + it[jobs.updatedAt] = now + } == 1 + } + } + + override suspend fun stepProgress(id: String, step: Long): Boolean { + val now = Instant.now(clock).toEpochMilli() + return query { + jobs.update({ jobs.id eq id.toLong() }) { + it[jobs.step] = jobs.step + step.toInt() + it[jobs.updatedAt] = now + } == 1 + } + } + + override suspend fun update( + id: String, + oldKjobId: UUID?, + kjobId: UUID?, + status: JobStatus, + statusMessage: String?, + retries: Int + ): Boolean { + return query { + jobs.update({ (jobs.id eq id.toLong()) and if (oldKjobId == null) jobs.kjobId.isNull() else jobs.kjobId eq oldKjobId.toString() }) { + it[jobs.status] = status.name + it[jobs.retries] = retries + it[jobs.updatedAt] = Instant.now(clock).toEpochMilli() + it[jobs.id] = id.toLong() + it[jobs.statusMessage] = statusMessage + it[jobs.kjobId] = kjobId.toString() + } == 1 + } + } + + private fun String?.parseJsonMap(): Map { + this ?: return emptyMap() + return json.parseToJsonElement(this).jsonObject.mapValues { (_, el) -> + if (el is JsonObject) { + val t = el["t"]?.jsonPrimitive?.content ?: error("Cannot get jsonPrimitive") + val value = el["v"]?.jsonArray ?: error("Cannot get jsonArray") + when (t) { + "s" -> value.map { it.jsonPrimitive.content } + "d" -> value.map { it.jsonPrimitive.double } + "l" -> value.map { it.jsonPrimitive.long } + "i" -> value.map { it.jsonPrimitive.int } + "b" -> value.map { it.jsonPrimitive.boolean } + else -> error("Unknown type prefix '$t'") + }.toList() + } else { + val content = el.jsonPrimitive.content + val t = content.substringBefore(':') + val value = content.substringAfter(':') + when (t) { + "s" -> value + "d" -> value.toDouble() + "l" -> value.toLong() + "i" -> value.toInt() + "b" -> value.toBoolean() + else -> error("Unknown type prefix '$t'") + } + } + } + } + + private fun Map.stringify(): String? { + if (isEmpty()) { + return null + } + + fun listSerialize(value: List<*>): JsonElement { + return if (value.isEmpty()) { + buildJsonObject { + put("t", "s") + putJsonArray("v") {} + } + } else { + val (t, values) = when (val item = value.first()) { + is Double -> "d" to (value as List).map(::JsonPrimitive) + is Long -> "l" to (value as List).map(::JsonPrimitive) + is Int -> "i" to (value as List).map(::JsonPrimitive) + is String -> "s" to (value as List).map(::JsonPrimitive) + is Boolean -> "b" to (value as List).map(::JsonPrimitive) + else -> error("Cannot serialize unsupported list property value: $value") + } + buildJsonObject { + put("t", t) + put("v", JsonArray(values)) + } + } + } + + fun createJsonPrimitive(string: String, value: Any) = JsonPrimitive("$string:$value") + + val jsonObject = JsonObject( + mapValues { (_, value) -> + when (value) { + is List<*> -> listSerialize(value) + is Double -> createJsonPrimitive("d", value) + is Long -> createJsonPrimitive("l", value) + is Int -> createJsonPrimitive("i", value) + is String -> createJsonPrimitive("s", value) + is Boolean -> createJsonPrimitive("b", value) + else -> error("Cannot serialize unsupported property value: $value") + } + } + ) + return json.encodeToString(jsonObject) + } + + private fun ResultRow.toScheduledJob(): ScheduledJob { + val single = this + jobs.run { + return ScheduledJob( + id = single[this.id].value.toString(), + status = JobStatus.valueOf(single[status]), + runAt = single[runAt]?.let { Instant.ofEpochMilli(it) }, + statusMessage = single[statusMessage], + retries = single[retries], + kjobId = single[kjobId]?.let { + try { + UUID.fromString(it) + } catch (e: IllegalArgumentException) { + null + } + }, + createdAt = Instant.ofEpochMilli(single[createdAt]), + updatedAt = Instant.ofEpochMilli(single[updatedAt]), + settings = JobSettings( + id = single[jobId], + name = single[name], + properties = single[properties].parseJsonMap() + ), + progress = JobProgress( + step = single[step].toLong(), + max = single[max]?.toLong(), + startedAt = single[startedAt]?.let { Instant.ofEpochMilli(it) }, + completedAt = single[completedAt]?.let { Instant.ofEpochMilli(it) } + ) + ) + } + } +} diff --git a/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedKJob.kt b/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedKJob.kt new file mode 100644 index 00000000..76d00008 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedKJob.kt @@ -0,0 +1,50 @@ +package dev.usbharu.kjob.exposed + +import kjob.core.BaseKJob +import kjob.core.KJob +import kjob.core.KJobFactory +import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.sql.Database +import java.time.Clock + +class ExposedKJob(config: Configuration) : BaseKJob(config) { + + companion object : KJobFactory { + override fun create(configure: Configuration.() -> Unit): KJob { + return ExposedKJob(Configuration().apply(configure)) + } + } + + class Configuration : BaseKJob.Configuration() { + var connectionString: String? = null + var driverClassName: String? = null + var connectionDatabase: Database? = null + + var jobTableName = "kjobJobs" + + var lockTableName = "kjobLocks" + + var expireLockInMinutes = 5L + } + + private val database: Database = config.connectionDatabase ?: Database.connect( + requireNotNull(config.connectionString), + requireNotNull(config.driverClassName) + ) + + override val jobRepository: ExposedJobRepository + get() = ExposedJobRepository(database, config.jobTableName, Clock.systemUTC(), config.json) + override val lockRepository: ExposedLockRepository + get() = ExposedLockRepository(database, config, clock) + + override fun start(): KJob { + jobRepository.createTable() + lockRepository.createTable() + return super.start() + } + + override fun shutdown() = runBlocking { + super.shutdown() + lockRepository.clearExpired() + } +} diff --git a/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedLockRepository.kt b/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedLockRepository.kt new file mode 100644 index 00000000..76d6fa44 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/kjob/exposed/ExposedLockRepository.kt @@ -0,0 +1,74 @@ +package dev.usbharu.kjob.exposed + +import kjob.core.job.Lock +import kjob.core.repository.LockRepository +import kotlinx.coroutines.Dispatchers +import org.jetbrains.exposed.dao.id.UUIDTable +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import org.jetbrains.exposed.sql.transactions.transaction +import java.time.Clock +import java.time.Instant +import java.util.* +import kotlin.time.Duration.Companion.minutes + +class ExposedLockRepository( + private val database: Database, + private val config: ExposedKJob.Configuration, + private val clock: Clock +) : LockRepository { + + class Locks(tableName: String) : UUIDTable(tableName) { + val updatedAt = long("updatedAt") + val expiresAt = long("expiresAt") + } + + val locks: Locks = Locks(config.lockTableName) + + fun createTable() { + transaction(database) { + SchemaUtils.create(locks) + } + } + + suspend fun query(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() } + + override suspend fun exists(id: UUID): Boolean { + val now = Instant.now(clock) + return query { + locks.select(locks.id eq id and locks.expiresAt.greater(now.toEpochMilli())).empty().not() + } + } + + override suspend fun ping(id: UUID): Lock { + val now = Instant.now(clock) + val expiresAt = now.plusSeconds(config.expireLockInMinutes.minutes.inWholeSeconds) + val lock = Lock(id, now) + query { + if (locks.select(locks.id eq id).limit(1) + .map { Lock(it[locks.id].value, Instant.ofEpochMilli(it[locks.expiresAt])) }.isEmpty() + ) { + locks.insert { + it[locks.id] = id + it[locks.updatedAt] = now.toEpochMilli() + it[locks.expiresAt] = expiresAt.toEpochMilli() + } + } else { + locks.update({ locks.id eq id }) { + it[locks.updatedAt] = now.toEpochMilli() + it[locks.expiresAt] = expiresAt.toEpochMilli() + } + } + } + return lock + } + + suspend fun clearExpired() { + val now = Instant.now(clock).toEpochMilli() + query { + locks.deleteWhere { locks.expiresAt greater now } + } + } +} diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 0a1d02eb..beb8fa3b 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -6,13 +6,13 @@ ktor { watch = [classes, resources] } application { - modules = [dev.usbharu.hideout.ApplicationKt.module] + modules = [dev.usbharu.hideout.ApplicationKt.parent,dev.usbharu.hideout.ApplicationKt.worker] } } hideout { - hostname = "https://localhost:8080" - hostname = ${?HOSTNAME} + url = "http://localhost:8080" + database { url = "jdbc:h2:./test;MODE=POSTGRESQL" driver = "org.h2.Driver" diff --git a/src/test/kotlin/dev/usbharu/hideout/Empty.kt b/src/test/kotlin/dev/usbharu/hideout/Empty.kt new file mode 100644 index 00000000..0203d877 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/Empty.kt @@ -0,0 +1,7 @@ +package dev.usbharu.hideout + +import io.ktor.server.application.* + +fun Application.empty(){ + +} diff --git a/src/test/kotlin/dev/usbharu/hideout/plugins/ActivityPubKtTest.kt b/src/test/kotlin/dev/usbharu/hideout/plugins/ActivityPubKtTest.kt index 8c7e7d76..19246b90 100644 --- a/src/test/kotlin/dev/usbharu/hideout/plugins/ActivityPubKtTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/plugins/ActivityPubKtTest.kt @@ -7,8 +7,8 @@ import dev.usbharu.hideout.domain.model.UserAuthenticationEntity import dev.usbharu.hideout.domain.model.UserEntity import dev.usbharu.hideout.repository.IUserAuthRepository import dev.usbharu.hideout.repository.IUserRepository -import dev.usbharu.hideout.service.UserAuthService -import dev.usbharu.hideout.service.toPem +import dev.usbharu.hideout.service.impl.UserAuthService +import dev.usbharu.hideout.service.impl.toPem import io.ktor.client.* import io.ktor.client.engine.mock.* import io.ktor.client.plugins.logging.* @@ -17,7 +17,6 @@ import org.junit.jupiter.api.Test import java.security.KeyPairGenerator import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPublicKey -import java.util.* class ActivityPubKtTest { @Test @@ -32,8 +31,24 @@ class ActivityPubKtTest { TODO("Not yet implemented") } + override suspend fun findByIds(ids: List): List { + TODO("Not yet implemented") + } + override suspend fun findByName(name: String): UserEntity? { - return UserEntity(1, "test", "localhost", "test", "") + return UserEntity(1, "test", "localhost", "test", "","","","") + } + + override suspend fun findByNameAndDomains(names: List>): List { + TODO("Not yet implemented") + } + + override suspend fun findByUrl(url: String): UserEntity? { + TODO("Not yet implemented") + } + + override suspend fun findByUrls(urls: List): List { + TODO("Not yet implemented") } override suspend fun update(userEntity: UserEntity) { diff --git a/src/test/kotlin/dev/usbharu/hideout/plugins/KtorKeyMapTest.kt b/src/test/kotlin/dev/usbharu/hideout/plugins/KtorKeyMapTest.kt index 9f9a7cfe..02cfb9b4 100644 --- a/src/test/kotlin/dev/usbharu/hideout/plugins/KtorKeyMapTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/plugins/KtorKeyMapTest.kt @@ -6,13 +6,12 @@ import dev.usbharu.hideout.domain.model.UserAuthenticationEntity import dev.usbharu.hideout.domain.model.UserEntity import dev.usbharu.hideout.repository.IUserAuthRepository import dev.usbharu.hideout.repository.IUserRepository -import dev.usbharu.hideout.service.UserAuthService -import dev.usbharu.hideout.service.toPem +import dev.usbharu.hideout.service.impl.UserAuthService +import dev.usbharu.hideout.service.impl.toPem import org.junit.jupiter.api.Test import java.security.KeyPairGenerator import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPublicKey -import java.util.* class KtorKeyMapTest { @@ -27,8 +26,24 @@ class KtorKeyMapTest { TODO("Not yet implemented") } + override suspend fun findByIds(ids: List): List { + TODO("Not yet implemented") + } + override suspend fun findByName(name: String): UserEntity? { - return UserEntity(1, "test", "localhost", "test", "") + return UserEntity(1, "test", "localhost", "test", "","","","") + } + + override suspend fun findByNameAndDomains(names: List>): List { + TODO("Not yet implemented") + } + + override suspend fun findByUrl(url: String): UserEntity? { + TODO("Not yet implemented") + } + + override suspend fun findByUrls(urls: List): List { + TODO("Not yet implemented") } override suspend fun update(userEntity: UserEntity) { diff --git a/src/test/kotlin/dev/usbharu/hideout/repository/UserRepositoryTest.kt b/src/test/kotlin/dev/usbharu/hideout/repository/UserRepositoryTest.kt new file mode 100644 index 00000000..740bc90e --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/repository/UserRepositoryTest.kt @@ -0,0 +1,120 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package dev.usbharu.hideout.repository + +import dev.usbharu.hideout.domain.model.User +import dev.usbharu.hideout.domain.model.Users +import dev.usbharu.hideout.domain.model.UsersFollowers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertIterableEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + + +class UserRepositoryTest { + + lateinit var db: Database + + @BeforeEach + fun beforeEach() { + db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver") + transaction(db) { + SchemaUtils.create(Users) + SchemaUtils.create(UsersFollowers) + } + } + + @AfterEach + fun tearDown() { + transaction(db) { + + SchemaUtils.drop(UsersFollowers) + SchemaUtils.drop(Users) + } + } + + @Test + fun `findFollowersById フォロワー一覧を取得`() = runTest { + val userRepository = UserRepository(db) + val user = userRepository.create( + User( + "test", + "example.com", + "testUser", + "This user is test user.", + "https://example.com/inbox", + "https://example.com/outbox", + "https://example.com" + ) + ) + val follower = userRepository.create( + User( + "follower", + "follower.example.com", + "followerUser", + "This user is follower user.", + "https://follower.example.com/inbox", + "https://follower.example.com/outbox", + "https://follower.example.com" + ) + ) + val follower2 = userRepository.create( + User( + "follower2", + "follower2.example.com", + "followerUser2", + "This user is follower user 2.", + "https://follower2.example.com/inbox", + "https://follower2.example.com/outbox", + "https://follower2.example.com" + ) + ) + userRepository.createFollower(user.id, follower.id) + userRepository.createFollower(user.id, follower2.id) + userRepository.findFollowersById(user.id).let { + assertIterableEquals(listOf(follower, follower2), it) + } + + } + + @Test + fun `createFollower フォロワー追加`() = runTest { + val userRepository = UserRepository(db) + val user = userRepository.create( + User( + "test", + "example.com", + "testUser", + "This user is test user.", + "https://example.com/inbox", + "https://example.com/outbox", + "https://example.com" + ) + ) + val follower = userRepository.create( + User( + "follower", + "follower.example.com", + "followerUser", + "This user is follower user.", + "https://follower.example.com/inbox", + "https://follower.example.com/outbox", + "https://follower.example.com" + ) + ) + userRepository.createFollower(user.id, follower.id) + transaction { + + val followerIds = + UsersFollowers.select { UsersFollowers.userId eq user.id }.map { it[UsersFollowers.followerId] } + assertIterableEquals(listOf(follower.id), followerIds) + } + + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/routing/UserRoutingKtTest.kt b/src/test/kotlin/dev/usbharu/hideout/routing/UserRoutingKtTest.kt deleted file mode 100644 index 6027b7e2..00000000 --- a/src/test/kotlin/dev/usbharu/hideout/routing/UserRoutingKtTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.usbharu.hideout.routing - -import org.junit.jupiter.api.Test - -class UserRoutingKtTest { - @Test - fun userIconTest() { - println(String.Companion::class.java.classLoader) - println(String::class.java.classLoader) - println(String.javaClass.classLoader.getResourceAsStream("icon.png")?.readAllBytes()) - } -} diff --git a/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/InboxRoutingKtTest.kt b/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/InboxRoutingKtTest.kt new file mode 100644 index 00000000..c4de8bfb --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/InboxRoutingKtTest.kt @@ -0,0 +1,96 @@ +package dev.usbharu.hideout.routing.activitypub + +import dev.usbharu.hideout.exception.JsonParseException +import dev.usbharu.hideout.plugins.configureRouting +import dev.usbharu.hideout.plugins.configureSerialization +import dev.usbharu.hideout.plugins.configureStatusPages +import dev.usbharu.hideout.service.activitypub.ActivityPubService +import dev.usbharu.hideout.service.activitypub.ActivityPubUserService +import dev.usbharu.hideout.service.impl.UserService +import dev.usbharu.hideout.service.signature.HttpSignatureVerifyService +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.server.config.* +import io.ktor.server.testing.* +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock + +class InboxRoutingKtTest { + @Test + fun `sharedInboxにGETしたら405が帰ってくる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + application { + configureSerialization() + configureRouting(mock(), mock(), mock(), mock()) + } + client.get("/inbox").let { + Assertions.assertEquals(HttpStatusCode.MethodNotAllowed, it.status) + } + } + + @Test + fun `sharedInboxに空のリクエストボディでPOSTしたら400が帰ってくる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val httpSignatureVerifyService = mock{ + on { verify(any()) } doReturn true + } + val activityPubService = mock{ + on { parseActivity(any()) } doThrow JsonParseException() + } + val userService = mock() + val activityPubUserService = mock() + application { + configureStatusPages() + configureSerialization() + configureRouting(httpSignatureVerifyService, activityPubService, userService, activityPubUserService) + } + client.post("/inbox").let { + Assertions.assertEquals(HttpStatusCode.BadRequest, it.status) + } + } + + @Test + fun `ユーザのinboxにGETしたら405が帰ってくる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + application { + configureSerialization() + configureRouting(mock(), mock(), mock(), mock()) + } + client.get("/users/test/inbox").let { + Assertions.assertEquals(HttpStatusCode.MethodNotAllowed, it.status) + } + } + + @Test + fun `ユーザーのinboxに空のリクエストボディでPOSTしたら400が帰ってくる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val httpSignatureVerifyService = mock{ + on { verify(any()) } doReturn true + } + val activityPubService = mock{ + on { parseActivity(any()) } doThrow JsonParseException() + } + val userService = mock() + val activityPubUserService = mock() + application { + configureStatusPages() + configureSerialization() + configureRouting(httpSignatureVerifyService, activityPubService, userService, activityPubUserService) + } + client.post("/users/test/inbox").let { + Assertions.assertEquals(HttpStatusCode.BadRequest, it.status) + } + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/UsersAPTest.kt b/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/UsersAPTest.kt new file mode 100644 index 00000000..e09f504b --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/routing/activitypub/UsersAPTest.kt @@ -0,0 +1,91 @@ +package dev.usbharu.hideout.routing.activitypub + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.ap.Image +import dev.usbharu.hideout.ap.Key +import dev.usbharu.hideout.ap.Person +import dev.usbharu.hideout.plugins.configureRouting +import dev.usbharu.hideout.plugins.configureSerialization +import dev.usbharu.hideout.service.activitypub.ActivityPubService +import dev.usbharu.hideout.service.activitypub.ActivityPubUserService +import dev.usbharu.hideout.service.impl.UserService +import dev.usbharu.hideout.service.signature.HttpSignatureVerifyService +import dev.usbharu.hideout.util.HttpUtil.Activity +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.config.* +import io.ktor.server.testing.* +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + + +class UsersAPTest { + + @Test() + fun `ユーザのURLにAcceptヘッダーをActivityにしてアクセスしたときPersonが返ってくる`() = testApplication { + environment { + config = ApplicationConfig("empty.conf") + } + val person = Person( + type = emptyList(), + name = "test", + id = "http://example.com/users/test", + preferredUsername = "test", + summary = "test user", + inbox = "http://example.com/users/test/inbox", + outbox = "http://example.com/users/test/outbox", + url = "http://example.com/users/test", + icon = Image( + type = emptyList(), + name = "http://example.com/users/test/icon.png", + mediaType = "image/png", + url = "http://example.com/users/test/icon.png" + ), + publicKey = Key( + type = emptyList(), + name = "Public Key", + id = "http://example.com/users/test#pubkey", + owner = "https://example.com/users/test", + publicKeyPem = "-----BEGIN PUBLIC KEY-----\n\n-----END PUBLIC KEY-----" + ) + ) + person.context = listOf("https://www.w3.org/ns/activitystreams") + + val httpSignatureVerifyService = mock {} + val activityPubService = mock {} + val userService = mock {} + + val activityPubUserService = mock { + onBlocking { getPersonByName(anyString()) } doReturn person + } + + application { + configureSerialization() + configureRouting(httpSignatureVerifyService, activityPubService, userService, activityPubUserService) + } + client.get("/users/test") { + accept(ContentType.Application.Activity) + }.let { + val objectMapper = jacksonObjectMapper().enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + .setSerializationInclusion(JsonInclude.Include.NON_EMPTY) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + objectMapper.configOverride(List::class.java).setSetterInfo( + JsonSetter.Value.forValueNulls( + Nulls.AS_EMPTY + ) + ) + val actual = it.bodyAsText() + val readValue = objectMapper.readValue(actual) + assertEquals(person, readValue) + } + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowServiceImplTest.kt new file mode 100644 index 00000000..d447ce3e --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/service/activitypub/ActivityPubFollowServiceImplTest.kt @@ -0,0 +1,153 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package dev.usbharu.hideout.service.activitypub + +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.ap.* +import dev.usbharu.hideout.config.Config +import dev.usbharu.hideout.config.ConfigData +import dev.usbharu.hideout.domain.model.UserEntity +import dev.usbharu.hideout.domain.model.job.ReceiveFollowJob +import dev.usbharu.hideout.service.impl.UserService +import dev.usbharu.hideout.service.job.JobQueueParentService +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import kjob.core.dsl.ScheduleContext +import kjob.core.job.JobProps +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.* +import utils.JsonObjectMapper + +class ActivityPubFollowServiceImplTest { + @Test + fun `receiveFollow フォロー受付処理`() = runTest { + val jobQueueParentService = mock { + onBlocking { schedule(eq(ReceiveFollowJob), any()) } doReturn Unit + } + val activityPubFollowService = ActivityPubFollowServiceImpl(jobQueueParentService, mock(), mock(), mock()) + activityPubFollowService.receiveFollow( + Follow( + emptyList(), + "Follow", + "https://example.com", + "https://follower.example.com" + ) + ) + verify(jobQueueParentService, times(1)).schedule(eq(ReceiveFollowJob), any()) + argumentCaptor.(ReceiveFollowJob) -> Unit> { + verify(jobQueueParentService, times(1)).schedule(eq(ReceiveFollowJob), capture()) + val scheduleContext = ScheduleContext(Json) + firstValue.invoke(scheduleContext, ReceiveFollowJob) + val actor = scheduleContext.props.props[ReceiveFollowJob.actor.name] + val targetActor = scheduleContext.props.props[ReceiveFollowJob.targetActor.name] + val follow = scheduleContext.props.props[ReceiveFollowJob.follow.name] + assertEquals("https://follower.example.com", actor) + assertEquals("https://example.com", targetActor) + assertEquals( + """{"type":"Follow","name":"Follow","object":"https://example.com","actor":"https://follower.example.com","@context":null}""", + follow + ) + } + } + + @Test + fun `receiveFollowJob フォロー受付処理のJob`() = runTest { + Config.configData = ConfigData(objectMapper = JsonObjectMapper.objectMapper) + val person = Person( + type = emptyList(), + name = "follower", + id = "https://follower.example.com", + preferredUsername = "followerUser", + summary = "This user is follower user.", + inbox = "https://follower.example.com/inbox", + outbox = "https://follower.example.com/outbox", + url = "https://follower.example.com", + icon = Image( + type = emptyList(), + name = "https://follower.example.com/image", + mediaType = "image/png", + url = "https://follower.example.com/image" + ), + publicKey = Key( + type = emptyList(), + name = "Public Key", + id = "https://follower.example.com#main-key", + owner = "https://follower.example.com", + publicKeyPem = "BEGIN PUBLIC KEY...END PUBLIC KEY", + ) + + ) + val activityPubUserService = mock { + onBlocking { fetchPerson(anyString()) } doReturn person + } + val userService = mock { + onBlocking { findByUrls(any()) } doReturn listOf( + UserEntity( + id = 1L, + name = "test", + domain = "example.com", + screenName = "testUser", + description = "This user is test user.", + inbox = "https://example.com/inbox", + outbox = "https://example.com/outbox", + url = "https://example.com" + ), + UserEntity( + id = 2L, + name = "follower", + domain = "follower.example.com", + screenName = "followerUser", + description = "This user is test follower user.", + inbox = "https://follower.example.com/inbox", + outbox = "https://follower.example.com/outbox", + url = "https://follower.example.com" + ) + ) + onBlocking { addFollowers(any(), any()) } doReturn Unit + } + val activityPubFollowService = + ActivityPubFollowServiceImpl( + mock(), + activityPubUserService, + userService, + HttpClient(MockEngine { httpRequestData -> + assertEquals(person.inbox, httpRequestData.url.toString()) + val accept = Accept( + type = emptyList(), + name = "Follow", + `object` = Follow( + type = emptyList(), + name = "Follow", + `object` = "https://example.com", + actor = "https://follower.example.com" + ), + actor = "https://example.com" + ) + accept.context += "https://www.w3.org/ns/activitystreams" + assertEquals( + accept, + Config.configData.objectMapper.readValue( + httpRequestData.body.toByteArray().decodeToString() + ) + ) + respondOk() + }) + ) + activityPubFollowService.receiveFollowJob( + JobProps( + data = mapOf( + ReceiveFollowJob.actor.name to "https://follower.example.com", + ReceiveFollowJob.targetActor.name to "https://example.com", + ReceiveFollowJob.follow.name to """{"type":"Follow","name":"Follow","object":"https://example.com","actor":"https://follower.example.com","@context":null}""" + ), + json = Json + ) + ) + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/service/job/KJobJobQueueWorkerServiceTest.kt b/src/test/kotlin/dev/usbharu/hideout/service/job/KJobJobQueueWorkerServiceTest.kt new file mode 100644 index 00000000..0244399a --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/service/job/KJobJobQueueWorkerServiceTest.kt @@ -0,0 +1,16 @@ +package dev.usbharu.hideout.service.job + +import kjob.core.Job +import org.jetbrains.exposed.sql.Database +import org.junit.jupiter.api.Test + +class KJobJobQueueWorkerServiceTest { + + object TestJob : Job("test-job") + + @Test + fun init() { + val kJobJobWorkerService = KJobJobQueueWorkerService(Database.connect("jdbc:h2:mem:")) + kJobJobWorkerService.init(listOf(TestJob to { it -> execute { it as TestJob;println(it.propNames) } })) + } +} diff --git a/src/test/kotlin/utils/DBResetInterceptor.kt b/src/test/kotlin/utils/DBResetInterceptor.kt new file mode 100644 index 00000000..32fc88a9 --- /dev/null +++ b/src/test/kotlin/utils/DBResetInterceptor.kt @@ -0,0 +1,28 @@ +package utils + +import org.jetbrains.exposed.sql.Transaction +import org.jetbrains.exposed.sql.transactions.TransactionManager +import org.junit.jupiter.api.extension.* + +class DBResetInterceptor : BeforeAllCallback,AfterAllCallback,BeforeEachCallback,AfterEachCallback { + private lateinit var transactionAll: Transaction + private lateinit var transactionEach: Transaction + + override fun beforeAll(context: ExtensionContext?) { + transactionAll = TransactionManager.manager.newTransaction() + } + + override fun afterAll(context: ExtensionContext?) { + transactionAll.rollback() + transactionAll.close() + } + + override fun beforeEach(context: ExtensionContext?) { + transactionEach = TransactionManager.manager.newTransaction(outerTransaction = transactionAll) + } + + override fun afterEach(context: ExtensionContext?) { + transactionEach.rollback() + transactionEach.close() + } +} diff --git a/src/test/kotlin/utils/DatabaseTestBase.kt b/src/test/kotlin/utils/DatabaseTestBase.kt new file mode 100644 index 00000000..10653631 --- /dev/null +++ b/src/test/kotlin/utils/DatabaseTestBase.kt @@ -0,0 +1,18 @@ +package utils + +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.DatabaseConfig +import org.junit.jupiter.api.extension.ExtendWith + + +@ExtendWith(DBResetInterceptor::class) +abstract class DatabaseTestBase { + companion object { + init { + Database.connect( + "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", + driver = "org.h2.Driver", + databaseConfig = DatabaseConfig { useNestedTransactions = true }) + } + } +} diff --git a/src/test/kotlin/utils/JsonObjectMapper.kt b/src/test/kotlin/utils/JsonObjectMapper.kt new file mode 100644 index 00000000..4a595630 --- /dev/null +++ b/src/test/kotlin/utils/JsonObjectMapper.kt @@ -0,0 +1,22 @@ +package utils + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +object JsonObjectMapper { + val objectMapper: com.fasterxml.jackson.databind.ObjectMapper = + jacksonObjectMapper().enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + .setSerializationInclusion(JsonInclude.Include.NON_EMPTY) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + init { + objectMapper.configOverride(List::class.java).setSetterInfo( + JsonSetter.Value.forValueNulls( + Nulls.AS_EMPTY + ) + ) + } +} diff --git a/src/test/resources/empty.conf b/src/test/resources/empty.conf new file mode 100644 index 00000000..ba691e1c --- /dev/null +++ b/src/test/resources/empty.conf @@ -0,0 +1,21 @@ +ktor { + development = true + deployment { + port = 8080 + port = ${?PORT} + } + application { + modules = [dev.usbharu.hideout.EmptyKt.empty] + } +} + +hideout { + hostname = "https://localhost:8080" + hostname = ${?HOSTNAME} + database { + url = "jdbc:h2:./test;MODE=POSTGRESQL" + driver = "org.h2.Driver" + username = "" + password = "" + } +}