* [feature] issue #634: HTTPS + reverse-proxy server endpoint support - Add ServerEndpoint model (scheme-aware base URL, resolve/webSocketUrl/relativeRequestPath, cleartext policy) - AuthManager: baseUrl is canonical; setHost/setPort derive from endpoint; endpointForBuild() for scheme-aware URLs - ApiClient/CookieManager/HermesWsClient: route through ServerEndpoint (no hardcoded http://) - Data migration: ServerUrlMigration converts legacy host/port to baseUrl - UI: Connect/AuthLogin/Settings now use a single Server URL field with transport warning - Build: ALLOW_CLEARTEXT buildConfigField + manifest placeholder for dev - Tests: ServerEndpointTest + ServerUrlMigrationTest; adapt existing suite to baseUrl API Co-Authored-By: M57 <Hy4ri@users.noreply.github.com> * fix(authlogin): basic-auth derives token automatically, no manual entry connectBasicAuth() built a fresh OkHttpClient for the password-login call and a different one for the ws-ticket call, so the Set-Cookie from /auth/password-login (the session token lives in the cookie, not the JSON body) never reached /api/auth/ws-ticket. That 401'd the ticket request, returned null, and the UI fell back to asking the user to paste a token. Both calls now use the shared OkHttpProvider.probe client (same CookieManager jar), so the session cookie flows through and the WS ticket mints automatically — matching the dashboard contract (routes.py: password-login sets Set-Cookie, ws-ticket requires it). Add AuthLoginBasicAuthFlowTest proving the cookie propagates on the shared client (and that ws-ticket 401s without it). Fixes the basic-auth token prompt reported against issue #634. * chore: remove TASK-634-TESTS.md (stray task-tracking doc, not part of feature) * ci: broaden .gitguardian.yml test-file ignores (unit + instrumented + variant dirs) * feat(authlogin): smart auth-mode detection from /api/status + drop pairing-string login - Replace 302-sniff probe with authoritative GET /api/status read (auth_required + auth_providers) to derive DashboardAuthMode. - Add OAUTH mode (stubbed, 'coming soon' UI, TODO tracks issue #639). - Render login UI by detected mode: token (loopback), username+password (basic/gated), or OAuth notice. - Remove legacy pairing-string box + onPairingString/applyLegacyPairing. - Extract internal deriveAuthMode() + add 5 unit tests. Closes nothing; OAuth tracked in #639. * fix(chat): don't block WS connect on empty token in gated (ticket) mode connectWebSocket() gated on getToken() != null, correct for loopback (token) mode but wrong for gated (ticket) mode: the ticket is minted fresh by HermesWsClient.refreshWsTicketIfNeeded() from the persisted session cookie, so getToken() is expected empty at connect time. The early return left the WS unopened -> DISCONNECTED -> chat showed 'reconnect' immediately after basic-auth login. Only require a non-blank token in non-gated mode. Fixes #640. * test: remove onPairingString test cases for deleted pairing-login path The pairing-string login (onPairingString/applyLegacyPairing) was removed from ConnectViewModel, so the 5 tests that exercised it (3 in ConnectViewModelTest, 2 in E2eIntegrationTest) no longer compile. Remove them. Also drop the now-orphaned connect_pairing_string from values-ko. Fixes the Unit & Integration Tests CI failure on PR #638. --------- Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
d839c5e9ad
commit
26b104b2bb
40 files changed
+1299
-688
No files matched your search
@@ -172,23 +172,6 @@ fun ConnectScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.connect_pairing_string),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
var pairingString by remember { mutableStateOf("") }
|
||||
OutlinedTextField(
|
||||
value = pairingString,
|
||||
onValueChange = {
|
||||
pairingString = it
|
||||
if (it.isNotBlank()) viewModel.onPairingString(it)
|
||||
},
|
||||
placeholder = { Text(stringResource(R.string.connect_placeholder_pairing)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// Error message
|
||||
AnimatedVisibility(
|
||||
visible = state.errorMessage != null,
|
||||
@@ -275,6 +258,31 @@ fun ConnectScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
HorizontalDivider()
|
||||
OutlinedTextField(
|
||||
value = state.baseUrl,
|
||||
onValueChange = viewModel::onBaseUrlChange,
|
||||
label = { Text(stringResource(R.string.connect_server_url_label)) },
|
||||
placeholder = { Text(stringResource(R.string.connect_placeholder_server_url)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
// Cleartext transport warning
|
||||
AnimatedVisibility(
|
||||
visible = state.transportWarning != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
state.transportWarning?.let { warning ->
|
||||
Text(
|
||||
text = warning,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = state.token,
|
||||
onValueChange = viewModel::onTokenChange,
|
||||
@@ -307,23 +315,6 @@ fun ConnectScreen(
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.host,
|
||||
onValueChange = viewModel::onHostChange,
|
||||
label = { Text(stringResource(R.string.settings_field_host)) },
|
||||
placeholder = { Text(stringResource(R.string.connect_placeholder_host)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.port,
|
||||
onValueChange = viewModel::onPortChange,
|
||||
label = { Text(stringResource(R.string.settings_field_port)) },
|
||||
placeholder = { Text(stringResource(R.string.connect_placeholder_port)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -19,13 +20,11 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
data class ConnectUiState(
|
||||
val token: String = "",
|
||||
val host: String = "127.0.0.1",
|
||||
val port: String = "9119",
|
||||
val baseUrl: String = ServerEndpoint.DEFAULT_BASE_URL,
|
||||
val transportWarning: String? = null,
|
||||
val isConnecting: Boolean = false,
|
||||
val connectionSuccess: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
@@ -47,16 +46,14 @@ class ConnectViewModel(
|
||||
|
||||
fun loadSavedValues() {
|
||||
val savedToken = AuthManager.getToken() ?: ""
|
||||
val savedHost = AuthManager.getHost()
|
||||
val savedPort = AuthManager.getPort()
|
||||
val savedBaseUrl = AuthManager.getBaseUrl()
|
||||
val profiles = AuthManager.getConnectionProfiles()
|
||||
val selectedId = AuthManager.getSelectedProfileId()
|
||||
val selectedProfile = profiles.firstOrNull { it.id == selectedId }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
token = savedToken,
|
||||
host = selectedProfile?.host ?: savedHost,
|
||||
port = (selectedProfile?.port ?: savedPort).toString(),
|
||||
baseUrl = selectedProfile?.resolvedBaseUrl ?: savedBaseUrl,
|
||||
profiles = profiles,
|
||||
selectedProfile = selectedProfile,
|
||||
profileName = selectedProfile?.name ?: "",
|
||||
@@ -79,8 +76,7 @@ class ConnectViewModel(
|
||||
it.copy(
|
||||
selectedProfile = profile,
|
||||
profileName = profile.name,
|
||||
host = profile.host,
|
||||
port = profile.port.toString(),
|
||||
baseUrl = profile.resolvedBaseUrl,
|
||||
token = token,
|
||||
)
|
||||
}
|
||||
@@ -90,12 +86,27 @@ class ConnectViewModel(
|
||||
_uiState.update { it.copy(token = value.trim(), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onBaseUrlChange(value: String) {
|
||||
val trimmed = value.trim()
|
||||
val warning =
|
||||
runCatching {
|
||||
ServerEndpoint.parse(trimmed, CleartextPolicy.ALLOW_WITH_WARNING).securityWarning
|
||||
}.getOrNull()
|
||||
_uiState.update { it.copy(baseUrl = trimmed, transportWarning = warning, errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onHostChange(value: String) {
|
||||
_uiState.update { it.copy(host = value.trim(), errorMessage = null) }
|
||||
val endpoint = AuthManager.endpoint()
|
||||
val next = endpoint.baseUrl.newBuilder().host(value.trim()).build().toString()
|
||||
onBaseUrlChange(next)
|
||||
}
|
||||
|
||||
fun onPortChange(value: String) {
|
||||
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, errorMessage = null) }
|
||||
val portText = value.filter { c -> c.isDigit() }
|
||||
val port = portText.toIntOrNull() ?: return
|
||||
val endpoint = AuthManager.endpoint()
|
||||
val next = endpoint.baseUrl.newBuilder().port(port).build().toString()
|
||||
onBaseUrlChange(next)
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
@@ -104,13 +115,10 @@ class ConnectViewModel(
|
||||
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_token_required)) }
|
||||
return
|
||||
}
|
||||
if (state.host.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_host_required)) }
|
||||
return
|
||||
}
|
||||
val port = state.port.toIntOrNull()
|
||||
if (port == null || port !in 1..65535) {
|
||||
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_port_invalid)) }
|
||||
val endpoint =
|
||||
runCatching { ServerEndpoint.parseForBuild(state.baseUrl) }.getOrNull()
|
||||
if (endpoint == null) {
|
||||
_uiState.update { it.copy(errorMessage = app.getString(R.string.connect_error_url_invalid)) }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,17 +127,14 @@ class ConnectViewModel(
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
val tempApi = ApiClient.createTempService(state.host, port, state.token)
|
||||
val tempApi = ApiClient.createTempService(endpoint.baseUrl.toString(), state.token)
|
||||
safeApiCall { tempApi.getStatus() }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
// Persist credentials to the selected (Default) profile upon successful verification.
|
||||
// setToken/setHost/setPort delegate to the currently-selected profile, which is
|
||||
// always non-null after issue #478 (never the legacy standalone path).
|
||||
AuthManager.setToken(state.token)
|
||||
AuthManager.setHost(state.host)
|
||||
AuthManager.setPort(port)
|
||||
AuthManager.setBaseUrl(state.baseUrl)
|
||||
ApiClient.rebuild()
|
||||
|
||||
if (state.saveProfile) {
|
||||
@@ -143,12 +148,11 @@ class ConnectViewModel(
|
||||
}
|
||||
val targetProfile =
|
||||
if (existingIndex >= 0) {
|
||||
currentProfiles[existingIndex].copy(host = state.host, port = port)
|
||||
currentProfiles[existingIndex].copy(baseUrl = state.baseUrl)
|
||||
} else {
|
||||
ConnectionProfile(
|
||||
name = state.profileName,
|
||||
host = state.host,
|
||||
port = port,
|
||||
baseUrl = state.baseUrl,
|
||||
)
|
||||
}
|
||||
val updatedProfiles =
|
||||
@@ -274,99 +278,6 @@ class ConnectViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onPairingString(value: String) {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank()) return
|
||||
|
||||
try {
|
||||
if (trimmed.startsWith("hermes://connect?", ignoreCase = true)) {
|
||||
val uri = android.net.Uri.parse(trimmed)
|
||||
val host = uri.getQueryParameter("host")
|
||||
val port = uri.getQueryParameter("port")
|
||||
val token = uri.getQueryParameter("token")
|
||||
if (host != null && port != null && token != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
host = host,
|
||||
port = port,
|
||||
token = token,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
connect()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Try decoding as Base64 JSON
|
||||
try {
|
||||
val decodedBytes = android.util.Base64.decode(trimmed, android.util.Base64.DEFAULT)
|
||||
val decodedString = String(decodedBytes, Charsets.UTF_8)
|
||||
if (decodedString.startsWith("{") && decodedString.endsWith("}")) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(decodedString).jsonObject
|
||||
val host = json["host"]?.jsonPrimitive?.content
|
||||
val port = json["port"]?.jsonPrimitive?.content
|
||||
val token = json["token"]?.jsonPrimitive?.content
|
||||
if (host != null && port != null && token != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
host = host,
|
||||
port = port,
|
||||
token = token,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
connect()
|
||||
return
|
||||
}
|
||||
// Decoded valid JSON but missing required fields
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = app.getString(R.string.connect_error_missing_fields),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Decoded to valid string but not JSON shape — not a pairing string
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = app.getString(R.string.connect_error_malformed),
|
||||
)
|
||||
}
|
||||
return
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Not valid Base64 — check if input looks like a raw token
|
||||
if (trimmed.length >= 32 && trimmed.matches(Regex("[A-Za-z0-9_\\-]+"))) {
|
||||
_uiState.update { it.copy(token = trimmed, errorMessage = null) }
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = app.getString(R.string.connect_error_malformed),
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage =
|
||||
String.format(
|
||||
app.getString(R.string.connect_error_parse_failed),
|
||||
e.message ?: "",
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = String.format(app.getString(R.string.connect_error_parse_failed), e.message ?: ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ConnectViewModelFactory(
|
||||
|
||||
Reference in new issue
Block a user