Merge pull request #14 from usbharu/feature/api

Feature/api
This commit is contained in:
usbharu 2023-05-20 19:22:55 +09:00 committed by GitHub
commit 34e48cb429
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 2827 additions and 204 deletions

View File

@ -1,5 +1,7 @@
build:
maxIssues: 20
weights:
Indentation: 0
style:
ClassOrdering:
@ -32,6 +34,15 @@ style:
ForbiddenComment:
active: false
ThrowsCount:
active: false
UseCheckOrError:
active: false
UseRequire:
active: false
complexity:
CognitiveComplexMethod:
active: true

View File

@ -100,11 +100,12 @@ fun Application.parent() {
inject<JwkProvider>().value,
)
configureRouting(
inject<HttpSignatureVerifyService>().value,
inject<ActivityPubService>().value,
inject<IUserService>().value,
inject<ActivityPubUserService>().value,
inject<IPostService>().value
httpSignatureVerifyService = inject<HttpSignatureVerifyService>().value,
activityPubService = inject<ActivityPubService>().value,
userService = inject<IUserService>().value,
activityPubUserService = inject<ActivityPubUserService>().value,
postService = inject<IPostService>().value,
userApiService = inject<IUserApiService>().value,
)
}

View File

@ -0,0 +1,3 @@
package dev.usbharu.hideout.domain.model
data class Acct(val username: String, val domain: String? = null, val isRemote: Boolean = domain == null)

View File

@ -1,54 +0,0 @@
package dev.usbharu.hideout.domain.model
import dev.usbharu.hideout.repository.Users
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.Table
object Posts : Table() {
val id = long("id")
val userId = long("userId").references(Users.id)
val overview = varchar("overview", 100).nullable()
val text = varchar("text", 3000)
val createdAt = long("createdAt")
val visibility = integer("visibility").default(0)
val url = varchar("url", 500)
val repostId = long("repostId").references(id).nullable()
val replyId = long("replyId").references(id).nullable()
override val primaryKey: PrimaryKey = PrimaryKey(id)
}
data class Post(
val userId: Long,
val overview: String? = null,
val text: String,
val createdAt: Long,
val visibility: Int,
val repostId: Long? = null,
val replyId: Long? = null
)
data class PostEntity(
val id: Long,
val userId: Long,
val overview: String? = null,
val text: String,
val createdAt: Long,
val visibility: Int,
val url: String,
val repostId: Long? = null,
val replyId: Long? = null
)
fun ResultRow.toPost(): PostEntity {
return PostEntity(
id = this[Posts.id],
userId = this[Posts.userId],
overview = this[Posts.overview],
text = this[Posts.text],
createdAt = this[Posts.createdAt],
visibility = this[Posts.visibility],
url = this[Posts.url],
repostId = this[Posts.repostId],
replyId = this[Posts.replyId]
)
}

View File

@ -9,7 +9,11 @@ open class Follow : Object {
name: String,
`object`: String?,
actor: String?
) : super(add(type, "Follow"), name, actor) {
) : super(
type = add(type, "Follow"),
name = name,
actor = actor
) {
this.`object` = `object`
}
}

View File

@ -8,10 +8,14 @@ open class Key : Object {
constructor(
type: List<String>,
name: String,
id: String?,
id: String,
owner: String?,
publicKeyPem: String?
) : super(add(type, "Key"), name, id) {
) : super(
type = add(list = type, type = "Key"),
name = name,
id = id
) {
this.owner = owner
this.publicKeyPem = publicKeyPem
}
@ -21,16 +25,16 @@ open class Key : Object {
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
}
override fun toString(): String = "Key(owner=$owner, publicKeyPem=$publicKeyPem) ${super.toString()}"
}

View File

