现场事故(新设备 SM-S9180 / Android 16,WebView 正常 → 走 JS 取址): 277 JS [KtvApi] URL OK: 7303235 ls=1 via http://w.w345.my 278 点歌编号=7303235 标题=情歌(HD) 请求源=http://w.w345.my 状态=成功 urlHost=gz.ac16.vip 279 PLAY id=7303235 title=情歌(HD) src=local 即 gz.ac16.vip 的广告视频被当成歌曲下载、缓存并播放。 三处漏洞叠加: 1. ktv_api.js 的 isDemoUrl 不认识广告域名;且"ts 文件名与编号比对"用的是 /\/(\d+)\.ts/,只匹配纯数字名 —— my_ad_video.ts 这类非数字名字直接放行; 2. SongOkDownloadManager.buildDownloadUrl 的**成功分支完全没有广告校验** (AdVideoFilter.isAdVideo 全项目只在"缓存地址"与"原生解析"两处被调用); 3. SongApiClient.fetchFromDb 只做 isLikelyDemoUrl(纯 IP / r2.dev),挡不住广告域名。 且**内容校验挡不住广告**: 该广告通过 SongFileValidator(≥6MB/≥60s/TS 包完整), 被 isDownloaded() 一直复用,广告会反复播。 修复(单一闸门,全链路把关): - AdVideoFilter 新增 isTrustedSongUrl(url, musicno):非 http(s)、命中广告特征 (路径 /ad_files/、my_ad_video、**域名 *.ac16.vip**)、或 URL 里 .ts 文件名不等于 点歌编号 → 一律不可信。真源恒为 /<musicno>.ts(已实测 txog.ktvsky.com 与 download.origjoy.com 两个 CDN 域共 6 首全部相符)。 - 该闸门接入全部出入口: JS isDemoUrl(同步加固 + KTV_BRIDGE_API 8→9)、 SongUrlResolver.isValidSongUrl、SongApiClient.fetchFromDb、 以及最终兜底 SongOkDownloadManager.buildDownloadUrl(成功/缓存两条分支)。 - 判定不可信时**同时删除已落盘的歌曲文件**(purgeCachedSongFile), 否则 isDownloaded() 会一直复用那个广告文件。 - 新增 SongOkDownloadManager.purgeLegacySongCacheOnce(): 升级后一次性清理历史遗留的 可疑歌曲缓存(marker: filesDir/song_cache_purged_v1)。只是让歌曲重新下载,不动用户数据。 - 新增回归测试 AdVideoFilterTest(6 例,用日志里的真实 URL 形态,含两个真源 CDN 域); 该测试当场抓到本修复自身的一个 bug:正则把扩展名也捕获进去导致真源被误杀,已修正。 版本 1.4.9(29) 到 1.4.10(30);签名证书不变。
226 lines
9.6 KiB
Kotlin
226 lines
9.6 KiB
Kotlin
package com.local.ktv
|
||
|
||
import android.util.Log
|
||
import org.json.JSONObject
|
||
import java.security.MessageDigest
|
||
import java.util.Collections
|
||
|
||
/**
|
||
* 歌曲取址的**原生兜底实现**。
|
||
*
|
||
* ## 为什么需要它
|
||
* 取址原本完全依赖隐藏 WebView 里的 `ktv_api.js`。但在一部分老电视盒子/投影上
|
||
* (实测:极米 H1S / GMUI2.0 / Android 5.1.1 / API 22)**WebView 不可用**:
|
||
* `KtvJsBridge` 永远 `isReady()==false`,`SongApiClient` 等 15 秒后只能回退到数据库里的
|
||
* `cloud_url`,取不到就报「无法生成下载地址」。特征是**远程日志里连一条 `JS ...` 都没有**
|
||
* (JS 从没跑起来)。
|
||
*
|
||
* ## 做法
|
||
* 用 Kotlin 复刻 `ktv_api.js` 的同一套协议:
|
||
* `GET <host>/i.php`(取 token)→ `GET <host>/music/do.php`(取播放地址),
|
||
* 并镜像 JS 的多源排序(失败源沉底)与广告/错号资源校验(`isDemoUrl`)。
|
||
*
|
||
* ⚠️ 它**必须走与 JS 相同的 HTTP 通道**([KtvJsBridge.rawCleanGet] 的裸 Socket,
|
||
* 只发 Host + Accept),否则会被源站风控拒绝(带 UA/Connection 头会被判"异常行为")。
|
||
*
|
||
* ⚠️ 这里的 APP_ID/APP_KEY/SDK_KEY/HOSTS 是 `ktv_api.js` CONFIG 的镜像。
|
||
* 源站配置若变更,JS 可热更新而这里需要发版 —— 因此它**只作为兜底**:
|
||
* WebView 可用时仍以 JS 为准(见 [SongApiClient.getSongDownloadUrl])。
|
||
*/
|
||
object SongUrlResolver {
|
||
private const val TAG = "SongUrlResolver"
|
||
|
||
// ── 以下与 assets/mobile/ktv_api.js 的 CONFIG 保持一致 ──
|
||
// 注意 VER/VN 与 KtvJsBridge 注入 JS 时覆盖的值一致(不是 JS 文件里的原始值)。
|
||
private const val APP_ID = "d4eeacc6cec3434fbc8c41608a3056a0"
|
||
private const val APP_KEY = "024210cba40d4385a93e6c2d3249bfb5"
|
||
private const val SDK_KEY = "19042303a8374f67ae3fe1e25c97936f"
|
||
private const val VER = "2.0"
|
||
private const val VN = "4.1.3.03281430"
|
||
private const val TIMEOUT_MS = 8_000
|
||
|
||
private val DEFAULT_HOSTS = listOf(
|
||
"http://w.w345.my",
|
||
"http://desktok.top",
|
||
"http://mm.kk456.top",
|
||
"http://w.w345.my:66",
|
||
"http://z.w345.my",
|
||
"http://b.sktv.top",
|
||
"http://w345.my",
|
||
)
|
||
|
||
/** 运行时源优先级(与 JS 一样:解析失败的源沉到队尾)。 */
|
||
private val hostOrder: MutableList<String> = Collections.synchronizedList(DEFAULT_HOSTS.toMutableList())
|
||
|
||
@Volatile private var fetch: ((String) -> String?)? = null
|
||
|
||
@Volatile private var sn: String = KtvJsBridge.DEVICE_SN
|
||
|
||
/**
|
||
* 最近一次取址命中的源 host。
|
||
* 供 `SongApiClient.lastSongSourceHost()` 使用 —— 走原生兜底时 JS 的
|
||
* `reportSongResult` 不会触发,否则日志里「请求源=」永远是空的。
|
||
*/
|
||
@Volatile
|
||
var lastHost: String = ""
|
||
private set
|
||
|
||
/**
|
||
* 安装取址通道。
|
||
* @param fetch 干净的 HTTP GET(应传 [KtvJsBridge.rawCleanGet]);
|
||
* 入参 url,返回 `{"status":..,"body":..,"error":..}` 的 JSON 字符串。
|
||
*/
|
||
fun install(fetch: (String) -> String?) {
|
||
this.fetch = fetch
|
||
Log.i(TAG, "native song url resolver installed (${DEFAULT_HOSTS.size} hosts)")
|
||
}
|
||
|
||
private fun demote(host: String) {
|
||
synchronized(hostOrder) {
|
||
val index = hostOrder.indexOf(host)
|
||
if (index >= 0 && index < hostOrder.size - 1) {
|
||
hostOrder.removeAt(index)
|
||
hostOrder.add(host)
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun promote(host: String) {
|
||
synchronized(hostOrder) {
|
||
val index = hostOrder.indexOf(host)
|
||
if (index > 0) {
|
||
hostOrder.removeAt(index)
|
||
hostOrder.add(0, host)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */
|
||
fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? {
|
||
if (musicno.isBlank()) return null
|
||
val channel = fetch ?: run {
|
||
MiliLog.warn("NATIVE-SONG 未安装取址通道,跳过原生兜底")
|
||
return null
|
||
}
|
||
val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn)
|
||
val startedAt = System.currentTimeMillis()
|
||
val hosts = synchronized(hostOrder) { hostOrder.toList() }
|
||
var lastDetail = "no-source"
|
||
|
||
for ((index, host) in hosts.withIndex()) {
|
||
val token = runCatching { fetchToken(channel, host, mac) }.getOrElse {
|
||
MiliLog.warn("NATIVE-SONG i.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
|
||
null
|
||
}
|
||
if (token.isNullOrEmpty()) {
|
||
lastDetail = "token-fail"
|
||
demote(host)
|
||
continue
|
||
}
|
||
val url = runCatching { fetchSongUrl(channel, host, mac, musicno, token, resolution, h265) }
|
||
.getOrElse {
|
||
MiliLog.warn("NATIVE-SONG do.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
|
||
null
|
||
}
|
||
if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) {
|
||
promote(host)
|
||
lastHost = host
|
||
val elapsed = System.currentTimeMillis() - startedAt
|
||
val urlHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "?"
|
||
MiliLog.info(
|
||
"NATIVE-SONG 取址成功 编号=$musicno 源=$host urlHost=$urlHost " +
|
||
"第${index + 1}个源 用时=${elapsed}ms",
|
||
)
|
||
return url
|
||
}
|
||
lastDetail = when {
|
||
url.isNullOrEmpty() -> "no-url"
|
||
else -> "demo-or-mismatch"
|
||
}
|
||
demote(host)
|
||
}
|
||
|
||
val elapsed = System.currentTimeMillis() - startedAt
|
||
MiliLog.error("NATIVE-SONG 全部源失败 编号=$musicno 详情=$lastDetail 用时=${elapsed}ms")
|
||
return null
|
||
}
|
||
|
||
// ─── 协议实现 ───────────────────────────────────────
|
||
|
||
private fun fetchToken(channel: (String) -> String?, host: String, mac: String): String? {
|
||
val ts = System.currentTimeMillis() / 1000
|
||
val params = "appid=$APP_ID&mac=${mac}_$sn&sn=$sn&time=$ts&ver=$VER&vn=$VN"
|
||
val sign = md5(params + APP_KEY)
|
||
val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null
|
||
if (json.optInt("code") != 200) {
|
||
MiliLog.warn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}")
|
||
return null
|
||
}
|
||
return json.optString("token").takeIf { it.isNotEmpty() }
|
||
}
|
||
|
||
private fun fetchSongUrl(
|
||
channel: (String) -> String?,
|
||
host: String,
|
||
mac: String,
|
||
musicno: String,
|
||
token: String,
|
||
resolution: String,
|
||
h265: Boolean,
|
||
): String? {
|
||
val ts = System.currentTimeMillis() / 1000
|
||
// 参数顺序、原始 token 中的 "=" 以及签名拼接必须与原 APK 一致(同 ktv_api.js)。
|
||
val params = "appid=$APP_ID&device=${mac}_$sn&ish265=${if (h265) "1" else "0"}&ls=1" +
|
||
"&musicno=$musicno&resolution=$resolution&sn=$sn&time=$ts&token=$token"
|
||
val sign = md5(params + SDK_KEY)
|
||
val json = getJson(channel, "$host/music/do.php?$params&sign=$sign") ?: return null
|
||
val code = json.optInt("code")
|
||
if (code != 200) {
|
||
MiliLog.warn("NATIVE-SONG do.php 源=$host code=$code msg=${json.optString("msg")}")
|
||
return null
|
||
}
|
||
return json.optString("data").takeIf { it.isNotEmpty() }
|
||
}
|
||
|
||
/** 解析 request() 的信封 `{status, body, error}`,再把 body 解析为 JSON。 */
|
||
private fun getJson(channel: (String) -> String?, url: String): JSONObject? {
|
||
val raw = channel(url) ?: return null
|
||
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return null
|
||
val status = envelope.optInt("status")
|
||
if (status !in 200..299) {
|
||
MiliLog.warn(
|
||
"NATIVE-SONG HTTP $status ${runCatching { java.net.URI(url).host }.getOrNull()} " +
|
||
"err=${envelope.optString("error").take(80)}",
|
||
)
|
||
return null
|
||
}
|
||
val body = envelope.optString("body")
|
||
if (body.isBlank()) return null
|
||
return runCatching { JSONObject(body) }.getOrElse {
|
||
MiliLog.warn("NATIVE-SONG 响应非 JSON: ${body.take(120)}")
|
||
null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 原生路径的可信校验:先过 `AdVideoFilter.isTrustedSongUrl`(广告域名/路径 + `.ts` 文件名
|
||
* 必须等于点歌编号),再补上 JS `isDemoUrl` 里那几条(wb66/demo、纯 IP、R2 测试桶)。
|
||
*/
|
||
private fun isValidSongUrl(url: String, musicno: String): Boolean {
|
||
if (!AdVideoFilter.isTrustedSongUrl(url, musicno)) return false
|
||
val lower = url.lowercase()
|
||
if (lower.contains("wb66") || lower.contains("demo")) return false
|
||
if (IP_URL.containsMatchIn(url)) return false
|
||
if (R2_DEMO.containsMatchIn(url)) return false
|
||
return true
|
||
}
|
||
|
||
private fun md5(text: String): String =
|
||
MessageDigest.getInstance("MD5").digest(text.toByteArray(Charsets.UTF_8))
|
||
.joinToString("") { "%02x".format(it) }
|
||
|
||
private val IP_URL = Regex("^https?://\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(?::\\d+)?/", RegexOption.IGNORE_CASE)
|
||
private val R2_DEMO = Regex("pub-[a-z0-9]+\\.r2\\.dev/ss/", RegexOption.IGNORE_CASE)
|
||
private val TS_NAME = Regex("/(\\d+)\\.ts(?:\\?|$)")
|
||
}
|