From 9dbafa8279cc13656f88fec00767fdbdc3a8ee8e Mon Sep 17 00:00:00 2001 From: DeterMination-Wind Date: Sat, 21 Feb 2026 12:59:59 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=93=9D=20Draft:=20=E8=93=9D=E5=9B=BE?= =?UTF-8?q?=E5=88=86=E4=BA=AB=E5=A4=9A=E6=BA=90=E6=8E=A5=E5=85=A5=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 724dd7c2ef300ac062ea582ccaa077ce26c2dcb6 Mon Sep 17 00:00:00 2001 From: Wind-DeterMination-backup Date: Sat, 14 Mar 2026 15:16:12 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20=E6=94=AF=E6=8C=81=E8=93=9D?= =?UTF-8?q?=E5=9B=BE=E5=88=86=E4=BA=AB=E5=A4=9A=E6=BA=90=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E4=B8=8E=20PrivateBin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/bundles/bundle-mdtx.properties | 2 + assets/bundles/bundle-mdtx_en.properties | 4 +- src/mindustryX/features/PastebinShare.kt | 623 +++++++++++++++++++++++ src/mindustryX/features/ShareFeature.kt | 53 +- 4 files changed, 661 insertions(+), 21 deletions(-) create mode 100644 src/mindustryX/features/PastebinShare.kt diff --git a/assets/bundles/bundle-mdtx.properties b/assets/bundles/bundle-mdtx.properties index 6b266a5556da..7f8524ddf9b1 100644 --- a/assets/bundles/bundle-mdtx.properties +++ b/assets/bundles/bundle-mdtx.properties @@ -145,6 +145,8 @@ settingV2.arcExtra.backgroundPath.description = 需要填写完整路径 settingV2.arcExtra.radarMode.name = 雷达扫描速度 settingV2.arcExtra.radarSize.name = 雷达大小 settingV2.arcExtra.schematicShare.name = 接收蓝图分享 +settingV2.arcExtra.schematicShare.pastebinSources.name = 蓝图分享源 +settingV2.arcExtra.schematicShare.pastebinSources.description = 旧版 pastebin.com 继续使用旧消息格式;其他源改用 SchemV2,避免旧版客户端错误拉取。\n默认备用源为 PrivateBin,需要携带解密 key。 settingV2.block.category = 建筑相关设置 settingV2.block.rotateCanvas.name = 蓝图旋转时会旋转画板内容 [orange]可能导致崩溃,小心使用 diff --git a/assets/bundles/bundle-mdtx_en.properties b/assets/bundles/bundle-mdtx_en.properties index dba6161c4c64..3a022b6bbb34 100644 --- a/assets/bundles/bundle-mdtx_en.properties +++ b/assets/bundles/bundle-mdtx_en.properties @@ -145,6 +145,8 @@ settingV2.arcExtra.backgroundPath.description = Full path required settingV2.arcExtra.radarMode.name = Radar Scan Speed settingV2.arcExtra.radarSize.name = Radar Size settingV2.arcExtra.schematicShare.name = Receive Schematic Share +settingV2.arcExtra.schematicShare.pastebinSources.name = Schematic Share Sources +settingV2.arcExtra.schematicShare.pastebinSources.description = pastebin.com keeps the legacy chat format; other sources use SchemV2 so old clients will not fetch the wrong raw paste.\nThe default fallback source is PrivateBin and requires its decryption key. settingV2.block.category = Block Settings settingV2.block.rotateCanvas.name = Rotate Canvas When Rotating Schematic [orange]May Cause Crash, Use Cautiously @@ -294,4 +296,4 @@ unit.quell-missile.name = Quell Missile unit.scathe-missile.name = Scathe Missile team.neoplastic.name = Neoplastic -# 学术特色翻译(无需翻译其他语言) \ No newline at end of file +# 学术特色翻译(无需翻译其他语言) diff --git a/src/mindustryX/features/PastebinShare.kt b/src/mindustryX/features/PastebinShare.kt new file mode 100644 index 000000000000..fd9bf4addc8f --- /dev/null +++ b/src/mindustryX/features/PastebinShare.kt @@ -0,0 +1,623 @@ +package mindustryX.features + +import arc.Core +import arc.scene.ui.CheckBox +import arc.scene.ui.TextButton +import arc.scene.ui.layout.Table +import arc.util.Http +import arc.util.Log +import arc.util.serialization.Jval +import mindustry.Vars +import mindustry.gen.Icon +import mindustry.ui.Styles +import java.net.URL +import java.net.URLEncoder +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +object PastebinShare { + private const val typePastebin = "pastebin" + private const val typePrivateBin = "privatebin" + private const val pastebinDevKey = "sdBDjI5mWBnHl9vBEDMNiYQ3IZe0LFEk" + private const val privateBinRequestedWith = "JSONHttpRequest" + private const val privateBinIterations = 100000 + private const val privateBinKeyBits = 256 + private const val privateBinKeyBytes = privateBinKeyBits / 8 + private const val privateBinTagBits = 128 + private const val privateBinTagBytes = privateBinTagBits / 8 + private const val privateBinSaltBytes = 8 + private val secureRandom = SecureRandom() + private val base64 = Base64.getEncoder() + private val base64Decoder = Base64.getDecoder() + private var preferredSourceId = 0 + + data class Source( + var id: Int, + var name: String, + var baseUrl: String, + var enabled: Boolean, + var type: String, + var expire: String + ) { + constructor() : this(0, "", "https://pastebin.com", true, typePastebin, "10M") + + fun normalizedBaseUrl(): String = normalizeBaseUrl(baseUrl) + fun normalizedType(): String = normalizeType(type) + fun normalizedExpire(): String = normalizeExpire(expire, normalizedType()) + + fun displayName(): String { + val trimmedName = name.trim() + if (trimmedName.isNotEmpty()) return trimmedName + + val base = normalizedBaseUrl() + return runCatching { + URL(base).host.takeIf { it.isNotBlank() } ?: base + }.getOrDefault(base.ifBlank { "source-$id" }) + } + } + + data class ShareLink( + val source: Source, + val id: String, + val link: String, + val key: String? = null + ) { + val baseUrl: String get() = source.normalizedBaseUrl() + val sourceType: String get() = source.normalizedType() + + fun useLegacyMessage(): Boolean = sourceType == typePastebin && isPastebinDotCom(baseUrl) + + fun chatPayload(): String { + return if (useLegacyMessage()) { + "[black]一坨兼容[] $id" + } else { + buildString { + append(" ") + append(sourceType).append(' ') + append(baseUrl).append(' ') + append(id) + key?.takeIf { it.isNotBlank() }?.let { + append(' ').append(it) + } + } + } + } + } + + @JvmField + val sources = object : SettingsV2.Data>("arcExtra.schematicShare.pastebinSources", defaults()) { + init { + persistentProvider = SettingsV2.PersistentProvider.AsUBJson( + SettingsV2.PersistentProvider.Arc(name), + List::class.java, + Source::class.java + ) + } + + override fun set(value: List) { + super.set(normalize(value)) + } + + override fun buildUI() = Table().let { root -> + var shown = false + root.button(title) { shown = !shown }.growX().height(55f).padBottom(2f).get().apply { + imageDraw { if (shown) Icon.downOpen else Icon.upOpen }.size(Vars.iconMed) + cells.reverse() + update { isChecked = shown } + } + root.row() + root.collapser(Table().apply { + defaults().pad(2f) + update { + if (changed()) clearChildren() + if (hasChildren()) return@update + + add("#").width(24f) + add("启用").width(36f) + add("类型").width(76f) + add("名称 / 地址").growX() + add("过期").width(72f) + add("操作").width(78f) + row() + + value.forEachIndexed { index, source -> + var edited = source + add(index.toString()).width(24f) + + val enabledBox = CheckBox("") + enabledBox.isChecked = source.enabled + enabledBox.changed { edited = edited.copy(enabled = enabledBox.isChecked) } + add(enabledBox).width(36f) + + val typeButton = TextButton("", Styles.togglet) + typeButton.clicked { + val nextType = nextType(edited.type) + val keepExpire = edited.expire.trim().isNotEmpty() && edited.expire != normalizeExpire(edited.expire, edited.type) + edited = edited.copy( + type = nextType, + expire = if (keepExpire) edited.expire.trim() else defaultExpire(nextType) + ) + } + typeButton.update { typeButton.setText(typeLabel(edited.type)) } + add(typeButton).width(76f) + + table { + it.defaults().growX().left() + it.field(source.name) { text -> edited = edited.copy(name = text) }.maxTextLength(32).row() + it.field(source.baseUrl) { text -> edited = edited.copy(baseUrl = text) }.maxTextLength(240) + }.growX() + + field(source.expire) { text -> edited = edited.copy(expire = text) }.maxTextLength(24).width(72f) + + table { ops -> + ops.button(Icon.trashSmall, Styles.clearNonei, Vars.iconMed) { + set(value.filterNot { it === source }) + } + ops.button(Icon.saveSmall, Styles.clearNonei, Vars.iconMed) { + set(value.map { if (it === source) edited else it }) + }.disabled { normalizeOne(edited) == source } + }.width(78f) + row() + } + + button("@add", Icon.addSmall) { + val nextId = (value.maxOfOrNull { it.id } ?: -1) + 1 + set(value + Source(nextId, "新分享源", "https://", true, typePastebin, "10M")) + }.colspan(columns).fillX().row() + add("[yellow]修改后请点击保存图标,PrivateBin 需携带解密 key").colspan(columns).center().padTop(-4f).row() + } + }) { shown }.growX() + root.row() + } + } + + fun upload(content: String, callback: (ShareLink?) -> Unit) { + val candidates = orderedEnabledSources() + if (candidates.isEmpty()) { + Core.app.post { + Vars.ui.showInfo("未配置启用的蓝图分享源") + callback(null) + } + return + } + + val failures = mutableListOf() + tryUpload(candidates, 0, content, failures, callback) + } + + fun download( + sourceType: String, + baseUrl: String, + id: String, + key: String?, + callback: (String) -> Unit, + failed: (Throwable) -> Unit + ) { + when (normalizeType(sourceType)) { + typePrivateBin -> downloadPrivateBin(baseUrl, id, key, callback, failed) + else -> downloadPastebinLike(baseUrl, id, callback, failed) + } + } + + private fun tryUpload( + candidates: List, + index: Int, + content: String, + failures: MutableList, + callback: (ShareLink?) -> Unit + ) { + if (index >= candidates.size) { + Core.app.post { + Vars.ui.showInfo( + buildString { + append("蓝图上传失败") + if (failures.isNotEmpty()) { + append(":\n") + append(failures.joinToString("\n")) + } + } + ) + callback(null) + } + return + } + + val source = candidates[index] + val onFailure: (String) -> Unit = { reason -> + failures += "${source.displayName()}: $reason" + tryUpload(candidates, index + 1, content, failures, callback) + } + + if (source.normalizedType() == typePrivateBin) { + uploadPrivateBin(source, content, callback, onFailure) + } else { + uploadPastebinLike(source, content, callback, onFailure) + } + } + + private fun uploadPastebinLike( + source: Source, + content: String, + callback: (ShareLink?) -> Unit, + failed: (String) -> Unit + ) { + val baseUrl = source.normalizedBaseUrl() + val expire = source.normalizedExpire() + val body = formEncode( + "api_dev_key" to pastebinDevKey, + "api_option" to "paste", + "api_paste_expire_date" to expire, + "api_paste_code" to content + ) + + Http.post("$baseUrl/api/api_post.php") + .header("Content-Type", "application/x-www-form-urlencoded") + .content(body) + .timeout(10000) + .error { failed(extractHttpError(it)) } + .submit { res -> + val raw = res.resultAsString.trim() + val shareLink = parsePastebinResponse(source, raw) + if (shareLink == null) { + failed(raw.ifBlank { "返回内容为空" }) + return@submit + } + preferredSourceId = source.id + Core.app.post { callback(shareLink) } + } + } + + private fun uploadPrivateBin( + source: Source, + content: String, + callback: (ShareLink?) -> Unit, + failed: (String) -> Unit + ) { + val baseUrl = source.normalizedBaseUrl() + val expire = source.normalizedExpire() + val encrypted = runCatching { PrivateBinCodec.encrypt(content, expire) } + .getOrElse { + failed(it.message ?: it.toString()) + return + } + + Http.post(baseUrl) + .header("Content-Type", "application/json") + .header("X-Requested-With", privateBinRequestedWith) + .content(encrypted.requestJson) + .timeout(10000) + .error { failed(extractHttpError(it)) } + .submit { res -> + val raw = res.resultAsString + val id = runCatching { + val json = Jval.read(raw) + if (json.getInt("status", -1) != 0) error(json.getString("message", "PrivateBin 返回失败")) + json.getString("id", "").takeIf { it.isNotBlank() } ?: error("PrivateBin 未返回 id") + }.getOrElse { + failed(it.message ?: it.toString()) + return@submit + } + + preferredSourceId = source.id + Core.app.post { + callback( + ShareLink( + source = source, + id = id, + link = "$baseUrl/?$id#${encrypted.passcode}", + key = encrypted.passcode + ) + ) + } + } + } + + private fun downloadPastebinLike( + baseUrl: String, + id: String, + callback: (String) -> Unit, + failed: (Throwable) -> Unit + ) { + Http.get("${normalizeBaseUrl(baseUrl)}/raw/$id") + .timeout(10000) + .error(failed) + .submit { res -> + val content = res.resultAsString.replace(" ", "+") + Core.app.post { callback(content) } + } + } + + private fun downloadPrivateBin( + baseUrl: String, + id: String, + key: String?, + callback: (String) -> Unit, + failed: (Throwable) -> Unit + ) { + if (key.isNullOrBlank()) { + failed(IllegalArgumentException("PrivateBin 分享缺少解密 key")) + return + } + + Http.get("${normalizeBaseUrl(baseUrl)}/?pasteid=$id") + .header("X-Requested-With", privateBinRequestedWith) + .timeout(10000) + .error(failed) + .submit { res -> + val content = runCatching { + PrivateBinCodec.decrypt(res.resultAsString, key).replace(" ", "+") + }.getOrElse { + failed(it) + return@submit + } + Core.app.post { callback(content) } + } + } + + private fun parsePastebinResponse(source: Source, raw: String): ShareLink? { + if (raw.isBlank() || raw.startsWith("Bad API request", ignoreCase = true)) return null + + val id = when { + raw.startsWith("http://") || raw.startsWith("https://") -> raw.substringBefore('?').substringAfterLast('/').trim() + raw.matches(Regex("[A-Za-z0-9]+")) -> raw + else -> return null + } + if (id.isBlank()) return null + + val baseUrl = source.normalizedBaseUrl() + return ShareLink(source, id, "$baseUrl/$id") + } + + private fun orderedEnabledSources(): List { + val enabled = sources.value.filter { it.enabled && it.normalizedBaseUrl().isNotBlank() } + if (enabled.isEmpty()) return emptyList() + + val preferred = enabled.firstOrNull { it.id == preferredSourceId } ?: enabled.first() + return listOf(preferred) + enabled.filterNot { it.id == preferred.id } + } + + private fun normalize(raw: List): List { + return raw.mapNotNull { normalizeOne(it) } + } + + private fun normalizeOne(source: Source): Source? { + val baseUrl = normalizeBaseUrl(source.baseUrl) + if (baseUrl.isBlank()) return null + + val type = normalizeType(source.type) + return source.copy( + name = source.name.trim(), + baseUrl = baseUrl, + type = type, + expire = normalizeExpire(source.expire, type) + ) + } + + private fun defaults(): List = listOf( + Source(0, "pastebin.com", "https://pastebin.com", true, typePastebin, "10M"), + Source(1, "PrivateBin 备用", "https://8.136.36.61:8080/", true, typePrivateBin, "1day") + ) + + private fun nextType(type: String): String = if (normalizeType(type) == typePastebin) typePrivateBin else typePastebin + + private fun typeLabel(type: String): String = when (normalizeType(type)) { + typePrivateBin -> "PrivateBin" + else -> "Pastebin" + } + + private fun defaultExpire(type: String): String = when (normalizeType(type)) { + typePrivateBin -> "1day" + else -> "10M" + } + + private fun normalizeExpire(expire: String, type: String): String { + val trimmed = expire.trim() + return if (trimmed.isNotEmpty()) trimmed else defaultExpire(type) + } + + private fun normalizeType(type: String): String { + return if (type.equals(typePrivateBin, ignoreCase = true)) typePrivateBin else typePastebin + } + + private fun normalizeBaseUrl(raw: String): String { + val trimmed = raw.trim().trimEnd('/') + if (trimmed.isBlank() || trimmed == "https://" || trimmed == "http://") return "" + return when { + trimmed.startsWith("https://", ignoreCase = true) -> trimmed + trimmed.startsWith("http://", ignoreCase = true) -> trimmed + else -> "https://$trimmed" + } + } + + private fun isPastebinDotCom(baseUrl: String): Boolean = runCatching { + val host = URL(baseUrl).host.lowercase() + host == "pastebin.com" || host == "www.pastebin.com" + }.getOrDefault(false) + + private fun formEncode(vararg parts: Pair): String { + return parts.joinToString("&") { (key, value) -> + "${urlEncode(key)}=${urlEncode(value)}" + } + } + + private fun urlEncode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) + + private fun extractHttpError(error: Throwable): String { + if (error is Http.HttpStatusException) { + val body = error.response.getResultAsString().trim() + return buildString { + append("HTTP ").append(error.status.code) + if (body.isNotBlank()) append(": ").append(body) + } + } + return error.message ?: error.toString() + } + + private object PrivateBinCodec { + data class EncryptedPaste(val requestJson: String, val passcode: String) + + fun encrypt(content: String, expire: String): EncryptedPaste { + val iv = ByteArray(privateBinTagBytes).also(secureRandom::nextBytes) + val salt = ByteArray(privateBinSaltBytes).also(secureRandom::nextBytes) + val passphrase = ByteArray(privateBinKeyBytes).also(secureRandom::nextBytes) + val adata = buildAdata(iv, salt) + val derivedKey = deriveKey(passphrase, salt) + val plaintext = Jval.newObject().put("paste", content).toString().toByteArray(StandardCharsets.UTF_8) + val encrypted = crypt(Cipher.ENCRYPT_MODE, derivedKey, iv, adata.toString(), plaintext) + val json = Jval.newObject() + .put("v", 2) + .put("ct", base64.encodeToString(encrypted)) + .put("adata", adata) + .put("meta", Jval.newObject().put("expire", expire)) + .toString() + return EncryptedPaste(json, Base58.encode(passphrase)) + } + + fun decrypt(raw: String, passcode: String): String { + val json = Jval.read(raw) + if (json.getInt("status", 0) != 0) error(json.getString("message", "PrivateBin 返回失败")) + + val adata = json.get("adata") + val params = adata.asArray().get(0).asArray() + val iv = base64Decoder.decode(params.get(0).asString()) + val salt = base64Decoder.decode(params.get(1).asString()) + val compression = params.get(7).asString() + require(compression == "none") { "不支持的 PrivateBin 压缩方式: $compression" } + + val derivedKey = deriveKey(Base58.decode(passcode), salt) + val encrypted = base64Decoder.decode(json.getString("ct", "")) + val decrypted = crypt(Cipher.DECRYPT_MODE, derivedKey, iv, adata.toString(), encrypted) + return Jval.read(String(decrypted, StandardCharsets.UTF_8)).getString("paste", "") + } + + private fun buildAdata(iv: ByteArray, salt: ByteArray): Jval { + return Jval.newArray() + .add( + Jval.newArray() + .add(base64.encodeToString(iv)) + .add(base64.encodeToString(salt)) + .add(privateBinIterations) + .add(privateBinKeyBits) + .add(privateBinTagBits) + .add("aes") + .add("gcm") + .add("none") + ) + .add("plaintext") + .add(0) + .add(0) + } + + private fun deriveKey(passphrase: ByteArray, salt: ByteArray): ByteArray { + return pbkdf2Sha256(passphrase, salt, privateBinIterations, privateBinKeyBytes) + } + + private fun crypt(mode: Int, key: ByteArray, iv: ByteArray, aad: String, input: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(privateBinTagBits, iv)) + cipher.updateAAD(aad.toByteArray(StandardCharsets.UTF_8)) + return cipher.doFinal(input) + } + + private fun pbkdf2Sha256(password: ByteArray, salt: ByteArray, iterations: Int, length: Int): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(password, "HmacSHA256")) + + val block = ByteBuffer.allocate(salt.size + 4) + .put(salt) + .putInt(1) + .array() + var u = mac.doFinal(block) + val output = u.copyOf() + repeat(iterations - 1) { + u = mac.doFinal(u) + for (i in output.indices) { + output[i] = (output[i].toInt() xor u[i].toInt()).toByte() + } + } + return output.copyOf(length) + } + } + + private object Base58 { + private const val alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + private val indexes = IntArray(128) { -1 }.also { table -> + alphabet.forEachIndexed { index, c -> table[c.code] = index } + } + + fun encode(input: ByteArray): String { + if (input.isEmpty()) return "" + + var zeros = 0 + while (zeros < input.size && input[zeros].toInt() == 0) zeros++ + + val encoded = CharArray(input.size * 2) + val copy = input.copyOf() + var outputStart = encoded.size + var startAt = zeros + while (startAt < copy.size) { + val mod = divmod58(copy, startAt) + if (copy[startAt].toInt() == 0) startAt++ + encoded[--outputStart] = alphabet[mod] + } + while (outputStart < encoded.size && encoded[outputStart] == alphabet[0]) outputStart++ + repeat(zeros) { encoded[--outputStart] = alphabet[0] } + return String(encoded, outputStart, encoded.size - outputStart) + } + + fun decode(input: String): ByteArray { + if (input.isEmpty()) return ByteArray(0) + + val input58 = ByteArray(input.length) + input.forEachIndexed { index, c -> + val value = if (c.code < indexes.size) indexes[c.code] else -1 + require(value >= 0) { "非法的 PrivateBin key" } + input58[index] = value.toByte() + } + + var zeros = 0 + while (zeros < input58.size && input58[zeros].toInt() == 0) zeros++ + + val decoded = ByteArray(input.length) + var outputStart = decoded.size + var startAt = zeros + while (startAt < input58.size) { + val mod = divmod256(input58, startAt) + if (input58[startAt].toInt() == 0) startAt++ + decoded[--outputStart] = mod.toByte() + } + while (outputStart < decoded.size && decoded[outputStart].toInt() == 0) outputStart++ + return ByteArray(decoded.size - outputStart + zeros).also { result -> + decoded.copyInto(result, zeros, outputStart, decoded.size) + } + } + + private fun divmod58(number: ByteArray, startAt: Int): Int { + var remainder = 0 + for (i in startAt until number.size) { + val digit = number[i].toInt() and 0xff + val temp = remainder * 256 + digit + number[i] = (temp / 58).toByte() + remainder = temp % 58 + } + return remainder + } + + private fun divmod256(number58: ByteArray, startAt: Int): Int { + var remainder = 0 + for (i in startAt until number58.size) { + val digit = number58[i].toInt() and 0xff + val temp = remainder * 58 + digit + number58[i] = (temp / 256).toByte() + remainder = temp % 256 + } + return remainder + } + } +} diff --git a/src/mindustryX/features/ShareFeature.kt b/src/mindustryX/features/ShareFeature.kt index 3544f649bd39..ad4661ff909d 100644 --- a/src/mindustryX/features/ShareFeature.kt +++ b/src/mindustryX/features/ShareFeature.kt @@ -33,6 +33,9 @@ import mindustryX.features.ui.FormatDefault.format import mindustryX.features.ui.comp.GridTable object ShareFeature { + @Suppress("unused") + private val pastebinShare = PastebinShare + private fun tag(icon: Char) = "" @JvmStatic @@ -47,30 +50,15 @@ object ShareFeature { @JvmStatic fun shareSchematic(s: Schematic?) { - uploadPasteBin(Vars.schematics.writeBase64(s)) { url -> - if (url == null) return@uploadPasteBin - val code = url.substring(url.lastIndexOf('/') + 1) - send(Iconc.paste, "[black]一坨兼容[] $code") - } - } - - private fun uploadPasteBin(content: String, callback: (String?) -> Unit) { - val req = Http.post("https://pastebin.com/api/api_post.php", "api_dev_key=sdBDjI5mWBnHl9vBEDMNiYQ3IZe0LFEk&api_option=paste&api_paste_expire_date=10M&api_paste_code=$content") - req.submit { res -> - val code = res!!.getResultAsString() - Core.app.post { callback(code) } - } - req.error { - Core.app.post { - Vars.ui.showException(i("上传失败,再重试一下?"), it) - Core.app.post { callback(null) } - } + PastebinShare.upload(Vars.schematics.writeBase64(s)) { shareLink -> + if (shareLink == null) return@upload + send(if (shareLink.useLegacyMessage()) Iconc.paste else Iconc.link, shareLink.chatPayload()) } } @JvmStatic fun shareSchematicClipboard(schem: Schematic) { - uploadPasteBin(Vars.schematics.writeBase64(schem)) { link: String? -> + PastebinShare.upload(Vars.schematics.writeBase64(schem)) { shareLink -> val msg = VarsX.bundle.shareSchematic( name = schem.name(), playerName = Vars.player.name, @@ -93,7 +81,7 @@ object ShareFeature { append("-").append(Strings.autoFixed(cons, 2)) } }, - link = link, code = Vars.schematics.writeBase64(schem) + link = shareLink?.link, code = Vars.schematics.writeBase64(schem) ) Core.app.setClipboardText(Strings.stripColors(msg)) UIExt.announce(i("已保存至剪贴板")) @@ -273,6 +261,30 @@ object ShareFeature { return true } + private val schematicV2Pattern = Regex("""\s+(\S+)\s+(\S+)\s+(\S+)(?:\s+(\S+))?""") + private fun resolveSchematicShareV2(message: String, sender: Player?): Boolean { + if (!ArcOld.schematicShare.get()) return false + + val match = schematicV2Pattern.find(message) ?: return false + val sourceType = match.groupValues[1] + val baseUrl = match.groupValues[2] + val id = match.groupValues[3] + val key = match.groupValues.getOrNull(4)?.takeIf { it.isNotBlank() } + + PastebinShare.download( + sourceType = sourceType, + baseUrl = baseUrl, + id = id, + key = key, + callback = { content -> + runCatching { Vars.ui.schematics.readShare(content, sender) } + .onFailure { Log.err("Fail read schematic share v2: ", it) } + }, + failed = { Log.err("Fail fetch schematic share v2: ", it) } + ) + return true + } + private val posPattern = Regex("\\((\\d+),(\\d+)\\)") private fun resolvePositionShare(message: String): Vec2? { val match = posPattern.find(message) ?: return null @@ -291,6 +303,7 @@ object ShareFeature { ArcMessageDialog.Msg(ArcMessageDialog.Type.markPlayer, message, sender).add() } + resolveSchematicShareV2(message, sender) -> {} resolveSchematicShare(message, sender) -> {} else -> { if (sender == null) { From 567aa05c03924f326831058ad442e1d833e1b563 Mon Sep 17 00:00:00 2001 From: Wind-DeterMination-backup Date: Sat, 14 Mar 2026 16:52:24 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20=E4=B8=BA=20Private?= =?UTF-8?q?Bin=20=E7=BC=96=E8=A7=A3=E7=A0=81=E8=A1=A5=E5=85=85=E8=87=AA?= =?UTF-8?q?=E6=A3=80=E5=90=91=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mindustryX/features/PastebinShare.kt | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/mindustryX/features/PastebinShare.kt b/src/mindustryX/features/PastebinShare.kt index fd9bf4addc8f..807ae480bed4 100644 --- a/src/mindustryX/features/PastebinShare.kt +++ b/src/mindustryX/features/PastebinShare.kt @@ -462,6 +462,10 @@ object PastebinShare { private object PrivateBinCodec { data class EncryptedPaste(val requestJson: String, val passcode: String) + init { + runSelfCheck() + } + fun encrypt(content: String, expire: String): EncryptedPaste { val iv = ByteArray(privateBinTagBytes).also(secureRandom::nextBytes) val salt = ByteArray(privateBinSaltBytes).also(secureRandom::nextBytes) @@ -543,6 +547,30 @@ object PastebinShare { } return output.copyOf(length) } + + // PrivateBin compatibility depends on these low-level helpers being byte-exact. + private fun runSelfCheck() { + val password = hexToBytes("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + val salt = hexToBytes("0001020304050607") + check( + pbkdf2Sha256(password, salt, privateBinIterations, privateBinKeyBytes) + .contentEquals(hexToBytes("0c22cef0bac57e3665d31565b9bbd940a6c110f0b11945d1cb6c6520cec59d4f")) + ) { "PrivateBin PBKDF2 self-check failed" } + + check(Base58.encode(password) == "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE") { + "PrivateBin Base58 encode self-check failed" + } + check(Base58.decode("112VfUX").contentEquals(hexToBytes("000001020304"))) { + "PrivateBin Base58 decode self-check failed" + } + } + + private fun hexToBytes(hex: String): ByteArray { + require(hex.length % 2 == 0) { "Invalid hex length" } + return ByteArray(hex.length / 2) { index -> + hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + } } private object Base58 { From b3b09820789723bce3e92baf9e3783286e5dd776 Mon Sep 17 00:00:00 2001 From: Wind-DeterMination-backup Date: Sun, 15 Mar 2026 01:00:26 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E6=9A=82=E6=97=B6?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=20PrivateBin=20=E5=B9=B6=E5=88=87=E6=8D=A2?= =?UTF-8?q?=200x0.st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/bundles/bundle-mdtx.properties | 2 +- assets/bundles/bundle-mdtx_en.properties | 2 +- src/mindustryX/features/PastebinShare.kt | 326 ++++------------------- src/mindustryX/features/ShareFeature.kt | 1 - 4 files changed, 55 insertions(+), 276 deletions(-) diff --git a/assets/bundles/bundle-mdtx.properties b/assets/bundles/bundle-mdtx.properties index 7f8524ddf9b1..50b38f894cbf 100644 --- a/assets/bundles/bundle-mdtx.properties +++ b/assets/bundles/bundle-mdtx.properties @@ -146,7 +146,7 @@ settingV2.arcExtra.radarMode.name = 雷达扫描速度 settingV2.arcExtra.radarSize.name = 雷达大小 settingV2.arcExtra.schematicShare.name = 接收蓝图分享 settingV2.arcExtra.schematicShare.pastebinSources.name = 蓝图分享源 -settingV2.arcExtra.schematicShare.pastebinSources.description = 旧版 pastebin.com 继续使用旧消息格式;其他源改用 SchemV2,避免旧版客户端错误拉取。\n默认备用源为 PrivateBin,需要携带解密 key。 +settingV2.arcExtra.schematicShare.pastebinSources.description = 旧版 pastebin.com 继续使用旧消息格式;其他源改用 SchemV2,避免旧版客户端错误拉取。\n当前默认备用源改为 0x0.st,因为它更稳定。 settingV2.block.category = 建筑相关设置 settingV2.block.rotateCanvas.name = 蓝图旋转时会旋转画板内容 [orange]可能导致崩溃,小心使用 diff --git a/assets/bundles/bundle-mdtx_en.properties b/assets/bundles/bundle-mdtx_en.properties index 3a022b6bbb34..d421ad7a7b5b 100644 --- a/assets/bundles/bundle-mdtx_en.properties +++ b/assets/bundles/bundle-mdtx_en.properties @@ -146,7 +146,7 @@ settingV2.arcExtra.radarMode.name = Radar Scan Speed settingV2.arcExtra.radarSize.name = Radar Size settingV2.arcExtra.schematicShare.name = Receive Schematic Share settingV2.arcExtra.schematicShare.pastebinSources.name = Schematic Share Sources -settingV2.arcExtra.schematicShare.pastebinSources.description = pastebin.com keeps the legacy chat format; other sources use SchemV2 so old clients will not fetch the wrong raw paste.\nThe default fallback source is PrivateBin and requires its decryption key. +settingV2.arcExtra.schematicShare.pastebinSources.description = pastebin.com keeps the legacy chat format; other sources use SchemV2 so old clients will not fetch the wrong raw paste.\nThe current default fallback source is 0x0.st because it is more stable. settingV2.block.category = Block Settings settingV2.block.rotateCanvas.name = Rotate Canvas When Rotating Schematic [orange]May Cause Crash, Use Cautiously diff --git a/src/mindustryX/features/PastebinShare.kt b/src/mindustryX/features/PastebinShare.kt index 807ae480bed4..a388e3a9ab57 100644 --- a/src/mindustryX/features/PastebinShare.kt +++ b/src/mindustryX/features/PastebinShare.kt @@ -5,36 +5,19 @@ import arc.scene.ui.CheckBox import arc.scene.ui.TextButton import arc.scene.ui.layout.Table import arc.util.Http -import arc.util.Log -import arc.util.serialization.Jval import mindustry.Vars import mindustry.gen.Icon import mindustry.ui.Styles +import mindustryX.VarsX import java.net.URL import java.net.URLEncoder -import java.nio.ByteBuffer import java.nio.charset.StandardCharsets -import java.security.SecureRandom -import java.util.Base64 -import javax.crypto.Cipher -import javax.crypto.Mac -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec object PastebinShare { private const val typePastebin = "pastebin" - private const val typePrivateBin = "privatebin" + private const val typeDirect = "direct" private const val pastebinDevKey = "sdBDjI5mWBnHl9vBEDMNiYQ3IZe0LFEk" - private const val privateBinRequestedWith = "JSONHttpRequest" - private const val privateBinIterations = 100000 - private const val privateBinKeyBits = 256 - private const val privateBinKeyBytes = privateBinKeyBits / 8 - private const val privateBinTagBits = 128 - private const val privateBinTagBytes = privateBinTagBits / 8 - private const val privateBinSaltBytes = 8 - private val secureRandom = SecureRandom() - private val base64 = Base64.getEncoder() - private val base64Decoder = Base64.getDecoder() + private const val directUploadUserAgent = "MindustryX-schematic-share" private var preferredSourceId = 0 data class Source( @@ -65,8 +48,7 @@ object PastebinShare { data class ShareLink( val source: Source, val id: String, - val link: String, - val key: String? = null + val link: String ) { val baseUrl: String get() = source.normalizedBaseUrl() val sourceType: String get() = source.normalizedType() @@ -77,15 +59,7 @@ object PastebinShare { return if (useLegacyMessage()) { "[black]一坨兼容[] $id" } else { - buildString { - append(" ") - append(sourceType).append(' ') - append(baseUrl).append(' ') - append(id) - key?.takeIf { it.isNotBlank() }?.let { - append(' ').append(it) - } - } + " $sourceType $baseUrl $id" } } } @@ -170,7 +144,7 @@ object PastebinShare { val nextId = (value.maxOfOrNull { it.id } ?: -1) + 1 set(value + Source(nextId, "新分享源", "https://", true, typePastebin, "10M")) }.colspan(columns).fillX().row() - add("[yellow]修改后请点击保存图标,PrivateBin 需携带解密 key").colspan(columns).center().padTop(-4f).row() + add("[yellow]0x0.st 直链更稳定,但仍是公共文件托管服务").colspan(columns).center().padTop(-4f).row() } }) { shown }.growX() root.row() @@ -195,12 +169,11 @@ object PastebinShare { sourceType: String, baseUrl: String, id: String, - key: String?, callback: (String) -> Unit, failed: (Throwable) -> Unit ) { when (normalizeType(sourceType)) { - typePrivateBin -> downloadPrivateBin(baseUrl, id, key, callback, failed) + typeDirect -> downloadDirect(baseUrl, id, callback, failed) else -> downloadPastebinLike(baseUrl, id, callback, failed) } } @@ -234,8 +207,8 @@ object PastebinShare { tryUpload(candidates, index + 1, content, failures, callback) } - if (source.normalizedType() == typePrivateBin) { - uploadPrivateBin(source, content, callback, onFailure) + if (source.normalizedType() == typeDirect) { + uploadDirectHost(source, content, callback, onFailure) } else { uploadPastebinLike(source, content, callback, onFailure) } @@ -273,7 +246,7 @@ object PastebinShare { } } - private fun uploadPrivateBin( + private fun uploadDirectHost( source: Source, content: String, callback: (ShareLink?) -> Unit, @@ -281,40 +254,41 @@ object PastebinShare { ) { val baseUrl = source.normalizedBaseUrl() val expire = source.normalizedExpire() - val encrypted = runCatching { PrivateBinCodec.encrypt(content, expire) } - .getOrElse { - failed(it.message ?: it.toString()) - return + val boundary = "----MindustryX${System.currentTimeMillis()}" + val body = buildString { + append("--").append(boundary).append("\r\n") + append("Content-Disposition: form-data; name=\"file\"; filename=\"schematic-").append(System.currentTimeMillis()).append(".txt\"\r\n") + append("Content-Type: text/plain; charset=UTF-8\r\n\r\n") + append(content).append("\r\n") + + append("--").append(boundary).append("\r\n") + append("Content-Disposition: form-data; name=\"secret\"\r\n\r\n") + append("1\r\n") + + if (expire.isNotBlank()) { + append("--").append(boundary).append("\r\n") + append("Content-Disposition: form-data; name=\"expires\"\r\n\r\n") + append(expire).append("\r\n") } + append("--").append(boundary).append("--\r\n") + } + Http.post(baseUrl) - .header("Content-Type", "application/json") - .header("X-Requested-With", privateBinRequestedWith) - .content(encrypted.requestJson) - .timeout(10000) + .header("Content-Type", "multipart/form-data; boundary=$boundary") + .header("User-Agent", "$directUploadUserAgent/${VarsX.version}") + .content(body) + .timeout(15000) .error { failed(extractHttpError(it)) } .submit { res -> - val raw = res.resultAsString - val id = runCatching { - val json = Jval.read(raw) - if (json.getInt("status", -1) != 0) error(json.getString("message", "PrivateBin 返回失败")) - json.getString("id", "").takeIf { it.isNotBlank() } ?: error("PrivateBin 未返回 id") - }.getOrElse { - failed(it.message ?: it.toString()) + val raw = res.resultAsString.trim() + val shareLink = parseDirectResponse(source, raw) + if (shareLink == null) { + failed(raw.ifBlank { "返回内容为空" }) return@submit } - preferredSourceId = source.id - Core.app.post { - callback( - ShareLink( - source = source, - id = id, - link = "$baseUrl/?$id#${encrypted.passcode}", - key = encrypted.passcode - ) - ) - } + Core.app.post { callback(shareLink) } } } @@ -333,29 +307,17 @@ object PastebinShare { } } - private fun downloadPrivateBin( + private fun downloadDirect( baseUrl: String, id: String, - key: String?, callback: (String) -> Unit, failed: (Throwable) -> Unit ) { - if (key.isNullOrBlank()) { - failed(IllegalArgumentException("PrivateBin 分享缺少解密 key")) - return - } - - Http.get("${normalizeBaseUrl(baseUrl)}/?pasteid=$id") - .header("X-Requested-With", privateBinRequestedWith) + Http.get("${normalizeBaseUrl(baseUrl)}/$id") .timeout(10000) .error(failed) .submit { res -> - val content = runCatching { - PrivateBinCodec.decrypt(res.resultAsString, key).replace(" ", "+") - }.getOrElse { - failed(it) - return@submit - } + val content = res.resultAsString.replace(" ", "+") Core.app.post { callback(content) } } } @@ -374,6 +336,14 @@ object PastebinShare { return ShareLink(source, id, "$baseUrl/$id") } + private fun parseDirectResponse(source: Source, raw: String): ShareLink? { + if (!raw.startsWith("http://") && !raw.startsWith("https://")) return null + + val url = runCatching { URL(raw) }.getOrNull() ?: return null + val path = url.path.trim('/').takeIf { it.isNotBlank() } ?: return null + return ShareLink(source, path, raw) + } + private fun orderedEnabledSources(): List { val enabled = sources.value.filter { it.enabled && it.normalizedBaseUrl().isNotBlank() } if (enabled.isEmpty()) return emptyList() @@ -401,18 +371,18 @@ object PastebinShare { private fun defaults(): List = listOf( Source(0, "pastebin.com", "https://pastebin.com", true, typePastebin, "10M"), - Source(1, "PrivateBin 备用", "https://8.136.36.61:8080/", true, typePrivateBin, "1day") + Source(1, "0x0.st 备用", "https://0x0.st", true, typeDirect, "24") ) - private fun nextType(type: String): String = if (normalizeType(type) == typePastebin) typePrivateBin else typePastebin + private fun nextType(type: String): String = if (normalizeType(type) == typePastebin) typeDirect else typePastebin private fun typeLabel(type: String): String = when (normalizeType(type)) { - typePrivateBin -> "PrivateBin" + typeDirect -> "0x0.st" else -> "Pastebin" } private fun defaultExpire(type: String): String = when (normalizeType(type)) { - typePrivateBin -> "1day" + typeDirect -> "24" else -> "10M" } @@ -422,7 +392,7 @@ object PastebinShare { } private fun normalizeType(type: String): String { - return if (type.equals(typePrivateBin, ignoreCase = true)) typePrivateBin else typePastebin + return if (type.equals(typeDirect, ignoreCase = true)) typeDirect else typePastebin } private fun normalizeBaseUrl(raw: String): String { @@ -458,194 +428,4 @@ object PastebinShare { } return error.message ?: error.toString() } - - private object PrivateBinCodec { - data class EncryptedPaste(val requestJson: String, val passcode: String) - - init { - runSelfCheck() - } - - fun encrypt(content: String, expire: String): EncryptedPaste { - val iv = ByteArray(privateBinTagBytes).also(secureRandom::nextBytes) - val salt = ByteArray(privateBinSaltBytes).also(secureRandom::nextBytes) - val passphrase = ByteArray(privateBinKeyBytes).also(secureRandom::nextBytes) - val adata = buildAdata(iv, salt) - val derivedKey = deriveKey(passphrase, salt) - val plaintext = Jval.newObject().put("paste", content).toString().toByteArray(StandardCharsets.UTF_8) - val encrypted = crypt(Cipher.ENCRYPT_MODE, derivedKey, iv, adata.toString(), plaintext) - val json = Jval.newObject() - .put("v", 2) - .put("ct", base64.encodeToString(encrypted)) - .put("adata", adata) - .put("meta", Jval.newObject().put("expire", expire)) - .toString() - return EncryptedPaste(json, Base58.encode(passphrase)) - } - - fun decrypt(raw: String, passcode: String): String { - val json = Jval.read(raw) - if (json.getInt("status", 0) != 0) error(json.getString("message", "PrivateBin 返回失败")) - - val adata = json.get("adata") - val params = adata.asArray().get(0).asArray() - val iv = base64Decoder.decode(params.get(0).asString()) - val salt = base64Decoder.decode(params.get(1).asString()) - val compression = params.get(7).asString() - require(compression == "none") { "不支持的 PrivateBin 压缩方式: $compression" } - - val derivedKey = deriveKey(Base58.decode(passcode), salt) - val encrypted = base64Decoder.decode(json.getString("ct", "")) - val decrypted = crypt(Cipher.DECRYPT_MODE, derivedKey, iv, adata.toString(), encrypted) - return Jval.read(String(decrypted, StandardCharsets.UTF_8)).getString("paste", "") - } - - private fun buildAdata(iv: ByteArray, salt: ByteArray): Jval { - return Jval.newArray() - .add( - Jval.newArray() - .add(base64.encodeToString(iv)) - .add(base64.encodeToString(salt)) - .add(privateBinIterations) - .add(privateBinKeyBits) - .add(privateBinTagBits) - .add("aes") - .add("gcm") - .add("none") - ) - .add("plaintext") - .add(0) - .add(0) - } - - private fun deriveKey(passphrase: ByteArray, salt: ByteArray): ByteArray { - return pbkdf2Sha256(passphrase, salt, privateBinIterations, privateBinKeyBytes) - } - - private fun crypt(mode: Int, key: ByteArray, iv: ByteArray, aad: String, input: ByteArray): ByteArray { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(privateBinTagBits, iv)) - cipher.updateAAD(aad.toByteArray(StandardCharsets.UTF_8)) - return cipher.doFinal(input) - } - - private fun pbkdf2Sha256(password: ByteArray, salt: ByteArray, iterations: Int, length: Int): ByteArray { - val mac = Mac.getInstance("HmacSHA256") - mac.init(SecretKeySpec(password, "HmacSHA256")) - - val block = ByteBuffer.allocate(salt.size + 4) - .put(salt) - .putInt(1) - .array() - var u = mac.doFinal(block) - val output = u.copyOf() - repeat(iterations - 1) { - u = mac.doFinal(u) - for (i in output.indices) { - output[i] = (output[i].toInt() xor u[i].toInt()).toByte() - } - } - return output.copyOf(length) - } - - // PrivateBin compatibility depends on these low-level helpers being byte-exact. - private fun runSelfCheck() { - val password = hexToBytes("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") - val salt = hexToBytes("0001020304050607") - check( - pbkdf2Sha256(password, salt, privateBinIterations, privateBinKeyBytes) - .contentEquals(hexToBytes("0c22cef0bac57e3665d31565b9bbd940a6c110f0b11945d1cb6c6520cec59d4f")) - ) { "PrivateBin PBKDF2 self-check failed" } - - check(Base58.encode(password) == "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE") { - "PrivateBin Base58 encode self-check failed" - } - check(Base58.decode("112VfUX").contentEquals(hexToBytes("000001020304"))) { - "PrivateBin Base58 decode self-check failed" - } - } - - private fun hexToBytes(hex: String): ByteArray { - require(hex.length % 2 == 0) { "Invalid hex length" } - return ByteArray(hex.length / 2) { index -> - hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() - } - } - } - - private object Base58 { - private const val alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - private val indexes = IntArray(128) { -1 }.also { table -> - alphabet.forEachIndexed { index, c -> table[c.code] = index } - } - - fun encode(input: ByteArray): String { - if (input.isEmpty()) return "" - - var zeros = 0 - while (zeros < input.size && input[zeros].toInt() == 0) zeros++ - - val encoded = CharArray(input.size * 2) - val copy = input.copyOf() - var outputStart = encoded.size - var startAt = zeros - while (startAt < copy.size) { - val mod = divmod58(copy, startAt) - if (copy[startAt].toInt() == 0) startAt++ - encoded[--outputStart] = alphabet[mod] - } - while (outputStart < encoded.size && encoded[outputStart] == alphabet[0]) outputStart++ - repeat(zeros) { encoded[--outputStart] = alphabet[0] } - return String(encoded, outputStart, encoded.size - outputStart) - } - - fun decode(input: String): ByteArray { - if (input.isEmpty()) return ByteArray(0) - - val input58 = ByteArray(input.length) - input.forEachIndexed { index, c -> - val value = if (c.code < indexes.size) indexes[c.code] else -1 - require(value >= 0) { "非法的 PrivateBin key" } - input58[index] = value.toByte() - } - - var zeros = 0 - while (zeros < input58.size && input58[zeros].toInt() == 0) zeros++ - - val decoded = ByteArray(input.length) - var outputStart = decoded.size - var startAt = zeros - while (startAt < input58.size) { - val mod = divmod256(input58, startAt) - if (input58[startAt].toInt() == 0) startAt++ - decoded[--outputStart] = mod.toByte() - } - while (outputStart < decoded.size && decoded[outputStart].toInt() == 0) outputStart++ - return ByteArray(decoded.size - outputStart + zeros).also { result -> - decoded.copyInto(result, zeros, outputStart, decoded.size) - } - } - - private fun divmod58(number: ByteArray, startAt: Int): Int { - var remainder = 0 - for (i in startAt until number.size) { - val digit = number[i].toInt() and 0xff - val temp = remainder * 256 + digit - number[i] = (temp / 58).toByte() - remainder = temp % 58 - } - return remainder - } - - private fun divmod256(number58: ByteArray, startAt: Int): Int { - var remainder = 0 - for (i in startAt until number58.size) { - val digit = number58[i].toInt() and 0xff - val temp = remainder * 58 + digit - number58[i] = (temp / 256).toByte() - remainder = temp % 256 - } - return remainder - } - } } diff --git a/src/mindustryX/features/ShareFeature.kt b/src/mindustryX/features/ShareFeature.kt index ad4661ff909d..ffbf8e3149d2 100644 --- a/src/mindustryX/features/ShareFeature.kt +++ b/src/mindustryX/features/ShareFeature.kt @@ -275,7 +275,6 @@ object ShareFeature { sourceType = sourceType, baseUrl = baseUrl, id = id, - key = key, callback = { content -> runCatching { Vars.ui.schematics.readShare(content, sender) } .onFailure { Log.err("Fail read schematic share v2: ", it) } From d7d890570ed981c0fa249ec248ddc9acbf3a9628 Mon Sep 17 00:00:00 2001 From: Wind-DeterMination-backup Date: Sun, 15 Mar 2026 01:10:36 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=A7=B9=20=E8=B7=9F=E8=BF=9B=20Gemini?= =?UTF-8?q?=20review=20=E6=B8=85=E7=90=86=E5=88=86=E4=BA=AB=E6=BA=90?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mindustryX/features/PastebinShare.kt | 18 ++++++++++-------- src/mindustryX/features/ShareFeature.kt | 1 - 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/mindustryX/features/PastebinShare.kt b/src/mindustryX/features/PastebinShare.kt index a388e3a9ab57..76ccf52550f5 100644 --- a/src/mindustryX/features/PastebinShare.kt +++ b/src/mindustryX/features/PastebinShare.kt @@ -12,6 +12,7 @@ import mindustryX.VarsX import java.net.URL import java.net.URLEncoder import java.nio.charset.StandardCharsets +import java.util.UUID object PastebinShare { private const val typePastebin = "pastebin" @@ -21,12 +22,12 @@ object PastebinShare { private var preferredSourceId = 0 data class Source( - var id: Int, - var name: String, - var baseUrl: String, - var enabled: Boolean, - var type: String, - var expire: String + val id: Int, + val name: String, + val baseUrl: String, + val enabled: Boolean, + val type: String, + val expire: String ) { constructor() : this(0, "", "https://pastebin.com", true, typePastebin, "10M") @@ -254,10 +255,11 @@ object PastebinShare { ) { val baseUrl = source.normalizedBaseUrl() val expire = source.normalizedExpire() - val boundary = "----MindustryX${System.currentTimeMillis()}" + val timestamp = System.currentTimeMillis() + val boundary = "----MindustryX${UUID.randomUUID()}" val body = buildString { append("--").append(boundary).append("\r\n") - append("Content-Disposition: form-data; name=\"file\"; filename=\"schematic-").append(System.currentTimeMillis()).append(".txt\"\r\n") + append("Content-Disposition: form-data; name=\"file\"; filename=\"schematic-").append(timestamp).append(".txt\"\r\n") append("Content-Type: text/plain; charset=UTF-8\r\n\r\n") append(content).append("\r\n") diff --git a/src/mindustryX/features/ShareFeature.kt b/src/mindustryX/features/ShareFeature.kt index ffbf8e3149d2..e16c55d04984 100644 --- a/src/mindustryX/features/ShareFeature.kt +++ b/src/mindustryX/features/ShareFeature.kt @@ -269,7 +269,6 @@ object ShareFeature { val sourceType = match.groupValues[1] val baseUrl = match.groupValues[2] val id = match.groupValues[3] - val key = match.groupValues.getOrNull(4)?.takeIf { it.isNotBlank() } PastebinShare.download( sourceType = sourceType,