@ -27,7 +27,8 @@ open class Object : JsonLd {
if (type != other.type) return false
if (name != other.name) return false
return actor == other.actor
if (actor != other.actor) return false
return id == other.id
}
override fun hashCode(): Int {
@ -35,10 +36,11 @@ open class Object : JsonLd {
result = 31 * result + type.hashCode()
result = 31 * result + (name?.hashCode() ?: 0)
result = 31 * result + (actor?.hashCode() ?: 0)
result = 31 * result + (id?.hashCode() ?: 0)
return result
}
override fun toString(): String = "Object(type=$type, name=$name, actor=$actor) ${super.toString()}"
override fun toString(): String = "Object(type=$type, name=$name, actor=$actor, id=$id) ${super.toString()}"
companion object {
@JvmStatic

View File

@ -1,4 +1,4 @@
package dev.usbharu.hideout.domain.model.api
package dev.usbharu.hideout.domain.model.api.mastodon
data class StatusForPost(
val status: String,

View File

@ -0,0 +1,12 @@
package dev.usbharu.hideout.domain.model.hideout.dto
import dev.usbharu.hideout.domain.model.hideout.entity.Visibility
data class PostCreateDto(
val text: String,
val overview: String? = null,
val visibility: Visibility = Visibility.PUBLIC,
val repostId: Long? = null,
val repolyId: Long? = null,
val userId: Long
)

View File

@ -0,0 +1,27 @@
package dev.usbharu.hideout.domain.model.hideout.dto
import dev.usbharu.hideout.domain.model.hideout.entity.User
data class UserResponse(
val id: Long,
val name: String,
val domain: String,
val screenName: String,
val description: String = "",
val url: String,
val createdAt: Long
) {
companion object {
fun from(user: User): UserResponse {
return UserResponse(
id = user.id,
name = user.name,
domain = user.domain,
screenName = user.screenName,
description = user.description,
url = user.url,
createdAt = user.createdAt.toEpochMilli()
)
}
}
}

View File

@ -0,0 +1,13 @@
package dev.usbharu.hideout.domain.model.hideout.entity
data class Post(
val id: Long,
val userId: Long,
val overview: String? = null,
val text: String,
val createdAt: Long,
val visibility: Visibility,
val url: String,
val repostId: Long? = null,
val replyId: Long? = null
)

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.domain.model.hideout.entity
enum class Visibility {
PUBLIC,
UNLISTED,
FOLLOWERS,
DIRECT
}

View File

@ -0,0 +1,11 @@
package dev.usbharu.hideout.domain.model.hideout.form
import dev.usbharu.hideout.domain.model.hideout.entity.Visibility
data class Post(
val text: String,
val overview: String? = null,
val visibility: Visibility = Visibility.PUBLIC,
val repostId: Long? = null,
val replyId: Long? = null
)

View File

@ -0,0 +1,3 @@
package dev.usbharu.hideout.domain.model.hideout.form
data class UserCreate(val username: String, val password: String)

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.exception
class PostNotFoundException : IllegalArgumentException {
constructor() : super()
constructor(s: String?) : super(s)
constructor(message: String?, cause: Throwable?) : super(message, cause)
constructor(cause: Throwable?) : super(cause)
}

View File

@ -3,9 +3,12 @@ package dev.usbharu.hideout.plugins
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.api.v1.statuses
import dev.usbharu.hideout.routing.api.internal.v1.posts
import dev.usbharu.hideout.routing.api.internal.v1.users
import dev.usbharu.hideout.routing.api.mastodon.v1.statuses
import dev.usbharu.hideout.routing.wellknown.webfinger
import dev.usbharu.hideout.service.IPostService
import dev.usbharu.hideout.service.IUserApiService
import dev.usbharu.hideout.service.activitypub.ActivityPubService
import dev.usbharu.hideout.service.activitypub.ActivityPubUserService
import dev.usbharu.hideout.service.impl.IUserService
@ -14,12 +17,14 @@ import io.ktor.server.application.*
import io.ktor.server.plugins.autohead.*
import io.ktor.server.routing.*
@Suppress("LongParameterList")
fun Application.configureRouting(
httpSignatureVerifyService: HttpSignatureVerifyService,
activityPubService: ActivityPubService,
userService: IUserService,
activityPubUserService: ActivityPubUserService,
postService: IPostService
postService: IPostService,
userApiService: IUserApiService
) {
install(AutoHeadResponse)
routing {
@ -31,5 +36,9 @@ fun Application.configureRouting(
route("/api/v1") {
statuses(postService)
}
route("/api/internal/v1") {
posts(postService)
users(userService, userApiService)
}
}
}

View File

@ -35,11 +35,14 @@ fun Application.configureSecurity(
acceptLeeway(3)
}
validate { jwtCredential ->
if (jwtCredential.payload.getClaim("username")?.asString().isNullOrBlank().not()) {
JWTPrincipal(jwtCredential.payload)
} else {
null
val uid = jwtCredential.payload.getClaim("uid")
if (uid.isMissing) {
return@validate null
}
if (uid.asLong() == null) {
return@validate null
}
return@validate JWTPrincipal(jwtCredential.payload)
}
}
}
@ -73,8 +76,8 @@ fun Application.configureSecurity(
}
authenticate(TOKEN_AUTH) {
get("/auth-check") {
val principal = call.principal<JWTPrincipal>()
val username = principal!!.payload.getClaim("username")
val principal = call.principal<JWTPrincipal>() ?: throw IllegalStateException("no principal")
val username = principal.payload.getClaim("uid")
call.respondText("Hello $username")
}
}

View File

@ -1,10 +1,10 @@
package dev.usbharu.hideout.repository
import dev.usbharu.hideout.domain.model.Post
import dev.usbharu.hideout.domain.model.PostEntity
import dev.usbharu.hideout.domain.model.hideout.entity.Post
interface IPostRepository {
suspend fun insert(post: Post): PostEntity
suspend fun findOneById(id: Long): PostEntity
suspend fun generateId(): Long
suspend fun save(post: Post): Post
suspend fun findOneById(id: Long): Post
suspend fun delete(id: Long)
}

View File

@ -1,10 +1,7 @@
package dev.usbharu.hideout.repository
import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.domain.model.Post
import dev.usbharu.hideout.domain.model.PostEntity
import dev.usbharu.hideout.domain.model.Posts
import dev.usbharu.hideout.domain.model.toPost
import dev.usbharu.hideout.domain.model.hideout.entity.Post
import dev.usbharu.hideout.domain.model.hideout.entity.Visibility
import dev.usbharu.hideout.service.IdGenerateService
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.*
@ -22,41 +19,30 @@ class PostRepositoryImpl(database: Database, private val idGenerateService: IdGe
}
}
override suspend fun generateId(): Long = idGenerateService.generateId()
@Suppress("InjectDispatcher")
suspend fun <T> query(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
override suspend fun insert(post: Post): PostEntity {
override suspend fun save(post: Post): Post {
return query {
val generateId = idGenerateService.generateId()
val name = Users.select { Users.id eq post.userId }.single().toUser().name
val postUrl = Config.configData.url + "/users/$name/posts/$generateId"
Posts.insert {
it[id] = generateId
it[id] = post.id
it[userId] = post.userId
it[overview] = post.overview
it[text] = post.text
it[createdAt] = post.createdAt
it[visibility] = post.visibility
it[url] = postUrl
it[visibility] = post.visibility.ordinal
it[url] = post.url
it[repostId] = post.repostId
it[replyId] = post.replyId
}
return@query PostEntity(
id = generateId,
userId = post.userId,
overview = post.overview,
text = post.text,
createdAt = post.createdAt,
visibility = post.visibility,
url = postUrl,
repostId = post.repostId,
replyId = post.replyId
)
return@query post
}
}
override suspend fun findOneById(id: Long): PostEntity {
override suspend fun findOneById(id: Long): Post {
return query {
Posts.select { Posts.id eq id }.single().toPost()
}
@ -68,3 +54,30 @@ class PostRepositoryImpl(database: Database, private val idGenerateService: IdGe
}
}
}
object Posts : Table() {
val id = long("id")
val userId = long("userId").references(Users.id)
val overview = varchar("overview", 100).nullable()
val text = varchar("text", 3000)
val createdAt = long("createdAt")
val visibility = integer("visibility").default(0)
val url = varchar("url", 500)
val repostId = long("repostId").references(id).nullable()
val replyId = long("replyId").references(id).nullable()
override val primaryKey: PrimaryKey = PrimaryKey(id)
}
fun ResultRow.toPost(): Post {
return Post(
id = this[Posts.id],
userId = this[Posts.userId],
overview = this[Posts.overview],
text = this[Posts.text],
createdAt = this[Posts.createdAt],
visibility = Visibility.values().first { visibility -> visibility.ordinal == this[Posts.visibility] },
url = this[Posts.url],
repostId = this[Posts.repostId],
replyId = this[Posts.replyId]
)
}

View File

@ -26,7 +26,9 @@ fun Routing.usersAP(activityPubUserService: ActivityPubUserService, userService:
)
}
get {
val userEntity = userService.findByNameLocalUser(call.parameters["name"]!!)
val userEntity = userService.findByNameLocalUser(
call.parameters["name"] ?: throw ParameterNotExistException("Parameter(name='name') does not exist.")
)
call.respondText(userEntity.toString() + "\n" + userService.findFollowersById(userEntity.id))
}
}

View File

@ -0,0 +1,95 @@
package dev.usbharu.hideout.routing.api.internal.v1
import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto
import dev.usbharu.hideout.domain.model.hideout.form.Post
import dev.usbharu.hideout.exception.ParameterNotExistException
import dev.usbharu.hideout.exception.PostNotFoundException
import dev.usbharu.hideout.plugins.TOKEN_AUTH
import dev.usbharu.hideout.service.IPostService
import dev.usbharu.hideout.util.AcctUtil
import dev.usbharu.hideout.util.InstantParseUtil
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
@Suppress("LongMethod")
fun Route.posts(postService: IPostService) {
route("/posts") {
authenticate(TOKEN_AUTH) {
post {
val principal = call.principal<JWTPrincipal>() ?: throw IllegalStateException("no principal")
val userId = principal.payload.getClaim("uid").asLong()
val receive = call.receive<Post>()
val postCreateDto = PostCreateDto(
text = receive.text,
overview = receive.overview,
visibility = receive.visibility,
repostId = receive.repostId,
repolyId = receive.replyId,
userId = userId
)
val create = postService.create(postCreateDto)
call.response.header("Location", create.url)
call.respond(HttpStatusCode.OK)
}
}
authenticate(TOKEN_AUTH, optional = true) {
get {
val userId = call.principal<JWTPrincipal>()?.payload?.getClaim("uid")?.asLong()
val since = InstantParseUtil.parse(call.request.queryParameters["since"])
val until = InstantParseUtil.parse(call.request.queryParameters["until"])
val minId = call.request.queryParameters["minId"]?.toLong()
val maxId = call.request.queryParameters["maxId"]?.toLong()
val limit = call.request.queryParameters["limit"]?.toInt()
call.respond(HttpStatusCode.OK, postService.findAll(since, until, minId, maxId, limit, userId))
}
get("/{id}") {
val userId = call.principal<JWTPrincipal>()?.payload?.getClaim("uid")?.asLong()
val id = call.parameters["id"]?.toLong()
?: throw ParameterNotExistException("Parameter(id='postsId') does not exist.")
val post = (
postService.findByIdForUser(id, userId)
?: throw PostNotFoundException("$id was not found or is not authorized.")
)
call.respond(post)
}
}
}
route("/users/{name}/posts") {
authenticate(TOKEN_AUTH, optional = true) {
get {
val userId = call.principal<JWTPrincipal>()?.payload?.getClaim("uid")?.asLong()
val targetUserName = call.parameters["name"]
?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.")
val targetUserId = targetUserName.toLongOrNull()
val posts = if (targetUserId == null) {
val acct = AcctUtil.parse(targetUserName)
postService.findByUserNameAndDomainForUser(
acct.username,
acct.domain ?: Config.configData.domain,
forUserId = userId
)
} else {
postService.findByUserIdForUser(targetUserId, forUserId = userId)
}
call.respond(posts)
}
get("/{id}") {
val userId = call.principal<JWTPrincipal>()?.payload?.getClaim("uid")?.asLong()
val id = call.parameters["id"]?.toLong()
?: throw ParameterNotExistException("Parameter(name='postsId' does not exist.")
val post = (
postService.findByIdForUser(id, userId)
?: throw PostNotFoundException("$id was not found or is not authorized.")
)
call.respond(post)
}
}
}
}

View File

@ -0,0 +1,105 @@
package dev.usbharu.hideout.routing.api.internal.v1
import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.domain.model.hideout.dto.UserCreateDto
import dev.usbharu.hideout.domain.model.hideout.form.UserCreate
import dev.usbharu.hideout.exception.ParameterNotExistException
import dev.usbharu.hideout.plugins.TOKEN_AUTH
import dev.usbharu.hideout.service.IUserApiService
import dev.usbharu.hideout.service.impl.IUserService
import dev.usbharu.hideout.util.AcctUtil
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
@Suppress("LongMethod")
fun Route.users(userService: IUserService, userApiService: IUserApiService) {
route("/users") {
get {
call.respond(userApiService.findAll())
}
post {
val userCreate = call.receive<UserCreate>()
if (userService.usernameAlreadyUse(userCreate.username)) {
return@post call.respond(HttpStatusCode.BadRequest)
}
val user = userService.createLocalUser(
UserCreateDto(
userCreate.username,
userCreate.username,
"",
userCreate.password
)
)
call.response.header("Location", "${Config.configData.url}/api/internal/v1/users/${user.name}")
call.respond(HttpStatusCode.Created)
}
route("/{name}") {
authenticate(TOKEN_AUTH, optional = true) {
get {
val userParameter = (
call.parameters["name"]
?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.")
)
if (userParameter.toLongOrNull() != null) {
return@get call.respond(userApiService.findById(userParameter.toLong()))
} else {
val acct = AcctUtil.parse(userParameter)
return@get call.respond(userApiService.findByAcct(acct))
}
}
}
route("/followers") {
get {
val userParameter = call.parameters["name"]
?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.")
if (userParameter.toLongOrNull() != null) {
return@get call.respond(userApiService.findFollowers(userParameter.toLong()))
}
val acct = AcctUtil.parse(userParameter)
return@get call.respond(userApiService.findFollowersByAcct(acct))
}
authenticate(TOKEN_AUTH) {
post {
val userId = call.principal<JWTPrincipal>()?.payload?.getClaim("uid")?.asLong()
?: throw IllegalStateException("no principal")
val userParameter = call.parameters["name"]
?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.")
if (userParameter.toLongOrNull() != null) {
if (userService.addFollowers(userParameter.toLong(), userId)) {
return@post call.respond(HttpStatusCode.OK)
} else {
return@post call.respond(HttpStatusCode.Accepted)
}
}
val acct = AcctUtil.parse(userParameter)
val targetUser = userApiService.findByAcct(acct)
if (userService.addFollowers(targetUser.id, userId)) {
return@post call.respond(HttpStatusCode.OK)
} else {
return@post call.respond(HttpStatusCode.Accepted)
}
}
}
}
route("/following") {
get {
val userParameter = (
call.parameters["name"]
?: throw ParameterNotExistException("Parameter(name='userName@domain') does not exist.")
)
if (userParameter.toLongOrNull() != null) {
return@get call.respond(userApiService.findFollowings(userParameter.toLong()))
}
val acct = AcctUtil.parse(userParameter)
return@get call.respond(userApiService.findFollowingsByAcct(acct))
}
}
}
}
}

View File

@ -0,0 +1,21 @@
package dev.usbharu.hideout.routing.api.mastodon.v1
import dev.usbharu.hideout.service.IPostService
import io.ktor.server.routing.*
@Suppress("UnusedPrivateMember")
fun Route.statuses(postService: IPostService) {
// route("/statuses") {
// post {
// val status: StatusForPost = call.receive()
// val post = dev.usbharu.hideout.domain.model.hideout.form.Post(
// userId = status.userId,
// createdAt = System.currentTimeMillis(),
// text = status.status,
// visibility = 1
// )
// postService.create(post)
// call.respond(status)
// }
// }
}

View File

@ -1,25 +0,0 @@
package dev.usbharu.hideout.routing.api.v1
import dev.usbharu.hideout.domain.model.Post
import dev.usbharu.hideout.domain.model.api.StatusForPost
import dev.usbharu.hideout.service.IPostService
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.statuses(postService: IPostService) {
route("/statuses") {
post {
val status: StatusForPost = call.receive()
val post = Post(
userId = status.userId,
createdAt = System.currentTimeMillis(),
text = status.status,
visibility = 1
)
postService.create(post)
call.respond(status)
}
}
}

View File

@ -1,7 +1,61 @@
package dev.usbharu.hideout.service
import dev.usbharu.hideout.domain.model.Post
import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto
import dev.usbharu.hideout.domain.model.hideout.entity.Post
import java.time.Instant
@Suppress("LongParameterList")
interface IPostService {
suspend fun create(post: Post)
suspend fun create(post: Post): Post
suspend fun create(post: PostCreateDto): Post
suspend fun findAll(
since: Instant? = null,
until: Instant? = null,
minId: Long? = null,
maxId: Long? = null,
limit: Int? = 10,
userId: Long? = null
): List<Post>
suspend fun findById(id: String): Post
/**
* 権限を考慮して投稿を取得します
*
* @param id
* @param userId
* @return
*/
suspend fun findByIdForUser(id: Long, userId: Long?): Post?
/**
* 権限を考慮してユーザーの投稿を取得します
*
* @param userId
* @param forUserId
* @return
*/
suspend fun findByUserIdForUser(
userId: Long,
since: Instant? = null,
until: Instant? = null,
minId: Long? = null,
maxId: Long? = null,
limit: Int? = null,
forUserId: Long? = null
): List<Post>
suspend fun findByUserNameAndDomainForUser(
userName: String,
domain: String = Config.configData.domain,
since: Instant? = null,
until: Instant? = null,
minId: Long? = null,
maxId: Long? = null,
limit: Int? = null,
forUserId: Long? = null
): List<Post>
suspend fun delete(id: String)
}

View File

@ -0,0 +1,24 @@
package dev.usbharu.hideout.service
import dev.usbharu.hideout.domain.model.Acct
import dev.usbharu.hideout.domain.model.hideout.dto.UserResponse
interface IUserApiService {
suspend fun findAll(limit: Int? = 100, offset: Long = 0): List<UserResponse>
suspend fun findById(id: Long): UserResponse
suspend fun findByIds(ids: List<Long>): List<UserResponse>
suspend fun findByAcct(acct: Acct): UserResponse
suspend fun findByAccts(accts: List<Acct>): List<UserResponse>
suspend fun findFollowers(userId: Long): List<UserResponse>
suspend fun findFollowings(userId: Long): List<UserResponse>
suspend fun findFollowersByAcct(acct: Acct): List<UserResponse>
suspend fun findFollowingsByAcct(acct: Acct): List<UserResponse>
}

View File

@ -52,7 +52,7 @@ class JwtServiceImpl(
.withAudience("${Config.configData.url}/users/${user.name}")
.withIssuer(Config.configData.url)
.withKeyId(keyId.await().toString())
.withClaim("username", user.name)
.withClaim("uid", user.id)
.withExpiresAt(now.plus(30, ChronoUnit.MINUTES))
.sign(Algorithm.RSA256(publicKey.await(), privateKey.await()))

View File

@ -25,7 +25,7 @@ class ServerInitialiseServiceImpl(private val metaRepository: IMetaRepository) :
return
}
if (isVersionChanged(savedMeta!!)) {
if (isVersionChanged(requireNotNull(savedMeta))) {
logger.info("Version changed!! (${savedMeta.version} -> $implementationVersion)")
updateVersion(savedMeta, implementationVersion)
}

View File

@ -0,0 +1,38 @@
package dev.usbharu.hideout.service
import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.domain.model.Acct
import dev.usbharu.hideout.domain.model.hideout.dto.UserResponse
import dev.usbharu.hideout.service.impl.IUserService
import org.koin.core.annotation.Single
@Single
class UserApiServiceImpl(private val userService: IUserService) : IUserApiService {
override suspend fun findAll(limit: Int?, offset: Long): List<UserResponse> =
userService.findAll(limit, offset).map { UserResponse.from(it) }
override suspend fun findById(id: Long): UserResponse = UserResponse.from(userService.findById(id))
override suspend fun findByIds(ids: List<Long>): List<UserResponse> =
userService.findByIds(ids).map { UserResponse.from(it) }
override suspend fun findByAcct(acct: Acct): UserResponse =
UserResponse.from(userService.findByNameAndDomain(acct.username, acct.domain))
override suspend fun findByAccts(accts: List<Acct>): List<UserResponse> {
return userService.findByNameAndDomains(accts.map { it.username to (it.domain ?: Config.configData.domain) })
.map { UserResponse.from(it) }
}
override suspend fun findFollowers(userId: Long): List<UserResponse> =
userService.findFollowersById(userId).map { UserResponse.from(it) }
override suspend fun findFollowings(userId: Long): List<UserResponse> =
userService.findFollowingById(userId).map { UserResponse.from(it) }
override suspend fun findFollowersByAcct(acct: Acct): List<UserResponse> =
userService.findFollowersByNameAndDomain(acct.username, acct.domain).map { UserResponse.from(it) }
override suspend fun findFollowingsByAcct(acct: Acct): List<UserResponse> =
userService.findFollowingByNameAndDomain(acct.username, acct.domain).map { UserResponse.from(it) }
}

View File

@ -1,11 +1,11 @@
package dev.usbharu.hideout.service.activitypub
import dev.usbharu.hideout.domain.model.PostEntity
import dev.usbharu.hideout.domain.model.hideout.entity.Post
import dev.usbharu.hideout.domain.model.job.DeliverPostJob
import kjob.core.job.JobProps
interface ActivityPubNoteService {
suspend fun createNote(post: PostEntity)
suspend fun createNote(post: Post)
suspend fun createNoteJob(props: JobProps<DeliverPostJob>)
}

View File

@ -2,9 +2,9 @@ package dev.usbharu.hideout.service.activitypub
import com.fasterxml.jackson.module.kotlin.readValue
import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.domain.model.PostEntity
import dev.usbharu.hideout.domain.model.ap.Create
import dev.usbharu.hideout.domain.model.ap.Note
import dev.usbharu.hideout.domain.model.hideout.entity.Post
import dev.usbharu.hideout.domain.model.job.DeliverPostJob
import dev.usbharu.hideout.plugins.postAp
import dev.usbharu.hideout.service.impl.IUserService
@ -24,7 +24,7 @@ class ActivityPubNoteServiceImpl(
private val logger = LoggerFactory.getLogger(this::class.java)
override suspend fun createNote(post: PostEntity) {
override suspend fun createNote(post: Post) {
val followers = userService.findFollowersById(post.userId)
val userEntity = userService.findById(post.userId)
val note = Config.configData.objectMapper.writeValueAsString(post)
@ -39,7 +39,7 @@ class ActivityPubNoteServiceImpl(
override suspend fun createNoteJob(props: JobProps<DeliverPostJob>) {
val actor = props[DeliverPostJob.actor]
val postEntity = Config.configData.objectMapper.readValue<PostEntity>(props[DeliverPostJob.post])
val postEntity = Config.configData.objectMapper.readValue<Post>(props[DeliverPostJob.post])
val note = Note(
name = "Note",
id = postEntity.url,

View File

@ -16,6 +16,8 @@ interface IUserService {
suspend fun findByNameLocalUser(name: String): User
suspend fun findByNameAndDomain(name: String, domain: String? = null): User
suspend fun findByNameAndDomains(names: List<Pair<String, String>>): List<User>
suspend fun findByUrl(url: String): User
@ -30,5 +32,18 @@ interface IUserService {
suspend fun findFollowersById(id: Long): List<User>
suspend fun addFollowers(id: Long, follower: Long)
suspend fun findFollowersByNameAndDomain(name: String, domain: String?): List<User>
suspend fun findFollowingById(id: Long): List<User>
suspend fun findFollowingByNameAndDomain(name: String, domain: String?): List<User>
/**
* フォロワーを追加する
*
* @param id
* @param follower
* @return リクエストが成功したか
*/
suspend fun addFollowers(id: Long, follower: Long): Boolean
}

View File

@ -1,23 +1,130 @@
package dev.usbharu.hideout.service.impl
import dev.usbharu.hideout.domain.model.Post
import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto
import dev.usbharu.hideout.domain.model.hideout.entity.Post
import dev.usbharu.hideout.domain.model.hideout.entity.Visibility
import dev.usbharu.hideout.repository.IPostRepository
import dev.usbharu.hideout.repository.Posts
import dev.usbharu.hideout.repository.UsersFollowers
import dev.usbharu.hideout.repository.toPost
import dev.usbharu.hideout.service.IPostService
import dev.usbharu.hideout.service.activitypub.ActivityPubNoteService
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inSubQuery
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.orIfNotNull
import org.jetbrains.exposed.sql.orWhere
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.core.annotation.Single
import org.slf4j.LoggerFactory
import java.time.Instant
@Single
class PostService(
private val postRepository: IPostRepository,
private val activityPubNoteService: ActivityPubNoteService
private val activityPubNoteService: ActivityPubNoteService,
private val userService: IUserService
) : IPostService {
private val logger = LoggerFactory.getLogger(this::class.java)
override suspend fun create(post: Post) {
override suspend fun create(post: Post): Post {
logger.debug("create post={}", post)
val postEntity = postRepository.insert(post)
val postEntity = postRepository.save(post)
activityPubNoteService.createNote(postEntity)
return post
}
override suspend fun create(post: PostCreateDto): Post {
logger.debug("create post={}", post)
val user = userService.findById(post.userId)
val id = postRepository.generateId()
val postEntity = Post(
id = id,
userId = user.id,
overview = null,
text = post.text,
createdAt = Instant.now().toEpochMilli(),
visibility = Visibility.PUBLIC,
url = "${user.url}/posts/$id",
repostId = null,
replyId = null
)
postRepository.save(postEntity)
return postEntity
}
override suspend fun findAll(
since: Instant?,
until: Instant?,
minId: Long?,
maxId: Long?,
limit: Int?,
userId: Long?
): List<Post> {
return transaction {
val select = Posts.select {
Posts.visibility.eq(Visibility.PUBLIC.ordinal)
}
if (userId != null) {
select.orWhere {
Posts.userId.inSubQuery(
UsersFollowers.slice(UsersFollowers.userId).select(UsersFollowers.followerId eq userId)
)
}
}
select.map { it.toPost() }
}
}
override suspend fun findById(id: String): Post {
TODO("Not yet implemented")
}
override suspend fun findByIdForUser(id: Long, userId: Long?): Post? {
return transaction {
val select = Posts.select(
Posts.id.eq(id).and(
Posts.visibility.eq(Visibility.PUBLIC.ordinal).orIfNotNull(
userId?.let {
Posts.userId.inSubQuery(
UsersFollowers.slice(UsersFollowers.userId).select(UsersFollowers.followerId.eq(userId))
)
}
)
)
)
select.singleOrNull()?.toPost()
}
}
override suspend fun findByUserIdForUser(
userId: Long,
since: Instant?,
until: Instant?,
minId: Long?,
maxId: Long?,
limit: Int?,
forUserId: Long?
): List<Post> {
TODO("Not yet implemented")
}
override suspend fun findByUserNameAndDomainForUser(
userName: String,
domain: String,
since: Instant?,
until: Instant?,
minId: Long?,
maxId: Long?,
limit: Int?,
forUserId: Long?
): List<Post> {
TODO("Not yet implemented")
}
override suspend fun delete(id: String) {
TODO("Not yet implemented")
}
}

View File

@ -35,6 +35,11 @@ class UserService(private val userRepository: IUserRepository, private val userA
?: throw UserNotFoundException("$name was not found.")
}
override suspend fun findByNameAndDomain(name: String, domain: String?): User {
return userRepository.findByNameAndDomain(name, domain ?: Config.configData.domain)
?: throw UserNotFoundException("$name was not found.")
}
override suspend fun findByNameAndDomains(names: List<Pair<String, String>>): List<User> =
userRepository.findByNameAndDomains(names)
@ -87,6 +92,21 @@ class UserService(private val userRepository: IUserRepository, private val userA
}
override suspend fun findFollowersById(id: Long): List<User> = userRepository.findFollowersById(id)
override suspend fun addFollowers(id: Long, follower: Long) = userRepository.createFollower(id, follower)
override suspend fun findFollowersByNameAndDomain(name: String, domain: String?): List<User> {
TODO("Not yet implemented")
}
override suspend fun findFollowingById(id: Long): List<User> {
TODO("Not yet implemented")
}
override suspend fun findFollowingByNameAndDomain(name: String, domain: String?): List<User> {
TODO("Not yet implemented")
}
// TODO APのフォロー処理を作る
override suspend fun addFollowers(id: Long, follower: Long): Boolean {
userRepository.createFollower(id, follower)
return false
}
}

View File

@ -0,0 +1,42 @@
package dev.usbharu.hideout.util
import dev.usbharu.hideout.domain.model.Acct
object AcctUtil {
fun parse(string: String): Acct {
if (string.isBlank()) {
throw IllegalArgumentException("Invalid acct.(Blank)")
}
return when (string.count { c -> c == '@' }) {
0 -> {
Acct(string)
}
1 -> {
if (string.startsWith("@")) {
Acct(string.substring(1 until string.length))
} else {
Acct(string.substringBefore("@"), string.substringAfter("@"))
}
}
2 -> {
if (string.startsWith("@")) {
val substring = string.substring(1 until string.length)
val userName = substring.substringBefore("@")
val domain = substring.substringAfter("@")
Acct(
userName,
domain.ifBlank { null }
)
} else {
throw IllegalArgumentException("Invalid acct.(@ are in the wrong position)")
}
}
else -> {
throw IllegalArgumentException("Invalid acct.(Too many @)")
}
}
}
}

View File

@ -0,0 +1,18 @@
package dev.usbharu.hideout.util
import java.time.Instant
import java.time.format.DateTimeParseException
object InstantParseUtil {
fun parse(str: String?): Instant? {
return try {
Instant.ofEpochMilli(str?.toLong() ?: return null)
} catch (e: NumberFormatException) {
try {
Instant.parse(str)
} catch (e: DateTimeParseException) {
null
}
}
}
}

View File

@ -0,0 +1,572 @@
openapi: "3.0.3"
info:
title: "hideout API"
description: "hideout API"
version: "1.0.0"
servers:
- url: "https://hideout"
paths:
/.well-known/jwks.json:
get:
description: ""
responses:
"200":
description: "OK"
content:
application/json:
schema:
type: "string"
/auth-check:
get:
description: ""
responses:
"200":
description: "OK"
content:
text/plain:
schema:
type: "string"
examples:
Example#1:
value: ""
/login:
post:
description: ""
requestBody:
content:
'*/*':
schema:
$ref: "#/components/schemas/UserLogin"
required: true
responses:
"401":
description: "Unauthorized"
content:
'*/*':
schema:
type: "object"
"200":
description: "OK"
content:
'*/*':
schema:
$ref: "#/components/schemas/JwtToken"
/refresh-token:
post:
description: ""
requestBody:
content:
'*/*':
schema:
$ref: "#/components/schemas/RefreshToken"
required: true
responses:
"200":
description: "OK"
content:
'*/*':
schema:
$ref: "#/components/schemas/JwtToken"
/.well-known/webfinger:
get:
description: ""
parameters:
- name: "resource"
in: "query"
required: false
schema:
type: "string"
responses:
"200":
description: "OK"
content:
'*/*':
schema:
$ref: "#/components/schemas/WebFinger"
/api/internal/v1/posts:
get:
description: ""
parameters:
- name: "since"
in: "query"
required: false
schema:
type: "string"
- name: "until"
in: "query"
required: false
schema:
type: "string"
- name: "minId"
in: "query"
required: false
schema:
type: "number"
- name: "maxId"
in: "query"
required: false
schema:
type: "number"
- name: "limit"
in: "query"
required: false
schema:
type: "integer"
responses:
"200":
description: "OK"
content:
'*/*':
schema:
type: "array"
items:
$ref: "#/components/schemas/Post"
post:
description: ""
requestBody:
content:
'*/*':
schema:
$ref: "#/components/schemas/Post"
required: true
responses:
"200":
description: "OK"
content:
'*/*':
schema:
type: "object"
/api/internal/v1/posts/{id}:
get:
description: ""
parameters:
- name: "id"
in: "path"
required: true
schema:
type: "number"
responses:
"200":
description: "OK"
content:
'*/*':
schema:
$ref: "#/components/schemas/Post"
/api/internal/v1/users:
get:
description: ""
responses:
"200":
description: "OK"
content:
'*/*':
schema:
type: "array"
items:
$ref: "#/components/schemas/UserResponse"
post:
description: ""
requestBody:
content:
'*/*':
schema:
$ref: "#/components/schemas/UserCreate"
required: true
responses:
"400":
description: "Bad Request"
content:
'*/*':
schema:
type: "object"
"201":
description: "Created"
content:
'*/*':
schema:
type: "object"
/api/internal/v1/users/{name}:
get:
description: ""
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
responses:
"200":
description: "OK"
content:
'*/*':
schema:
type: "object"
/api/internal/v1/users/{name}/followers:
get:
description: ""
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
responses:
"200":
description: "OK"
content:
'*/*':
schema:
type: "array"
items:
type: "object"
post:
description: ""
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
responses:
"200":
description: "OK"
content:
'*/*':
schema:
type: "object"
"202":
description: "Accepted"
content:
'*/*':
schema:
type: "object"
/api/internal/v1/users/{name}/following:
get:
description: ""
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
responses:
"200":
description: "OK"
content:
'*/*':
schema:
type: "array"
items:
type: "object"
/api/internal/v1/users/{name}/posts:
get:
description: ""
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
responses:
"200":
description: "OK"
content:
'*/*':
schema:
type: "array"
items:
$ref: "#/components/schemas/Post"
/api/internal/v1/users/{name}/posts/{id}:
get:
description: ""
parameters:
- name: "id"
in: "path"
required: true
schema:
type: "number"
- name: "name"
in: "path"
required: true
schema:
type: "string"
responses:
"200":
description: "OK"
content:
'*/*':
schema:
$ref: "#/components/schemas/Post"
/inbox:
get:
description: ""
responses:
"405":
description: "Method Not Allowed"
content:
'*/*':
schema:
type: "object"
post:
description: ""
responses:
"200":
description: "OK"
content:
'*/*':
schema:
type: "string"
"501":
description: "Not Implemented"
content:
'*/*':
schema:
type: "object"
/outbox:
get:
description: ""
responses:
"501":
description: "Not Implemented"
content:
'*/*':
schema:
type: "object"
post:
description: ""
responses:
"501":
description: "Not Implemented"
content:
'*/*':
schema:
type: "object"
/users/{name}:
get:
description: ""
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
responses:
"200":
description: "OK"
content:
text/plain:
schema:
type: "string"
/users/{name}/inbox:
get:
description: ""
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
responses:
"405":
description: "Method Not Allowed"
content:
'*/*':
schema:
type: "object"
post:
description: ""
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
responses:
"200":
description: "OK"
content:
'*/*':
schema:
type: "string"
"501":
description: "Not Implemented"
content:
'*/*':
schema:
type: "object"
/users/{name}/outbox:
get:
description: ""
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
responses:
"501":
description: "Not Implemented"
content:
'*/*':
schema:
type: "object"
post:
description: ""
parameters:
- name: "name"
in: "path"
required: true
schema:
type: "string"
responses:
"501":
description: "Not Implemented"
content:
'*/*':
schema:
type: "object"
/:
get:
description: ""
responses:
"200":
description: "OK"
content:
text/html:
schema:
type: "string"
/register:
get:
description: ""
responses:
"200":
description: "OK"
content:
text/html:
schema:
type: "string"
post:
description: ""
parameters:
- name: "password"
in: "query"
required: false
schema:
type: "string"
- name: "username"
in: "query"
required: false
schema:
type: "string"
responses:
"200":
description: "OK <br> Redirect"
content:
text/plain:
schema:
type: "string"
examples:
Example#1:
value: ""
Example#2:
value: "/register"
Example#3:
value: "/register"
Example#4:
value: "/register"
components:
schemas:
UserLogin:
type: "object"
properties:
username:
type: "string"
password:
type: "string"
JwtToken:
type: "object"
properties:
token:
type: "string"
refreshToken:
type: "string"
RefreshToken:
type: "object"
properties:
refreshToken:
type: "string"
Link:
type: "object"
properties:
rel:
type: "string"
type:
type: "string"
href:
type: "string"
WebFinger:
type: "object"
properties:
subject:
type: "string"
links:
type: "array"
items:
$ref: "#/components/schemas/Link"
Post:
type: "object"
properties:
id:
type: "integer"
format: "int64"
userId:
type: "integer"
format: "int64"
overview:
type: "string"
text:
type: "string"
createdAt:
type: "integer"
format: "int64"
visibility:
type: "string"
enum:
- "PUBLIC"
- "UNLISTED"
- "FOLLOWERS"
- "DIRECT"
url:
type: "string"
repostId:
type: "integer"
format: "int64"
replyId:
type: "integer"
format: "int64"
UserResponse:
type: "object"
properties:
id:
type: "integer"
format: "int64"
name:
type: "string"
domain:
type: "string"
screenName:
type: "string"
description:
type: "string"
url:
type: "string"
createdAt:
type: "integer"
format: "int64"
UserCreate:
type: "object"
properties:
username:
type: "string"
password:
type: "string"

View File

@ -217,7 +217,7 @@ class SecurityKtTest {
.withAudience("${Config.configData.url}/users/test")
.withIssuer(Config.configData.url)
.withKeyId(kid.toString())
.withClaim("username", "test")
.withClaim("uid", 123456L)
.withExpiresAt(now.plus(30, ChronoUnit.MINUTES))
.sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey))
val metaService = mock<IMetaService> {
@ -255,7 +255,7 @@ class SecurityKtTest {
header("Authorization", "Bearer $token")
}.apply {
assertEquals(HttpStatusCode.OK, call.response.status)
assertEquals("Hello \"test\"", call.response.bodyAsText())
assertEquals("Hello 123456", call.response.bodyAsText())
}
}
@ -277,7 +277,7 @@ class SecurityKtTest {
.withAudience("${Config.configData.url}/users/test")
.withIssuer(Config.configData.url)
.withKeyId(kid.toString())
.withClaim("username", "test")
.withClaim("uid", 123345L)
.withExpiresAt(now.minus(30, ChronoUnit.MINUTES))
.sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey))
val metaService = mock<IMetaService> {
@ -335,7 +335,7 @@ class SecurityKtTest {
.withAudience("${Config.configData.url}/users/test")
.withIssuer("https://example.com")
.withKeyId(kid.toString())
.withClaim("username", "test")
.withClaim("uid", 12345L)
.withExpiresAt(now.plus(30, ChronoUnit.MINUTES))
.sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey))
val metaService = mock<IMetaService> {
@ -393,7 +393,7 @@ class SecurityKtTest {
.withAudience("${Config.configData.url}/users/test")
.withIssuer(Config.configData.url)
.withKeyId(kid.toString())
.withClaim("username", "")
.withClaim("uid", null as Long?)
.withExpiresAt(now.plus(30, ChronoUnit.MINUTES))
.sign(Algorithm.RSA256(rsaPublicKey, keyPair.private as RSAPrivateKey))
val metaService = mock<IMetaService> {

View File

@ -1,7 +1,6 @@
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
@ -11,6 +10,7 @@ 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.routing.*
import io.ktor.server.testing.*
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
@ -27,7 +27,9 @@ class InboxRoutingKtTest {
}
application {
configureSerialization()
configureRouting(mock(), mock(), mock(), mock(), mock())
routing {
inbox(mock(), mock())
}
}
client.get("/inbox").let {
Assertions.assertEquals(HttpStatusCode.MethodNotAllowed, it.status)
@ -45,18 +47,14 @@ class InboxRoutingKtTest {
val activityPubService = mock<ActivityPubService> {
on { parseActivity(any()) } doThrow JsonParseException()
}
val userService = mock<IUserService>()
val activityPubUserService = mock<ActivityPubUserService>()
mock<IUserService>()
mock<ActivityPubUserService>()
application {
configureStatusPages()
configureSerialization()
configureRouting(
httpSignatureVerifyService,
activityPubService,
userService,
activityPubUserService,
mock()
)
routing {
inbox(httpSignatureVerifyService, activityPubService)
}
}
client.post("/inbox").let {
Assertions.assertEquals(HttpStatusCode.BadRequest, it.status)
@ -70,7 +68,9 @@ class InboxRoutingKtTest {
}
application {
configureSerialization()
configureRouting(mock(), mock(), mock(), mock(), mock())
routing {
inbox(mock(), mock())
}
}
client.get("/users/test/inbox").let {
Assertions.assertEquals(HttpStatusCode.MethodNotAllowed, it.status)
@ -88,18 +88,14 @@ class InboxRoutingKtTest {
val activityPubService = mock<ActivityPubService> {
on { parseActivity(any()) } doThrow JsonParseException()
}
val userService = mock<IUserService>()
val activityPubUserService = mock<ActivityPubUserService>()
mock<IUserService>()
mock<ActivityPubUserService>()
application {
configureStatusPages()
configureSerialization()
configureRouting(
httpSignatureVerifyService,
activityPubService,
userService,
activityPubUserService,
mock()
)
routing {
inbox(httpSignatureVerifyService, activityPubService)
}
}
client.post("/users/test/inbox").let {
Assertions.assertEquals(HttpStatusCode.BadRequest, it.status)

View File

@ -10,18 +10,16 @@ import dev.usbharu.hideout.domain.model.ap.Image
import dev.usbharu.hideout.domain.model.ap.Key
import dev.usbharu.hideout.domain.model.ap.Person
import dev.usbharu.hideout.domain.model.hideout.entity.User
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.IUserService
import dev.usbharu.hideout.service.signature.HttpSignatureVerifyService
import dev.usbharu.hideout.util.HttpUtil.Activity
import dev.usbharu.hideout.util.HttpUtil.JsonLd
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.config.*
import io.ktor.server.routing.*
import io.ktor.server.testing.*
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyString
@ -64,8 +62,6 @@ class UsersAPTest {
)
person.context = listOf("https://www.w3.org/ns/activitystreams")
val httpSignatureVerifyService = mock<HttpSignatureVerifyService> {}
val activityPubService = mock<ActivityPubService> {}
val userService = mock<IUserService> {}
val activityPubUserService = mock<ActivityPubUserService> {
@ -74,13 +70,9 @@ class UsersAPTest {
application {
configureSerialization()
configureRouting(
httpSignatureVerifyService,
activityPubService,
userService,
activityPubUserService,
mock()
)
routing {
usersAP(activityPubUserService, userService)
}
}
client.get("/users/test") {
accept(ContentType.Application.Activity)
@ -130,8 +122,6 @@ class UsersAPTest {
)
person.context = listOf("https://www.w3.org/ns/activitystreams")
val httpSignatureVerifyService = mock<HttpSignatureVerifyService> {}
val activityPubService = mock<ActivityPubService> {}
val userService = mock<IUserService> {}
val activityPubUserService = mock<ActivityPubUserService> {
@ -140,13 +130,9 @@ class UsersAPTest {
application {
configureSerialization()
configureRouting(
httpSignatureVerifyService,
activityPubService,
userService,
activityPubUserService,
mock()
)
routing {
usersAP(activityPubUserService, userService)
}
}
client.get("/users/test") {
accept(ContentType.Application.JsonLd)
@ -205,13 +191,9 @@ class UsersAPTest {
)
}
application {
configureRouting(
mock(),
mock(),
userService,
mock(),
mock()
)
routing {
usersAP(mock(), userService)
}
}
client.get("/users/test") {
accept(ContentType.Text.Html)

View File

@ -0,0 +1,619 @@
package dev.usbharu.hideout.routing.api.internal.v1
import com.auth0.jwt.interfaces.Claim
import com.auth0.jwt.interfaces.Payload
import com.fasterxml.jackson.module.kotlin.readValue
import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto
import dev.usbharu.hideout.domain.model.hideout.entity.Post
import dev.usbharu.hideout.domain.model.hideout.entity.Visibility
import dev.usbharu.hideout.plugins.TOKEN_AUTH
import dev.usbharu.hideout.plugins.configureSecurity
import dev.usbharu.hideout.plugins.configureSerialization
import dev.usbharu.hideout.service.IPostService
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.config.*
import io.ktor.server.routing.*
import io.ktor.server.testing.*
import org.junit.jupiter.api.Test
import org.mockito.kotlin.*
import utils.JsonObjectMapper
import java.time.Instant
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
class PostsTest {
@Test
fun 認証情報無しでpostsにGETしたらPUBLICな投稿一覧が返ってくる() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val posts = listOf(
Post(
id = 12345,
userId = 4321,
text = "test1",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/1"
),
Post(
id = 123456,
userId = 4322,
text = "test2",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/2"
)
)
val postService = mock<IPostService> {
onBlocking {
findAll(
since = anyOrNull(),
until = anyOrNull(),
minId = anyOrNull(),
maxId = anyOrNull(),
limit = anyOrNull(),
userId = isNull()
)
} doReturn posts
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/posts").apply {
assertEquals(HttpStatusCode.OK, status)
assertContentEquals(posts, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun 認証情報ありでpostsにGETすると権限のある投稿が返ってくる() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val claim = mock<Claim> {
on { asLong() } doReturn 1234
}
val payload = mock<Payload> {
on { getClaim(eq("uid")) } doReturn claim
}
val posts = listOf(
Post(
id = 12345,
userId = 4321,
text = "test1",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/1"
),
Post(
id = 123456,
userId = 4322,
text = "test2",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/2"
),
Post(
id = 1234567,
userId = 4333,
text = "Followers only",
visibility = Visibility.FOLLOWERS,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/3"
)
)
val postService = mock<IPostService> {
onBlocking {
findAll(
since = anyOrNull(),
until = anyOrNull(),
minId = anyOrNull(),
maxId = anyOrNull(),
limit = anyOrNull(),
userId = isNotNull()
)
} doReturn posts
}
application {
authentication {
bearer(TOKEN_AUTH) {
authenticate {
JWTPrincipal(payload)
}
}
}
configureSerialization()
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/posts") {
header("Authorization", "Bearer asdkaf")
}.apply {
assertEquals(HttpStatusCode.OK, status)
}
}
@Test
fun `posts id にGETしたらPUBLICな投稿を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val post = Post(
12345, 1234, text = "aaa", visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(), url = "https://example.com/posts/1"
)
val postService = mock<IPostService> {
onBlocking { findByIdForUser(any(), anyOrNull()) } doReturn post
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/posts/1").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `認証情報ありでposts id にGETしたら権限のある投稿を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val post = Post(
12345, 1234, text = "aaa", visibility = Visibility.FOLLOWERS,
createdAt = Instant.now().toEpochMilli(), url = "https://example.com/posts/1"
)
val postService = mock<IPostService> {
onBlocking { findByIdForUser(any(), isNotNull()) } doReturn post
}
val claim = mock<Claim> {
on { asLong() } doReturn 1234
}
val payload = mock<Payload> {
on { getClaim(eq("uid")) } doReturn claim
}
application {
configureSerialization()
authentication {
bearer(TOKEN_AUTH) {
authenticate {
JWTPrincipal(payload)
}
}
}
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/posts/1") {
header("Authorization", "Bearer asdkaf")
}.apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `posts-post postsにpostしたら投稿できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val claim = mock<Claim> {
on { asLong() } doReturn 1234
}
val payload = mock<Payload> {
on { getClaim(eq("uid")) } doReturn claim
}
val postService = mock<IPostService> {
onBlocking { create(any<PostCreateDto>()) } doAnswer {
val argument = it.getArgument<PostCreateDto>(0)
Post(
123L,
argument.userId,
null,
argument.text,
Instant.now().toEpochMilli(),
Visibility.PUBLIC,
"https://example.com"
)
}
}
application {
authentication {
bearer(TOKEN_AUTH) {
authenticate {
println("aaaaaaaaaaaa")
JWTPrincipal(payload)
}
}
}
routing {
route("/api/internal/v1") {
posts(postService)
}
}
configureSerialization()
}
val post = dev.usbharu.hideout.domain.model.hideout.form.Post("test")
client.post("/api/internal/v1/posts") {
header("Authorization", "Bearer asdkaf")
contentType(ContentType.Application.Json)
setBody(Config.configData.objectMapper.writeValueAsString(post))
}.apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals("https://example.com", headers["Location"])
}
argumentCaptor<PostCreateDto> {
verify(postService).create(capture())
assertEquals(PostCreateDto("test", userId = 1234), firstValue)
}
}
@Test
fun `users userId postsにGETしたらユーザーのPUBLICな投稿一覧を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val posts = listOf(
Post(
id = 12345,
userId = 1,
text = "test1",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/1"
),
Post(
id = 123456,
userId = 1,
text = "test2",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/2"
)
)
val postService = mock<IPostService> {
onBlocking {
findByUserIdForUser(
userId = any(),
since = anyOrNull(),
until = anyOrNull(),
minId = anyOrNull(),
maxId = anyOrNull(),
limit = anyOrNull(),
forUserId = anyOrNull()
)
} doReturn posts
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/users/1/posts").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(posts, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users username postsにGETしたらユーザーのPUBLICな投稿一覧を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val posts = listOf(
Post(
id = 12345,
userId = 1,
text = "test1",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/1"
),
Post(
id = 123456,
userId = 1,
text = "test2",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/2"
)
)
val postService = mock<IPostService> {
onBlocking {
findByUserNameAndDomainForUser(
userName = eq("test1"),
domain = eq(Config.configData.domain),
since = anyOrNull(),
until = anyOrNull(),
minId = anyOrNull(),
maxId = anyOrNull(),
limit = anyOrNull(),
forUserId = anyOrNull()
)
} doReturn posts
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/users/test1/posts").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(posts, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users username@domain postsにGETしたらユーザーのPUBLICな投稿一覧を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val posts = listOf(
Post(
id = 12345,
userId = 1,
text = "test1",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/1"
),
Post(
id = 123456,
userId = 1,
text = "test2",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/2"
)
)
val postService = mock<IPostService> {
onBlocking {
findByUserNameAndDomainForUser(
userName = eq("test1"),
domain = eq("example.com"),
since = anyOrNull(),
until = anyOrNull(),
minId = anyOrNull(),
maxId = anyOrNull(),
limit = anyOrNull(),
forUserId = anyOrNull()
)
} doReturn posts
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/users/test1@example.com/posts").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(posts, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users @username@domain postsにGETしたらユーザーのPUBLICな投稿一覧を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val posts = listOf(
Post(
id = 12345,
userId = 1,
text = "test1",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/1"
),
Post(
id = 123456,
userId = 1,
text = "test2",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/2"
)
)
val postService = mock<IPostService> {
onBlocking {
findByUserNameAndDomainForUser(
userName = eq("test1"),
domain = eq("example.com"),
since = anyOrNull(),
until = anyOrNull(),
minId = anyOrNull(),
maxId = anyOrNull(),
limit = anyOrNull(),
forUserId = anyOrNull()
)
} doReturn posts
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/users/@test1@example.com/posts").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(posts, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users name posts id にGETしたらPUBLICな投稿を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val post = Post(
id = 123456,
userId = 1,
text = "test2",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/2"
)
val postService = mock<IPostService> {
onBlocking { findByIdForUser(eq(12345L), anyOrNull()) } doReturn post
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/users/test/posts/12345").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users id posts id にGETしたらPUBLICな投稿を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val post = Post(
id = 123456,
userId = 1,
text = "test2",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/2"
)
val postService = mock<IPostService> {
onBlocking { findByIdForUser(eq(12345L), anyOrNull()) } doReturn post
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/users/1/posts/12345").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users name posts id にGETしたらUserIdが間違っててもPUBLICな投稿を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val post = Post(
id = 123456,
userId = 1,
text = "test2",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/2"
)
val postService = mock<IPostService> {
onBlocking { findByIdForUser(eq(12345L), anyOrNull()) } doReturn post
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/users/423827849732847/posts/12345").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users name posts id にGETしたらuserNameが間違っててもPUBLICな投稿を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val post = Post(
id = 123456,
userId = 1,
text = "test2",
visibility = Visibility.PUBLIC,
createdAt = Instant.now().toEpochMilli(),
url = "https://example.com/posts/2"
)
val postService = mock<IPostService> {
onBlocking { findByIdForUser(eq(12345L), anyOrNull()) } doReturn post
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
posts(postService)
}
}
}
client.get("/api/internal/v1/users/invalidUserName/posts/12345").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(post, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
}

View File

@ -0,0 +1,691 @@
package dev.usbharu.hideout.routing.api.internal.v1
import com.auth0.jwt.interfaces.Claim
import com.auth0.jwt.interfaces.Payload
import com.fasterxml.jackson.module.kotlin.readValue
import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.domain.model.hideout.dto.UserResponse
import dev.usbharu.hideout.domain.model.hideout.entity.User
import dev.usbharu.hideout.domain.model.hideout.form.UserCreate
import dev.usbharu.hideout.plugins.TOKEN_AUTH
import dev.usbharu.hideout.plugins.configureSecurity
import dev.usbharu.hideout.plugins.configureSerialization
import dev.usbharu.hideout.service.IUserApiService
import dev.usbharu.hideout.service.impl.IUserService
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.config.*
import io.ktor.server.routing.*
import io.ktor.server.testing.*
import org.junit.jupiter.api.Test
import org.mockito.kotlin.*
import utils.JsonObjectMapper
import java.time.Instant
import kotlin.test.assertEquals
class UsersTest {
@Test
fun `users にGETするとユーザー一覧を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val users = listOf(
UserResponse(
12345,
"test1",
"example.com",
"test",
"",
"https://example.com/test",
Instant.now().toEpochMilli()
),
UserResponse(
12343,
"tes2",
"example.com",
"test",
"",
"https://example.com/tes2",
Instant.now().toEpochMilli()
),
)
val userService = mock<IUserApiService> {
onBlocking { findAll(anyOrNull(), anyOrNull()) } doReturn users
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(mock(), userService)
}
}
}
client.get("/api/internal/v1/users").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(users, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users にPOSTすると新規ユーザー作成ができる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val userCreateDto = UserCreate("test", "XXXXXXX")
val userService = mock<IUserService> {
onBlocking { usernameAlreadyUse(any()) } doReturn false
onBlocking { createLocalUser(any()) } doReturn User(
id = 12345,
name = "test",
domain = "example.com",
screenName = "testUser",
description = "test user",
password = "XXXXXXX",
inbox = "https://example.com/inbox",
outbox = "https://example.com/outbox",
url = "https://example.com",
publicKey = "-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----",
privateKey = "-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----",
createdAt = Instant.now()
)
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(userService, mock())
}
}
}
client.post("/api/internal/v1/users") {
contentType(ContentType.Application.Json)
setBody(JsonObjectMapper.objectMapper.writeValueAsString(userCreateDto))
}.apply {
assertEquals(HttpStatusCode.Created, status)
assertEquals(
"${Config.configData.url}/api/internal/v1/users/${userCreateDto.username}",
headers["Location"]
)
}
}
@Test
fun `users 既にユーザー名が使用されているときはBadRequestが帰ってくる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val userCreateDto = UserCreate("test", "XXXXXXX")
val userService = mock<IUserService> {
onBlocking { usernameAlreadyUse(any()) } doReturn true
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(userService, mock())
}
}
}
client.post("/api/internal/v1/users") {
contentType(ContentType.Application.Json)
setBody(JsonObjectMapper.objectMapper.writeValueAsString(userCreateDto))
}.apply {
assertEquals(HttpStatusCode.BadRequest, status)
}
}
@Test
fun `users name にGETしたらユーザーを取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val userResponse = UserResponse(
1234,
"test1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
val userApiService = mock<IUserApiService> {
onBlocking { findByAcct(any()) } doReturn userResponse
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(mock(), userApiService)
}
}
}
client.get("/api/internal/v1/users/test1").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(userResponse, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users id にGETしたらユーザーを取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val userResponse = UserResponse(
1234,
"test1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
val userApiService = mock<IUserApiService> {
onBlocking { findById(any()) } doReturn userResponse
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(mock(), userApiService)
}
}
}
client.get("/api/internal/v1/users/1234").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(userResponse, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users name@domain にGETしたらユーザーを取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val userResponse = UserResponse(
1234,
"test1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
val userApiService = mock<IUserApiService> {
onBlocking { findByAcct(any()) } doReturn userResponse
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(mock(), userApiService)
}
}
}
client.get("/api/internal/v1/users/test1").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(userResponse, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users @name@domain にGETしたらユーザーを取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val userResponse = UserResponse(
1234,
"test1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
val userApiService = mock<IUserApiService> {
onBlocking { findByAcct(any()) } doReturn userResponse
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(mock(), userApiService)
}
}
}
client.get("/api/internal/v1/users/test1").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(userResponse, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users name followers にGETしたらフォロワー一覧を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val followers = listOf(
UserResponse(
1235,
"follower1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
), UserResponse(
1236,
"follower2",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
)
val userApiService = mock<IUserApiService> {
onBlocking { findFollowersByAcct(any()) } doReturn followers
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(mock(), userApiService)
}
}
}
client.get("/api/internal/v1/users/test1/followers").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users name@domain followers にGETしたらフォロワー一覧を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val followers = listOf(
UserResponse(
1235,
"follower1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
), UserResponse(
1236,
"follower2",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
)
val userApiService = mock<IUserApiService> {
onBlocking { findFollowersByAcct(any()) } doReturn followers
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(mock(), userApiService)
}
}
}
client.get("/api/internal/v1/users/@test1@example.com/followers").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users id followers にGETしたらフォロワー一覧を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val followers = listOf(
UserResponse(
1235,
"follower1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
), UserResponse(
1236,
"follower2",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
)
val userApiService = mock<IUserApiService> {
onBlocking { findFollowers(any()) } doReturn followers
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(mock(), userApiService)
}
}
}
client.get("/api/internal/v1/users/1234/followers").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users name followers に認証情報ありでGETしたらフォローできる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val claim = mock<Claim> {
on { asLong() } doReturn 1234
}
val payload = mock<Payload> {
on { getClaim(eq("uid")) } doReturn claim
}
val userApiService = mock<IUserApiService> {
onBlocking { findByAcct(any()) } doReturn UserResponse(
1235,
"follower1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
}
val userService = mock<IUserService> {
onBlocking { addFollowers(eq(1235), eq(1234)) } doReturn true
}
application {
configureSerialization()
authentication {
bearer(TOKEN_AUTH) {
authenticate {
JWTPrincipal(payload)
}
}
}
routing {
route("/api/internal/v1") {
users(userService, userApiService)
}
}
}
client.post("/api/internal/v1/users/test1/followers") {
header(HttpHeaders.Authorization, "Bearer test")
}.apply {
assertEquals(HttpStatusCode.OK, status)
}
}
@Test
fun `users name followers に認証情報ありでGETしたらフォロー処理受付になることもある`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val claim = mock<Claim> {
on { asLong() } doReturn 1234
}
val payload = mock<Payload> {
on { getClaim(eq("uid")) } doReturn claim
}
val userApiService = mock<IUserApiService> {
onBlocking { findByAcct(any()) } doReturn UserResponse(
1235,
"follower1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
}
val userService = mock<IUserService> {
onBlocking { addFollowers(eq(1235), eq(1234)) } doReturn false
}
application {
configureSerialization()
authentication {
bearer(TOKEN_AUTH) {
authenticate {
JWTPrincipal(payload)
}
}
}
routing {
route("/api/internal/v1") {
users(userService, userApiService)
}
}
}
client.post("/api/internal/v1/users/test1/followers") {
header(HttpHeaders.Authorization, "Bearer test")
}.apply {
assertEquals(HttpStatusCode.Accepted, status)
}
}
@Test
fun `users id followers に認証情報ありでGETしたらフォロー処理受付になることもある`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val claim = mock<Claim> {
on { asLong() } doReturn 1234
}
val payload = mock<Payload> {
on { getClaim(eq("uid")) } doReturn claim
}
val userApiService = mock<IUserApiService> {
onBlocking { findById(any()) } doReturn UserResponse(
1235,
"follower1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
}
val userService = mock<IUserService> {
onBlocking { addFollowers(eq(1235), eq(1234)) } doReturn false
}
application {
configureSerialization()
authentication {
bearer(TOKEN_AUTH) {
authenticate {
JWTPrincipal(payload)
}
}
}
routing {
route("/api/internal/v1") {
users(userService, userApiService)
}
}
}
client.post("/api/internal/v1/users/1235/followers") {
header(HttpHeaders.Authorization, "Bearer test")
}.apply {
assertEquals(HttpStatusCode.Accepted, status)
}
}
@Test
fun `users name following にGETしたらフォロイー一覧を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val followers = listOf(
UserResponse(
1235,
"follower1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
), UserResponse(
1236,
"follower2",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
)
val userApiService = mock<IUserApiService> {
onBlocking { findFollowingsByAcct(any()) } doReturn followers
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(mock(), userApiService)
}
}
}
client.get("/api/internal/v1/users/test1/following").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users name@domain following にGETしたらフォロイー一覧を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val followers = listOf(
UserResponse(
1235,
"follower1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
), UserResponse(
1236,
"follower2",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
)
val userApiService = mock<IUserApiService> {
onBlocking { findFollowingsByAcct(any()) } doReturn followers
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(mock(), userApiService)
}
}
}
client.get("/api/internal/v1/users/test1@domain/following").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
@Test
fun `users id following にGETしたらフォロイー一覧を取得できる`() = testApplication {
environment {
config = ApplicationConfig("empty.conf")
}
val followers = listOf(
UserResponse(
1235,
"follower1",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
), UserResponse(
1236,
"follower2",
"example.com",
"test",
"test User",
"https://example.com/test",
Instant.now().toEpochMilli()
)
)
val userApiService = mock<IUserApiService> {
onBlocking { findFollowings(any()) } doReturn followers
}
application {
configureSerialization()
configureSecurity(mock(), mock(), mock(), mock(), mock())
routing {
route("/api/internal/v1") {
users(mock(), userApiService)
}
}
}
client.get("/api/internal/v1/users/1234/following").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals(followers, JsonObjectMapper.objectMapper.readValue(bodyAsText()))
}
}
}

View File

@ -115,7 +115,7 @@ class ActivityPubFollowServiceImplTest {
createdAt = Instant.now()
)
)
onBlocking { addFollowers(any(), any()) } doReturn Unit
onBlocking { addFollowers(any(), any()) } doReturn false
}
val activityPubFollowService =
ActivityPubFollowServiceImpl(

View File

@ -5,8 +5,9 @@ package dev.usbharu.hideout.service.activitypub
import dev.usbharu.hideout.config.Config
import dev.usbharu.hideout.config.ConfigData
import dev.usbharu.hideout.domain.model.PostEntity
import dev.usbharu.hideout.domain.model.hideout.entity.Post
import dev.usbharu.hideout.domain.model.hideout.entity.User
import dev.usbharu.hideout.domain.model.hideout.entity.Visibility
import dev.usbharu.hideout.domain.model.job.DeliverPostJob
import dev.usbharu.hideout.service.impl.IUserService
import dev.usbharu.hideout.service.job.JobQueueParentService
@ -72,13 +73,13 @@ class ActivityPubNoteServiceImplTest {
}
val jobQueueParentService = mock<JobQueueParentService>()
val activityPubNoteService = ActivityPubNoteServiceImpl(mock(), jobQueueParentService, userService)
val postEntity = PostEntity(
val postEntity = Post(
1L,
1L,
null,
"test text",
1L,
1,
Visibility.PUBLIC,
"https://example.com"
)
activityPubNoteService.createNote(postEntity)
@ -99,8 +100,14 @@ class ActivityPubNoteServiceImplTest {
JobProps(
data = mapOf<String, Any>(
DeliverPostJob.actor.name to "https://follower.example.com",
DeliverPostJob.post.name to "{\"id\":1,\"userId\":1,\"inReplyToId\":null,\"text\":\"test text\"," +
"\"createdAt\":1,\"updatedAt\":1,\"url\":\"https://example.com\"}",
DeliverPostJob.post.name to """{
"id": 1,
"userId": 1,
"text": "test text",
"createdAt": 132525324,
"visibility": 0,
"url": "https://example.com"
}""",
DeliverPostJob.inbox.name to "https://follower.example.com/inbox"
),
json = Json

View File

@ -0,0 +1,162 @@
@file:OptIn(ExperimentalCoroutinesApi::class)
package dev.usbharu.hideout.service.impl
import dev.usbharu.hideout.domain.model.hideout.entity.Post
import dev.usbharu.hideout.domain.model.hideout.entity.Visibility
import dev.usbharu.hideout.repository.Posts
import dev.usbharu.hideout.repository.UsersFollowers
import dev.usbharu.hideout.service.TwitterSnowflakeIdGenerateService
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.batchInsert
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.kotlin.mock
import java.time.Instant
import kotlin.test.assertContentEquals
class PostServiceTest {
lateinit var db: Database
@BeforeEach
fun setUp() {
db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
transaction(db) {
SchemaUtils.create(Posts)
connection.prepareStatement("SET REFERENTIAL_INTEGRITY FALSE", false).executeUpdate()
}
}
@AfterEach
fun tearDown() {
transaction(db) {
SchemaUtils.drop(Posts)
}
}
@Test
fun `findAll 公開投稿を取得できる`() = runTest {
val postService = PostService(mock(), mock(), mock())
suspend fun createPost(userId: Long, text: String, visibility: Visibility = Visibility.PUBLIC): Post {
return Post(
TwitterSnowflakeIdGenerateService.generateId(),
userId,
null,
text,
Instant.now().toEpochMilli(),
visibility,
"https://example.com"
)
}
val userA: Long = 1
val userB: Long = 2
val posts = listOf(
createPost(userA, "hello"),
createPost(userA, "hello1"),
createPost(userA, "hello2"),
createPost(userA, "hello3"),
createPost(userA, "hello4"),
createPost(userA, "hello5"),
createPost(userA, "hello6"),
createPost(userB, "good bay ", Visibility.FOLLOWERS),
createPost(userB, "good bay1", Visibility.FOLLOWERS),
createPost(userB, "good bay2", Visibility.FOLLOWERS),
createPost(userB, "good bay3", Visibility.FOLLOWERS),
createPost(userB, "good bay4", Visibility.FOLLOWERS),
createPost(userB, "good bay5", Visibility.FOLLOWERS),
createPost(userB, "good bay6", Visibility.FOLLOWERS),
)
transaction {
Posts.batchInsert(posts) {
this[Posts.id] = it.id
this[Posts.userId] = it.userId
this[Posts.overview] = it.overview
this[Posts.text] = it.text
this[Posts.createdAt] = it.createdAt
this[Posts.visibility] = it.visibility.ordinal
this[Posts.url] = it.url
this[Posts.replyId] = it.replyId
this[Posts.repostId] = it.repostId
}
}
val expect = posts.filter { it.visibility == Visibility.PUBLIC }
val actual = postService.findAll()
assertContentEquals(expect, actual)
}
@Test
fun `findAll フォロー限定投稿を見れる`() = runTest {
val postService = PostService(mock(), mock(), mock())
suspend fun createPost(userId: Long, text: String, visibility: Visibility = Visibility.PUBLIC): Post {
return Post(
TwitterSnowflakeIdGenerateService.generateId(),
userId,
null,
text,
Instant.now().toEpochMilli(),
visibility,
"https://example.com"
)
}
val userA: Long = 1
val userB: Long = 2
val posts = listOf(
createPost(userA, "hello"),
createPost(userA, "hello1"),
createPost(userA, "hello2"),
createPost(userA, "hello3"),
createPost(userA, "hello4"),
createPost(userA, "hello5"),
createPost(userA, "hello6"),
createPost(userB, "good bay ", Visibility.FOLLOWERS),
createPost(userB, "good bay1", Visibility.FOLLOWERS),
createPost(userB, "good bay2", Visibility.FOLLOWERS),
createPost(userB, "good bay3", Visibility.FOLLOWERS),
createPost(userB, "good bay4", Visibility.FOLLOWERS),
createPost(userB, "good bay5", Visibility.FOLLOWERS),
createPost(userB, "good bay6", Visibility.FOLLOWERS),
)
transaction(db) {
SchemaUtils.create(UsersFollowers)
}
transaction {
Posts.batchInsert(posts) {
this[Posts.id] = it.id
this[Posts.userId] = it.userId
this[Posts.overview] = it.overview
this[Posts.text] = it.text
this[Posts.createdAt] = it.createdAt
this[Posts.visibility] = it.visibility.ordinal
this[Posts.url] = it.url
this[Posts.replyId] = it.replyId
this[Posts.repostId] = it.repostId
}
UsersFollowers.insert {
it[id] = 100L
it[userId] = userB
it[followerId] = userA
}
}
val actual = postService.findAll(userId = userA)
assertContentEquals(posts, actual)
}
}