Merge pull request 'feature/tukurinaosi' (#1) from feature/tukurinaosi into master

Reviewed-on: http://git.usbharu-server/usbharu/hideout/pulls/1
This commit is contained in:
usbharu 2023-04-12 16:19:07 +00:00
commit e39347b35b
69 changed files with 2121 additions and 183 deletions

View File

@ -28,6 +28,14 @@ repositories {
mavenCentral() mavenCentral()
} }
kotlin {
target {
compilations.all {
kotlinOptions.jvmTarget = JavaVersion.VERSION_11.toString()
}
}
}
dependencies { dependencies {
implementation("io.ktor:ktor-server-core-jvm:$ktor_version") implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
implementation("io.ktor:ktor-server-auth-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-ktor:$koin_version")
implementation("io.insert-koin:koin-logger-slf4j:$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-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("io.ktor:ktor-server-tests-jvm:$ktor_version")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_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-core:$ktor_version")
implementation("io.ktor:ktor-client-cio:$ktor_version") implementation("io.ktor:ktor-client-cio:$ktor_version")
implementation("io.ktor:ktor-client-content-negotiation:$ktor_version") implementation("io.ktor:ktor-client-content-negotiation:$ktor_version")
testImplementation("io.ktor:ktor-client-mock:$ktor_version") testImplementation("io.ktor:ktor-client-mock:$ktor_version")
implementation("tech.barbero.http-messages-signing:http-messages-signing-core:1.0.0") implementation("tech.barbero.http-messages-signing:http-messages-signing-core:1.0.0")
testImplementation("org.junit.jupiter:junit-jupiter:5.8.1") 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 { jib {

View File

@ -1,6 +1,6 @@
ktor_version=2.2.4 ktor_version=2.2.4
kotlin_version=1.8.10 kotlin_version=1.8.10
logback_version=1.2.11 logback_version=1.4.6
kotlin.code.style=official kotlin.code.style=official
exposed_version=0.41.1 exposed_version=0.41.1
h2_version=2.1.214 h2_version=2.1.214

View File

@ -5,54 +5,73 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import dev.usbharu.hideout.config.Config import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.config.ConfigData import dev.usbharu.hideout.config.ConfigData
import dev.usbharu.hideout.domain.model.job.ReceiveFollowJob
import dev.usbharu.hideout.plugins.* import dev.usbharu.hideout.plugins.*
import dev.usbharu.hideout.repository.IUserAuthRepository import dev.usbharu.hideout.repository.IUserAuthRepository
import dev.usbharu.hideout.repository.IUserRepository import dev.usbharu.hideout.repository.IUserRepository
import dev.usbharu.hideout.repository.UserAuthRepository import dev.usbharu.hideout.repository.UserAuthRepository
import dev.usbharu.hideout.repository.UserRepository import dev.usbharu.hideout.repository.UserRepository
import dev.usbharu.hideout.routing.* import dev.usbharu.hideout.routing.register
import dev.usbharu.hideout.service.* 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.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.* import io.ktor.client.plugins.logging.*
import io.ktor.serialization.jackson.*
import io.ktor.server.application.* 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.jetbrains.exposed.sql.Database
import org.koin.ktor.ext.inject import org.koin.ktor.ext.inject
import java.util.*
fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args) fun main(args: Array<String>): 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. val Application.property: Application.(propertyName: String) -> String
fun Application.module() { get() = {
val module = org.koin.dsl.module { environment.config.property(it).getString()
single<Database> {
Database.connect(
url = environment.config.property("hideout.database.url").getString(),
driver = environment.config.property("hideout.database.driver").getString(),
)
} }
single<ConfigData> {
ConfigData( @Suppress("unused") // application.conf references the main function. This annotation prevents the IDE from marking it as unused.
url = environment.config.propertyOrNull("hideout.url")?.getString() fun Application.parent() {
?: environment.config.property("hideout.hostname").getString(),
Config.configData = ConfigData(
url = property("hideout.url"),
objectMapper = jacksonObjectMapper().enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) objectMapper = jacksonObjectMapper().enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
.setSerializationInclusion(JsonInclude.Include.NON_EMPTY) .setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
) )
val module = org.koin.dsl.module {
single<Database> {
Database.connect(
url = property("hideout.database.url"),
driver = property("hideout.database.driver"),
user = property("hideout.database.username"),
password = property("hideout.database.password")
)
}
single<IUserRepository> { UserRepository(get()) }
single<IUserAuthRepository> { UserAuthRepository(get()) }
single<IUserAuthService> { UserAuthService(get(), get()) }
single<HttpSignatureVerifyService> { HttpSignatureVerifyServiceImpl(get()) }
single<JobQueueParentService> {
val kJobJobQueueService = KJobJobQueueParentService(get())
kJobJobQueueService.init(listOf())
kJobJobQueueService
} }
single<HttpClient> { single<HttpClient> {
HttpClient(CIO) { HttpClient(CIO).config {
install(ContentNegotiation) {
jackson {
enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
}
install(Logging) { install(Logging) {
logger = Logger.DEFAULT logger = Logger.DEFAULT
level = LogLevel.ALL level = LogLevel.ALL
@ -62,33 +81,37 @@ fun Application.module() {
} }
} }
} }
single<IUserRepository> { UserRepository(get()) } single<ActivityPubFollowService> { ActivityPubFollowServiceImpl(get(), get(), get(),get()) }
single<IUserAuthRepository> { UserAuthRepository(get()) } single<ActivityPubService> { ActivityPubServiceImpl(get()) }
single<IUserAuthService> { UserAuthService(get(), get()) }
single<UserService> { UserService(get()) } single<UserService> { UserService(get()) }
single<ActivityPubService> { ActivityPubService() } single<ActivityPubUserService> { ActivityPubUserServiceImpl(get(), get(), get()) }
single<ActivityPubUserService> { ActivityPubUserService(get(), get(), get(), get()) }
single<IWebFingerService> { WebFingerService(get(), get()) }
} }
configureKoin(module)
val configData by inject<ConfigData>()
Config.configData = configData
val decode = Base64.getDecoder().decode("76pc9N9hspQqapj30kCaLJA14O/50ptCg50zCA1oxjA=")
val pair = "admin" to decode
println(pair) configureKoin(module)
val userAuthService by inject<IUserAuthService>()
val userService by inject<UserService>()
configureSecurity(userAuthService)
configureHTTP() configureHTTP()
configureSockets()
configureMonitoring() configureMonitoring()
configureSerialization() configureSerialization()
configureSockets() register(inject<IUserAuthService>().value)
val activityPubUserService by inject<ActivityPubUserService>() configureRouting(
user(userService, activityPubUserService) inject<HttpSignatureVerifyService>().value,
login() inject<ActivityPubService>().value,
register(userAuthService) inject<UserService>().value,
wellKnown(userService) inject<ActivityPubUserService>().value
val activityPubService by inject<ActivityPubService>() )
userActivityPubRouting(activityPubService, activityPubUserService) }
@Suppress("unused")
fun Application.worker() {
val kJob = kjob(ExposedKJob) {
connectionDatabase = inject<Database>().value
}.start()
val activityPubService = inject<ActivityPubService>().value
kJob.register(ReceiveFollowJob){
execute {
activityPubService.processActivity(this,it)
}
}
} }

View File

@ -13,4 +13,26 @@ open class Accept : Object {
this.`object` = `object` this.`object` = `object`
this.actor = actor 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()}"
}
} }

View File

@ -13,4 +13,21 @@ open class Image : Object {
this.url = url 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
}
} }

