麦动KTV: 移除Gitee数据库(改官方Muse同步), 歌曲源切w.w345.my, 远程日志+广告屏蔽
- DatabaseBootstrapper: 删除 Gitee 分片/manifest/blob 下载逻辑, 曲库只走官方 MuseDbSync - build.gradle: 移除 GITEE_DATABASE_* 构建参数 - MainActivity: 重置数据库文案改为官方同步 - ktv_api.js: 歌曲源仅 w.w345.my, 缓存标记 7, 广告/错号资源拦截+重试 - 远程日志: 点歌记录 编号/标题/请求源/状态
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
package com.local.ktv
|
||||
|
||||
import android.content.Context
|
||||
import java.io.BufferedInputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.net.URLDecoder
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/** Small, dependency-free HTTP server used only by devices on the local LAN. */
|
||||
class LocalRemoteServer(private val context: Context) {
|
||||
data class ApiResponse(
|
||||
val status: Int = 200,
|
||||
val body: String = "",
|
||||
val contentType: String = "application/json; charset=utf-8",
|
||||
val bytes: ByteArray? = null
|
||||
)
|
||||
|
||||
interface Callback {
|
||||
fun statusJson(): String
|
||||
fun catalogJson(): String
|
||||
fun queueJson(): String
|
||||
fun rankJson(): String
|
||||
fun filterJson(type: String?, value: String?): String
|
||||
fun favoritesJson(): String
|
||||
fun search(query: String?): String
|
||||
fun order(query: String?): String
|
||||
fun command(action: String?, params: Map<String, String>): String
|
||||
fun handle(method: String, path: String, query: Map<String, String>, body: String): ApiResponse =
|
||||
ApiResponse(404, "{\"ok\":false,\"code\":\"NOT_FOUND\",\"error\":\"接口不存在\"}")
|
||||
}
|
||||
|
||||
private var pool = Executors.newCachedThreadPool()
|
||||
@Volatile private var running = false
|
||||
@Volatile private var boundAddress: String? = null
|
||||
@Volatile private var boundPort = -1
|
||||
@Volatile private var lastError: String? = null
|
||||
private var server: ServerSocket? = null
|
||||
private var callback: Callback? = null
|
||||
|
||||
@Synchronized
|
||||
fun start(address: String?, callback: Callback): Boolean {
|
||||
if (running && address == boundAddress) return true
|
||||
stop()
|
||||
this.callback = callback
|
||||
if (!isPrivateIpv4(address)) {
|
||||
lastError = "请先连接 Wi-Fi 或有线局域网"
|
||||
return false
|
||||
}
|
||||
if (pool.isShutdown) pool = Executors.newCachedThreadPool()
|
||||
var candidate: ServerSocket? = null
|
||||
for (port in 8765..8775) {
|
||||
try {
|
||||
candidate = ServerSocket(port, 32)
|
||||
boundPort = port
|
||||
break
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
if (candidate == null) {
|
||||
lastError = "端口 8765–8775 均被占用"
|
||||
return false
|
||||
}
|
||||
server = candidate
|
||||
boundAddress = address
|
||||
lastError = null
|
||||
running = true
|
||||
pool.execute {
|
||||
try {
|
||||
while (running) {
|
||||
val socket = server?.accept() ?: break
|
||||
pool.execute { handle(socket) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (running) lastError = e.message ?: "本地服务异常"
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
running = false
|
||||
runCatching { server?.close() }
|
||||
server = null
|
||||
boundPort = -1
|
||||
boundAddress = null
|
||||
pool.shutdownNow()
|
||||
}
|
||||
|
||||
fun isRunning(): Boolean = running
|
||||
fun port(): Int = boundPort
|
||||
fun address(): String? = boundAddress
|
||||
fun error(): String? = lastError
|
||||
fun url(): String? = if (running && boundAddress != null && boundPort > 0) {
|
||||
"http://$boundAddress:$boundPort/mobile"
|
||||
} else null
|
||||
|
||||
private fun handle(socket: Socket) {
|
||||
runCatching {
|
||||
socket.soTimeout = 5000
|
||||
socket.use { client ->
|
||||
val remote = client.inetAddress
|
||||
if (!isPrivateAddress(remote)) {
|
||||
write(client, ApiResponse(403, error("FORBIDDEN", "仅允许局域网设备访问")))
|
||||
return
|
||||
}
|
||||
val input = BufferedInputStream(client.getInputStream())
|
||||
val requestLine = readLine(input) ?: return
|
||||
val parts = requestLine.split(' ')
|
||||
if (parts.size < 2) {
|
||||
write(client, ApiResponse(400, error("BAD_REQUEST", "请求格式错误")))
|
||||
return
|
||||
}
|
||||
val method = parts[0].uppercase(Locale.US)
|
||||
val target = parts[1]
|
||||
val headers = linkedMapOf<String, String>()
|
||||
while (true) {
|
||||
val line = readLine(input) ?: break
|
||||
if (line.isEmpty()) break
|
||||
val colon = line.indexOf(':')
|
||||
if (colon > 0) headers[line.substring(0, colon).lowercase(Locale.US)] = line.substring(colon + 1).trim()
|
||||
}
|
||||
if (!validHost(headers["host"]) || !validOrigin(headers["origin"])) {
|
||||
write(client, ApiResponse(403, error("BAD_ORIGIN", "请求来源无效")))
|
||||
return
|
||||
}
|
||||
val length = headers["content-length"]?.toIntOrNull()?.coerceIn(0, 65536) ?: 0
|
||||
val bodyBytes = ByteArray(length)
|
||||
var offset = 0
|
||||
while (offset < length) {
|
||||
val count = input.read(bodyBytes, offset, length - offset)
|
||||
if (count < 0) break
|
||||
offset += count
|
||||
}
|
||||
val path = target.substringBefore('?')
|
||||
val query = parseQuery(target.substringAfter('?', ""))
|
||||
val body = String(bodyBytes, 0, offset, Charsets.UTF_8)
|
||||
val response = when {
|
||||
path == "/" || path == "/mobile" || path == "/mobile/" -> asset("mobile/index.html", "text/html; charset=utf-8")
|
||||
path == "/mobile/app.css" -> asset("mobile/app.css", "text/css; charset=utf-8")
|
||||
path == "/mobile/logo.css" -> asset("mobile/logo.css", "text/css; charset=utf-8")
|
||||
path == "/mobile/app.js" -> asset("mobile/app.js", "application/javascript; charset=utf-8")
|
||||
path == "/res/drawable-nodpi/ktv_logo.png" -> logoAsset()
|
||||
path.startsWith("/api/v1/") -> callback?.handle(method, path, query, body)
|
||||
?: ApiResponse(503, error("NOT_READY", "服务尚未准备完成"))
|
||||
else -> ApiResponse(404, error("NOT_FOUND", "接口不存在"))
|
||||
}
|
||||
write(client, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun asset(name: String, type: String): ApiResponse = try {
|
||||
val text = context.assets.open(name).bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||
ApiResponse(200, text, type)
|
||||
} catch (_: Exception) {
|
||||
ApiResponse(404, error("ASSET_NOT_FOUND", "页面资源缺失"))
|
||||
}
|
||||
|
||||
private fun logoAsset(): ApiResponse = try {
|
||||
val bytes = context.resources.openRawResource(R.drawable.ktv_logo).use { it.readBytes() }
|
||||
ApiResponse(200, contentType = "image/png", bytes = bytes)
|
||||
} catch (_: Exception) {
|
||||
ApiResponse(404, error("ASSET_NOT_FOUND", "Logo 资源缺失"))
|
||||
}
|
||||
|
||||
private fun validHost(host: String?): Boolean {
|
||||
if (host.isNullOrBlank()) return false
|
||||
val expected = boundAddress ?: return false
|
||||
val name = host.substringBefore(':')
|
||||
return name == expected || isPrivateIpv4(name)
|
||||
}
|
||||
|
||||
private fun validOrigin(origin: String?): Boolean {
|
||||
if (origin.isNullOrBlank() || origin == "null") return true
|
||||
return runCatching {
|
||||
val uri = URI(origin)
|
||||
uri.scheme == "http" && uri.port == boundPort && isPrivateIpv4(uri.host)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun readLine(input: BufferedInputStream): String? {
|
||||
val bytes = ArrayList<Byte>()
|
||||
while (bytes.size < 8192) {
|
||||
val b = input.read()
|
||||
if (b < 0) return if (bytes.isEmpty()) null else bytes.toByteArray().toString(Charsets.UTF_8)
|
||||
if (b == '\n'.code) break
|
||||
if (b != '\r'.code) bytes.add(b.toByte())
|
||||
}
|
||||
return bytes.toByteArray().toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun parseQuery(query: String): Map<String, String> = buildMap {
|
||||
if (query.isBlank()) return@buildMap
|
||||
query.split('&').forEach { part ->
|
||||
val key = URLDecoder.decode(part.substringBefore('='), "UTF-8")
|
||||
val value = URLDecoder.decode(part.substringAfter('=', ""), "UTF-8")
|
||||
put(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun write(socket: Socket, response: ApiResponse) {
|
||||
val bytes = response.bytes ?: response.body.toByteArray(Charsets.UTF_8)
|
||||
val reason = when (response.status) {
|
||||
200 -> "OK"; 201 -> "Created"; 204 -> "No Content"; 400 -> "Bad Request"
|
||||
403 -> "Forbidden"; 404 -> "Not Found"; 409 -> "Conflict"; else -> "Error"
|
||||
}
|
||||
val output = socket.getOutputStream()
|
||||
output.write(("HTTP/1.1 ${response.status} $reason\r\n" +
|
||||
"Content-Type: ${response.contentType}\r\n" +
|
||||
"Cache-Control: no-store\r\n" +
|
||||
"X-Content-Type-Options: nosniff\r\n" +
|
||||
"Content-Security-Policy: default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'\r\n" +
|
||||
"Content-Length: ${bytes.size}\r\nConnection: close\r\n\r\n").toByteArray())
|
||||
output.write(bytes)
|
||||
output.flush()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun isPrivateIpv4(value: String?): Boolean = runCatching {
|
||||
val address = InetAddress.getByName(value)
|
||||
address is Inet4Address && isPrivateAddress(address)
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun isPrivateAddress(address: InetAddress): Boolean {
|
||||
if (address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress) return true
|
||||
val b = address.address
|
||||
return b.size == 4 && (b[0].toInt() and 0xff) == 100 && (b[1].toInt() and 0xc0) == 64
|
||||
}
|
||||
|
||||
private fun error(code: String, message: String): String =
|
||||
"{\"ok\":false,\"code\":\"$code\",\"error\":\"$message\"}"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user