重构上传
This commit is contained in:
@@ -1,101 +1,105 @@
|
||||
package net.bluemangoo.indpjsk.upload
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.bluemangoo.indpjsk.App
|
||||
import net.bluemangoo.indpjsk.data.CaptureResult
|
||||
import net.bluemangoo.indpjsk.data.SettingsStore
|
||||
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
|
||||
|
||||
/**
|
||||
* 把抓到的响应体上传到工具箱:
|
||||
*
|
||||
* POST https://toolbox-api-direct.haruki.seiunx.com/ios/script/{upload_code}/upload
|
||||
* Content-Type: application/octet-stream (原始二进制,每片最大 1MB)
|
||||
* X-Script-Version: 1.0.1
|
||||
* X-Upload-Code: {upload_code}
|
||||
* X-Upload-Id: 9 位随机字母数字(同一文件的所有分片共用)
|
||||
* X-Chunk-Index: "0", "1", ...
|
||||
* X-Total-Chunks: 分片总数(ceil(size / 1MB))
|
||||
* X-Original-Url: 抓到的原始游戏 URL(后端用它识别区服 / UID / 数据类型)
|
||||
*
|
||||
* 分片串行发送,每一片都必须返回 HTTP 200。
|
||||
*/
|
||||
object Uploader {
|
||||
|
||||
private const val BASE = "https://toolbox-api-direct.haruki.seiunx.com/ios/script"
|
||||
private const val CHUNK_SIZE = 1024 * 1024
|
||||
private const val SCRIPT_VERSION = "1.0.1"
|
||||
|
||||
suspend fun upload(
|
||||
uploadCode: String,
|
||||
suspend fun tryUpload(result: CaptureResult) {
|
||||
val entries = UploadStore.load().filter { it.enabled }
|
||||
if (entries.isEmpty()) return
|
||||
|
||||
VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置,串行)")
|
||||
for ((_, profile) in entries) {
|
||||
try {
|
||||
val uploaded = uploadChunked(profile, result.url, result.file)
|
||||
if (uploaded) {
|
||||
VpnLog.success("[${profile.id}] ${result.step.name} 上传完成")
|
||||
} else {
|
||||
VpnLog.info("[${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,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
require(file.exists()) { "文件不存在: ${file.absolutePath}" }
|
||||
val type = when (file.name) {
|
||||
"suite" -> "suite"
|
||||
"mysekai" -> "mysekai"
|
||||
"mysekai_birthday" -> "birthday"
|
||||
else -> {
|
||||
VpnLog.error("${profile.id} 未知的文件类型 ${file.name}")
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
val url = profile.getUploadUrl(type)?.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("${file.name} 上传开始")
|
||||
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(uploadCode, uploadId, index, total, originalUrl, buf, len)
|
||||
val code = postChunk(profile, url, uploadId, index, total, originalUrl, buf, file, len)
|
||||
if (code != 200) throw IOException("${file.name} 上传失败: HTTP $code")
|
||||
}
|
||||
VpnLog.info("${file.name} 上传完成")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun tryUpload(result: CaptureResult) {
|
||||
val context = App.appContext
|
||||
// 复选框没勾就不传(没填上传码时复选框本身不可用)
|
||||
if (!SettingsStore.loadUseUpload(context)) return
|
||||
val uploadCode = SettingsStore.loadUploadCode(context).trim()
|
||||
if (uploadCode.isEmpty()) {
|
||||
VpnLog.warn("未填写上传码,跳过上传")
|
||||
return
|
||||
}
|
||||
VpnLog.info("${result.profile.name}${result.step.name} 开始上传")
|
||||
upload(uploadCode, result.url, result.file)
|
||||
VpnLog.success("${result.profile.name}${result.step.name} 上传完成")
|
||||
true
|
||||
}
|
||||
|
||||
private fun postChunk(
|
||||
uploadCode: String,
|
||||
profile: UploadProfile,
|
||||
url: String,
|
||||
uploadId: String,
|
||||
index: Int,
|
||||
total: Int,
|
||||
originalUrl: String,
|
||||
data: ByteArray,
|
||||
file: File,
|
||||
length: Int,
|
||||
): Int {
|
||||
val conn = URL("$BASE/$uploadCode/upload").openConnection() as HttpURLConnection
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
try {
|
||||
conn.requestMethod = "POST"
|
||||
conn.doOutput = true
|
||||
conn.instanceFollowRedirects = false
|
||||
conn.instanceFollowRedirects = true
|
||||
conn.connectTimeout = 15_000
|
||||
conn.readTimeout = 60_000
|
||||
conn.setRequestProperty("Content-Type", "application/octet-stream")
|
||||
conn.setRequestProperty("X-Script-Version", SCRIPT_VERSION)
|
||||
conn.setRequestProperty("X-Upload-Code", uploadCode)
|
||||
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
|
||||
@@ -106,7 +110,7 @@ object Uploader {
|
||||
?.use { it.readText() }
|
||||
?.take(300)
|
||||
}.getOrNull().orEmpty()
|
||||
throw IOException("分片 ${index + 1}/$total 上传失败: HTTP $code $body")
|
||||
throw IOException("${profile.id} 分片 ${index + 1}/$total 上传失败: HTTP $code $body")
|
||||
}
|
||||
return code
|
||||
} finally {
|
||||
@@ -114,7 +118,6 @@ object Uploader {
|
||||
}
|
||||
}
|
||||
|
||||
/** 9 位随机字母数字,同一文件所有分片共用一个 */
|
||||
private fun randomUploadId(): String {
|
||||
val chars = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
return buildString(9) {
|
||||
@@ -122,3 +125,182 @@ object Uploader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("自定义")
|
||||
}
|
||||
|
||||
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")) {
|
||||
map["QQ 号"] = payload.optString("qqnum")
|
||||
}
|
||||
if (payload.has("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>) :
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user