View File

@ -25,6 +25,23 @@ open class JsonLd {
} }
protected constructor() 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<String>() { public class ContextDeserializer : JsonDeserializer<String>() {

View File

@ -1,9 +1,9 @@
package dev.usbharu.hideout.ap package dev.usbharu.hideout.ap
open class Key : Object{ open class Key : Object{
private var id:String? = null var id:String? = null
private var owner:String? = null var owner:String? = null
private var publicKeyPem:String? = null var publicKeyPem:String? = null
protected constructor() : super() protected constructor() : super()
constructor( constructor(
type: List<String>, type: List<String>,
@ -17,5 +17,23 @@ open class Key : Object{
this.publicKeyPem = publicKeyPem 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
}
} }

View File

@ -24,6 +24,26 @@ open class Object : JsonLd {
return toMutableList.distinct() 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<List<String>>() { public class TypeSerializer : JsonSerializer<List<String>>() {

View File

@ -5,10 +5,10 @@ open class Person : Object {
var preferredUsername:String? = null var preferredUsername:String? = null
var summary:String? = null var summary:String? = null
var inbox:String? = null var inbox:String? = null
private var outbox:String? = null var outbox:String? = null
private var url:String? = null private var url:String? = null
private var icon:Image? = null private var icon:Image? = null
private var publicKey:Key? = null var publicKey:Key? = null
protected constructor() : super() protected constructor() : super()
constructor( constructor(
type: List<String> = emptyList(), type: List<String> = emptyList(),
@ -32,4 +32,31 @@ open class Person : Object {
this.publicKey = publicKey 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
}
} }

View File

@ -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)

View File

@ -2,16 +2,36 @@ package dev.usbharu.hideout.domain.model
import org.jetbrains.exposed.dao.id.LongIdTable 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( data class UserEntity(
val id: Long, val id: Long,
val name: String, val name: String,
val domain: String, val domain: String,
val screenName: 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") { object Users : LongIdTable("users") {
@ -19,6 +39,10 @@ object Users : LongIdTable("users") {
val domain = varchar("domain", length = 255) val domain = varchar("domain", length = 255)
val screenName = varchar("screen_name", length = 64) val screenName = varchar("screen_name", length = 64)
val description = varchar("description", length = 600) 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 { init {
uniqueIndex(name, domain) uniqueIndex(name, domain)
} }

View File

@ -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")
}

View File

@ -0,0 +1,5 @@
package dev.usbharu.hideout.domain.model.wellknown
data class WebFinger(val subject:String,val links:List<Link>){
data class Link(val rel:String,val type:String,val href:String)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -3,7 +3,7 @@ package dev.usbharu.hideout.plugins
import dev.usbharu.hideout.ap.JsonLd import dev.usbharu.hideout.ap.JsonLd
import dev.usbharu.hideout.config.Config import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.service.IUserAuthService 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 dev.usbharu.hideout.util.HttpUtil.Activity
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.plugins.api.* import io.ktor.client.plugins.api.*
@ -12,7 +12,6 @@ import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.util.*
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import tech.barbero.http.message.signing.HttpMessage import tech.barbero.http.message.signing.HttpMessage
import tech.barbero.http.message.signing.HttpMessageSigner import tech.barbero.http.message.signing.HttpMessageSigner

View File

@ -8,6 +8,5 @@ import io.ktor.server.application.*
fun Application.configureMonitoring() { fun Application.configureMonitoring() {
install(CallLogging) { install(CallLogging) {
level = Level.INFO level = Level.INFO
filter { call -> call.request.path().startsWith("/") }
} }
} }

View File

@ -1,15 +1,28 @@
package dev.usbharu.hideout.plugins package dev.usbharu.hideout.plugins
import io.ktor.server.routing.* import dev.usbharu.hideout.routing.activitypub.inbox
import io.ktor.server.response.* import dev.usbharu.hideout.routing.activitypub.outbox
import io.ktor.server.plugins.autohead.* 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.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) install(AutoHeadResponse)
routing { routing {
get("/") { inbox(httpSignatureVerifyService, activityPubService)
call.respondText("Hello World!") outbox()
} usersAP(activityPubUserService)
webfinger(userService)
} }
} }

View File

@ -1,5 +1,11 @@
package dev.usbharu.hideout.plugins 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.serialization.jackson.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.contentnegotiation.*
@ -8,11 +14,11 @@ import io.ktor.server.routing.*
fun Application.configureSerialization() { fun Application.configureSerialization() {
install(ContentNegotiation) { install(ContentNegotiation) {
jackson() jackson {
} enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
routing { setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
get("/json/kotlinx-serialization") { configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
call.respond(mapOf("hello" to "world")) configOverride(List::class.java).setSetterInfo(JsonSetter.Value.forContentNulls(Nulls.AS_EMPTY))
} }
} }
} }

View File

@ -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<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
exception<IllegalArgumentException> { call, cause ->
call.respondText(text = "400: $cause", status = HttpStatusCode.BadRequest)
}
}
}

View File

@ -8,8 +8,16 @@ interface IUserRepository {
suspend fun findById(id: Long): UserEntity? suspend fun findById(id: Long): UserEntity?
suspend fun findByIds(ids: List<Long>): List<UserEntity>
suspend fun findByName(name: String): UserEntity? suspend fun findByName(name: String): UserEntity?
suspend fun findByNameAndDomains(names: List<Pair<String,String>>): List<UserEntity>
suspend fun findByUrl(url:String):UserEntity?
suspend fun findByUrls(urls: List<String>): List<UserEntity>
suspend fun update(userEntity: UserEntity) suspend fun update(userEntity: UserEntity)
suspend fun delete(id: Long) suspend fun delete(id: Long)

View File

@ -25,7 +25,10 @@ class UserRepository(private val database: Database) : IUserRepository {
this[Users.name], this[Users.name],
this[Users.domain], this[Users.domain],
this[Users.screenName], 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.name],
this[Users.domain], this[Users.domain],
this[Users.screenName], 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[domain] = user.domain
it[screenName] = user.screenName it[screenName] = user.screenName
it[description] = user.description it[description] = user.description
it[inbox] = user.inbox
it[outbox] = user.outbox
it[url] = user.url
}[Users.id].value, user) }[Users.id].value, user)
} }
} }
@ -70,6 +79,14 @@ class UserRepository(private val database: Database) : IUserRepository {
} }
} }
override suspend fun findByIds(ids: List<Long>): List<UserEntity> {
return query {
Users.select { Users.id inList ids }.map {
it.toUserEntity()
}
}
}
override suspend fun findByName(name: String): UserEntity? { override suspend fun findByName(name: String): UserEntity? {
return query { return query {
Users.select { Users.name eq name }.map { Users.select { Users.name eq name }.map {
@ -78,6 +95,26 @@ class UserRepository(private val database: Database) : IUserRepository {
} }
} }
override suspend fun findByNameAndDomains(names: List<Pair<String, String>>): List<UserEntity> {
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<String>): List<UserEntity> {
TODO("Not yet implemented")
}
override suspend fun findFollowersById(id: Long): List<UserEntity> { override suspend fun findFollowersById(id: Long): List<UserEntity> {
return query { return query {
val followers = Users.alias("FOLLOWERS") val followers = Users.alias("FOLLOWERS")
@ -91,7 +128,16 @@ class UserRepository(private val database: Database) : IUserRepository {
onColumn = { UsersFollowers.followerId }, onColumn = { UsersFollowers.followerId },
otherColumn = { followers[Users.id] }) 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 } .select { Users.id eq id }
.map { .map {
UserEntity( UserEntity(
@ -100,6 +146,9 @@ class UserRepository(private val database: Database) : IUserRepository {
domain = it[followers[Users.domain]], domain = it[followers[Users.domain]],
screenName = it[followers[Users.screenName]], screenName = it[followers[Users.screenName]],
description = it[followers[Users.description]], 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[domain] = userEntity.domain
it[screenName] = userEntity.screenName it[screenName] = userEntity.screenName
it[description] = userEntity.description it[description] = userEntity.description
it[inbox] = userEntity.inbox
it[outbox] = userEntity.outbox
it[url] = userEntity.url
} }
} }
} }

View File

@ -4,8 +4,8 @@ import dev.usbharu.hideout.domain.model.User
import dev.usbharu.hideout.plugins.UserSession import dev.usbharu.hideout.plugins.UserSession
import dev.usbharu.hideout.plugins.respondAp import dev.usbharu.hideout.plugins.respondAp
import dev.usbharu.hideout.plugins.tokenAuth import dev.usbharu.hideout.plugins.tokenAuth
import dev.usbharu.hideout.service.ActivityPubUserService import dev.usbharu.hideout.service.impl.ActivityPubUserService
import dev.usbharu.hideout.service.UserService import dev.usbharu.hideout.service.impl.UserService
import dev.usbharu.hideout.util.HttpUtil import dev.usbharu.hideout.util.HttpUtil
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
@ -13,7 +13,6 @@ import io.ktor.server.auth.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import io.ktor.server.sessions.*
@Suppress("unused") @Suppress("unused")
fun Application.user(userService: UserService, activityPubUserService: ActivityPubUserService) { fun Application.user(userService: UserService, activityPubUserService: ActivityPubUserService) {

View File

@ -1,7 +1,7 @@
package dev.usbharu.hideout.routing package dev.usbharu.hideout.routing
import dev.usbharu.hideout.config.Config 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 dev.usbharu.hideout.util.HttpUtil.Activity
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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
}
}
}

View File

@ -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<Follow>(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)
}
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -1,5 +1,6 @@
package dev.usbharu.hideout.service package dev.usbharu.hideout.service
import dev.usbharu.hideout.domain.model.UserAuthentication
import dev.usbharu.hideout.domain.model.UserAuthenticationEntity import dev.usbharu.hideout.domain.model.UserAuthenticationEntity
interface IUserAuthService { interface IUserAuthService {
@ -13,4 +14,5 @@ interface IUserAuthService {
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
} }

View File

@ -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<ReceiveFollowJob>)
}

View File

@ -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<ReceiveFollowJob>) {
val actor = props[ReceiveFollowJob.actor]
val person = activityPubUserService.fetchPerson(actor)
val follow = Config.configData.objectMapper.readValue<Follow>(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)
}
}

