Add self-hosted instance login support
Signed-off-by: Orange Studio <orange@blllll.cn>
This commit is contained in:
parent
4ede85eafb
commit
a5760be03b
|
|
@ -76,6 +76,7 @@ import chat.stoat.StoatApplication
|
|||
import chat.stoat.api.HitRateLimitException
|
||||
import chat.stoat.api.StoatAPI
|
||||
import chat.stoat.api.StoatHttp
|
||||
import chat.stoat.api.StoatInstance
|
||||
import chat.stoat.api.api
|
||||
import chat.stoat.api.routes.microservices.geo.queryGeo
|
||||
import chat.stoat.api.routes.microservices.health.healthCheck
|
||||
|
|
@ -312,8 +313,11 @@ class MainActivityViewModel(
|
|||
|
||||
init {
|
||||
Log.d("MainActivity", "Starting up")
|
||||
doPreStartupTasks()
|
||||
checkLoggedInState()
|
||||
viewModelScope.launch {
|
||||
StoatInstance.restore(kvStorage)
|
||||
doPreStartupTasks()
|
||||
checkLoggedInState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,18 +56,20 @@ import chat.stoat.core.model.schemas.Channel as ChannelSchema
|
|||
|
||||
private const val USE_ALPHA_API = false
|
||||
|
||||
val STOAT_BASE =
|
||||
if (USE_ALPHA_API) "https://alpha.revolt.chat/api" else "https://api.stoat.chat/0.8"
|
||||
val STOAT_BASE: String
|
||||
get() = if (USE_ALPHA_API) "https://alpha.revolt.chat/api" else StoatInstance.apiBase
|
||||
const val STOAT_SUPPORT = "https://support.stoat.chat"
|
||||
const val STOAT_MARKETING = "https://stoat.chat"
|
||||
val STOAT_FILES =
|
||||
if (USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else "https://cdn.stoatusercontent.com"
|
||||
val STOAT_PROXY =
|
||||
if (USE_ALPHA_API) "https://alpha.revolt.chat/january" else "https://proxy.stoatusercontent.com"
|
||||
const val STOAT_WEB_APP = "https://stoat.chat"
|
||||
const val STOAT_INVITES = "https://stt.gg"
|
||||
val STOAT_WEBSOCKET =
|
||||
if (USE_ALPHA_API) "wss://alpha.revolt.chat/ws" else "wss://events.stoat.chat"
|
||||
val STOAT_FILES: String
|
||||
get() = if (USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else StoatInstance.filesBase
|
||||
val STOAT_PROXY: String
|
||||
get() = if (USE_ALPHA_API) "https://alpha.revolt.chat/january" else StoatInstance.proxyBase
|
||||
val STOAT_WEB_APP: String
|
||||
get() = if (USE_ALPHA_API) "https://alpha.revolt.chat" else StoatInstance.webApp
|
||||
val STOAT_INVITES: String
|
||||
get() = if (USE_ALPHA_API) "https://alpha.revolt.chat" else StoatInstance.invites
|
||||
val STOAT_WEBSOCKET: String
|
||||
get() = if (USE_ALPHA_API) "wss://alpha.revolt.chat/ws" else StoatInstance.websocket
|
||||
|
||||
fun String.api(): String {
|
||||
return "$STOAT_BASE$this"
|
||||
|
|
@ -385,4 +387,4 @@ data class RateLimitResponse(@SerialName("retry_after") val retryAfter: Int) {
|
|||
internal const val NO_RETRY_AFTER = Int.MIN_VALUE
|
||||
|
||||
class HitRateLimitException(retryAfter: Int = NO_RETRY_AFTER) :
|
||||
Exception(if (retryAfter == NO_RETRY_AFTER) "Hit rate limit" else "Hit rate limit, retry after ${retryAfter}ms")
|
||||
Exception(if (retryAfter == NO_RETRY_AFTER) "Hit rate limit" else "Hit rate limit, retry after ${retryAfter}ms")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
package chat.stoat.api
|
||||
|
||||
import chat.stoat.persistence.KVStorage
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object StoatInstance {
|
||||
const val KV_INSTANCE_API_BASE = "instanceApiBase"
|
||||
|
||||
private const val DEFAULT_API_BASE = "https://api.stoat.chat/0.8"
|
||||
private const val DEFAULT_FILES_BASE = "https://cdn.stoatusercontent.com"
|
||||
private const val DEFAULT_PROXY_BASE = "https://proxy.stoatusercontent.com"
|
||||
private const val DEFAULT_WEB_APP = "https://stoat.chat"
|
||||
private const val DEFAULT_INVITES = "https://stt.gg"
|
||||
private const val DEFAULT_WEBSOCKET = "wss://events.stoat.chat"
|
||||
|
||||
var apiBase: String = DEFAULT_API_BASE
|
||||
private set
|
||||
var filesBase: String = DEFAULT_FILES_BASE
|
||||
private set
|
||||
var proxyBase: String = DEFAULT_PROXY_BASE
|
||||
private set
|
||||
var webApp: String = DEFAULT_WEB_APP
|
||||
private set
|
||||
var invites: String = DEFAULT_INVITES
|
||||
private set
|
||||
var websocket: String = DEFAULT_WEBSOCKET
|
||||
private set
|
||||
|
||||
suspend fun restore(kvStorage: KVStorage) {
|
||||
configure(kvStorage.get(KV_INSTANCE_API_BASE) ?: DEFAULT_API_BASE)
|
||||
}
|
||||
|
||||
suspend fun configure(rawApiBase: String, kvStorage: KVStorage? = null) {
|
||||
val normalizedApiBase = normalizeApiBase(rawApiBase)
|
||||
applyFallbacks(normalizedApiBase)
|
||||
discoverInstanceMetadata(normalizedApiBase)
|
||||
kvStorage?.set(KV_INSTANCE_API_BASE, normalizedApiBase)
|
||||
}
|
||||
|
||||
fun normalizeApiBase(rawApiBase: String): String {
|
||||
val trimmed = rawApiBase.trim().ifBlank { DEFAULT_API_BASE }
|
||||
val withScheme = if (trimmed.contains("://")) trimmed else "https://$trimmed"
|
||||
return withScheme.trimEnd('/')
|
||||
}
|
||||
|
||||
private fun applyFallbacks(normalizedApiBase: String) {
|
||||
apiBase = normalizedApiBase
|
||||
if (normalizedApiBase == DEFAULT_API_BASE) {
|
||||
filesBase = DEFAULT_FILES_BASE
|
||||
proxyBase = DEFAULT_PROXY_BASE
|
||||
webApp = DEFAULT_WEB_APP
|
||||
invites = DEFAULT_INVITES
|
||||
websocket = DEFAULT_WEBSOCKET
|
||||
return
|
||||
}
|
||||
|
||||
val serviceRoot = normalizedApiBase.removeSuffix("/0.8").removeSuffix("/api")
|
||||
filesBase = "$serviceRoot/autumn"
|
||||
proxyBase = "$serviceRoot/january"
|
||||
webApp = serviceRoot
|
||||
invites = serviceRoot
|
||||
websocket = serviceRoot
|
||||
.replaceFirst("https://", "wss://")
|
||||
.replaceFirst("http://", "ws://") + "/ws"
|
||||
}
|
||||
|
||||
private suspend fun discoverInstanceMetadata(normalizedApiBase: String) {
|
||||
val client = HttpClient(OkHttp) {
|
||||
install(ContentNegotiation) {
|
||||
json(StoatJson)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val metadata = client.get(normalizedApiBase).body<InstanceMetadata>()
|
||||
metadata.ws?.trimEnd('/')?.let { websocket = it }
|
||||
metadata.revolt?.trimEnd('/')?.let { webApp = it }
|
||||
metadata.features?.autumn?.url?.trimEnd('/')?.let { filesBase = it }
|
||||
metadata.features?.january?.url?.trimEnd('/')?.let { proxyBase = it }
|
||||
} catch (_: Exception) {
|
||||
// Keep derived fallbacks. Login requests will surface connection/auth errors to the user.
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class InstanceMetadata(
|
||||
val revolt: String? = null,
|
||||
val ws: String? = null,
|
||||
val features: InstanceFeatures? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class InstanceFeatures(
|
||||
val autumn: InstanceFeatureEndpoint? = null,
|
||||
val january: InstanceFeatureEndpoint? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class InstanceFeatureEndpoint(
|
||||
val enabled: Boolean = false,
|
||||
@SerialName("url")
|
||||
val url: String? = null
|
||||
)
|
||||
|
|
@ -50,6 +50,7 @@ import chat.stoat.R
|
|||
import chat.stoat.StoatApplication
|
||||
import chat.stoat.api.STOAT_WEB_APP
|
||||
import chat.stoat.api.StoatAPI
|
||||
import chat.stoat.api.StoatInstance
|
||||
import chat.stoat.api.routes.account.EmailPasswordAssessment
|
||||
import chat.stoat.api.routes.account.negotiateAuthentication
|
||||
import chat.stoat.api.routes.onboard.needsOnboarding
|
||||
|
|
@ -71,6 +72,10 @@ class LoginViewModel(
|
|||
val password: String
|
||||
get() = _password
|
||||
|
||||
private var _instanceUrl by mutableStateOf(StoatInstance.apiBase)
|
||||
val instanceUrl: String
|
||||
get() = _instanceUrl
|
||||
|
||||
private var _error by mutableStateOf<String?>(null)
|
||||
val error: String?
|
||||
get() = _error
|
||||
|
|
@ -83,10 +88,18 @@ class LoginViewModel(
|
|||
val mfaResponse: EmailPasswordAssessment?
|
||||
get() = _mfaResponse
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_instanceUrl = kvStorage.get(StoatInstance.KV_INSTANCE_API_BASE) ?: StoatInstance.apiBase
|
||||
}
|
||||
}
|
||||
|
||||
fun doLogin() {
|
||||
_error = null
|
||||
|
||||
viewModelScope.launch {
|
||||
StoatInstance.configure(_instanceUrl, kvStorage)
|
||||
|
||||
val response = try {
|
||||
negotiateAuthentication(_email, _password)
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -145,6 +158,10 @@ class LoginViewModel(
|
|||
fun setPassword(password: String) {
|
||||
_password = password
|
||||
}
|
||||
|
||||
fun setInstanceUrl(instanceUrl: String) {
|
||||
_instanceUrl = instanceUrl
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -231,6 +248,18 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = koinVi
|
|||
contentType = ContentType.EmailAddress
|
||||
}
|
||||
)
|
||||
FormTextField(
|
||||
value = viewModel.instanceUrl,
|
||||
label = stringResource(R.string.instance_api_url),
|
||||
type = KeyboardType.Uri,
|
||||
action = ImeAction.Next,
|
||||
onChange = viewModel::setInstanceUrl,
|
||||
supportingText = {
|
||||
Text(text = stringResource(R.string.instance_api_url_hint))
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(bottom = 25.dp)
|
||||
)
|
||||
SecureTextField(
|
||||
passwordTextFieldState,
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
|
||||
<string name="email">Email</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="instance_api_url">Instance API URL</string>
|
||||
<string name="instance_api_url_hint">Use the default Stoat API, or enter a self-hosted Revolt API URL.</string>
|
||||
|
||||
<string name="hide_password">Hide password</string>
|
||||
<string name="show_password">Show password</string>
|
||||
|
|
|
|||
Loading…
Reference in New Issue