From 669766ba608cd702590c3ea061447d07b2cebe4f Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Sat, 26 Sep 2026 21:17:04 +0800 Subject: [PATCH] =?UTF-8?q?V1.4.10:=20=E4=BF=AE=E5=A4=8D=E5=B9=BF=E5=91=8A?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E5=A4=B1=E6=95=88=EF=BC=88gz.ac16.vip=20?= =?UTF-8?q?=E5=B9=BF=E5=91=8A=E8=A2=AB=E5=BD=93=E6=88=90=E6=AD=8C=E6=9B=B2?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E6=92=AD=E6=94=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场事故(新设备 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 文件名不等于 点歌编号 → 一律不可信。真源恒为 /.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);签名证书不变。 --- AGENTS.md | 20 ++++- app/build.gradle | 4 +- app/src/main/assets/mobile/ktv_api.js | 16 ++-- .../main/java/com/local/ktv/AdVideoFilter.kt | 46 ++++++++++- .../main/java/com/local/ktv/KtvJsBridge.kt | 4 +- .../main/java/com/local/ktv/MainActivity.kt | 4 + .../main/java/com/local/ktv/SongApiClient.kt | 7 +- .../com/local/ktv/SongOkDownloadManager.kt | 81 +++++++++++++++++-- .../java/com/local/ktv/SongUrlResolver.kt | 9 ++- .../java/com/local/ktv/AdVideoFilterTest.java | 66 +++++++++++++++ 10 files changed, 234 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/com/local/ktv/AdVideoFilterTest.java diff --git a/AGENTS.md b/AGENTS.md index e3af43e..d06026f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,7 +160,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.4.9"` / `versionCode 29`(18~21 被本机临时诊断包占用过) +- `app/build.gradle`:`versionName "1.4.10"` / `versionCode 30`(18~21 被本机临时诊断包占用过) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 @@ -273,6 +273,24 @@ $BT/apksigner verify 目标名.apk `NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch`; 桥侧另有 `JS-BRIDGE ...`(ready / KtvApi 未导出 / WebView 初始化失败 / 15s 未就绪)。 - **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本 +- **⚠️ 广告/错号地址只有一道闸门:`AdVideoFilter.isTrustedSongUrl(url, musicno)`** + (2026-09-26 事故:新设备点歌 7303235 拿到 `gz.ac16.vip` 的**广告视频并被当成歌曲下载播放**)。 + 约定: + 1. **任何来源**的播放地址都必须过这道闸门 —— JS 取址(`ktv_api.js` 的 `isDemoUrl`)、 + 原生兜底(`SongUrlResolver.isValidSongUrl`)、DB 回退(`SongApiClient.fetchFromDb`), + 以及**最终兜底** `SongOkDownloadManager.buildDownloadUrl` 的成功分支。 + 只加在某一层必然漏:这次就是 JS 层的规则不认广告域名、且 Kotlin 的成功分支**完全没做校验**; + 2. 判定三条:非 http(s);命中广告特征(路径 `/ad_files/`、`my_ad_video`、**域名 `*.ac16.vip`**); + **URL 里的 `.ts` 文件名必须等于点歌编号**(真源恒为 `/.ts`,已实测 + `txog.ktvsky.com` / `download.origjoy.com` 两个 CDN 域)。旧规则只比对"纯数字文件名", + `my_ad_video.ts` 这类非数字名字直接漏过; + 3. **内容校验挡不住广告**:广告视频能通过 `SongFileValidator`(≥6MB / ≥60s / TS 包完整), + 所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须**同时删除已落盘的歌曲文件** + (`purgeCachedSongFile`),否则 `isDownloaded()` 会一直复用那个广告文件,广告反复播; + 4. 历史遗留由 `SongOkDownloadManager.purgeLegacySongCacheOnce()` 在升级后清一次 + (marker:`filesDir/song_cache_purged_v1`)。清掉只是让歌曲重新下载,不影响用户数据; + 5. 回归测试 `app/src/test/java/com/local/ktv/AdVideoFilterTest.java` 用日志里的**真实 URL 形态** + 锁住行为(含两个真源 CDN 域),改这块逻辑必须先让它通过。 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件 - **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载) - **麦克风**:`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建 diff --git a/app/build.gradle b/app/build.gradle index 44db3de..07b7968 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 29 - versionName "1.4.9" + versionCode 30 + versionName "1.4.10" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/assets/mobile/ktv_api.js b/app/src/main/assets/mobile/ktv_api.js index 6ab447f..41ae1b8 100644 --- a/app/src/main/assets/mobile/ktv_api.js +++ b/app/src/main/assets/mobile/ktv_api.js @@ -12,7 +12,7 @@ (function(global) { 'use strict'; - // KTV_BRIDGE_API: 8 (Android 缓存校验标记,请勿删除) + // KTV_BRIDGE_API: 9 (Android 缓存校验标记,请勿删除) // ─── 配置 ────────────────────────────────────────── const CONFIG = { @@ -138,13 +138,19 @@ function isDemoUrl(url, musicno) { var s = String(url || ''); if (/(?:wb66|demo)/i.test(s)) return true; + // 广告视频专用域名(含子域)。2026-09-26 事故:新设备(WebView 正常 → 走本 JS 取址) + // 点歌拿到了 gz.ac16.vip 的广告视频,而它通过了下述所有旧规则 → 被当成歌曲下载播放。 + if (/^https?:\/\/(?:[^\/]+\.)?ac16\.vip(?::\d+)?\//i.test(s)) return true; + if (/\/ad_files\/|my_ad_video/i.test(s)) return true; if (/^https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?\//i.test(s)) return true; // R2 demo bucket: pub-*.r2.dev/ss/ 通用测试资源 if (/pub-[a-z0-9]+\.r2\.dev\/ss\//i.test(s)) return true; - // ts 文件名是纯数字, 若与请求的 musicno 不一致则视为广告/测试资源。 - // 原版真源 URL 的 ts 文件名总是等于 musicno (如 /7120742.ts)。 + // ts 文件名必须等于 musicno。原版真源 URL 恒为 /.ts(已实测两个 CDN 域 + // txog.ktvsky.com / download.origjoy.com 的 6 首歌全部如此)。 + // ⚠️ 旧规则只比对"纯数字文件名"(/\/(\d+)\.ts/),像 my_ad_video.ts 这种非数字名字 + // 会直接漏过 —— 这正是广告漏放的直接原因之一。 if (musicno) { - var m = s.match(/\/(\d+)\.ts(?:\?|$)/); + var m = s.match(/\/([^\/?]+)\.ts(?:\?|$)/i); if (m && m[1] !== String(musicno)) return true; } return false; @@ -435,7 +441,7 @@ // ─── 导出 ────────────────────────────────────────── global.KtvApi = KtvApi; global.KtvApiConfig = CONFIG; - global.KtvBridgeApiVersion = 8; + global.KtvBridgeApiVersion = 9; if (typeof module !== 'undefined' && module.exports) { module.exports = {KtvApi: KtvApi, CONFIG: CONFIG}; diff --git a/app/src/main/java/com/local/ktv/AdVideoFilter.kt b/app/src/main/java/com/local/ktv/AdVideoFilter.kt index fb76b8f..3b5512b 100644 --- a/app/src/main/java/com/local/ktv/AdVideoFilter.kt +++ b/app/src/main/java/com/local/ktv/AdVideoFilter.kt @@ -21,6 +21,19 @@ object AdVideoFilter { "my_ad_video", ) + /** + * 广告视频专用域名(命中即判定为广告,含其子域)。 + * + * 2026-09-26 现场事故:新设备(WebView 正常 → 走 JS 取址)点歌 7303235 拿到了 + * `gz.ac16.vip` 的广告视频,而且它**通过了内容校验**(≥6MB / ≥60s / TS 包完整), + * 于是被当歌曲缓存并播放。仅靠路径片段(`/ad_files/`、`my_ad_video`)不足以拦住 —— + * 广告方一改文件名就漏,因此必须**按域名**兜底。 + */ + private val AD_HOSTS = listOf( + "gz.ac16.vip", + "ac16.vip", + ) + /** * 替换地址。为 null 时表示「屏蔽」(返回 null,由 DB 真实歌曲地址兜底); * 设置后,命中广告时用该地址替换原广告。 @@ -29,14 +42,43 @@ object AdVideoFilter { @JvmField var replacementUrl: String? = null - /** 判断是否为广告视频地址。 */ + /** 判断是否为广告视频地址(路径特征 + 域名)。 */ @JvmStatic fun isAdVideo(url: String?): Boolean { if (url.isNullOrEmpty()) return false val lower = url.lowercase() - return AD_MARKERS.any { lower.contains(it) } + if (AD_MARKERS.any { lower.contains(it) }) return true + val host = hostOf(url) ?: return false + return AD_HOSTS.any { host == it || host.endsWith(".$it") } } + /** + * 取址链路的**统一可信校验**(所有来源的地址都必须过这一关)。 + * + * 判定为不可信的情况: + * 1. 非 http(s); + * 2. 命中广告特征([isAdVideo]); + * 3. **URL 里的 `.ts` 文件名与点歌编号不一致** —— 真源 URL 的文件名恒等于点歌编号 + * (见 `ktv_api.js` 的说明),而广告文件常叫 `my_ad_video.ts`。 + * 旧规则只处理"纯数字且与编号不同"(`/\/(\d+)\.ts/`),非数字文件名直接放行, + * 这正是本次广告漏放的直接原因。 + */ + @JvmStatic + fun isTrustedSongUrl(url: String?, musicno: String?): Boolean { + if (url.isNullOrEmpty()) return false + if (!url.startsWith("http://") && !url.startsWith("https://")) return false + if (isAdVideo(url)) return false + val name = TS_BASENAME.find(url)?.groupValues?.getOrNull(1) + if (name != null && !musicno.isNullOrEmpty() && name != musicno) return false + return true + } + + private fun hostOf(url: String): String? = + runCatching { java.net.URI(url).host?.lowercase() }.getOrNull() + + /** 取 `.ts` 的**文件名主体**(不含扩展名),供与点歌编号比对。 */ + private val TS_BASENAME = Regex("/([^/?]+)\\.ts(?:\\?|$)", RegexOption.IGNORE_CASE) + /** * 处理播放地址:广告地址返回 null(屏蔽)或 [replacementUrl](替换); * 非广告地址原样返回。 diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt index 2e7ca98..c13567d 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -335,7 +335,7 @@ class KtvJsBridge(context: Context) { runCatching { file.isFile && file.length() in 1_001..500_000 && file.readText(Charsets.UTF_8).let { - it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 8") + it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 9") } }.getOrDefault(false) @@ -361,7 +361,7 @@ class KtvJsBridge(context: Context) { val script = String(bytes, StandardCharsets.UTF_8) require( script.length > 1000 && script.contains("KtvApi") && - script.contains("KTV_BRIDGE_API: 8"), + script.contains("KTV_BRIDGE_API: 9"), ) { "Invalid or incompatible JS file" } tempFile.writeText(script, Charsets.UTF_8) if (!tempFile.renameTo(cacheFile)) { diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index efd48be..d6f9692 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -529,6 +529,10 @@ class MainActivity : AppCompatActivity() { initializeDatabase() startRemoteServer() updateMobileQrOverlay() + // 一次性清理历史遗留的可疑歌曲缓存:1.4.9 及以前 JS 取址的地址没做广告校验, + // 广告视频会被当成歌曲落盘,且能通过内容校验被 isDownloaded() 一直复用(广告反复播)。 + // 清理只是让歌曲重新下载,不影响用户数据;之后由「取址即校验」保证不再产生。 + io.execute { SongOkDownloadManager.purgeLegacySongCacheOnce(applicationContext) } main.postDelayed(remoteNetworkMonitor, 5000) // 网络状态监测: 断网提示 + 恢复后重建歌曲源会话/重试下载。 KtvNetworkMonitor.start(applicationContext) { online, initial -> diff --git a/app/src/main/java/com/local/ktv/SongApiClient.kt b/app/src/main/java/com/local/ktv/SongApiClient.kt index 10a6e12..4d7bc84 100644 --- a/app/src/main/java/com/local/ktv/SongApiClient.kt +++ b/app/src/main/java/com/local/ktv/SongApiClient.kt @@ -112,8 +112,11 @@ object SongApiClient { if (db.open()) { val url = db.getCloudUrl(musicno) db.close() - // DB 中的 cloud_url 可能也是 demo 地址, 做 URL 基本校验 - if (url != null && !isLikelyDemoUrl(url)) return url + // DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验 + // (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。 + if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) { + return url + } } } catch (_: Exception) {} return null diff --git a/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt index 6fee6c9..bd28cf8 100644 --- a/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt +++ b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt @@ -23,6 +23,9 @@ object SongOkDownloadManager { private const val MAX_RETRY_COUNT = 3 private const val STALL_TIMEOUT_MS = 25_000L + /** 曲库视频目录下会被当作"已下载歌曲"的文件后缀(用于一次性清理历史广告缓存)。 */ + private val MEDIA_EXTENSIONS = setOf("ts", "ls", "mp4", "mkv") + interface DownloadCallback { fun onDownloadStart(song: Song) fun onDownloadProgress(song: Song, progress: Int) @@ -73,6 +76,41 @@ object SongOkDownloadManager { return downloaded } + /** + * 一次性清理历史遗留的歌曲缓存(广告漏放期间的产物)。 + * + * 背景(2026-09-26 事故):1.4.9 及以前,JS 取址返回的地址在 `buildDownloadUrl` 的成功分支里 + * **完全没有过广告校验**,广告视频(如 `gz.ac16.vip`)会被当成歌曲下载并落盘;它又能通过 + * [SongFileValidator] 的内容校验(≥6MB / ≥60s / TS 包完整),因此**靠内容无法识别**, + * 而且 [isDownloaded] 会直接复用该文件、连取址都不会再发生 —— 广告会一直播下去。 + * + * 这类遗留文件只能在升级时清一次;此后由「取址即校验」( + * [AdVideoFilter.isTrustedSongUrl])保证不再产生。清掉只是让歌曲重新下载,不影响任何用户数据。 + * + * @return 删除的文件数(已清理过则返回 0) + */ + @JvmStatic + fun purgeLegacySongCacheOnce(context: android.content.Context): Int { + val marker = File(context.filesDir, "song_cache_purged_v1") + if (marker.exists()) return 0 + var removed = 0 + runCatching { + val dir = File("${MuseDatabase.VIDEO_ROOT}/${MuseDatabase.CLOUD_SONG_DIR}") + dir.listFiles()?.forEach { file -> + if (file.isFile && file.extension.lowercase() in MEDIA_EXTENSIONS) { + SongFileValidator.forget(file) + if (file.delete()) removed++ + } + } + marker.writeText("purged=${System.currentTimeMillis()} removed=$removed", Charsets.UTF_8) + }.onFailure { Log.w(TAG, "purge legacy song cache failed: ${it.message}") } + // 首次启动才会真正有动作,之后 marker 存在会直接返回 0,不产生噪声日志。 + if (removed > 0 || !marker.exists()) { + MiliLog.warn("SONG-AD 一次性清理历史歌曲缓存 removed=$removed(旧版本可能把广告缓存成歌曲)") + } + return removed + } + @JvmStatic fun isDownloading(song: Song): Boolean { val key = songKey(song) @@ -173,33 +211,66 @@ object SongOkDownloadManager { } private fun buildDownloadUrl(song: Song, forceRefresh: Boolean = false): String { + val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id if (!forceRefresh) { song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached -> - if (!AdVideoFilter.isAdVideo(cached)) return cached - // 缓存的下载地址是广告视频,清掉并重新解析(广告会被桥接层屏蔽/替换)。 - Log.w(TAG, "丢弃广告缓存地址: ${song.title}") + if (AdVideoFilter.isTrustedSongUrl(cached, musicNo)) return cached + // 缓存的地址不可信(广告 / 文件名与点歌编号不符):除了清地址,还要**删掉已落盘的 + // 同名文件** —— 广告视频能通过内容校验(≥6MB / ≥60s / TS 包完整), + // 一旦被缓存成歌曲,isDownloaded() 会直接复用、连取址都不会再发生。 + Log.w(TAG, "丢弃不可信缓存地址: ${song.title} -> $cached") + MiliLog.warn("SONG-AD 丢弃不可信缓存地址 编号=$musicNo url=$cached") song.downloadUrl = null + purgeCachedSongFile(song, "untrusted-cached-url") } } - val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id return SongApiClient.getSongDownloadUrl(musicNo).orEmpty().also { url -> // 请求源 = 本次点歌实际命中的源(JS 上报); 取不到时留空。 val srcHost = SongApiClient.lastSongSourceHost() - if (url.isNotEmpty()) { + // ⚠️ 广告漏放的**最终兜底防线**:不管地址来自 JS 取址、原生兜底还是 DB 回退, + // 都必须过 AdVideoFilter.isTrustedSongUrl(2026-09-26 事故:JS 路径返回 + // gz.ac16.vip 的广告视频,因为这里的成功分支完全没做广告校验而被当成歌曲下载播放)。 + if (url.isNotEmpty() && AdVideoFilter.isTrustedSongUrl(url, musicNo)) { song.downloadUrl = url val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown" MiliLog.info( "点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=成功 urlHost=$cdnHost", ) + } else if (url.isNotEmpty()) { + Log.w(TAG, "Untrusted URL rejected for ${song.title}: $url") + MiliLog.error( + "点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 " + + "详情=地址不可信(广告或文件名不符) url=$url", + ) + purgeCachedSongFile(song, "untrusted-resolved-url") + "" } else { Log.w(TAG, "Unable to get download URL: ${song.title}") MiliLog.error( "点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 详情=无法生成下载地址", ) + "" } } } + /** 删除某首歌已落盘的文件(发现地址不可信/命中广告时调用,让下次点歌重新取址下载)。 */ + private fun purgeCachedSongFile(song: Song, reason: String) { + runCatching { + val file = getLocalFile(song) + if (file.exists()) { + val sizeMb = file.length() / 1024 / 1024 + SongFileValidator.forget(file) + val deleted = file.delete() + MiliLog.warn( + "SONG-AD 清除可疑歌曲缓存 编号=${song.id} 原因=$reason " + + "大小=${sizeMb}MB 已删除=$deleted", + ) + } + song.path = null + }.onFailure { Log.w(TAG, "purge cached file failed: ${it.message}") } + } + private fun startDownloadTask(song: Song, url: String, callback: DownloadCallback?, attempt: Int) { val key = songKey(song) val target = getLocalFile(song) diff --git a/app/src/main/java/com/local/ktv/SongUrlResolver.kt b/app/src/main/java/com/local/ktv/SongUrlResolver.kt index b6fb8d8..ec29eeb 100644 --- a/app/src/main/java/com/local/ktv/SongUrlResolver.kt +++ b/app/src/main/java/com/local/ktv/SongUrlResolver.kt @@ -202,15 +202,16 @@ object SongUrlResolver { } } - /** 镜像 ktv_api.js 的 `isDemoUrl`:广告视频 / 纯 IP / R2 测试桶 / ts 编号与请求不一致。 */ + /** + * 原生路径的可信校验:先过 `AdVideoFilter.isTrustedSongUrl`(广告域名/路径 + `.ts` 文件名 + * 必须等于点歌编号),再补上 JS `isDemoUrl` 里那几条(wb66/demo、纯 IP、R2 测试桶)。 + */ private fun isValidSongUrl(url: String, musicno: String): Boolean { - if (AdVideoFilter.isAdVideo(url)) return false + 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 - val matched = TS_NAME.find(url)?.groupValues?.getOrNull(1) - if (matched != null && matched != musicno) return false return true } diff --git a/app/src/test/java/com/local/ktv/AdVideoFilterTest.java b/app/src/test/java/com/local/ktv/AdVideoFilterTest.java new file mode 100644 index 0000000..8725bc3 --- /dev/null +++ b/app/src/test/java/com/local/ktv/AdVideoFilterTest.java @@ -0,0 +1,66 @@ +package com.local.ktv; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * 广告地址拦截的回归测试。 + * + * 对应 2026-09-26 现场事故:新设备(WebView 正常 → 走 JS 取址)点歌 7303235(情歌 HD) + * 拿到 `gz.ac16.vip` 的广告视频,被当成歌曲下载并播放。当时的漏洞有三处: + * 1. JS 的 isDemoUrl 不认识该广告域名,且只比对"纯数字文件名",非数字名字直接放行; + * 2. Kotlin 的 buildDownloadUrl 成功分支**完全没做广告校验**; + * 3. DB 回退路径只做 isLikelyDemoUrl,也挡不住广告域名。 + * 这里用日志里的**真实 URL 形态**锁住入参校验行为。 + */ +public final class AdVideoFilterTest { + + /** 真源地址:两个 CDN 域实测文件名都严格等于点歌编号,必须放行。 */ + @Test + public void acceptsRealSourceUrls() { + assertTrue(AdVideoFilter.isTrustedSongUrl( + "http://txog.ktvsky.com/264/lvbr/480/E/ts/47.1/202209220333/480p/7303235.ts?token=526e505e", + "7303235")); + assertTrue(AdVideoFilter.isTrustedSongUrl( + "http://download.origjoy.com/264/lvbr/480/E/ts/39.6/202309070206/480p/7445418.ts?token=abc", + "7445418")); + } + + /** 广告域名:即使文件名伪装成正确的点歌编号,也必须拦下。 */ + @Test + public void rejectsAdHostEvenWithMatchingName() { + assertFalse(AdVideoFilter.isTrustedSongUrl("http://gz.ac16.vip/ad/7303235.ts", "7303235")); + assertFalse(AdVideoFilter.isTrustedSongUrl("http://cdn.ac16.vip/x/7303235.ts", "7303235")); + } + + /** 广告文件名的经典形态:非数字名字,旧规则的正则匹配不到。 */ + @Test + public void rejectsAdFileName() { + assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/ad_files/my_ad_video.ts", "7303235")); + assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/my_ad_video.ts", "7303235")); + } + + /** .ts 文件名与点歌编号不符(即使不命中任何广告域名)也视为不可信。 */ + @Test + public void rejectsMismatchedFileName() { + assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/9999999.ts", "7303235")); + assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/promo.ts", "7303235")); + } + + /** 非 http(s) 一律不可信。 */ + @Test + public void rejectsNonHttpSchemes() { + assertFalse(AdVideoFilter.isTrustedSongUrl("file:///sdcard/x/7303235.ts", "7303235")); + assertFalse(AdVideoFilter.isTrustedSongUrl("ftp://example.com/7303235.ts", "7303235")); + assertFalse(AdVideoFilter.isTrustedSongUrl("", "7303235")); + assertFalse(AdVideoFilter.isTrustedSongUrl(null, "7303235")); + } + + /** 没有 .ts 文件名时不做文件名比对(避免误杀非 ts 的真源)。 */ + @Test + public void allowsNonTsUrlWithoutAdMarkers() { + assertTrue(AdVideoFilter.isTrustedSongUrl("http://cdn.example.com/path/stream?token=abc", "7303235")); + } +}