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:
parent
8592f15663
commit
62ddf50638
@ -25,7 +25,7 @@ class WebViewSessionSaver @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* 提取并保存 Cookie 列表。返回保存后的凭据。
|
* 提取并保存 Cookie 列表。返回保存后的凭据。
|
||||||
*/
|
*/
|
||||||
fun saveFromCookieManager(
|
suspend fun saveFromCookieManager(
|
||||||
service: ServiceType,
|
service: ServiceType,
|
||||||
url: String,
|
url: String,
|
||||||
token: String? = null,
|
token: String? = null,
|
||||||
@ -42,10 +42,8 @@ class WebViewSessionSaver @Inject constructor(
|
|||||||
expiresAt = expiresAt,
|
expiresAt = expiresAt,
|
||||||
lastVerifiedAt = System.currentTimeMillis()
|
lastVerifiedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
// 用 runBlocking 写凭据 —— 调用方在 Composable 中,可以接受
|
// 挂起写凭据:调用方在 viewModelScope 中,避免在主线程做 Keystore 加解密 + DataStore 写盘
|
||||||
kotlinx.coroutines.runBlocking {
|
|
||||||
credentialRepository.save(session)
|
credentialRepository.save(session)
|
||||||
}
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -851,17 +851,20 @@ private fun DashboardCard(
|
|||||||
inkMuted(),
|
inkMuted(),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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 &&
|
if (card.service == ServiceType.SUB2API &&
|
||||||
card.credentialState != CredentialStatus.State.NOT_CONFIGURED &&
|
card.credentialState != CredentialStatus.State.NOT_CONFIGURED &&
|
||||||
onOpenPanelManagement != null
|
onOpenPanelManagement != null
|
||||||
) {
|
) {
|
||||||
|
// 清零内边距让文字贴到卡片右内缘。
|
||||||
|
// 注意:Material3 的最小尺寸挂在 TextButton 内层节点上,外层 modifier 传
|
||||||
|
// defaultMinSize(0) 压不下去(实测为 no-op),这里不再写无用约束。
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onOpenPanelManagement,
|
onClick = onOpenPanelManagement,
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
|
contentPadding = PaddingValues(0.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.sub2_panel_manage_title),
|
stringResource(R.string.sub2_panel_manage_title),
|
||||||
@ -875,7 +878,9 @@ private fun DashboardCard(
|
|||||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = StrawberryPink,
|
tint = StrawberryPink,
|
||||||
modifier = Modifier.size(16.dp)
|
// 图标盒即箭头可视范围;不用 offset 微调(offset 只改放置不改测量,
|
||||||
|
// 会让右移的那几 dp 落在按钮命中区之外造成误触)。
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,7 @@ import java.util.Locale
|
|||||||
// ── Service-specific balance Composables ──
|
// ── Service-specific balance Composables ──
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BalanceMainArea(card: DashboardCardUi, balanceTrailing: (@Composable () -> Unit)? = null) {
|
internal fun BalanceMainArea(card: DashboardCardUi) {
|
||||||
val balance = card.displayBalance
|
val balance = card.displayBalance
|
||||||
when {
|
when {
|
||||||
card.credentialState == CredentialStatus.State.NOT_CONFIGURED -> {
|
card.credentialState == CredentialStatus.State.NOT_CONFIGURED -> {
|
||||||
@ -99,7 +99,7 @@ internal fun BalanceMainArea(card: DashboardCardUi, balanceTrailing: (@Composabl
|
|||||||
OllamaUsageWindows(balance)
|
OllamaUsageWindows(balance)
|
||||||
}
|
}
|
||||||
card.service == ServiceType.SUB2API -> {
|
card.service == ServiceType.SUB2API -> {
|
||||||
Sub2ApiBalanceWithUsage(balance, balanceTrailing = balanceTrailing)
|
Sub2ApiBalanceWithUsage(balance)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
@ -647,16 +647,9 @@ internal fun usageUpdatedAtText(updatedAt: Long): String {
|
|||||||
* 磁贴等宽 + 两行固定结构,保证 360dp 窄屏不换行、不同周期数值长度不一会错位。
|
* 磁贴等宽 + 两行固定结构,保证 360dp 窄屏不换行、不同周期数值长度不一会错位。
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun Sub2ApiBalanceWithUsage(
|
internal fun Sub2ApiBalanceWithUsage(balance: ServiceBalance) {
|
||||||
balance: ServiceBalance,
|
|
||||||
balanceTrailing: (@Composable () -> Unit)? = null
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
// 余额与右侧入口(面板管理)同处一行、垂直居中,使按钮与余额数字高度齐平
|
Sub2ApiBalanceHero(balance)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Sub2ApiBalanceHero(balance, modifier = Modifier.weight(1f))
|
|
||||||
balanceTrailing?.invoke()
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
Sub2ApiUsageTiles(balance)
|
Sub2ApiUsageTiles(balance)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(登录成功后自动捕获并回调关闭)
|
// 轮询 localStorage.auth_token(登录成功后自动捕获并回调关闭)
|
||||||
LaunchedEffect(detected) {
|
LaunchedEffect(detected, expectedHost) {
|
||||||
while (!detected) {
|
while (!detected) {
|
||||||
kotlinx.coroutines.delay(1500)
|
kotlinx.coroutines.delay(1500)
|
||||||
val web = webViewRef ?: continue
|
val web = webViewRef ?: continue
|
||||||
|
// 回传 "hosttoken",避免 JSON 在 Kotlin 字符串里的多层转义
|
||||||
web.evaluateJavascript(
|
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 ->
|
) { value ->
|
||||||
val token = value?.trim('"') ?: ""
|
if (detected) return@evaluateJavascript
|
||||||
if (token.length > 20 && !detected) {
|
// 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
|
detected = true
|
||||||
onToken(token)
|
onToken(token)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -147,6 +147,7 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun saveSession(url: String) {
|
private fun saveSession(url: String) {
|
||||||
val service = _uiState.value.service ?: return
|
val service = _uiState.value.service ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
val saved = sessionSaver.saveFromCookieManager(service = service, url = url)
|
val saved = sessionSaver.saveFromCookieManager(service = service, url = url)
|
||||||
if (saved != null) {
|
if (saved != null) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@ -165,6 +166,7 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun looksLikeLoggedInPage(url: String): Boolean {
|
private fun looksLikeLoggedInPage(url: String): Boolean {
|
||||||
val u = url.lowercase()
|
val u = url.lowercase()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user