add some stuff to backend base

This commit is contained in:
2026-09-13 16:32:32 +03:00
parent 688d687d4d
commit 1c573c4366
27 changed files with 440 additions and 313 deletions
@@ -2,17 +2,16 @@ package ru.shadowsparky.gigawear.backend.presentation
import io.ktor.server.request.receive
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 ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
import ru.shadowsparky.chat.domain.Message
private const val MOCK_USER_ID = -1L
fun Routing.setupChat(useCase: ProcessUserMessageUseCase) {
fun Route.setupChat(useCase: ProcessUserMessageUseCase) {
post("chat") {
val message = call.receive<Message>().copy(timestamp = System.currentTimeMillis())
call.respond(useCase.execute(message, MOCK_USER_ID))
}
}
@@ -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.ApplicationStopping
import io.ktor.server.routing.routing
import kotlinx.coroutines.runBlocking
import org.koin.core.annotation.KoinApplication
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.plugin.module.dsl.startKoin
import ru.shadowsparky.backend.presentation.installStatusPages
import ru.shadowsparky.koin
import ru.shadowsparky.vbox.backend.domain.CacheStorage
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.configureWebSocket
import ru.shadowsparky.vbox.backend.presentation.routing.setupAuthMethods
@KoinApplication(modules = [BackendModule::class])
object VBoxBackend
@@ -30,5 +32,6 @@ fun Application.module() {
configureSerialization(koin.get())
configureJwt(koin.get())
configureWebSocket(koin.get())
configureRouting(koin.get(), koin.get())
installStatusPages()
routing { setupAuthMethods(koin.get(), koin.get()) }
}
@@ -4,10 +4,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
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.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.SearchRepository
import java.sql.SQLException
@@ -59,6 +60,9 @@ class BackendSearchRepository(
}
private suspend fun notifyChanged() {
eventHandler.notify(RemoteEvent.OnSearch(System.currentTimeMillis(), userId))
eventHandler.notify(
RemoteEvent.OnSearch(System.currentTimeMillis(), userId),
RemoteEvent.serializer()
)
}
}
@@ -3,11 +3,12 @@ package ru.shadowsparky.vbox.backend.data.recent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
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.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.model.RecentlyWatchedInfo
import java.sql.SQLException
@@ -85,7 +86,8 @@ class BackendRecentlyWatchedRepository(
System.currentTimeMillis(),
userId,
seasonId
)
),
RemoteEvent.serializer()
)
}
}
@@ -7,10 +7,11 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
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.shared.di.factory.DispatcherProvider
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.model.SERIAL_FLAG
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
@@ -50,7 +51,10 @@ class BackendSavedMovieRepository(
details.id,
userId
)
eventHandler.notify(RemoteEvent.OnSaved(userId, details.id))
eventHandler.notify(
RemoteEvent.OnSaved(userId, details.id),
RemoteEvent.serializer()
)
} catch (_: SQLException) {
}
}
@@ -67,7 +71,10 @@ class BackendSavedMovieRepository(
logger.debug(TAG, "remove(${id})")
withContext(dispatcherProvider.io) {
db.saved_movieQueries.removeSavedMovie(userId, id)
eventHandler.notify(RemoteEvent.OnSaved(userId, id))
eventHandler.notify(
RemoteEvent.OnSaved(userId, id),
RemoteEvent.serializer()
)
}
}
@@ -8,11 +8,12 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
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.http.domain.BadRequestException
import ru.shadowsparky.vbox.backend.AppDatabase
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.TagInfo
@@ -62,12 +63,18 @@ class BackendMovieTagRepository(
override suspend fun link(movieId: Long, tag: String) {
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) {
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) {
@@ -5,10 +5,11 @@ import app.cash.sqldelight.coroutines.mapToList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
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.shared.di.factory.DispatcherProvider
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.UserTagRepository
@@ -47,16 +48,25 @@ class BackendUserTagRepository(
override suspend fun add(tag: String) {
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) {
queries.updateTagText(newTag, oldTag, userId).await()
userTagEventHandler.notify(RemoteEvent.OnUserTag(userId))
userTagEventHandler.notify(
RemoteEvent.OnUserTag(userId),
RemoteEvent.serializer()
)
}
override suspend fun delete(tag: String) {
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 org.koin.core.annotation.Single
import ru.shadowsparky.backend.data.TokenVerifier
import ru.shadowsparky.backend.data.event.SessionRegistry
import ru.shadowsparky.backend.domain.JwtInfo
import ru.shadowsparky.backend.domain.LoginVerifier
import ru.shadowsparky.backend.domain.RemoteEventHandler
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
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.chat.BackendChatRepositoryFactory
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.SavedMovieRepositoryFactory
import ru.shadowsparky.vbox.backend.di.factory.SearchRepositoryFactory
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.VideoApi
@Single
@@ -1,13 +1,13 @@
package ru.shadowsparky.vbox.backend.di.factory
import org.koin.core.annotation.Factory
import ru.shadowsparky.backend.domain.RemoteEventHandler
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.backend.data.recent.BackendRecentlyWatchedRepository
import ru.shadowsparky.vbox.backend.data.recent.CacheRecentlyWatchedRepository
import ru.shadowsparky.vbox.backend.domain.CacheStorage
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
@Factory
class RecentlyWatchedRepositoryFactory(
@@ -2,8 +2,8 @@ package ru.shadowsparky.vbox.backend.di.factory
import org.koin.core.annotation.Factory
import ru.shadowsparky.backend.data.Logger
import ru.shadowsparky.backend.data.event.BackendRemoteEventHandler
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.CacheSavedMovieRepository
import ru.shadowsparky.vbox.backend.domain.CacheStorage
@@ -1,10 +1,10 @@
package ru.shadowsparky.vbox.backend.di.factory
import org.koin.core.annotation.Factory
import ru.shadowsparky.backend.domain.RemoteEventHandler
import ru.shadowsparky.vbox.backend.AppDatabase
import ru.shadowsparky.vbox.backend.data.BackendSearchRepository
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.SearchRepository
@Factory
@@ -1,59 +1,17 @@
package ru.shadowsparky.vbox.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.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.backend.presentation.configureJwt
import ru.shadowsparky.backend.presentation.obtainUserId
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"
fun Application.configureJwt(authEntryPoint: AuthEntryPoint) = with(authEntryPoint) {
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,
ServerExceptionInfo(INVALID_TOKEN_MSG)
)
}
}
}
configureJwt(jwtInfo, tokenVerifier, loginVerifier)
}
fun ApplicationCall.obtainUserId(): Long {
val principal = authentication.principal<JWTPrincipal>()
?: throw VerifyTokenException(INVALID_TOKEN_MSG)
return principal.payload
.getClaim(USER_ID_ARG)
.asLong()
return obtainUserId()
}
@@ -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)
}
}
@@ -1,100 +1,10 @@
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.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.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.backend.presentation.setupWebSocket
import ru.shadowsparky.vbox.backend.di.WebSocketEntryPoint
import ru.shadowsparky.vbox.shared.domain.AuthRequest
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
import ru.shadowsparky.vbox.shared.domain.ON_EVENT
fun Application.configureWebSocket(socketEntryPoint: WebSocketEntryPoint) = with(socketEntryPoint) {
install(WebSockets) {
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)
setupWebSocket(ON_EVENT, json, tokenVerifier, sessionRegistry)
}
@@ -5,6 +5,8 @@ import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
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.domain.ChatRepository
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.shared.domain.DYNAMIC_PREFIX
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
fun Route.setupChat(
chatRepositoryFactory: BackendChatRepositoryFactory,
@@ -33,7 +34,10 @@ fun Route.setupChat(
val info = call.receive<Message>()
if (info.role != ChatRoles.USER) throw BadRequestException("invalid role")
val response = processUserMessageUseCase.execute(info, userId)
remoteEventHandler.notify(RemoteEvent.OnChatUpdate(userId))
remoteEventHandler.notify(
RemoteEvent.OnChatUpdate(userId),
RemoteEvent.serializer()
)
call.respond(response)
}
}
@@ -4,114 +4,50 @@ import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.url
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.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.SharingStarted
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.serialization.json.Json
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.vbox.shared.domain.AuthRequest
import ru.shadowsparky.vbox.shared.domain.AuthResponse
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.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import kotlin.math.pow
import kotlin.time.Duration.Companion.milliseconds
@Single
class RemoteEventListenerImpl(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
private val authTokenCache: TokenStorage,
authTokenCache: TokenStorage,
private val healthCheck: HealthCheck,
private val json: Json,
private val log: Log
private val processor: RemoveEventProcessor,
private val json: Json
) : RemoteEventListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@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()
log.d(TAG, "listener started")
try {
httpClient.webSocket(
method = HttpMethod.Get,
request = { url(config.asWebSocketStr() + "/${RemoteEventHandler.ON_EVENT}") }
request = { url(config.asWebSocketStr() + "/${ON_EVENT}") }
) {
authV2(token.token, incoming, outgoing)
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")
val flow = processor.process(token.token, this) { healthCheck.check() }
.map { json.decodeFromString<RemoteEvent>(it) }
emitAll(flow)
}
}.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"
}
}
@@ -4,13 +4,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
interface RemoteEventHandler {
suspend fun notify(eventInfo: RemoteEvent)
companion object {
const val ON_EVENT = "$DYNAMIC_PREFIX/onEvent"
}
}
const val ON_EVENT = "$DYNAMIC_PREFIX/onEvent"
interface RemoteEventListener {
val event: Flow<RemoteEvent>
@@ -45,11 +39,3 @@ sealed interface RemoteEvent {
@SerialName("OnChatUpdate")
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)
+8
View File
@@ -1,8 +1,16 @@
plugins {
alias(libs.plugins.convention.jvm)
alias(libs.plugins.convention.koin)
alias(libs.plugins.ktor)
alias(libs.plugins.convention.serialization)
}
dependencies {
implementation(project(":libs:http-client"))
implementation(libs.java.jwt)
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)
}
@@ -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.slf4j.Logger
import org.slf4j.LoggerFactory
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.backend.domain.RemoteEventHandler
val eventLogger: Logger = LoggerFactory.getLogger("events")
@Single
class BackendRemoteEventHandler(
private val json: Json,
private val registry: SessionRegistry
) : RemoteEventHandler {
override suspend fun notify(eventInfo: RemoteEvent) {
val jsonText = json.encodeToString(eventInfo)
val writers = registry.get(eventInfo.userId)
override suspend fun notify(eventInfo: JsonElement) {
val userId = eventInfo.jsonObject["userId"]?.jsonPrimitive?.longOrNull
?: error("User id field required")
val writers = registry.get(userId)
if (writers.isNullOrEmpty()) {
eventLogger.info("unable to notify {}. sessions not found, cache {}", eventInfo, registry)
return
@@ -24,10 +26,10 @@ class BackendRemoteEventHandler(
writers.toList().forEach { writer ->
eventLogger.info("notify[{}]. session {}", eventInfo, writer)
try {
writer.writeText(jsonText)
writer.writeText(eventInfo.toString())
} catch (e: Exception) {
eventLogger.error("unable to notify {}. delete session. Reason: {}", writer, e.message)
registry.remove(eventInfo.userId, writer)
registry.remove(userId, writer)
}
}
}
@@ -1,4 +1,4 @@
package ru.shadowsparky.vbox.backend.data
package ru.shadowsparky.backend.data.event
import org.koin.core.annotation.Single
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)
@@ -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 ?: "Неизвестная ошибка")
)
}
}
}
@@ -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)
}
@@ -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)