diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index bb96db02..18ccde98 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -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 diff --git a/data/src/commonMain/kotlin/ca/gosyer/jui/data/DataComponent.kt b/data/src/commonMain/kotlin/ca/gosyer/jui/data/DataComponent.kt index fc690166..6416b398 100644 --- a/data/src/commonMain/kotlin/ca/gosyer/jui/data/DataComponent.kt +++ b/data/src/commonMain/kotlin/ca/gosyer/jui/data/DataComponent.kt @@ -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 typealias ApolloAppClientNoAuth = StateFlow +@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, ): 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 diff --git a/data/src/commonMain/kotlin/ca/gosyer/jui/data/util/ApolloAuthInterceptor.kt b/data/src/commonMain/kotlin/ca/gosyer/jui/data/util/ApolloAuthInterceptor.kt new file mode 100644 index 00000000..8aace8fd --- /dev/null +++ b/data/src/commonMain/kotlin/ca/gosyer/jui/data/util/ApolloAuthInterceptor.kt @@ -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 +) : ApolloInterceptor { + override fun intercept( + request: ApolloRequest, + chain: ApolloInterceptorChain, + ): Flow> { + 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() + } +} diff --git a/data/src/commonMain/kotlin/ca/gosyer/jui/data/util/KtorWebSocketEngine.kt b/data/src/commonMain/kotlin/ca/gosyer/jui/data/util/KtorWebSocketEngine.kt new file mode 100644 index 00000000..50a03406 --- /dev/null +++ b/data/src/commonMain/kotlin/ca/gosyer/jui/data/util/KtorWebSocketEngine.kt @@ -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(Channel.UNLIMITED) + private val sendFrameChannel = Channel(Channel.UNLIMITED) + + override fun newWebSocket(url: String, headers: List, listener: WebSocketListener): WebSocket { + return open(Url(url), headers, listener) + } + + private fun open(url: Url, headers: List, 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, 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() + } +} diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/migration/interactor/RunMigrations.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/migration/interactor/RunMigrations.kt index e84ba057..e67e12ba 100644 --- a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/migration/interactor/RunMigrations.kt +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/migration/interactor/RunMigrations.kt @@ -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) + } + } } } } diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/HttpClient.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/HttpClient.kt index edafe948..05330d2a 100644 --- a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/HttpClient.kt +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/HttpClient.kt @@ -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 } diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/model/Auth.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/model/Auth.kt index 295b8eda..62162ec5 100644 --- a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/model/Auth.kt +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/server/model/Auth.kt @@ -14,6 +14,7 @@ import kotlinx.serialization.Serializable enum class Auth { NONE, BASIC, + @Deprecated("") DIGEST, SIMPLE, UI, diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/updates/interactor/UpdateChecker.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/updates/interactor/UpdateChecker.kt index 1ef4afd3..62495e0c 100644 --- a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/updates/interactor/UpdateChecker.kt +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/updates/interactor/UpdateChecker.kt @@ -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, diff --git a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/service/UserPreferences.kt b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/service/UserPreferences.kt index f78e38fe..c9e21f4c 100644 --- a/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/service/UserPreferences.kt +++ b/domain/src/commonMain/kotlin/ca/gosyer/jui/domain/user/service/UserPreferences.kt @@ -8,7 +8,7 @@ class UserPreferences( ) { fun uiRefreshToken(): Preference = preferenceStore.getString("ui_refresh_token", "") - fun uiAccessToken(): Preference = preferenceStore.getString("ui_refresh_token", "") + fun uiAccessToken(): Preference = preferenceStore.getString("ui_access_token", "") fun simpleSession(): Preference = preferenceStore.getString("simple_session", "") } diff --git a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsServerScreen.kt b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsServerScreen.kt index d8ecef48..9a3a501f 100644 --- a/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsServerScreen.kt +++ b/presentation/src/commonMain/kotlin/ca/gosyer/jui/ui/settings/SettingsServerScreen.kt @@ -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), )