add some stuff to backend base
This commit is contained in:
+2
-3
@@ -2,17 +2,16 @@ package ru.shadowsparky.gigawear.backend.presentation
|
|||||||
|
|
||||||
import io.ktor.server.request.receive
|
import io.ktor.server.request.receive
|
||||||
import io.ktor.server.response.respond
|
import io.ktor.server.response.respond
|
||||||
import io.ktor.server.routing.Routing
|
import io.ktor.server.routing.Route
|
||||||
import io.ktor.server.routing.post
|
import io.ktor.server.routing.post
|
||||||
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
|
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
|
||||||
import ru.shadowsparky.chat.domain.Message
|
import ru.shadowsparky.chat.domain.Message
|
||||||
|
|
||||||
private const val MOCK_USER_ID = -1L
|
private const val MOCK_USER_ID = -1L
|
||||||
|
|
||||||
fun Routing.setupChat(useCase: ProcessUserMessageUseCase) {
|
fun Route.setupChat(useCase: ProcessUserMessageUseCase) {
|
||||||
post("chat") {
|
post("chat") {
|
||||||
val message = call.receive<Message>().copy(timestamp = System.currentTimeMillis())
|
val message = call.receive<Message>().copy(timestamp = System.currentTimeMillis())
|
||||||
call.respond(useCase.execute(message, MOCK_USER_ID))
|
call.respond(useCase.execute(message, MOCK_USER_ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package ru.shadowsparky.gigawear.backend.presentation
|
||||||
|
|
||||||
|
import io.ktor.server.request.receive
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.Routing
|
||||||
|
import io.ktor.server.routing.post
|
||||||
|
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
|
||||||
|
import ru.shadowsparky.chat.domain.Message
|
||||||
|
|
||||||
|
fun Route.setupUpdate(useCase: ProcessUserMessageUseCase) {
|
||||||
|
post("chat") {
|
||||||
|
val message = call.receive<Message>().copy(timestamp = System.currentTimeMillis())
|
||||||
|
call.respond(useCase.execute(message, MOCK_USER_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,17 +2,19 @@ package ru.shadowsparky.vbox.backend
|
|||||||
|
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.application.ApplicationStopping
|
import io.ktor.server.application.ApplicationStopping
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koin.core.annotation.KoinApplication
|
import org.koin.core.annotation.KoinApplication
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.get
|
import org.koin.core.component.get
|
||||||
import org.koin.plugin.module.dsl.startKoin
|
import org.koin.plugin.module.dsl.startKoin
|
||||||
|
import ru.shadowsparky.backend.presentation.installStatusPages
|
||||||
import ru.shadowsparky.koin
|
import ru.shadowsparky.koin
|
||||||
import ru.shadowsparky.vbox.backend.domain.CacheStorage
|
import ru.shadowsparky.vbox.backend.domain.CacheStorage
|
||||||
import ru.shadowsparky.vbox.backend.presentation.configureJwt
|
import ru.shadowsparky.vbox.backend.presentation.configureJwt
|
||||||
import ru.shadowsparky.vbox.backend.presentation.configureRouting
|
|
||||||
import ru.shadowsparky.vbox.backend.presentation.configureSerialization
|
import ru.shadowsparky.vbox.backend.presentation.configureSerialization
|
||||||
import ru.shadowsparky.vbox.backend.presentation.configureWebSocket
|
import ru.shadowsparky.vbox.backend.presentation.configureWebSocket
|
||||||
|
import ru.shadowsparky.vbox.backend.presentation.routing.setupAuthMethods
|
||||||
|
|
||||||
@KoinApplication(modules = [BackendModule::class])
|
@KoinApplication(modules = [BackendModule::class])
|
||||||
object VBoxBackend
|
object VBoxBackend
|
||||||
@@ -30,5 +32,6 @@ fun Application.module() {
|
|||||||
configureSerialization(koin.get())
|
configureSerialization(koin.get())
|
||||||
configureJwt(koin.get())
|
configureJwt(koin.get())
|
||||||
configureWebSocket(koin.get())
|
configureWebSocket(koin.get())
|
||||||
configureRouting(koin.get(), koin.get())
|
installStatusPages()
|
||||||
|
routing { setupAuthMethods(koin.get(), koin.get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -4,10 +4,11 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.shadowsparky.backend.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.backend.domain.notify
|
||||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||||
import java.sql.SQLException
|
import java.sql.SQLException
|
||||||
|
|
||||||
@@ -59,6 +60,9 @@ class BackendSearchRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun notifyChanged() {
|
private suspend fun notifyChanged() {
|
||||||
eventHandler.notify(RemoteEvent.OnSearch(System.currentTimeMillis(), userId))
|
eventHandler.notify(
|
||||||
|
RemoteEvent.OnSearch(System.currentTimeMillis(), userId),
|
||||||
|
RemoteEvent.serializer()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -3,11 +3,12 @@ package ru.shadowsparky.vbox.backend.data.recent
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.shadowsparky.backend.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.backend.domain.notify
|
||||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
|
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
|
||||||
import java.sql.SQLException
|
import java.sql.SQLException
|
||||||
|
|
||||||
@@ -85,7 +86,8 @@ class BackendRecentlyWatchedRepository(
|
|||||||
System.currentTimeMillis(),
|
System.currentTimeMillis(),
|
||||||
userId,
|
userId,
|
||||||
seasonId
|
seasonId
|
||||||
)
|
),
|
||||||
|
RemoteEvent.serializer()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+10
-3
@@ -7,10 +7,11 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import ru.shadowsparky.backend.data.Logger
|
import ru.shadowsparky.backend.data.Logger
|
||||||
|
import ru.shadowsparky.backend.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.backend.domain.notify
|
||||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
|
||||||
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
|
import ru.shadowsparky.vbox.shared.domain.model.SERIAL_FLAG
|
||||||
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
|
||||||
@@ -50,7 +51,10 @@ class BackendSavedMovieRepository(
|
|||||||
details.id,
|
details.id,
|
||||||
userId
|
userId
|
||||||
)
|
)
|
||||||
eventHandler.notify(RemoteEvent.OnSaved(userId, details.id))
|
eventHandler.notify(
|
||||||
|
RemoteEvent.OnSaved(userId, details.id),
|
||||||
|
RemoteEvent.serializer()
|
||||||
|
)
|
||||||
} catch (_: SQLException) {
|
} catch (_: SQLException) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +71,10 @@ class BackendSavedMovieRepository(
|
|||||||
logger.debug(TAG, "remove(${id})")
|
logger.debug(TAG, "remove(${id})")
|
||||||
withContext(dispatcherProvider.io) {
|
withContext(dispatcherProvider.io) {
|
||||||
db.saved_movieQueries.removeSavedMovie(userId, id)
|
db.saved_movieQueries.removeSavedMovie(userId, id)
|
||||||
eventHandler.notify(RemoteEvent.OnSaved(userId, id))
|
eventHandler.notify(
|
||||||
|
RemoteEvent.OnSaved(userId, id),
|
||||||
|
RemoteEvent.serializer()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -8,11 +8,12 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koin.core.annotation.Factory
|
import org.koin.core.annotation.Factory
|
||||||
|
import ru.shadowsparky.backend.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.backend.domain.notify
|
||||||
import ru.shadowsparky.domain.DispatcherProvider
|
import ru.shadowsparky.domain.DispatcherProvider
|
||||||
import ru.shadowsparky.http.domain.BadRequestException
|
import ru.shadowsparky.http.domain.BadRequestException
|
||||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
import ru.shadowsparky.vbox.shared.domain.tag.MovieTagRepository
|
||||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||||
|
|
||||||
@@ -62,12 +63,18 @@ class BackendMovieTagRepository(
|
|||||||
|
|
||||||
override suspend fun link(movieId: Long, tag: String) {
|
override suspend fun link(movieId: Long, tag: String) {
|
||||||
dbQueries.linkMovieTag(movieId, findTagId(tag), userId).await()
|
dbQueries.linkMovieTag(movieId, findTagId(tag), userId).await()
|
||||||
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
|
movieEventHandler.notify(
|
||||||
|
RemoteEvent.OnMovieTag(userId, movieId),
|
||||||
|
RemoteEvent.serializer()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun unlink(movieId: Long, tag: String) {
|
override suspend fun unlink(movieId: Long, tag: String) {
|
||||||
dbQueries.unlinkMovieTag(movieId, findTagId(tag), userId).await()
|
dbQueries.unlinkMovieTag(movieId, findTagId(tag), userId).await()
|
||||||
movieEventHandler.notify(RemoteEvent.OnMovieTag(userId, movieId))
|
movieEventHandler.notify(
|
||||||
|
RemoteEvent.OnMovieTag(userId, movieId),
|
||||||
|
RemoteEvent.serializer()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun findTagId(tag: String): Long = withContext(dispatcherProvider.io) {
|
private suspend fun findTagId(tag: String): Long = withContext(dispatcherProvider.io) {
|
||||||
|
|||||||
+14
-4
@@ -5,10 +5,11 @@ import app.cash.sqldelight.coroutines.mapToList
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koin.core.annotation.Factory
|
import org.koin.core.annotation.Factory
|
||||||
|
import ru.shadowsparky.backend.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.backend.domain.notify
|
||||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
|
||||||
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
|
||||||
|
|
||||||
@@ -47,16 +48,25 @@ class BackendUserTagRepository(
|
|||||||
|
|
||||||
override suspend fun add(tag: String) {
|
override suspend fun add(tag: String) {
|
||||||
queries.insertUserTag(userId, tag).await()
|
queries.insertUserTag(userId, tag).await()
|
||||||
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
userTagEventHandler.notify(
|
||||||
|
RemoteEvent.OnUserTag(userId),
|
||||||
|
RemoteEvent.serializer()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun edit(oldTag: String, newTag: String) {
|
override suspend fun edit(oldTag: String, newTag: String) {
|
||||||
queries.updateTagText(newTag, oldTag, userId).await()
|
queries.updateTagText(newTag, oldTag, userId).await()
|
||||||
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
userTagEventHandler.notify(
|
||||||
|
RemoteEvent.OnUserTag(userId),
|
||||||
|
RemoteEvent.serializer()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun delete(tag: String) {
|
override suspend fun delete(tag: String) {
|
||||||
queries.deleteTagById(tag, userId).await()
|
queries.deleteTagById(tag, userId).await()
|
||||||
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
|
userTagEventHandler.notify(
|
||||||
|
RemoteEvent.OnUserTag(userId),
|
||||||
|
RemoteEvent.serializer()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package ru.shadowsparky.vbox.backend.di
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import ru.shadowsparky.backend.data.TokenVerifier
|
import ru.shadowsparky.backend.data.TokenVerifier
|
||||||
|
import ru.shadowsparky.backend.data.event.SessionRegistry
|
||||||
import ru.shadowsparky.backend.domain.JwtInfo
|
import ru.shadowsparky.backend.domain.JwtInfo
|
||||||
import ru.shadowsparky.backend.domain.LoginVerifier
|
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||||
|
import ru.shadowsparky.backend.domain.RemoteEventHandler
|
||||||
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
|
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
|
||||||
import ru.shadowsparky.vbox.backend.data.BackendUpdateFetcherFactory
|
import ru.shadowsparky.vbox.backend.data.BackendUpdateFetcherFactory
|
||||||
import ru.shadowsparky.vbox.backend.data.SessionRegistry
|
|
||||||
import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory
|
import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory
|
||||||
import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory
|
import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory
|
||||||
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
|
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
|
||||||
@@ -15,7 +16,6 @@ import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
|
|||||||
import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory
|
import ru.shadowsparky.vbox.backend.di.factory.RecentlyWatchedRepositoryFactory
|
||||||
import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory
|
import ru.shadowsparky.vbox.backend.di.factory.SavedMovieRepositoryFactory
|
||||||
import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory
|
import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
import ru.shadowsparky.vbox.shared.domain.VideoApi
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
|
|||||||
+1
-1
@@ -1,13 +1,13 @@
|
|||||||
package ru.shadowsparky.vbox.backend.di.factory
|
package ru.shadowsparky.vbox.backend.di.factory
|
||||||
|
|
||||||
import org.koin.core.annotation.Factory
|
import org.koin.core.annotation.Factory
|
||||||
|
import ru.shadowsparky.backend.domain.RemoteEventHandler
|
||||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
import ru.shadowsparky.vbox.backend.data.recent.BackendRecentlyWatchedRepository
|
import ru.shadowsparky.vbox.backend.data.recent.BackendRecentlyWatchedRepository
|
||||||
import ru.shadowsparky.vbox.backend.data.recent.CacheRecentlyWatchedRepository
|
import ru.shadowsparky.vbox.backend.data.recent.CacheRecentlyWatchedRepository
|
||||||
import ru.shadowsparky.vbox.backend.domain.CacheStorage
|
import ru.shadowsparky.vbox.backend.domain.CacheStorage
|
||||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
|
|
||||||
@Factory
|
@Factory
|
||||||
class RecentlyWatchedRepositoryFactory(
|
class RecentlyWatchedRepositoryFactory(
|
||||||
|
|||||||
+1
-1
@@ -2,8 +2,8 @@ package ru.shadowsparky.vbox.backend.di.factory
|
|||||||
|
|
||||||
import org.koin.core.annotation.Factory
|
import org.koin.core.annotation.Factory
|
||||||
import ru.shadowsparky.backend.data.Logger
|
import ru.shadowsparky.backend.data.Logger
|
||||||
|
import ru.shadowsparky.backend.data.event.BackendRemoteEventHandler
|
||||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
|
||||||
import ru.shadowsparky.vbox.backend.data.saved.BackendSavedMovieRepository
|
import ru.shadowsparky.vbox.backend.data.saved.BackendSavedMovieRepository
|
||||||
import ru.shadowsparky.vbox.backend.data.saved.CacheSavedMovieRepository
|
import ru.shadowsparky.vbox.backend.data.saved.CacheSavedMovieRepository
|
||||||
import ru.shadowsparky.vbox.backend.domain.CacheStorage
|
import ru.shadowsparky.vbox.backend.domain.CacheStorage
|
||||||
|
|||||||
+1
-1
@@ -1,10 +1,10 @@
|
|||||||
package ru.shadowsparky.vbox.backend.di.factory
|
package ru.shadowsparky.vbox.backend.di.factory
|
||||||
|
|
||||||
import org.koin.core.annotation.Factory
|
import org.koin.core.annotation.Factory
|
||||||
|
import ru.shadowsparky.backend.domain.RemoteEventHandler
|
||||||
import ru.shadowsparky.vbox.backend.AppDatabase
|
import ru.shadowsparky.vbox.backend.AppDatabase
|
||||||
import ru.shadowsparky.vbox.backend.data.BackendSearchRepository
|
import ru.shadowsparky.vbox.backend.data.BackendSearchRepository
|
||||||
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
import ru.shadowsparky.vbox.shared.domain.SearchRepository
|
||||||
|
|
||||||
@Factory
|
@Factory
|
||||||
|
|||||||
@@ -1,59 +1,17 @@
|
|||||||
package ru.shadowsparky.vbox.backend.presentation
|
package ru.shadowsparky.vbox.backend.presentation
|
||||||
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.application.ApplicationCall
|
import io.ktor.server.application.ApplicationCall
|
||||||
import io.ktor.server.application.install
|
import ru.shadowsparky.backend.presentation.configureJwt
|
||||||
import io.ktor.server.auth.Authentication
|
import ru.shadowsparky.backend.presentation.obtainUserId
|
||||||
import io.ktor.server.auth.authentication
|
|
||||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
|
||||||
import io.ktor.server.auth.jwt.jwt
|
|
||||||
import io.ktor.server.response.respond
|
|
||||||
import ru.shadowsparky.backend.domain.INVALID_TOKEN_MSG
|
|
||||||
import ru.shadowsparky.backend.domain.LOGIN_NAME
|
|
||||||
import ru.shadowsparky.backend.domain.VerifyTokenException
|
|
||||||
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
|
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
|
||||||
import ru.shadowsparky.vbox.shared.domain.USER_ID_ARG
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo
|
|
||||||
|
|
||||||
const val AUTH_JWT_NAME = "auth-jwt"
|
const val AUTH_JWT_NAME = "auth-jwt"
|
||||||
|
|
||||||
fun Application.configureJwt(authEntryPoint: AuthEntryPoint) = with(authEntryPoint) {
|
fun Application.configureJwt(authEntryPoint: AuthEntryPoint) = with(authEntryPoint) {
|
||||||
install(Authentication) {
|
configureJwt(jwtInfo, tokenVerifier, loginVerifier)
|
||||||
jwt(AUTH_JWT_NAME) {
|
|
||||||
realm = jwtInfo.realm
|
|
||||||
verifier(tokenVerifier.verifier)
|
|
||||||
validate { credential ->
|
|
||||||
val login = credential.payload.getClaim(LOGIN_NAME).asString()
|
|
||||||
try {
|
|
||||||
if (login != null) {
|
|
||||||
loginVerifier.verify(login)
|
|
||||||
if (credential.payload.expiresAt == null) {
|
|
||||||
throw VerifyTokenException("Static tokens not supported!")
|
|
||||||
}
|
|
||||||
JWTPrincipal(credential.payload)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} catch (e: VerifyTokenException) {
|
|
||||||
routingLogger.error("verify token failed ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
challenge { _, _ ->
|
|
||||||
call.respond(
|
|
||||||
HttpStatusCode.Unauthorized,
|
|
||||||
ServerExceptionInfo(INVALID_TOKEN_MSG)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ApplicationCall.obtainUserId(): Long {
|
fun ApplicationCall.obtainUserId(): Long {
|
||||||
val principal = authentication.principal<JWTPrincipal>()
|
return obtainUserId()
|
||||||
?: throw VerifyTokenException(INVALID_TOKEN_MSG)
|
|
||||||
return principal.payload
|
|
||||||
.getClaim(USER_ID_ARG)
|
|
||||||
.asLong()
|
|
||||||
}
|
}
|
||||||
|
|||||||
-42
@@ -1,42 +0,0 @@
|
|||||||
package ru.shadowsparky.vbox.backend.presentation
|
|
||||||
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import io.ktor.server.application.install
|
|
||||||
import io.ktor.server.plugins.statuspages.StatusPages
|
|
||||||
import io.ktor.server.response.respond
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import org.slf4j.Logger
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import ru.shadowsparky.http.domain.HttpException
|
|
||||||
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
|
|
||||||
import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint
|
|
||||||
import ru.shadowsparky.vbox.backend.presentation.routing.setupAuthMethods
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.model.ServerExceptionInfo
|
|
||||||
|
|
||||||
val routingLogger: Logger = LoggerFactory.getLogger("routing")
|
|
||||||
|
|
||||||
fun Application.configureRouting(
|
|
||||||
routingEntryPoint: RoutingEntryPoint,
|
|
||||||
authEntryPoint: AuthEntryPoint
|
|
||||||
) {
|
|
||||||
install(StatusPages) {
|
|
||||||
exception<HttpException> { call, cause ->
|
|
||||||
routingLogger.error("http exception occurred. returns ${cause.httpCode}", cause)
|
|
||||||
call.respond(
|
|
||||||
status = HttpStatusCode.fromValue(cause.httpCode),
|
|
||||||
message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
exception<Throwable> { call, cause ->
|
|
||||||
routingLogger.error("error occurred. returns 500...", cause)
|
|
||||||
call.respond(
|
|
||||||
status = HttpStatusCode.InternalServerError,
|
|
||||||
message = ServerExceptionInfo(cause.message ?: "Неизвестная ошибка")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
routing {
|
|
||||||
setupAuthMethods(routingEntryPoint, authEntryPoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-93
@@ -1,100 +1,10 @@
|
|||||||
package ru.shadowsparky.vbox.backend.presentation
|
package ru.shadowsparky.vbox.backend.presentation
|
||||||
|
|
||||||
import com.auth0.jwt.exceptions.TokenExpiredException
|
|
||||||
import com.auth0.jwt.interfaces.DecodedJWT
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.application.install
|
import ru.shadowsparky.backend.presentation.setupWebSocket
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import io.ktor.server.websocket.WebSockets
|
|
||||||
import io.ktor.server.websocket.pingPeriod
|
|
||||||
import io.ktor.server.websocket.timeout
|
|
||||||
import io.ktor.server.websocket.webSocket
|
|
||||||
import io.ktor.websocket.Frame
|
|
||||||
import io.ktor.websocket.readText
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import ru.shadowsparky.backend.data.TokenVerifier
|
|
||||||
import ru.shadowsparky.http.domain.BadRequestException
|
|
||||||
import ru.shadowsparky.http.domain.HttpException
|
|
||||||
import ru.shadowsparky.vbox.backend.data.SessionRegistry
|
|
||||||
import ru.shadowsparky.vbox.backend.data.eventLogger
|
|
||||||
import ru.shadowsparky.vbox.backend.di.WebSocketEntryPoint
|
import ru.shadowsparky.vbox.backend.di.WebSocketEntryPoint
|
||||||
import ru.shadowsparky.vbox.shared.domain.AuthRequest
|
import ru.shadowsparky.vbox.shared.domain.ON_EVENT
|
||||||
import ru.shadowsparky.vbox.shared.domain.AuthResponse
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.USER_ID_ARG
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with(socketEntryPoint) {
|
fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with(socketEntryPoint) {
|
||||||
install(WebSockets) {
|
setupWebSocket(ON_EVENT, json, tokenVerifier, sessionRegistry)
|
||||||
pingPeriod = (30).seconds
|
|
||||||
timeout = (30).seconds
|
|
||||||
maxFrameSize = Long.MAX_VALUE
|
|
||||||
masking = false
|
|
||||||
}
|
|
||||||
routing {
|
|
||||||
webSocket(RemoteEventHandler.ON_EVENT) {
|
|
||||||
val userId = incoming.authFlow(json, tokenVerifier, outgoing)
|
|
||||||
val session = SessionRegistry.Writer { text -> outgoing.trySend(Frame.Text(text)) }
|
|
||||||
sessionRegistry.put(userId, session)
|
|
||||||
val deferred = CompletableDeferred<Unit?>()
|
|
||||||
try {
|
|
||||||
outgoing.invokeOnClose { deferred.complete(null) }
|
|
||||||
deferred.await()
|
|
||||||
} finally {
|
|
||||||
sessionRegistry.remove(userId, session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun ReceiveChannel<Frame>.receiveTextOrNull(): String? {
|
|
||||||
val frame = receive()
|
|
||||||
return if (frame is Frame.Text) frame.readText() else null
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun ReceiveChannel<Frame>.authFlow(
|
|
||||||
json: Json,
|
|
||||||
tokenVerifier: TokenVerifier,
|
|
||||||
sendChannel: SendChannel<Frame>
|
|
||||||
): Long {
|
|
||||||
val rawRequest = receiveTextOrNull() ?: throw BadRequestException("Authentication request required")
|
|
||||||
val request = runCatching { json.decodeFromString<AuthRequest>(rawRequest) }.getOrNull()
|
|
||||||
val decodedJwt = if (request == null) {
|
|
||||||
tokenVerifier.verify(rawRequest)
|
|
||||||
} else {
|
|
||||||
authFlowV2(request, json, tokenVerifier, sendChannel)
|
|
||||||
}
|
|
||||||
return decodedJwt.getClaim(USER_ID_ARG).asLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val MAX_ATTEMPTS = 3
|
|
||||||
|
|
||||||
private suspend fun ReceiveChannel<Frame>.authFlowV2(
|
|
||||||
initialRequest: AuthRequest,
|
|
||||||
json: Json,
|
|
||||||
tokenVerifier: TokenVerifier,
|
|
||||||
sendChannel: SendChannel<Frame>
|
|
||||||
): DecodedJWT {
|
|
||||||
var currentRequest = initialRequest
|
|
||||||
repeat(MAX_ATTEMPTS) { attempt ->
|
|
||||||
try {
|
|
||||||
val jwt = tokenVerifier.verify(currentRequest.token)
|
|
||||||
sendChannel.send(Frame.Text(json.encodeToString(AuthResponse(true))))
|
|
||||||
return jwt
|
|
||||||
} catch (e: TokenExpiredException) {
|
|
||||||
eventLogger.error("token expired. attempt=$attempt", e)
|
|
||||||
if (attempt == MAX_ATTEMPTS - 1) return@repeat
|
|
||||||
sendChannel.send(Frame.Text(json.encodeToString(AuthResponse(false, e.message))))
|
|
||||||
val nextRaw = receiveTextOrNull() ?: return@repeat
|
|
||||||
currentRequest = json.decodeFromString<AuthRequest>(nextRaw)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
eventLogger.error("unable to verify token", e)
|
|
||||||
throw HttpException(HttpStatusCode.Unauthorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw HttpException(HttpStatusCode.Unauthorized)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -5,6 +5,8 @@ import io.ktor.server.response.respond
|
|||||||
import io.ktor.server.routing.Route
|
import io.ktor.server.routing.Route
|
||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
import io.ktor.server.routing.post
|
import io.ktor.server.routing.post
|
||||||
|
import ru.shadowsparky.backend.domain.RemoteEventHandler
|
||||||
|
import ru.shadowsparky.backend.domain.notify
|
||||||
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
|
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
|
||||||
import ru.shadowsparky.chat.domain.ChatRepository
|
import ru.shadowsparky.chat.domain.ChatRepository
|
||||||
import ru.shadowsparky.chat.domain.ChatRoles
|
import ru.shadowsparky.chat.domain.ChatRoles
|
||||||
@@ -15,7 +17,6 @@ import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory
|
|||||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||||
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
|
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
|
|
||||||
fun Route.setupChat(
|
fun Route.setupChat(
|
||||||
chatRepositoryFactory: BackendChatRepositoryFactory,
|
chatRepositoryFactory: BackendChatRepositoryFactory,
|
||||||
@@ -33,7 +34,10 @@ fun Route.setupChat(
|
|||||||
val info = call.receive<Message>()
|
val info = call.receive<Message>()
|
||||||
if (info.role != ChatRoles.USER) throw BadRequestException("invalid role")
|
if (info.role != ChatRoles.USER) throw BadRequestException("invalid role")
|
||||||
val response = processUserMessageUseCase.execute(info, userId)
|
val response = processUserMessageUseCase.execute(info, userId)
|
||||||
remoteEventHandler.notify(RemoteEvent.OnChatUpdate(userId))
|
remoteEventHandler.notify(
|
||||||
|
RemoteEvent.OnChatUpdate(userId),
|
||||||
|
RemoteEvent.serializer()
|
||||||
|
)
|
||||||
call.respond(response)
|
call.respond(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-80
@@ -4,114 +4,50 @@ import io.ktor.client.HttpClient
|
|||||||
import io.ktor.client.plugins.websocket.webSocket
|
import io.ktor.client.plugins.websocket.webSocket
|
||||||
import io.ktor.client.request.url
|
import io.ktor.client.request.url
|
||||||
import io.ktor.http.HttpMethod
|
import io.ktor.http.HttpMethod
|
||||||
import io.ktor.websocket.Frame
|
|
||||||
import io.ktor.websocket.readText
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.retryWhen
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.shareIn
|
import kotlinx.coroutines.flow.shareIn
|
||||||
import kotlinx.coroutines.flow.transformLatest
|
import kotlinx.coroutines.flow.transformLatest
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import ru.shadowsparky.domain.Log
|
import ru.shadowsparky.http.data.RemoveEventProcessor
|
||||||
|
import ru.shadowsparky.http.data.retryExponential
|
||||||
import ru.shadowsparky.http.domain.TokenStorage
|
import ru.shadowsparky.http.domain.TokenStorage
|
||||||
import ru.shadowsparky.vbox.shared.domain.AuthRequest
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.AuthResponse
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.HealthCheck
|
import ru.shadowsparky.vbox.shared.domain.HealthCheck
|
||||||
|
import ru.shadowsparky.vbox.shared.domain.ON_EVENT
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
|
||||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||||
import kotlin.math.pow
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
class RemoteEventListenerImpl(
|
class RemoteEventListenerImpl(
|
||||||
private val httpClient: HttpClient,
|
private val httpClient: HttpClient,
|
||||||
private val serverConfigurationRepository: ServerConfigurationRepository,
|
private val serverConfigurationRepository: ServerConfigurationRepository,
|
||||||
private val authTokenCache: TokenStorage,
|
authTokenCache: TokenStorage,
|
||||||
private val healthCheck: HealthCheck,
|
private val healthCheck: HealthCheck,
|
||||||
private val json: Json,
|
private val processor: RemoveEventProcessor,
|
||||||
private val log: Log
|
private val json: Json
|
||||||
) : RemoteEventListener {
|
) : RemoteEventListener {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
override val event = authTokenCache.token.filterNotNull().transformLatest { token ->
|
override val event: Flow<RemoteEvent> = authTokenCache.token.filterNotNull().transformLatest { token ->
|
||||||
val config = serverConfigurationRepository.getServerConfiguration()
|
val config = serverConfigurationRepository.getServerConfiguration()
|
||||||
log.d(TAG, "listener started")
|
httpClient.webSocket(
|
||||||
try {
|
method = HttpMethod.Get,
|
||||||
httpClient.webSocket(
|
request = { url(config.asWebSocketStr() + "/${ON_EVENT}") }
|
||||||
method = HttpMethod.Get,
|
) {
|
||||||
request = { url(config.asWebSocketStr() + "/${RemoteEventHandler.ON_EVENT}") }
|
val flow = processor.process(token.token, this) { healthCheck.check() }
|
||||||
) {
|
.map { json.decodeFromString<RemoteEvent>(it) }
|
||||||
authV2(token.token, incoming, outgoing)
|
emitAll(flow)
|
||||||
for (frame in incoming) {
|
|
||||||
if (frame is Frame.Text) {
|
|
||||||
val text = frame.readText()
|
|
||||||
try {
|
|
||||||
val decoded = json.decodeFromString<RemoteEvent>(text)
|
|
||||||
log.d(TAG, "receive $text $decoded")
|
|
||||||
emit(decoded)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.e(TAG, e, "failed to decode message: $text")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.e(TAG, e, "websocket closed with error")
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
log.d(TAG, "listener finished")
|
|
||||||
}
|
}
|
||||||
}.retryExponential { true }.shareIn(scope, SharingStarted.WhileSubscribed(500), 0)
|
}.retryExponential { true }.shareIn(scope, SharingStarted.WhileSubscribed(500), 0)
|
||||||
|
|
||||||
private suspend fun authV2(
|
|
||||||
token: String,
|
|
||||||
input: ReceiveChannel<Frame>,
|
|
||||||
output: SendChannel<Frame>
|
|
||||||
) {
|
|
||||||
output.send(Frame.Text(json.encodeToString(AuthRequest(token))))
|
|
||||||
val rawFrame = (input.receive() as Frame.Text).readText()
|
|
||||||
val response = json.decodeFromString<AuthResponse>(rawFrame)
|
|
||||||
if (!response.ok) {
|
|
||||||
healthCheck.check()
|
|
||||||
error("Authentication failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> Flow<T>.retryExponential(
|
|
||||||
maxRetries: Int = Int.MAX_VALUE,
|
|
||||||
initialDelay: Long = 5000L,
|
|
||||||
maxDelay: Long = 300_000L,
|
|
||||||
factor: Double = 2.0,
|
|
||||||
shouldRetry: (Throwable) -> Boolean = { true }
|
|
||||||
): Flow<T> = retryWhen { cause, attempt ->
|
|
||||||
log.d(TAG, "error occurred, attempt=$attempt", cause)
|
|
||||||
if (!shouldRetry(cause) || attempt >= maxRetries || cause is CancellationException) {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
val delayTime = (initialDelay * factor.pow(attempt.toDouble()))
|
|
||||||
.toLong()
|
|
||||||
.coerceAtMost(maxDelay)
|
|
||||||
log.d(TAG, "retry after delay $delayTime", cause)
|
|
||||||
delay(delayTime.milliseconds)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "RemoteSearchEventListener"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-15
@@ -4,13 +4,7 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
interface RemoteEventHandler {
|
const val ON_EVENT = "$DYNAMIC_PREFIX/onEvent"
|
||||||
suspend fun notify(eventInfo: RemoteEvent)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val ON_EVENT = "$DYNAMIC_PREFIX/onEvent"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RemoteEventListener {
|
interface RemoteEventListener {
|
||||||
val event: Flow<RemoteEvent>
|
val event: Flow<RemoteEvent>
|
||||||
@@ -45,11 +39,3 @@ sealed interface RemoteEvent {
|
|||||||
@SerialName("OnChatUpdate")
|
@SerialName("OnChatUpdate")
|
||||||
data class OnChatUpdate(override val userId: Long) : RemoteEvent
|
data class OnChatUpdate(override val userId: Long) : RemoteEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("AuthRequest")
|
|
||||||
data class AuthRequest(val token: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@SerialName("AuthResponse")
|
|
||||||
data class AuthResponse(val ok: Boolean, val error: String? = null)
|
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.jvm)
|
alias(libs.plugins.convention.jvm)
|
||||||
alias(libs.plugins.convention.koin)
|
alias(libs.plugins.convention.koin)
|
||||||
|
alias(libs.plugins.ktor)
|
||||||
|
alias(libs.plugins.convention.serialization)
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(project(":libs:http-client"))
|
||||||
implementation(libs.java.jwt)
|
implementation(libs.java.jwt)
|
||||||
implementation(libs.logback.classic)
|
implementation(libs.logback.classic)
|
||||||
|
implementation(libs.ktor.server.core.jvm)
|
||||||
|
implementation(libs.ktor.server.auth)
|
||||||
|
implementation(libs.ktor.server.websockets.jvm)
|
||||||
|
implementation(libs.ktor.server.auth.jwt)
|
||||||
|
implementation(libs.ktor.server.status.pages.jvm)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -1,22 +1,24 @@
|
|||||||
package ru.shadowsparky.vbox.backend.data
|
package ru.shadowsparky.backend.data.event
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import org.slf4j.Logger
|
import org.slf4j.Logger
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
|
import ru.shadowsparky.backend.domain.RemoteEventHandler
|
||||||
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
|
|
||||||
|
|
||||||
val eventLogger: Logger = LoggerFactory.getLogger("events")
|
val eventLogger: Logger = LoggerFactory.getLogger("events")
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
class BackendRemoteEventHandler(
|
class BackendRemoteEventHandler(
|
||||||
private val json: Json,
|
|
||||||
private val registry: SessionRegistry
|
private val registry: SessionRegistry
|
||||||
) : RemoteEventHandler {
|
) : RemoteEventHandler {
|
||||||
override suspend fun notify(eventInfo: RemoteEvent) {
|
override suspend fun notify(eventInfo: JsonElement) {
|
||||||
val jsonText = json.encodeToString(eventInfo)
|
val userId = eventInfo.jsonObject["userId"]?.jsonPrimitive?.longOrNull
|
||||||
val writers = registry.get(eventInfo.userId)
|
?: error("User id field required")
|
||||||
|
val writers = registry.get(userId)
|
||||||
if (writers.isNullOrEmpty()) {
|
if (writers.isNullOrEmpty()) {
|
||||||
eventLogger.info("unable to notify {}. sessions not found, cache {}", eventInfo, registry)
|
eventLogger.info("unable to notify {}. sessions not found, cache {}", eventInfo, registry)
|
||||||
return
|
return
|
||||||
@@ -24,10 +26,10 @@ class BackendRemoteEventHandler(
|
|||||||
writers.toList().forEach { writer ->
|
writers.toList().forEach { writer ->
|
||||||
eventLogger.info("notify[{}]. session {}", eventInfo, writer)
|
eventLogger.info("notify[{}]. session {}", eventInfo, writer)
|
||||||
try {
|
try {
|
||||||
writer.writeText(jsonText)
|
writer.writeText(eventInfo.toString())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
eventLogger.error("unable to notify {}. delete session. Reason: {}", writer, e.message)
|
eventLogger.error("unable to notify {}. delete session. Reason: {}", writer, e.message)
|
||||||
registry.remove(eventInfo.userId, writer)
|
registry.remove(userId, writer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package ru.shadowsparky.vbox.backend.data
|
package ru.shadowsparky.backend.data.event
|
||||||
|
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package ru.shadowsparky.backend.domain
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerializationStrategy
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import org.koin.mp.KoinPlatform
|
||||||
|
|
||||||
|
interface RemoteEventHandler {
|
||||||
|
suspend fun notify(eventInfo: JsonElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun <T> RemoteEventHandler.notify(
|
||||||
|
any: T,
|
||||||
|
serializer: SerializationStrategy<T>,
|
||||||
|
) {
|
||||||
|
val json = KoinPlatform.getKoin().get<Json>()
|
||||||
|
notify(json.encodeToJsonElement(serializer, any))
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.shadowsparky.backend.domain
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ExceptionInfo(val msg: String)
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
package ru.shadowsparky.backend.presentation
|
||||||
|
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.application.Application
|
||||||
|
import io.ktor.server.application.ApplicationCall
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.auth.Authentication
|
||||||
|
import io.ktor.server.auth.authentication
|
||||||
|
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||||
|
import io.ktor.server.auth.jwt.jwt
|
||||||
|
import io.ktor.server.plugins.statuspages.StatusPages
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import org.slf4j.Logger
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import ru.shadowsparky.backend.data.TokenVerifier
|
||||||
|
import ru.shadowsparky.backend.domain.ExceptionInfo
|
||||||
|
import ru.shadowsparky.backend.domain.INVALID_TOKEN_MSG
|
||||||
|
import ru.shadowsparky.backend.domain.JwtInfo
|
||||||
|
import ru.shadowsparky.backend.domain.LOGIN_NAME
|
||||||
|
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||||
|
import ru.shadowsparky.backend.domain.USER_ID_ARG
|
||||||
|
import ru.shadowsparky.backend.domain.VerifyTokenException
|
||||||
|
import ru.shadowsparky.http.domain.HttpException
|
||||||
|
|
||||||
|
const val AUTH_JWT_NAME = "auth-jwt"
|
||||||
|
|
||||||
|
val routingLogger: Logger = LoggerFactory.getLogger("routing")
|
||||||
|
|
||||||
|
fun ApplicationCall.obtainUserId(): Long {
|
||||||
|
val principal = authentication.principal<JWTPrincipal>()
|
||||||
|
?: throw VerifyTokenException(INVALID_TOKEN_MSG)
|
||||||
|
return principal.payload
|
||||||
|
.getClaim(USER_ID_ARG)
|
||||||
|
.asLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Application.configureJwt(
|
||||||
|
jwtInfo: JwtInfo,
|
||||||
|
tokenVerifier: TokenVerifier,
|
||||||
|
loginVerifier: LoginVerifier
|
||||||
|
) {
|
||||||
|
install(Authentication) {
|
||||||
|
jwt(AUTH_JWT_NAME) {
|
||||||
|
realm = jwtInfo.realm
|
||||||
|
verifier(tokenVerifier.verifier)
|
||||||
|
validate { credential ->
|
||||||
|
val login = credential.payload.getClaim(LOGIN_NAME).asString()
|
||||||
|
try {
|
||||||
|
if (login != null) {
|
||||||
|
loginVerifier.verify(login)
|
||||||
|
if (credential.payload.expiresAt == null) {
|
||||||
|
throw VerifyTokenException("Static tokens not supported!")
|
||||||
|
}
|
||||||
|
JWTPrincipal(credential.payload)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: VerifyTokenException) {
|
||||||
|
routingLogger.error("verify token failed ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
challenge { _, _ ->
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.Unauthorized,
|
||||||
|
ExceptionInfo(INVALID_TOKEN_MSG)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Application.installStatusPages() {
|
||||||
|
install(StatusPages) {
|
||||||
|
exception<HttpException> { call, cause ->
|
||||||
|
routingLogger.error("http exception occurred. returns ${cause.httpCode}", cause)
|
||||||
|
call.respond(
|
||||||
|
status = HttpStatusCode.fromValue(cause.httpCode),
|
||||||
|
message = ExceptionInfo(cause.message ?: "Неизвестная ошибка")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
exception<Throwable> { call, cause ->
|
||||||
|
routingLogger.error("error occurred. returns 500...", cause)
|
||||||
|
call.respond(
|
||||||
|
status = HttpStatusCode.InternalServerError,
|
||||||
|
message = ExceptionInfo(cause.message ?: "Неизвестная ошибка")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package ru.shadowsparky.backend.presentation
|
||||||
|
|
||||||
|
import com.auth0.jwt.exceptions.TokenExpiredException
|
||||||
|
import com.auth0.jwt.interfaces.DecodedJWT
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.application.Application
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.server.websocket.WebSockets
|
||||||
|
import io.ktor.server.websocket.pingPeriod
|
||||||
|
import io.ktor.server.websocket.timeout
|
||||||
|
import io.ktor.server.websocket.webSocket
|
||||||
|
import io.ktor.websocket.Frame
|
||||||
|
import io.ktor.websocket.readText
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import kotlinx.coroutines.channels.SendChannel
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import ru.shadowsparky.backend.data.TokenVerifier
|
||||||
|
import ru.shadowsparky.backend.data.event.SessionRegistry
|
||||||
|
import ru.shadowsparky.backend.data.event.eventLogger
|
||||||
|
import ru.shadowsparky.backend.domain.USER_ID_ARG
|
||||||
|
import ru.shadowsparky.http.domain.AuthRequest
|
||||||
|
import ru.shadowsparky.http.domain.AuthResponse
|
||||||
|
import ru.shadowsparky.http.domain.BadRequestException
|
||||||
|
import ru.shadowsparky.http.domain.HttpException
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
fun Application.setupWebSocket(
|
||||||
|
path: String?,
|
||||||
|
json: Json,
|
||||||
|
tokenVerifier: TokenVerifier,
|
||||||
|
sessionRegistry: SessionRegistry
|
||||||
|
) {
|
||||||
|
install(WebSockets) {
|
||||||
|
pingPeriod = (30).seconds
|
||||||
|
timeout = (30).seconds
|
||||||
|
maxFrameSize = Long.MAX_VALUE
|
||||||
|
masking = false
|
||||||
|
}
|
||||||
|
path?.let {
|
||||||
|
routing {
|
||||||
|
webSocket(path) {
|
||||||
|
val userId = incoming.authFlow(json, tokenVerifier, outgoing)
|
||||||
|
val session = SessionRegistry.Writer { text -> outgoing.trySend(Frame.Text(text)) }
|
||||||
|
sessionRegistry.put(userId, session)
|
||||||
|
val deferred = CompletableDeferred<Unit?>()
|
||||||
|
try {
|
||||||
|
outgoing.invokeOnClose { deferred.complete(null) }
|
||||||
|
deferred.await()
|
||||||
|
} finally {
|
||||||
|
sessionRegistry.remove(userId, session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ReceiveChannel<Frame>.receiveTextOrNull(): String? {
|
||||||
|
val frame = receive()
|
||||||
|
return if (frame is Frame.Text) frame.readText() else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ReceiveChannel<Frame>.authFlow(
|
||||||
|
json: Json,
|
||||||
|
tokenVerifier: TokenVerifier,
|
||||||
|
sendChannel: SendChannel<Frame>
|
||||||
|
): Long {
|
||||||
|
val rawRequest = receiveTextOrNull() ?: throw BadRequestException("Authentication request required")
|
||||||
|
val request = runCatching { json.decodeFromString<AuthRequest>(rawRequest) }.getOrNull()
|
||||||
|
val decodedJwt = if (request == null) {
|
||||||
|
tokenVerifier.verify(rawRequest)
|
||||||
|
} else {
|
||||||
|
authFlowV2(request, json, tokenVerifier, sendChannel)
|
||||||
|
}
|
||||||
|
return decodedJwt.getClaim(USER_ID_ARG).asLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
|
private suspend fun ReceiveChannel<Frame>.authFlowV2(
|
||||||
|
initialRequest: AuthRequest,
|
||||||
|
json: Json,
|
||||||
|
tokenVerifier: TokenVerifier,
|
||||||
|
sendChannel: SendChannel<Frame>
|
||||||
|
): DecodedJWT {
|
||||||
|
var currentRequest = initialRequest
|
||||||
|
repeat(MAX_ATTEMPTS) { attempt ->
|
||||||
|
try {
|
||||||
|
val jwt = tokenVerifier.verify(currentRequest.token)
|
||||||
|
sendChannel.send(Frame.Text(json.encodeToString(AuthResponse(true))))
|
||||||
|
return jwt
|
||||||
|
} catch (e: TokenExpiredException) {
|
||||||
|
eventLogger.error("token expired. attempt=$attempt", e)
|
||||||
|
if (attempt == MAX_ATTEMPTS - 1) return@repeat
|
||||||
|
sendChannel.send(Frame.Text(json.encodeToString(AuthResponse(false, e.message))))
|
||||||
|
val nextRaw = receiveTextOrNull() ?: return@repeat
|
||||||
|
currentRequest = json.decodeFromString<AuthRequest>(nextRaw)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
eventLogger.error("unable to verify token", e)
|
||||||
|
throw HttpException(HttpStatusCode.Unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw HttpException(HttpStatusCode.Unauthorized)
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
package ru.shadowsparky.http.data
|
||||||
|
|
||||||
|
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
|
||||||
|
import io.ktor.websocket.Frame
|
||||||
|
import io.ktor.websocket.readText
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import kotlinx.coroutines.channels.SendChannel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
import ru.shadowsparky.domain.Log
|
||||||
|
import ru.shadowsparky.http.domain.AuthRequest
|
||||||
|
import ru.shadowsparky.http.domain.AuthResponse
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@Single
|
||||||
|
class RemoveEventProcessor(
|
||||||
|
private val json: Json,
|
||||||
|
private val log: Log
|
||||||
|
) {
|
||||||
|
fun process(
|
||||||
|
token: String,
|
||||||
|
session: DefaultClientWebSocketSession,
|
||||||
|
onTokenInvalid: suspend () -> Unit
|
||||||
|
): Flow<String> {
|
||||||
|
val incoming = session.incoming
|
||||||
|
val outgoing = session.outgoing
|
||||||
|
return flow {
|
||||||
|
authV2(token, incoming, outgoing, onTokenInvalid)
|
||||||
|
for (frame in incoming) {
|
||||||
|
if (frame is Frame.Text) {
|
||||||
|
val text = frame.readText()
|
||||||
|
try {
|
||||||
|
log.d(TAG, "receive $text")
|
||||||
|
emit(text)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.e(TAG, e, "failed to decode message: $text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun authV2(
|
||||||
|
token: String,
|
||||||
|
input: ReceiveChannel<Frame>,
|
||||||
|
output: SendChannel<Frame>,
|
||||||
|
onTokenInvalid: suspend () -> Unit
|
||||||
|
) {
|
||||||
|
output.send(Frame.Text(json.encodeToString(AuthRequest(token))))
|
||||||
|
val rawFrame = (input.receive() as Frame.Text).readText()
|
||||||
|
val response = json.decodeFromString<AuthResponse>(rawFrame)
|
||||||
|
if (!response.ok) {
|
||||||
|
onTokenInvalid()
|
||||||
|
error("Authentication failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "RemoveEventProcessor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Flow<T>.retryExponential(
|
||||||
|
maxRetries: Int = Int.MAX_VALUE,
|
||||||
|
initialDelay: Long = 5000L,
|
||||||
|
maxDelay: Long = 300_000L,
|
||||||
|
factor: Double = 2.0,
|
||||||
|
shouldRetry: (Throwable) -> Boolean = { true }
|
||||||
|
): Flow<T> = retryWhen { cause, attempt ->
|
||||||
|
if (!shouldRetry(cause) || attempt >= maxRetries || cause is CancellationException) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
val delayTime = (initialDelay * factor.pow(attempt.toDouble()))
|
||||||
|
.toLong()
|
||||||
|
.coerceAtMost(maxDelay)
|
||||||
|
delay(delayTime.milliseconds)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package ru.shadowsparky.http.domain
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("AuthRequest")
|
||||||
|
data class AuthRequest(val token: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("AuthResponse")
|
||||||
|
data class AuthResponse(val ok: Boolean, val error: String? = null)
|
||||||
Reference in New Issue
Block a user