372 lines
12 KiB
Kotlin
372 lines
12 KiB
Kotlin
package net.bluemangoo.indpjsk.upload
|
||
|
||
import kotlinx.coroutines.CancellationException
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.withContext
|
||
import net.bluemangoo.indpjsk.data.CaptureResult
|
||
import net.bluemangoo.indpjsk.vpn.VpnLog
|
||
import org.json.JSONObject
|
||
import java.io.File
|
||
import java.io.IOException
|
||
import java.io.RandomAccessFile
|
||
import java.net.HttpURLConnection
|
||
import java.net.URL
|
||
import java.util.*
|
||
import kotlin.random.Random
|
||
|
||
object Uploader {
|
||
private const val CHUNK_SIZE = 1024 * 1024
|
||
|
||
suspend fun tryUpload(result: CaptureResult, onlyProfileIds: Set<String>? = null) {
|
||
val entries = UploadStore.load()
|
||
.filter { it.enabled }
|
||
.filter { onlyProfileIds == null || it.profile.id in onlyProfileIds }
|
||
.filter { entry ->
|
||
val definedHere =
|
||
entry.profile.id in result.step.explicitUploadIds ||
|
||
(onlyProfileIds != null && entry.profile.id in onlyProfileIds)
|
||
if (definedHere) {
|
||
true
|
||
} else {
|
||
val upload = (entry.profile as? UploadProfileCustom)?.mixin?.upload
|
||
upload == null || !upload.ignores(result.profile.id, result.step.uploadType)
|
||
}
|
||
}
|
||
if (entries.isEmpty()) return
|
||
|
||
VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置)")
|
||
for ((_, profile) in entries) {
|
||
try {
|
||
val uploaded = uploadChunked(profile, result.url, result.file, result.step.uploadType)
|
||
if (uploaded) {
|
||
VpnLog.success("[${profile.id}] ${result.step.name} 上传完成")
|
||
}
|
||
} catch (c: CancellationException) {
|
||
throw c
|
||
} catch (t: Throwable) {
|
||
VpnLog.error("[${profile.id}] ${result.step.name} 上传失败: ${t.message}")
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
suspend fun uploadChunked(
|
||
profile: UploadProfile,
|
||
originalUrl: String,
|
||
file: File,
|
||
type: String? = null,
|
||
): Boolean = withContext(Dispatchers.IO) {
|
||
require(file.exists()) { "文件不存在: ${file.absolutePath}" }
|
||
val uploadType = type ?: when (file.name) {
|
||
"suite" -> "suite"
|
||
"mysekai" -> "mysekai"
|
||
"mysekai_birthday" -> "birthday"
|
||
else -> null
|
||
}
|
||
if (uploadType == null) {
|
||
VpnLog.error("${profile.id} 未知的文件类型 ${file.name}")
|
||
return@withContext false
|
||
}
|
||
val url = profile.getUploadUrl(uploadType)?.takeIf { it.isNotBlank() }
|
||
?: return@withContext false
|
||
|
||
val uploadId = randomUploadId()
|
||
RandomAccessFile(file, "r").use { raf ->
|
||
val size = raf.length()
|
||
val total = if (size == 0L) 1 else ((size + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
|
||
val buf = ByteArray(CHUNK_SIZE)
|
||
VpnLog.info("[${profile.id}] ${file.name} 上传开始 (${size} bytes)")
|
||
|
||
for (index in 0 until total) {
|
||
val remaining = size - raf.filePointer
|
||
val len = if (remaining <= 0L) 0 else minOf(CHUNK_SIZE.toLong(), remaining).toInt()
|
||
if (len > 0) raf.readFully(buf, 0, len)
|
||
val code = postChunk(profile, url, uploadId, index, total, originalUrl, buf, file, len)
|
||
if (code != 200) throw IOException("${file.name} 上传失败: HTTP $code")
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
private fun postChunk(
|
||
profile: UploadProfile,
|
||
url: String,
|
||
uploadId: String,
|
||
index: Int,
|
||
total: Int,
|
||
originalUrl: String,
|
||
data: ByteArray,
|
||
file: File,
|
||
length: Int,
|
||
): Int {
|
||
val conn = URL(url).openConnection() as HttpURLConnection
|
||
try {
|
||
conn.requestMethod = "POST"
|
||
conn.doOutput = true
|
||
conn.instanceFollowRedirects = true
|
||
conn.connectTimeout = 15_000
|
||
conn.readTimeout = 60_000
|
||
conn.setRequestProperty("Content-Type", "application/octet-stream")
|
||
conn.setRequestProperty("X-Upload-Id", uploadId)
|
||
conn.setRequestProperty("X-Chunk-Index", index.toString())
|
||
conn.setRequestProperty("X-Total-Chunks", total.toString())
|
||
conn.setRequestProperty("X-Original-Url", originalUrl)
|
||
conn.setFixedLengthStreamingMode(length)
|
||
profile.preRequest(conn, originalUrl, file)
|
||
conn.outputStream.use { it.write(data, 0, length) }
|
||
|
||
val code = conn.responseCode
|
||
if (code != 200) {
|
||
val body = runCatching {
|
||
(conn.errorStream ?: conn.inputStream)
|
||
?.bufferedReader()
|
||
?.use { it.readText() }
|
||
?.take(300)
|
||
}.getOrNull().orEmpty()
|
||
throw IOException("${profile.id} 分片 ${index + 1}/$total 上传失败: HTTP $code $body")
|
||
}
|
||
return code
|
||
} finally {
|
||
conn.disconnect()
|
||
}
|
||
}
|
||
|
||
private fun randomUploadId(): String {
|
||
val chars = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||
return buildString(9) {
|
||
repeat(9) { append(chars[Random.nextInt(chars.length)]) }
|
||
}
|
||
}
|
||
}
|
||
|
||
sealed interface UploadUrl {
|
||
fun getUrl(type: String): String?
|
||
}
|
||
|
||
data class UploadUrlGeneral(
|
||
val url: String
|
||
) : UploadUrl {
|
||
override fun getUrl(type: String): String {
|
||
return url
|
||
}
|
||
}
|
||
|
||
data class UploadUrlEach(
|
||
val suite: String?,
|
||
val mysekai: String?,
|
||
val birthday: String?,
|
||
) : UploadUrl {
|
||
override fun getUrl(type: String): String? {
|
||
return when (type) {
|
||
"suite" -> suite
|
||
"mysekai" -> mysekai
|
||
"birthday" -> birthday
|
||
else -> null
|
||
}
|
||
}
|
||
}
|
||
|
||
enum class UploadProfileType(val displayName: String) {
|
||
HARUKI("Haruki"),
|
||
REASONA("Reasona"),
|
||
_8823("8823"),
|
||
CUSTOM("自定义")
|
||
}
|
||
|
||
/** mixin:在自定义上传配置里顺带定义抓包配置。 */
|
||
data class UploadMixin(
|
||
val profiles: List<UploadMixinProfile> = emptyList(),
|
||
val upload: UploadMixinUpload? = null,
|
||
)
|
||
|
||
data class UploadMixinProfile(
|
||
val name: String,
|
||
val steps: List<UploadMixinStep>,
|
||
)
|
||
|
||
/**
|
||
* name: 步骤显示名(同一 profile 内同名步骤会合并)
|
||
* hosts: 域名列表,可以填 `cn` / `jp` / `en` / `tw` / `kr` 这样的预设别名
|
||
* target: `suite` / `mysekai` / `birthday` 快捷方式,或直接填请求路径正则
|
||
*/
|
||
data class UploadMixinStep(
|
||
val name: String,
|
||
val hosts: List<String>,
|
||
val target: String,
|
||
)
|
||
|
||
/**
|
||
* `upload.ignore`:这个上传配置对哪些"不是自己 mixin 定义"的步骤不生效。
|
||
* 自己 mixin 定义出来的步骤永远会上传(见 [ignores] 的调用处)。
|
||
*/
|
||
data class UploadMixinUpload(
|
||
/** ignore: "*" */
|
||
val ignoreAll: Boolean = false,
|
||
val ignore: List<UploadIgnoreProfile> = emptyList(),
|
||
) {
|
||
fun ignores(region: String, stepType: String?): Boolean {
|
||
if (ignoreAll) return true
|
||
return ignore.any { item ->
|
||
item.region.equals(region, ignoreCase = true) &&
|
||
(item.steps.isEmpty() ||
|
||
(stepType != null && item.steps.any { it.equals(stepType, ignoreCase = true) }))
|
||
}
|
||
}
|
||
}
|
||
|
||
data class UploadIgnoreProfile(
|
||
/** Region:cn / jp / en / tw / kr */
|
||
val region: String,
|
||
/** 空表示整个配置 */
|
||
val steps: List<String> = emptyList(),
|
||
)
|
||
|
||
sealed interface UploadProfile {
|
||
val id: String
|
||
val type: UploadProfileType
|
||
|
||
val helpText: String?
|
||
|
||
fun getUploadUrl(type: String): String?
|
||
fun preRequest(
|
||
conn: HttpURLConnection, originalUrl: String, file: File,
|
||
)
|
||
|
||
val extraInfo: Map<String, String>
|
||
}
|
||
|
||
class UploadProfileHaruki(override val id: String, val uploadCode: String) : UploadProfile {
|
||
companion object {
|
||
private const val API_BASE = "https://toolbox-api-direct.haruki.seiunx.com/ios/script"
|
||
}
|
||
|
||
override val extraInfo: Map<String, String>
|
||
get() = emptyMap()
|
||
|
||
override val helpText: String =
|
||
"在 [Haruki 工具箱](https://haruki.seiunx.com/upload-data) 的「iOS模块」里复制上传码。"
|
||
|
||
override val type: UploadProfileType = UploadProfileType.HARUKI
|
||
override fun getUploadUrl(type: String): String {
|
||
return "$API_BASE/$uploadCode/upload"
|
||
}
|
||
|
||
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
|
||
conn.setRequestProperty("X-Script-Version", "1.0.1")
|
||
conn.setRequestProperty("X-Upload-Code", uploadCode)
|
||
}
|
||
}
|
||
|
||
class UploadProfileReasona(override val id: String, val uploadCode: String) : UploadProfile {
|
||
companion object {
|
||
private const val API_BASE = "https://api.resona.cn/scripts/"
|
||
}
|
||
|
||
override val type: UploadProfileType = UploadProfileType.REASONA
|
||
|
||
override val helpText: String =
|
||
"在 [Reasona 工具箱](https://web.resona.cn/tools/upload) 的 Haruki Proxy 里复制 token"
|
||
private val _uploadUrl = UploadUrlEach(
|
||
suite = "$API_BASE/suite/upload?jwt=$uploadCode",
|
||
mysekai = "$API_BASE/mysekai/upload?jwt=$uploadCode",
|
||
birthday = "$API_BASE/mysekai_birthday_party/upload?jwt=$uploadCode"
|
||
)
|
||
|
||
override fun getUploadUrl(type: String): String? {
|
||
return _uploadUrl.getUrl(type)
|
||
}
|
||
|
||
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
|
||
conn.setRequestProperty("X-Script-Version", "0.4.2")
|
||
val userId = originalUrl.tryParseUserId()
|
||
if (userId != null) {
|
||
conn.setRequestProperty("X-User-Id", userId)
|
||
}
|
||
}
|
||
|
||
private fun parseJwt(): JSONObject? {
|
||
val parts = uploadCode.split(".")
|
||
if (parts.size < 2) return null
|
||
|
||
val payload =
|
||
try {
|
||
val payloadBytes = Base64.getUrlDecoder().decode(parts[1])
|
||
String(payloadBytes, Charsets.UTF_8)
|
||
} catch (_: IllegalArgumentException) {
|
||
return null
|
||
}
|
||
return runCatching { JSONObject(payload) }.getOrNull()
|
||
}
|
||
|
||
override val extraInfo: Map<String, String>
|
||
get() {
|
||
val payload = parseJwt() ?: return emptyMap()
|
||
val map = mutableMapOf<String, String>()
|
||
if (payload.has("qqnum") && !payload.isNull("qqnum")) {
|
||
map["QQ 号"] = payload.optString("qqnum")
|
||
}
|
||
if (payload.has("sync_8823") && !payload.isNull("sync_8823")) {
|
||
map["同步给 8823"] = payload.optBoolean("sync_8823").toString()
|
||
}
|
||
return map
|
||
}
|
||
}
|
||
|
||
|
||
class UploadProfile8823(override val id: String) : UploadProfile {
|
||
override val type: UploadProfileType = UploadProfileType._8823
|
||
|
||
override val helpText: String? = null
|
||
|
||
private val _uploadUrl = UploadUrlGeneral("https://api-8823.resona.cn/api/public/V1/upload")
|
||
|
||
override fun getUploadUrl(type: String): String {
|
||
return _uploadUrl.getUrl(type)
|
||
}
|
||
|
||
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
|
||
conn.setRequestProperty("X-Script-Version", "0.3.0")
|
||
val userId = originalUrl.tryParseUserId()
|
||
if (userId != null) {
|
||
conn.setRequestProperty("X-User-Id", userId)
|
||
}
|
||
}
|
||
|
||
override val extraInfo: Map<String, String> = emptyMap()
|
||
}
|
||
|
||
class UploadProfileCustom(
|
||
override val id: String,
|
||
val uploadUrl: UploadUrl,
|
||
val customHeaders: Map<String, String>,
|
||
val mixin: UploadMixin? = null,
|
||
) : UploadProfile {
|
||
override val type: UploadProfileType = UploadProfileType.CUSTOM
|
||
|
||
override val helpText: String? = null
|
||
|
||
override fun getUploadUrl(type: String): String? {
|
||
return uploadUrl.getUrl(type)
|
||
}
|
||
|
||
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
|
||
customHeaders.forEach { (key, value) ->
|
||
when (value) {
|
||
$$"$original_url" -> conn.setRequestProperty(key, originalUrl)
|
||
$$"$file_name" -> conn.setRequestProperty(key, file.name)
|
||
$$"$user_id" -> originalUrl.tryParseUserId()?.let { conn.setRequestProperty(key, it) }
|
||
else -> conn.setRequestProperty(key, value)
|
||
}
|
||
}
|
||
}
|
||
|
||
override val extraInfo: Map<String, String> = emptyMap()
|
||
}
|
||
|
||
private fun String.tryParseUserId(): String? {
|
||
val regex = Regex("""/user/(\d+)|suite/user/(\d+)""")
|
||
val match = regex.find(this) ?: return null
|
||
return match.groupValues[1].ifEmpty { match.groupValues[2] }
|
||
.ifEmpty { null }
|
||
}
|