View File

@ -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 <T : HideoutJob> processActivity(job: JobContextWithProps<T>,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
}

View File

@ -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 <T : HideoutJob> processActivity(job: JobContextWithProps<T>, hideoutJob: HideoutJob) {
when (hideoutJob) {
ReceiveFollowJob -> activityPubFollowService.receiveFollowJob(job.props as JobProps<ReceiveFollowJob>)
}
}
}

View File

@ -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
}

View File

@ -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<Person>(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
}
}
}

View File

@ -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 import dev.usbharu.hideout.config.Config
class ActivityPubService() { class ActivityPubService() {

View File

@ -1,8 +1,10 @@
package dev.usbharu.hideout.service package dev.usbharu.hideout.service.impl
import dev.usbharu.hideout.ap.* import dev.usbharu.hideout.ap.*
import dev.usbharu.hideout.config.Config import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.plugins.postAp import dev.usbharu.hideout.plugins.postAp
import dev.usbharu.hideout.service.IUserAuthService
import dev.usbharu.hideout.service.IWebFingerService
import io.ktor.client.* import io.ktor.client.*
class ActivityPubUserService( class ActivityPubUserService(

View File

@ -1,4 +1,4 @@
package dev.usbharu.hideout.service package dev.usbharu.hideout.service.impl
import java.security.PrivateKey import java.security.PrivateKey

View File

@ -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.config.Config
import dev.usbharu.hideout.domain.model.User import dev.usbharu.hideout.domain.model.User
import dev.usbharu.hideout.domain.model.UserAuthentication import dev.usbharu.hideout.domain.model.UserAuthentication
import dev.usbharu.hideout.domain.model.UserAuthenticationEntity 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.exception.UserNotFoundException
import dev.usbharu.hideout.repository.IUserAuthRepository import dev.usbharu.hideout.repository.IUserAuthRepository
import dev.usbharu.hideout.repository.IUserRepository import dev.usbharu.hideout.repository.IUserRepository
import dev.usbharu.hideout.service.IUserAuthService
import io.ktor.util.* import io.ktor.util.*
import java.security.KeyPair import java.security.*
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.PublicKey
import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey import java.security.interfaces.RSAPublicKey
import java.util.* import java.util.*
@ -35,11 +32,15 @@ class UserAuthService(
} }
override suspend fun registerAccount(username: String, hash: String) { override suspend fun registerAccount(username: String, hash: String) {
val url = "${Config.configData.url}/users/$username"
val registerUser = User( val registerUser = User(
name = username, name = username,
domain = Config.configData.domain, domain = Config.configData.domain,
screenName = username, screenName = username,
description = "" description = "",
inbox = "$url/inbox",
outbox = "$url/outbox",
url = url
) )
val createdUser = userRepository.create(registerUser) val createdUser = userRepository.create(registerUser)
@ -76,6 +77,10 @@ class UserAuthService(
?: throw UserNotFoundException("$username auth data was not found") ?: throw UserNotFoundException("$username auth data was not found")
} }
override suspend fun createAccount(userEntity: UserAuthentication): UserAuthenticationEntity {
return userAuthRepository.create(userEntity)
}
private fun generateKeyPair(): KeyPair { private fun generateKeyPair(): KeyPair {
val keyPairGenerator = KeyPairGenerator.getInstance("RSA") val keyPairGenerator = KeyPairGenerator.getInstance("RSA")
keyPairGenerator.initialize(1024) keyPairGenerator.initialize(1024)
@ -83,8 +88,6 @@ class UserAuthService(
} }
companion object { companion object {
val sha256: MessageDigest = MessageDigest.getInstance("SHA-256") val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
} }

View File

@ -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.User
import dev.usbharu.hideout.domain.model.UserEntity 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.") return userRepository.findById(id) ?: throw UserNotFoundException("$id was not found.")
} }
suspend fun findByIds(ids: List<Long>): List<UserEntity> {
return userRepository.findByIds(ids)
}
suspend fun findByName(name: String): UserEntity { suspend fun findByName(name: String): UserEntity {
return userRepository.findByName(name) return userRepository.findByName(name)
?: throw UserNotFoundException("$name was not found.") ?: throw UserNotFoundException("$name was not found.")
} }
suspend fun findByNameAndDomains(names: List<Pair<String,String>>): List<UserEntity> {
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<String>): List<UserEntity> {
return userRepository.findByUrls(urls)
}
suspend fun create(user: User): UserEntity { suspend fun create(user: User): UserEntity {
return userRepository.create(user) return userRepository.create(user)
} }

View File

@ -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.ap.Person
import dev.usbharu.hideout.domain.model.User import dev.usbharu.hideout.domain.model.User
import dev.usbharu.hideout.domain.model.UserEntity import dev.usbharu.hideout.domain.model.UserEntity
import dev.usbharu.hideout.service.IWebFingerService
import dev.usbharu.hideout.util.HttpUtil import dev.usbharu.hideout.util.HttpUtil
import dev.usbharu.hideout.webfinger.WebFinger import dev.usbharu.hideout.webfinger.WebFinger
import io.ktor.client.* import io.ktor.client.*
@ -65,8 +66,12 @@ class WebFingerService(
userModel.preferredUsername ?: throw IllegalStateException(), userModel.preferredUsername ?: throw IllegalStateException(),
domain, domain,
userName, userName,
userModel.summary.orEmpty() userModel.summary.orEmpty(),
"",
"",
""
) )
TODO()
return userService.create(user) return userService.create(user)
} }
} }

