Files
IndPjsk/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt
T
Bluemangoo 544a68bbd4 修自定义配置
null、导入、mixin ignore
2026-10-30 23:40:05 +08:00

372 lines
12 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}