Add self-hosted instance login support

Signed-off-by: Orange Studio <orange@blllll.cn>
This commit is contained in:
Orange Studio 2026-05-15 21:57:16 +08:00
parent 4ede85eafb
commit a5760be03b
5 changed files with 163 additions and 13 deletions

View File

@ -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()
}
}
}

View File

@ -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")

View File

@ -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
)

View File

@ -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)) },

View File

@ -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>