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? = 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 = emptyList(), val upload: UploadMixinUpload? = null, ) data class UploadMixinProfile( val name: String, val steps: List, ) /** * name: 步骤显示名(同一 profile 内同名步骤会合并) * hosts: 域名列表,可以填 `cn` / `jp` / `en` / `tw` / `kr` 这样的预设别名 * target: `suite` / `mysekai` / `birthday` 快捷方式,或直接填请求路径正则 */ data class UploadMixinStep( val name: String, val hosts: List, val target: String, ) /** * `upload.ignore`:这个上传配置对哪些"不是自己 mixin 定义"的步骤不生效。 * 自己 mixin 定义出来的步骤永远会上传(见 [ignores] 的调用处)。 */ data class UploadMixinUpload( /** ignore: "*" */ val ignoreAll: Boolean = false, val ignore: List = 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 = 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 } 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 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 get() { val payload = parseJwt() ?: return emptyMap() val map = mutableMapOf() 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 = emptyMap() } class UploadProfileCustom( override val id: String, val uploadUrl: UploadUrl, val customHeaders: Map, 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 = 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 } }