View File

@ -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<Job>)
suspend fun <J : Job> schedule(job: J, block: ScheduleContext<J>.(J) -> Unit = {})
}

View File

@ -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<Pair<Job, JobRegisterContext<Job, JobContextWithProps<Job>>.(Job) -> KJobFunctions<Job, JobContextWithProps<Job>>>>)
}

View File

@ -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<Job>) {
}
override suspend fun <J : Job> schedule(job: J,block:ScheduleContext<J>.(J)->Unit) {
kjob.schedule(job,block)
}
}

View File

@ -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<Pair<Job,JobRegisterContext<Job, JobContextWithProps<Job>>.(Job) -> KJobFunctions<Job, JobContextWithProps<Job>>>>) {
defines.forEach { job ->
kjob.register(job.first, job.second)
}
}
}

View File

@ -0,0 +1,7 @@
package dev.usbharu.hideout.service.signature
import io.ktor.http.*
interface HttpSignatureVerifyService {
fun verify(headers:Headers):Boolean
}

View File

@ -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<String> {
return name?.let { headers.getAll(it) }?.toMutableList() ?: mutableListOf()
}
override fun addHeader(name: String?, value: String?) {
TODO()
}
})
}
}

View File

@ -28,5 +28,8 @@ object HttpUtil {
val ContentType.Application.Activity: ContentType val ContentType.Application.Activity: ContentType
get() = ContentType("application", "activity+json") 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 // fun
} }

