fix(sub2api): 面板管理按钮贴右边缘;补齐审查发现的登录安全与死代码问题

面板管理按钮(用户反馈「没有放到最右边」):
- 根因:底部 Row 里同时给左侧文案 weight(1f, fill=false) 和中间 Spacer weight(1f),
  两个 weight 平分剩余空间,按钮只被推到一半。改为只让文案 weight(1f) 吃掉全部剩余宽度。
- 同时清零 TextButton 的 contentPadding,并去掉无效的 defaultMinSize(0)
  (Material3 的最小尺寸挂在按钮内层节点,外层 modifier 压不下去,实测为 no-op)。
- 去掉箭头 Icon 的 offset 微调:offset 只改放置不改测量,右移的像素会落在按钮命中区之外造成误触。

独立复审发现并修复:
- 【安全】线上真正使用的面板登录弹窗 PanelLoginDialog 轮询 localStorage.auth_token 时
  完全没有来源校验——登录容器会跳转到第三方页(OAuth/CDN),它们可伪造 token 覆盖用户凭据。
  现要求 token 必须来自配置的面板域名(比较 hostname),并回传 host 一并校验。
- 【阻塞】saveFromCookieManager 仍在主线程 runBlocking 做 Keystore 加解密 + DataStore 写盘,
  改 suspend 并在 viewModelScope 中调用。
- 【死代码】移除已无调用点的 balanceTrailing 插槽(余额行入口已改到底部)。

说明:WebViewLoginScreen 那套 JS 桥(LoginProbeBridge/nonce/trustedHosts)经查其路由
Routes.WEBVIEW_LOGIN 没有任何 UI 入口(onStartWebViewLogin 只声明未调用),实际不可达,
故本次把安全校验补到了真正在跑的 PanelLoginDialog 上,避免两套并行实现语义分叉。

验证:compileDebugKotlin 与 lintDebug 通过;真机实测按钮与「刚刚更新」同行(y1492-1555)
且可点击区域右边缘到达卡片内容区右缘,点按正常打开弹窗。
This commit is contained in:
Liuxinyu176 2026-09-12 11:31:15 +08:00
parent 8592f15663
commit 62ddf50638
5 changed files with 61 additions and 40 deletions

View File

