mirror of
https://github.com/Suwayomi/TachideskJUI.git
synced 2025-12-10 14:52:03 +01:00
WS auth, currently times-out for some reason
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
val oldVersion = migrationPreferences.version().get()
|
||||
if (oldVersion < BuildKonfig.MIGRATION_CODE) {
|
||||
migrationPreferences.version().set(BuildKonfig.MIGRATION_CODE)
|
||||
|
||||
// Fresh install
|
||||
if (oldVersion == 0) {
|
||||
readerPreferences.modes().get().forEach {
|
||||
readerPreferences.getMode(it).direction().delete()
|
||||
}
|
||||
migrationPreferences.version().set(BuildKonfig.MIGRATION_CODE)
|
||||
return
|
||||
}
|
||||
|
||||
if (oldVersion < 6) {
|
||||
val authPreference = serverPreferences.auth()
|
||||
@Suppress("DEPRECATION")
|
||||
if (authPreference.get() == Auth.DIGEST) {
|
||||
authPreference.set(Auth.NONE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.serialization.Serializable
|
||||
enum class Auth {
|
||||
NONE,
|
||||
BASIC,
|
||||
@Deprecated("")
|
||||
DIGEST,
|
||||
SIMPLE,
|
||||
UI,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", "")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user