View File

@ -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 <T> 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<String>, status: Set<JobStatus>, limit: Int): Flow<ScheduledJob> {
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<String, Any> {
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<String, Any>.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<Double>).map(::JsonPrimitive)
is Long -> "l" to (value as List<Long>).map(::JsonPrimitive)
is Int -> "i" to (value as List<Int>).map(::JsonPrimitive)
is String -> "s" to (value as List<String>).map(::JsonPrimitive)
is Boolean -> "b" to (value as List<Boolean>).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) }
)
)
}
}
}

View File

@ -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<ExposedKJob.Configuration>(config) {
companion object : KJobFactory<ExposedKJob, Configuration> {
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()
}
}

View File

@ -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 <T> 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 }
}
}
}

View File

@ -6,13 +6,13 @@ ktor {
watch = [classes, resources] watch = [classes, resources]
} }
application { application {
modules = [dev.usbharu.hideout.ApplicationKt.module] modules = [dev.usbharu.hideout.ApplicationKt.parent,dev.usbharu.hideout.ApplicationKt.worker]
} }
} }
hideout { hideout {
hostname = "https://localhost:8080" url = "http://localhost:8080"
hostname = ${?HOSTNAME}
database { database {
url = "jdbc:h2:./test;MODE=POSTGRESQL" url = "jdbc:h2:./test;MODE=POSTGRESQL"
driver = "org.h2.Driver" driver = "org.h2.Driver"

View File

@ -0,0 +1,7 @@
package dev.usbharu.hideout
import io.ktor.server.application.*
fun Application.empty(){
}

View File

@ -7,8 +7,8 @@ import dev.usbharu.hideout.domain.model.UserAuthenticationEntity
import dev.usbharu.hideout.domain.model.UserEntity import dev.usbharu.hideout.domain.model.UserEntity
import dev.usbharu.hideout.repository.IUserAuthRepository import dev.usbharu.hideout.repository.IUserAuthRepository
import dev.usbharu.hideout.repository.IUserRepository import dev.usbharu.hideout.repository.IUserRepository
import dev.usbharu.hideout.service.UserAuthService import dev.usbharu.hideout.service.impl.UserAuthService
import dev.usbharu.hideout.service.toPem import dev.usbharu.hideout.service.impl.toPem
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.mock.* import io.ktor.client.engine.mock.*
import io.ktor.client.plugins.logging.* import io.ktor.client.plugins.logging.*
@ -17,7 +17,6 @@ import org.junit.jupiter.api.Test
import java.security.KeyPairGenerator import java.security.KeyPairGenerator
import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey import java.security.interfaces.RSAPublicKey
import java.util.*
class ActivityPubKtTest { class ActivityPubKtTest {
@Test @Test
@ -32,8 +31,24 @@ class ActivityPubKtTest {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun findByIds(ids: List<Long>): List<UserEntity> {
TODO("Not yet implemented")
}
override suspend fun findByName(name: String): UserEntity? { 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<Pair<String, String>>): List<UserEntity> {
TODO("Not yet implemented")
}
override suspend fun findByUrl(url: String): UserEntity? {
TODO("Not yet implemented")
}
override suspend fun findByUrls(urls: List<String>): List<UserEntity> {
TODO("Not yet implemented")
} }
override suspend fun update(userEntity: UserEntity) { override suspend fun update(userEntity: UserEntity) {

View File

@ -6,13 +6,12 @@ import dev.usbharu.hideout.domain.model.UserAuthenticationEntity
import dev.usbharu.hideout.domain.model.UserEntity import dev.usbharu.hideout.domain.model.UserEntity
import dev.usbharu.hideout.repository.IUserAuthRepository import dev.usbharu.hideout.repository.IUserAuthRepository
import dev.usbharu.hideout.repository.IUserRepository import dev.usbharu.hideout.repository.IUserRepository
import dev.usbharu.hideout.service.UserAuthService import dev.usbharu.hideout.service.impl.UserAuthService
import dev.usbharu.hideout.service.toPem import dev.usbharu.hideout.service.impl.toPem
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.security.KeyPairGenerator import java.security.KeyPairGenerator
import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey import java.security.interfaces.RSAPublicKey
import java.util.*
class KtorKeyMapTest { class KtorKeyMapTest {
@ -27,8 +26,24 @@ class KtorKeyMapTest {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun findByIds(ids: List<Long>): List<UserEntity> {
TODO("Not yet implemented")
}
override suspend fun findByName(name: String): UserEntity? { 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<Pair<String, String>>): List<UserEntity> {
TODO("Not yet implemented")
}
override suspend fun findByUrl(url: String): UserEntity? {
TODO("Not yet implemented")
}
override suspend fun findByUrls(urls: List<String>): List<UserEntity> {
TODO("Not yet implemented")
} }
override suspend fun update(userEntity: UserEntity) { override suspend fun update(userEntity: UserEntity) {

View File

@ -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)
}
}
}

View File

@ -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())
}
}

View File

@ -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<HttpSignatureVerifyService>{
on { verify(any()) } doReturn true
}
val activityPubService = mock<ActivityPubService>{
on { parseActivity(any()) } doThrow JsonParseException()
}
val userService = mock<UserService>()
val activityPubUserService = mock<ActivityPubUserService>()
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<HttpSignatureVerifyService>{
on { verify(any()) } doReturn true
}
val activityPubService = mock<ActivityPubService>{
on { parseActivity(any()) } doThrow JsonParseException()
}
val userService = mock<UserService>()
val activityPubUserService = mock<ActivityPubUserService>()
application {
configureStatusPages()
configureSerialization()
configureRouting(httpSignatureVerifyService, activityPubService, userService, activityPubUserService)
}
client.post("/users/test/inbox").let {
Assertions.assertEquals(HttpStatusCode.BadRequest, it.status)
}
}
}

View File

@ -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<HttpSignatureVerifyService> {}
val activityPubService = mock<ActivityPubService> {}
val userService = mock<UserService> {}
val activityPubUserService = mock<ActivityPubUserService> {
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<Person>(actual)
assertEquals(person, readValue)
}
}
}

View File

@ -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<JobQueueParentService> {
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<ScheduleContext<ReceiveFollowJob>.(ReceiveFollowJob) -> Unit> {
verify(jobQueueParentService, times(1)).schedule(eq(ReceiveFollowJob), capture())
val scheduleContext = ScheduleContext<ReceiveFollowJob>(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<ActivityPubUserService> {
onBlocking { fetchPerson(anyString()) } doReturn person
}
val userService = mock<UserService> {
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<Accept>(
httpRequestData.body.toByteArray().decodeToString()
)
)
respondOk()
})
)
activityPubFollowService.receiveFollowJob(
JobProps(
data = mapOf<String, Any>(
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
)
)
}
}

View File

@ -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) } }))
}
}

View File

@ -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()
}
}

View File

@ -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 })
}
}
}

View File

@ -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
)
)
}
}

View File

@ -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 = ""
}
}