@ -25,7 +25,7 @@ class WebViewSessionSaver @Inject constructor(
/**
* 提取并保存 Cookie 列表。返回保存后的凭据。
*/
fun saveFromCookieManager(
suspend fun saveFromCookieManager(
service: ServiceType,
url: String,
token: String? = null,
@ -42,10 +42,8 @@ class WebViewSessionSaver @Inject constructor(
expiresAt = expiresAt,
lastVerifiedAt = System.currentTimeMillis()
)
// 用 runBlocking 写凭据 —— 调用方在 Composable 中,可以接受
kotlinx.coroutines.runBlocking {
credentialRepository.save(session)
}
// 挂起写凭据:调用方在 viewModelScope 中,避免在主线程做 Keystore 加解密 + DataStore 写盘
credentialRepository.save(session)
return session
}

View File

@ -851,17 +851,20 @@ private fun DashboardCard(
inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
// 占据全部剩余宽度,把右侧按钮顶到卡片最右边。
// 注意不能再用一个 Spacer(weight) 平分剩余空间——那样按钮只会到中间。
modifier = Modifier.weight(1f)
)
// 弹性间距:窄屏/英文长文案时优先压缩左侧文案,按钮保持右对齐不被折行
Spacer(modifier = Modifier.weight(1f))
if (card.service == ServiceType.SUB2API &&
card.credentialState != CredentialStatus.State.NOT_CONFIGURED &&
onOpenPanelManagement != null
) {
// 清零内边距让文字贴到卡片右内缘。
// 注意:Material3 的最小尺寸挂在 TextButton 内层节点上,外层 modifier 传
// defaultMinSize(0) 压不下去(实测为 no-op),这里不再写无用约束。
TextButton(
onClick = onOpenPanelManagement,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
contentPadding = PaddingValues(0.dp)
) {
Text(
stringResource(R.string.sub2_panel_manage_title),
@ -875,7 +878,9 @@ private fun DashboardCard(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null,
tint = StrawberryPink,
modifier = Modifier.size(16.dp)
// 图标盒即箭头可视范围;不用 offset 微调(offset 只改放置不改测量,
// 会让右移的那几 dp 落在按钮命中区之外造成误触)。
modifier = Modifier.size(14.dp)
)
}
}

View File

@ -51,7 +51,7 @@ import java.util.Locale
// ── Service-specific balance Composables ──
@Composable
internal fun BalanceMainArea(card: DashboardCardUi, balanceTrailing: (@Composable () -> Unit)? = null) {
internal fun BalanceMainArea(card: DashboardCardUi) {
val balance = card.displayBalance
when {
card.credentialState == CredentialStatus.State.NOT_CONFIGURED -> {
@ -99,7 +99,7 @@ internal fun BalanceMainArea(card: DashboardCardUi, balanceTrailing: (@Composabl
OllamaUsageWindows(balance)
}
card.service == ServiceType.SUB2API -> {
Sub2ApiBalanceWithUsage(balance, balanceTrailing = balanceTrailing)
Sub2ApiBalanceWithUsage(balance)
}
else -> {
Row(verticalAlignment = Alignment.Bottom) {
@ -647,16 +647,9 @@ internal fun usageUpdatedAtText(updatedAt: Long): String {
* 磁贴等宽 + 两行固定结构,保证 360dp 窄屏不换行、不同周期数值长度不一会错位。
*/
@Composable
internal fun Sub2ApiBalanceWithUsage(
balance: ServiceBalance,
balanceTrailing: (@Composable () -> Unit)? = null
) {
internal fun Sub2ApiBalanceWithUsage(balance: ServiceBalance) {
Column(modifier = Modifier.fillMaxWidth()) {
// 余额与右侧入口(面板管理)同处一行、垂直居中,使按钮与余额数字高度齐平
Row(verticalAlignment = Alignment.CenterVertically) {
Sub2ApiBalanceHero(balance, modifier = Modifier.weight(1f))
balanceTrailing?.invoke()
}
Sub2ApiBalanceHero(balance)
Spacer(modifier = Modifier.height(10.dp))
Sub2ApiUsageTiles(balance)
}

View File

@ -1019,16 +1019,39 @@ private fun PanelLoginDialog(
}
}
// 只接受来自面板本域名的 auth_token:
// 登录容器会跳转到第三方页面(OAuth 回调 / CDN),任何被加载的页面都能改写自己的
// localStorage,不做来源校验就等于允许它们伪造 token 覆盖用户凭据。
val expectedHost = remember(baseUrl) {
val withScheme = if (baseUrl.contains("://")) baseUrl else "https://" + baseUrl
okhttp3.HttpUrl.Companion.run { withScheme.toHttpUrlOrNull()?.host }
}
// 轮询 localStorage.auth_token(登录成功后自动捕获并回调关闭)
LaunchedEffect(detected) {
LaunchedEffect(detected, expectedHost) {
while (!detected) {
kotlinx.coroutines.delay(1500)
val web = webViewRef ?: continue
// 回传 "hosttoken",避免 JSON 在 Kotlin 字符串里的多层转义
web.evaluateJavascript(
"(function(){try{var t=window.localStorage.getItem('auth_token');return t?t:''}catch(e){return ''}})()"
"(function(){try{" +
"var t=window.localStorage.getItem('auth_token')||'';" +
"var h=window.location.hostname||'';" +
"return h+'\u0001'+t;" +
"}catch(e){return ''}})()"
) { value ->
val token = value?.trim('"') ?: ""
if (token.length > 20 && !detected) {
if (detected) return@evaluateJavascript
// evaluateJavascript 回传的是 JS 字符串字面量,带引号与转义
val unquoted = value?.let { v ->
runCatching { org.json.JSONTokener(v).nextValue() as? String }.getOrNull()
} ?: return@evaluateJavascript
val sep = unquoted.indexOf('')
if (sep < 0) return@evaluateJavascript
val host = unquoted.substring(0, sep)
val token = unquoted.substring(sep + 1)
if (token.length > 20 && expectedHost != null &&
host.equals(expectedHost, ignoreCase = true)
) {
detected = true
onToken(token)
}

View File

@ -147,21 +147,23 @@ class WebViewLoginViewModel @Inject constructor(
private fun saveSession(url: String) {
val service = _uiState.value.service ?: return
val saved = sessionSaver.saveFromCookieManager(service = service, url = url)
if (saved != null) {
_uiState.update {
it.copy(
loginSucceeded = true,
savedSession = saved,
pendingManualConfirm = false
)
}
} else {
_uiState.update {
it.copy(
error = UiText.Resource(R.string.error_cookie_not_found),
pendingManualConfirm = true
)
viewModelScope.launch {
val saved = sessionSaver.saveFromCookieManager(service = service, url = url)
if (saved != null) {
_uiState.update {
it.copy(
loginSucceeded = true,
savedSession = saved,
pendingManualConfirm = false
)
}
} else {
_uiState.update {
it.copy(
error = UiText.Resource(R.string.error_cookie_not_found),
pendingManualConfirm = true
)
}
}
}
}