WS auth, currently times-out for some reason

This commit is contained in:
Syer10
2025-10-17 12:54:49 -04:00
parent f0a9f7aff1
commit d98ae70c91
10 changed files with 332 additions and 34 deletions

View File

@@ -4,9 +4,9 @@ object Config {
const val migrationCode = 6
// Suwayomi-Server version
const val tachideskVersion = "v2.1.1959"
const val tachideskVersion = "v2.1.1970"
// Match this to the Suwayomi-Server commit count
const val serverCode = 1959
const val serverCode = 1970
const val preview = true
val desktopJvmTarget = JvmTarget.JVM_17

View File

@@ -19,6 +19,8 @@ import ca.gosyer.jui.data.settings.SettingsRepositoryImpl
import ca.gosyer.jui.data.source.SourceRepositoryImpl
import ca.gosyer.jui.data.updates.UpdatesRepositoryImpl
import ca.gosyer.jui.data.user.UserRepositoryImpl
import ca.gosyer.jui.data.util.ApolloAuthInterceptor
import ca.gosyer.jui.data.util.KtorWebSocketEngine
import ca.gosyer.jui.domain.backup.service.BackupRepository
import ca.gosyer.jui.domain.category.service.CategoryRepository
import ca.gosyer.jui.domain.chapter.service.ChapterRepository
@@ -33,14 +35,18 @@ import ca.gosyer.jui.domain.server.service.ServerPreferences
import ca.gosyer.jui.domain.settings.service.SettingsRepository
import ca.gosyer.jui.domain.source.service.SourceRepository
import ca.gosyer.jui.domain.updates.service.UpdatesRepository
import ca.gosyer.jui.domain.user.interactor.UserRefreshUI
import ca.gosyer.jui.domain.user.service.UserRepository
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.network.ws.GraphQLWsProtocol
import com.apollographql.ktor.ktorClient
import com.apollographql.apollo.annotations.ApolloExperimental
import com.apollographql.apollo.network.websocket.GraphQLWsProtocol
import com.apollographql.apollo.network.websocket.WebSocketNetworkTransport
import com.apollographql.ktor.http.KtorHttpEngine
import io.ktor.client.HttpClient
import io.ktor.http.URLBuilder
import io.ktor.http.Url
import io.ktor.http.appendPathSegments
import korlibs.time.seconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.IO
@@ -54,17 +60,31 @@ typealias ApolloAppClient = StateFlow<ApolloClient>
typealias ApolloAppClientNoAuth = StateFlow<ApolloClient>
@OptIn(ApolloExperimental::class)
private fun getApolloClient(
httpClient: HttpClient,
serverUrl: Url,
authInterceptor: ApolloAuthInterceptor?,
): ApolloClient {
val url = URLBuilder(serverUrl)
.appendPathSegments("api", "graphql")
.buildString()
return ApolloClient.Builder()
.serverUrl(url)
.ktorClient(httpClient)
.wsProtocol(GraphQLWsProtocol.Factory(pingIntervalMillis = 30))
.httpEngine(KtorHttpEngine(httpClient))
.apply {
if (authInterceptor != null) {
addInterceptor(authInterceptor)
}
}
.subscriptionNetworkTransport(
WebSocketNetworkTransport.Builder()
.serverUrl(url)
.pingInterval(30.seconds)
.webSocketEngine(KtorWebSocketEngine(httpClient))
.wsProtocol(GraphQLWsProtocol())
.build()
)
.dispatcher(Dispatchers.IO)
.build()
}
@@ -74,15 +94,16 @@ interface DataComponent : SharedDataComponent {
@Provides
@AppScope
fun apolloAppClient(
http: Http,
http: HttpNoAuth,
serverPreferences: ServerPreferences,
apolloAuthInterceptor: ApolloAuthInterceptor,
): ApolloAppClient =
http
.map { getApolloClient(it, serverPreferences.serverUrl().get()) }
.map { getApolloClient(it, serverPreferences.serverUrl().get(), apolloAuthInterceptor) }
.stateIn(
GlobalScope,
SharingStarted.Eagerly,
getApolloClient(http.value, serverPreferences.serverUrl().get()),
getApolloClient(http.value, serverPreferences.serverUrl().get(), apolloAuthInterceptor),
)
@Provides
@@ -90,13 +111,14 @@ interface DataComponent : SharedDataComponent {
fun apolloAppClientNoAuth(
httpNoAuth: HttpNoAuth,
serverPreferences: ServerPreferences,
userRefreshUI: Lazy<UserRefreshUI>,
): ApolloAppClientNoAuth =
httpNoAuth
.map { getApolloClient(it, serverPreferences.serverUrl().get()) }
.map { getApolloClient(it, serverPreferences.serverUrl().get(), null) }
.stateIn(
GlobalScope,
SharingStarted.Eagerly,
getApolloClient(httpNoAuth.value, serverPreferences.serverUrl().get()),
getApolloClient(httpNoAuth.value, serverPreferences.serverUrl().get(), null),
)
@Provides

View File

@@ -0,0 +1,95 @@
package ca.gosyer.jui.data.util
import ca.gosyer.jui.domain.server.model.Auth
import ca.gosyer.jui.domain.server.service.ServerPreferences
import ca.gosyer.jui.domain.user.interactor.UserRefreshUI
import ca.gosyer.jui.domain.user.service.UserPreferences
import com.apollographql.apollo.api.ApolloRequest
import com.apollographql.apollo.api.ApolloResponse
import com.apollographql.apollo.api.Operation
import com.apollographql.apollo.api.http.HttpHeader
import com.apollographql.apollo.interceptor.ApolloInterceptor
import com.apollographql.apollo.interceptor.ApolloInterceptorChain
import com.diamondedge.logging.logging
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import me.tatarka.inject.annotations.Inject
import kotlin.io.encoding.Base64
@Inject
class ApolloAuthInterceptor(
private val userPreferences: UserPreferences,
private val serverPreferences: ServerPreferences,
private val userRefreshUI: Lazy<UserRefreshUI>
) : ApolloInterceptor {
override fun <D : Operation.Data> intercept(
request: ApolloRequest<D>,
chain: ApolloInterceptorChain,
): Flow<ApolloResponse<D>> {
return when (serverPreferences.auth().get()) {
Auth.NONE, Auth.DIGEST -> chain.proceed(request)
Auth.BASIC -> {
val username = serverPreferences.authUsername().get()
val password = serverPreferences.authPassword().get()
val authHeader = "Basic ${Base64.encode("${username}:${password}".encodeToByteArray())}"
val requestWithAuth = request.newBuilder()
.addHttpHeader("Authorization", authHeader)
.build()
chain.proceed(requestWithAuth)
}
Auth.SIMPLE -> {
val session = userPreferences.simpleSession().get()
val requestWithAuth = if (session.isNotEmpty()) {
request.newBuilder().addHttpHeader("Cookie", "JSESSIONID=$session")
.build()
} else {
request
}
chain.proceed(requestWithAuth)
}
Auth.UI -> {
val token = userPreferences.uiAccessToken().get()
return if (token.isNotEmpty()) {
chain.proceed(
request.newBuilder()
.httpHeaders(
request.httpHeaders
?.filterNot { it.name.equals("Authorization", true) }
.orEmpty()
.plus(HttpHeader("Authorization", "Bearer $token"))
)
.build()
).map { response ->
if (response.errors?.any { it.message.contains("unauthorized", true) } == true) {
log.warn { "${request.operation.name()} - Token expired, refreshing..." }
val newToken = userRefreshUI.value.await()
if (newToken != null) {
chain.proceed(
request.newBuilder()
.httpHeaders(
request.httpHeaders
?.filterNot { it.name.equals("Authorization", true) }
.orEmpty()
.plus(HttpHeader("Authorization", "Bearer $newToken"))
)
.build()
).first()
} else {
response
}
} else {
response
}
}
} else {
chain.proceed(request)
}
}
}
}
companion object {
private val log = logging()
}
}

View File

@@ -0,0 +1,178 @@
package ca.gosyer.jui.data.util
import com.apollographql.apollo.annotations.ApolloExperimental
import com.apollographql.apollo.api.http.HttpHeader
import com.apollographql.apollo.exception.ApolloNetworkException
import com.apollographql.apollo.exception.ApolloWebSocketClosedException
import com.apollographql.apollo.network.websocket.WebSocket
import com.apollographql.apollo.network.websocket.WebSocketEngine
import com.apollographql.apollo.network.websocket.WebSocketListener
import com.diamondedge.logging.logging
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.headers
import io.ktor.client.request.url
import io.ktor.http.URLBuilder
import io.ktor.http.URLProtocol
import io.ktor.http.Url
import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame
import io.ktor.websocket.readBytes
import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@OptIn(ApolloExperimental::class)
class KtorWebSocketEngine(
private val client: HttpClient,
) : WebSocketEngine {
constructor() : this(
HttpClient {
install(WebSockets)
}
)
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
//private val receiveMessageChannel = Channel<String>(Channel.UNLIMITED)
private val sendFrameChannel = Channel<Frame>(Channel.UNLIMITED)
override fun newWebSocket(url: String, headers: List<HttpHeader>, listener: WebSocketListener): WebSocket {
return open(Url(url), headers, listener)
}
private fun open(url: Url, headers: List<HttpHeader>, listener: WebSocketListener): WebSocket {
val newUrl = URLBuilder(url).apply {
protocol = when (url.protocol) {
URLProtocol.HTTPS -> URLProtocol.WSS
URLProtocol.HTTP -> URLProtocol.WS
URLProtocol.WS, URLProtocol.WSS -> url.protocol
/* URLProtocol.SOCKS */else -> throw UnsupportedOperationException("SOCKS is not a supported protocol")
}
}.build()
coroutineScope.launch {
try {
client.webSocket(
request = {
headers {
headers.forEach {
append(it.name, it.value)
}
}
url(newUrl)
},
) {
coroutineScope {
launch {
sendFrames(this@webSocket, listener)
}
try {
listener.onOpen()
receiveFrames(incoming, listener)
} catch (e: Throwable) {
val closeReason = closeReasonOrNull()
val apolloException = if (closeReason != null) {
ApolloWebSocketClosedException(
code = closeReason.code.toInt(),
reason = closeReason.message,
cause = e
)
} else {
ApolloNetworkException(
message = "Web socket communication error",
platformCause = e
)
}
listener.onError(apolloException)
log.warn(e) { "Closed websocket" }
throw e
}
}
}
} catch (e: Throwable) {
log.warn(e) { "Closed websocket catch" }
listener.onError(ApolloNetworkException(message = "Web socket communication error", platformCause = e))
} finally {
log.warn { "Closed websocket finally" }
// Not 100% sure this can happen. Better safe than sorry. close() is idempotent so it shouldn't hurt
listener.onError(ApolloNetworkException(message = "Web socket communication error", platformCause = null))
}
}
return object : WebSocket {
override fun send(data: ByteArray) {
log.debug { "send data: $data" }
sendFrameChannel.trySend(Frame.Binary(true, data))
}
override fun send(text: String) {
log.debug { "send text: $text" }
sendFrameChannel.trySend(Frame.Text(text))
}
override fun close(code: Int, reason: String) {
log.debug { "send close: code=$code, reason=$reason" }
sendFrameChannel.trySend(Frame.Close(CloseReason(code.toShort(), reason)))
}
}
}
private suspend fun DefaultClientWebSocketSession.closeReasonOrNull(): CloseReason? {
return try {
closeReason.await()
} catch (t: Throwable) {
if (t is CancellationException) {
throw t
}
null
}
}
private suspend fun sendFrames(session: DefaultClientWebSocketSession, listener: WebSocketListener) {
while (true) {
val frame = sendFrameChannel.receive()
session.send(frame)
if (frame is Frame.Close) {
// normal termination
listener.onClosed(1000, null)
}
}
}
private suspend fun receiveFrames(incoming: ReceiveChannel<Frame>, listener: WebSocketListener) {
while (true) {
when (val frame = incoming.receive()) {
is Frame.Text -> {
listener.onMessage(frame.readText())
}
is Frame.Binary -> {
listener.onMessage(frame.readBytes())
}
else -> error("unknown frame type")
}
}
}
override fun close() {
log.info { "Closing websocket" }
sendFrameChannel.close()
coroutineScope.cancel()
}
companion object {
private val log = logging()
}
}

View File

@@ -9,21 +9,36 @@ package ca.gosyer.jui.domain.migration.interactor
import ca.gosyer.jui.domain.build.BuildKonfig
import ca.gosyer.jui.domain.migration.service.MigrationPreferences
import ca.gosyer.jui.domain.reader.service.ReaderPreferences
import ca.gosyer.jui.domain.server.model.Auth
import ca.gosyer.jui.domain.server.service.ServerPreferences
import me.tatarka.inject.annotations.Inject
@Inject
class RunMigrations(
private val migrationPreferences: MigrationPreferences,
private val readerPreferences: ReaderPreferences,
private val serverPreferences: ServerPreferences,
) {
fun runMigrations() {
val code = migrationPreferences.version().get()
if (code <= 0) {
readerPreferences.modes().get().forEach {
readerPreferences.getMode(it).direction().delete()
}
val oldVersion = migrationPreferences.version().get()
if (oldVersion < BuildKonfig.MIGRATION_CODE) {
migrationPreferences.version().set(BuildKonfig.MIGRATION_CODE)
return
// Fresh install
if (oldVersion == 0) {
readerPreferences.modes().get().forEach {
readerPreferences.getMode(it).direction().delete()
}
return
}
if (oldVersion < 6) {
val authPreference = serverPreferences.auth()
@Suppress("DEPRECATION")
if (authPreference.get() == Auth.DIGEST) {
authPreference.set(Auth.NONE)
}
}
}
}
}

View File

@@ -23,9 +23,7 @@ import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.api.Send
import io.ktor.client.plugins.api.createClientPlugin
import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
import io.ktor.client.plugins.auth.providers.DigestAuthCredentials
import io.ktor.client.plugins.auth.providers.basic
import io.ktor.client.plugins.auth.providers.digest
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
@@ -151,7 +149,7 @@ private fun getHttpClient(
}
}
when (auth) {
Auth.NONE -> Unit
Auth.NONE, Auth.DIGEST -> Unit
Auth.BASIC -> AuthPlugin {
basic {
@@ -167,16 +165,6 @@ private fun getHttpClient(
}
}
Auth.DIGEST -> AuthPlugin {
digest {
credentials {
DigestAuthCredentials(
authUsername,
authPassword,
)
}
}
}
Auth.SIMPLE -> install(SimpleAuthPlugin) {
this.simpleSessionPreference = simpleSessionPreference
}

View File

@@ -14,6 +14,7 @@ import kotlinx.serialization.Serializable
enum class Auth {
NONE,
BASIC,
@Deprecated("")
DIGEST,
SIMPLE,
UI,

View File

@@ -7,7 +7,7 @@
package ca.gosyer.jui.domain.updates.interactor
import ca.gosyer.jui.domain.build.BuildKonfig
import ca.gosyer.jui.domain.server.Http
import ca.gosyer.jui.domain.server.HttpNoAuth
import ca.gosyer.jui.domain.updates.model.GithubRelease
import ca.gosyer.jui.domain.updates.service.UpdatePreferences
import com.diamondedge.logging.logging
@@ -24,7 +24,7 @@ import me.tatarka.inject.annotations.Inject
@Inject
class UpdateChecker(
private val updatePreferences: UpdatePreferences,
private val client: Http,
private val client: HttpNoAuth,
) {
suspend fun await(
manualFetch: Boolean,

View File

@@ -8,7 +8,7 @@ class UserPreferences(
) {
fun uiRefreshToken(): Preference<String> = preferenceStore.getString("ui_refresh_token", "")
fun uiAccessToken(): Preference<String> = preferenceStore.getString("ui_refresh_token", "")
fun uiAccessToken(): Preference<String> = preferenceStore.getString("ui_access_token", "")
fun simpleSession(): Preference<String> = preferenceStore.getString("simple_session", "")
}

View File

@@ -430,7 +430,6 @@ class SettingsServerViewModel(
persistentMapOf(
Auth.NONE to stringResource(MR.strings.no_auth),
Auth.BASIC to stringResource(MR.strings.basic_auth),
Auth.DIGEST to stringResource(MR.strings.digest_auth),
Auth.SIMPLE to stringResource(MR.strings.simple_auth),
Auth.UI to stringResource(MR.strings.ui_login),
)