mirror of
https://github.com/mamoe/mirai.git
synced 2025-02-24 19:20:30 +08:00
Update login protocol (#2433)
* Update login protocol Still need testing * Turn off debug option and make t547 null when failed * Fix wrong convert method and improve tips * Remove unused part and improve tips * Fix typo * Inline resultStatus for performance * Rename pow to PoW, the name should be "Proof of Work" * Add shadow and deps-test for kt-bignum * Try to fix deps-test * Fix deps-test again
This commit is contained in:
parent
56dea84336
commit
cc7f35519e
@ -267,6 +267,9 @@ const val `netty-transport` = "io.netty:netty-transport:${Versions.netty}"
|
||||
const val `netty-buffer` = "io.netty:netty-buffer:${Versions.netty}"
|
||||
const val `bouncycastle` = "org.bouncycastle:bcprov-jdk15on:${Versions.bouncycastle}"
|
||||
|
||||
const val `kt-bignum` = "com.ionspin.kotlin:bignum:0.3.7"
|
||||
val `kt-bignum_relocated` = RelocatedDependency(`kt-bignum`, "com.ionspin.kotlin.bignum")
|
||||
|
||||
const val `maven-resolver-api` = "org.apache.maven.resolver:maven-resolver-api:${Versions.mavenArtifactResolver}"
|
||||
const val `maven-resolver-impl` = "org.apache.maven.resolver:maven-resolver-impl:${Versions.mavenArtifactResolver}"
|
||||
const val `maven-resolver-connector-basic` =
|
||||
|
@ -25,6 +25,7 @@ dependencies {
|
||||
api(project(":mirai-core-utils"))
|
||||
implementation(`slf4j-api`) // Required by mirai-console
|
||||
|
||||
relocateImplementation(project, `kt-bignum_relocated`)
|
||||
relocateImplementation(project, `ktor-client-core_relocated`)
|
||||
relocateImplementation(project, `ktor-client-okhttp_relocated`)
|
||||
relocateImplementation(project, `ktor-io_relocated`)
|
||||
|
@ -24,6 +24,9 @@ public fun String.sha1(): ByteArray = toByteArray().sha1()
|
||||
|
||||
public expect fun ByteArray.sha1(offset: Int = 0, length: Int = size - offset): ByteArray
|
||||
|
||||
public fun String.sha256(): ByteArray = toByteArray().sha256()
|
||||
|
||||
public expect fun ByteArray.sha256(offset: Int = 0, length: Int = size - offset): ByteArray
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// How to choose 'inflate', 'inflateAllAvailable', 'InflateInput'?
|
||||
@ -112,4 +115,4 @@ public expect fun DeflateInput(source: Input): Input
|
||||
/**
|
||||
* @see DeflateInput
|
||||
*/
|
||||
public fun Input.deflateInput(): Input = DeflateInput(this)
|
||||
public fun Input.deflateInput(): Input = DeflateInput(this)
|
@ -13,7 +13,7 @@
|
||||
package net.mamoe.mirai.utils
|
||||
|
||||
import io.ktor.utils.io.core.*
|
||||
import io.ktor.utils.io.streams.asInput
|
||||
import io.ktor.utils.io.streams.*
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
@ -50,6 +50,10 @@ public fun InputStream.sha1(): ByteArray {
|
||||
return digest("SHA-1")
|
||||
}
|
||||
|
||||
public fun InputStream.sha256(): ByteArray {
|
||||
return digest("SHA-256")
|
||||
}
|
||||
|
||||
public actual fun ByteArray.md5(offset: Int, length: Int): ByteArray {
|
||||
checkOffsetAndLength(offset, length)
|
||||
return MessageDigest.getInstance("MD5").apply { update(this@md5, offset, length) }.digest()
|
||||
@ -62,6 +66,12 @@ public actual fun ByteArray.sha1(offset: Int, length: Int): ByteArray {
|
||||
return MessageDigest.getInstance("SHA-1").apply { update(this@sha1, offset, length) }.digest()
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
public actual fun ByteArray.sha256(offset: Int, length: Int): ByteArray {
|
||||
checkOffsetAndLength(offset, length)
|
||||
return MessageDigest.getInstance("SHA-256").apply { update(this@sha256, offset, length) }.digest()
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
public actual fun ByteArray.gzip(offset: Int, length: Int): ByteArray {
|
||||
ByteArrayOutputStream().use { buf ->
|
||||
|
@ -31,6 +31,11 @@ public actual fun ByteArray.sha1(offset: Int, length: Int): ByteArray = SHA1.cre
|
||||
return digest().bytes
|
||||
}
|
||||
|
||||
public actual fun ByteArray.sha256(offset: Int, length: Int): ByteArray = SHA256.create().run {
|
||||
update(this@sha256, offset, length)
|
||||
return digest().bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* WARNING: DO NOT SET THIS BUFFER TOO SMALL, OR YOU WILL SEE COMPRESSION ERROR.
|
||||
*/
|
||||
|
@ -272,3 +272,79 @@ internal class SHA1 : SHA(chunkSize = 64, digestSize = 20) {
|
||||
for (n in out.indices) out[n] = (h[n / 4] ushr (24 - 8 * (n % 4))).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
internal class SHA256 : SHA(chunkSize = 64, digestSize = 32) {
|
||||
companion object : HasherFactory({ SHA256() }) {
|
||||
private val H = intArrayOf(
|
||||
0x6a09e667, -0x4498517b, 0x3c6ef372, -0x5ab00ac6,
|
||||
0x510e527f, -0x64fa9774, 0x1f83d9ab, 0x5be0cd19
|
||||
)
|
||||
|
||||
private val K = intArrayOf(
|
||||
0x428a2f98, 0x71374491, -0x4a3f0431, -0x164a245b,
|
||||
0x3956c25b, 0x59f111f1, -0x6dc07d5c, -0x54e3a12b,
|
||||
-0x27f85568, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, -0x7f214e02, -0x6423f959, -0x3e640e8c,
|
||||
-0x1b64963f, -0x1041b87a, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
-0x67c1aeae, -0x57ce3993, -0x4ffcd838, -0x40a68039,
|
||||
-0x391ff40d, -0x2a586eb9, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, -0x7e3d36d2, -0x6d8dd37b,
|
||||
-0x5d40175f, -0x57e599b5, -0x3db47490, -0x3893ae5d,
|
||||
-0x2e6d17e7, -0x2966f9dc, -0xbf1ca7b, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, -0x7b3787ec, -0x7338fdf8,
|
||||
-0x6f410006, -0x5baf9315, -0x41065c09, -0x398e870e
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private val h = IntArray(8)
|
||||
private val r = IntArray(8)
|
||||
private val w = IntArray(64)
|
||||
|
||||
init {
|
||||
coreReset()
|
||||
}
|
||||
|
||||
override fun coreReset() {
|
||||
arraycopy(H, 0, h, 0, 8)
|
||||
}
|
||||
|
||||
override fun coreUpdate(chunk: ByteArray) {
|
||||
arraycopy(h, 0, r, 0, 8)
|
||||
|
||||
for (j in 0 until 16) w[j] = chunk.readS32_be(j * 4)
|
||||
for (j in 16 until 64) {
|
||||
val s0 = w[j - 15].rotateRight(7) xor w[j - 15].rotateRight(18) xor w[j - 15].ushr(3)
|
||||
val s1 = w[j - 2].rotateRight(17) xor w[j - 2].rotateRight(19) xor w[j - 2].ushr(10)
|
||||
w[j] = w[j - 16] + s0 + w[j - 7] + s1
|
||||
}
|
||||
|
||||
for (j in 0 until 64) {
|
||||
val s1 = r[4].rotateRight(6) xor r[4].rotateRight(11) xor r[4].rotateRight(25)
|
||||
val ch = r[4] and r[5] xor (r[4].inv() and r[6])
|
||||
val t1 = r[7] + s1 + ch + K[j] + w[j]
|
||||
val s0 = r[0].rotateRight(2) xor r[0].rotateRight(13) xor r[0].rotateRight(22)
|
||||
val maj = r[0] and r[1] xor (r[0] and r[2]) xor (r[1] and r[2])
|
||||
val t2 = s0 + maj
|
||||
r[7] = r[6]
|
||||
r[6] = r[5]
|
||||
r[5] = r[4]
|
||||
r[4] = r[3] + t1
|
||||
r[3] = r[2]
|
||||
r[2] = r[1]
|
||||
r[1] = r[0]
|
||||
r[0] = t1 + t2
|
||||
|
||||
}
|
||||
for (j in 0 until 8) h[j] += r[j]
|
||||
}
|
||||
|
||||
override fun coreDigest(out: ByteArray) {
|
||||
for (n in out.indices) out[n] = (h[n / 4] ushr (24 - 8 * (n % 4))).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -41,6 +41,7 @@ kotlin {
|
||||
api(`kotlinx-serialization-json`)
|
||||
api(`kotlinx-coroutines-core`)
|
||||
|
||||
implementation(`kt-bignum`)
|
||||
implementation(project(":mirai-core-utils"))
|
||||
implementation(`kotlinx-serialization-protobuf`)
|
||||
implementation(`kotlinx-atomicfu`)
|
||||
@ -108,6 +109,14 @@ kotlin {
|
||||
}
|
||||
|
||||
|
||||
// Kt bignum
|
||||
findByName("jvmBaseMain")?.apply {
|
||||
dependencies {
|
||||
relocateImplementation(`kt-bignum_relocated`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Ktor
|
||||
|
||||
findByName("commonMain")?.apply {
|
||||
|
@ -166,6 +166,7 @@ internal open class QQAndroidClient(
|
||||
var reserveUinInfo: ReserveUinInfo? = null
|
||||
var t402: ByteArray? = null
|
||||
lateinit var t104: ByteArray
|
||||
var t547: ByteArray? = null
|
||||
}
|
||||
|
||||
internal val QQAndroidClient.apkId: ByteArray get() = "com.tencent.mobileqq".toByteArray()
|
||||
|
@ -275,6 +275,15 @@ internal fun BytePacketBuilder.t104(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun BytePacketBuilder.t547(
|
||||
t547Data: ByteArray
|
||||
) {
|
||||
writeShort(0x547)
|
||||
writeShortLVPacket {
|
||||
writeFully(t547Data)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun BytePacketBuilder.t174(
|
||||
t174Data: ByteArray
|
||||
) {
|
||||
|
@ -210,6 +210,7 @@ internal class WtLogin {
|
||||
tlvMap[0x161]?.let { bot.client.analysisTlv161(it) }
|
||||
tlvMap[0x403]?.let { bot.client.randSeed = it }
|
||||
tlvMap[0x402]?.let { bot.client.t402 = it }
|
||||
tlvMap[0x546]?.let { bot.client.analysisTlv546(it) }
|
||||
// tlvMap[0x402]?.let { t402 ->
|
||||
// bot.client.G = buildPacket {
|
||||
// writeFully(bot.client.device.guid)
|
||||
|
@ -43,7 +43,7 @@ internal object WtLogin10 : WtLoginExt {
|
||||
0x0810
|
||||
) {
|
||||
writeShort(11) // subCommand
|
||||
writeShort(17)
|
||||
writeShort(18)
|
||||
t100(appId, subAppId, client.appClientVersion, client.ssoVersion, mainSigMap)
|
||||
t10a(client.wLoginSigInfo.tgt)
|
||||
t116(client.miscBitMap, client.subSigMap)
|
||||
@ -80,6 +80,7 @@ internal object WtLogin10 : WtLoginExt {
|
||||
t188(client.device.androidId)
|
||||
t194(client.device.imsiMd5)
|
||||
t511()
|
||||
t202(client.device.wifiBSSID, client.device.wifiSSID)
|
||||
//t544()
|
||||
|
||||
}
|
||||
|
@ -26,11 +26,20 @@ internal object WtLogin2 : WtLoginExt {
|
||||
writeSsoPacket(client, client.subAppId, WtLogin.Login.commandName, sequenceId = sequenceId) {
|
||||
writeOicqRequestPacket(client, commandId = 0x0810) {
|
||||
writeShort(2) // subCommand
|
||||
writeShort(4) // count of TLVs
|
||||
writeShort(
|
||||
if (client.t547 == null) {
|
||||
4
|
||||
} else {
|
||||
5
|
||||
}
|
||||
) // count of TLVs
|
||||
t193(ticket)
|
||||
t8(2052)
|
||||
t104(client.t104)
|
||||
t116(client.miscBitMap, client.subSigMap)
|
||||
client.t547?.let {
|
||||
t547(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,11 +52,20 @@ internal object WtLogin2 : WtLoginExt {
|
||||
writeSsoPacket(client, client.subAppId, WtLogin.Login.commandName, sequenceId = sequenceId) {
|
||||
writeOicqRequestPacket(client, commandId = 0x0810) {
|
||||
writeShort(2) // subCommand
|
||||
writeShort(4) // count of TLVs
|
||||
writeShort(
|
||||
if (client.t547 == null) {
|
||||
4
|
||||
} else {
|
||||
5
|
||||
}
|
||||
) // count of TLVs
|
||||
t2(captchaAnswer, captchaSign, 0)
|
||||
t8(2052)
|
||||
t104(client.t104)
|
||||
t116(client.miscBitMap, client.subSigMap)
|
||||
client.t547?.let {
|
||||
t547(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,9 @@
|
||||
|
||||
package net.mamoe.mirai.internal.network.protocol.packet.login.wtlogin
|
||||
|
||||
import com.ionspin.kotlin.bignum.integer.BigInteger
|
||||
import com.ionspin.kotlin.bignum.integer.util.fromTwosComplementByteArray
|
||||
import com.ionspin.kotlin.bignum.integer.util.toTwosComplementByteArray
|
||||
import io.ktor.utils.io.core.*
|
||||
import net.mamoe.mirai.internal.QQAndroidBot
|
||||
import net.mamoe.mirai.internal.network.LoginExtraData
|
||||
@ -171,6 +174,126 @@ internal interface WtLoginExt { // so as not to register to global extension
|
||||
this.t150 = Tlv(t150)
|
||||
}
|
||||
|
||||
fun QQAndroidClient.analysisTlv546(t546: ByteArray) {
|
||||
val version: Byte
|
||||
val algorithmType: Byte
|
||||
val hashType: Byte
|
||||
val maxIndex: Short
|
||||
val reserveBytes: ByteArray
|
||||
val inputBigNumArr: ByteArray
|
||||
val targetHashArr: ByteArray
|
||||
val reserveHashArr: ByteArray
|
||||
var resultArr: ByteArray = EMPTY_BYTE_ARRAY;
|
||||
var costTimeMS: Int = 0;
|
||||
var recursiveDepth: Int = 0;
|
||||
var failed = false
|
||||
|
||||
fun getPadRemaining(bigNumArr: ByteArray, bound: Short): Int {
|
||||
if (bound > 32) {
|
||||
return 1
|
||||
}
|
||||
var maxLoopCount = 255
|
||||
var index = 0
|
||||
while (maxLoopCount >= 0 && index < bound) {
|
||||
if (bigNumArr[maxLoopCount / 8].toInt() and (1 shl maxLoopCount) % 8 != 0) {
|
||||
return 2
|
||||
}
|
||||
maxLoopCount--
|
||||
index++
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
fun calcType1(bigNumArrIn: ByteArray, maxLength: Short) {
|
||||
var bigIntArrClone = bigNumArrIn.copyOf()
|
||||
val originLength = bigIntArrClone.size
|
||||
var bigInteger = BigInteger.fromTwosComplementByteArray(bigIntArrClone)
|
||||
while (true) {
|
||||
if (getPadRemaining(bigIntArrClone.sha256().copyOf(32), maxLength) == 0) {
|
||||
resultArr = bigIntArrClone
|
||||
return
|
||||
}
|
||||
recursiveDepth++
|
||||
bigInteger = bigInteger.add(BigInteger.ONE)
|
||||
bigIntArrClone = bigInteger.toTwosComplementByteArray()
|
||||
if (bigIntArrClone.size > originLength) {
|
||||
failed = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun calcType2(bigNumArrIn: ByteArray, hashTarget: ByteArray) {
|
||||
var bigIntArrClone = bigNumArrIn.copyOf()
|
||||
val originLength = bigIntArrClone.size
|
||||
var bigInteger = BigInteger.fromTwosComplementByteArray(bigIntArrClone)
|
||||
while (true) {
|
||||
if (bigIntArrClone.sha256().copyOf(32).contentEquals(hashTarget)) {
|
||||
resultArr = bigIntArrClone
|
||||
return
|
||||
}
|
||||
recursiveDepth++
|
||||
bigInteger = bigInteger.add(BigInteger.ONE)
|
||||
bigIntArrClone = bigInteger.toTwosComplementByteArray()
|
||||
if (bigIntArrClone.size > originLength) {
|
||||
failed = true
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
t546.toReadPacket().apply {
|
||||
version = readByte()
|
||||
algorithmType = readByte()
|
||||
hashType = readByte()
|
||||
readByte() // Ignore resultStatus since it's useless
|
||||
maxIndex = readShort()
|
||||
reserveBytes = readBytes(2)
|
||||
inputBigNumArr = readBytes(readShort().toInt())
|
||||
targetHashArr = readBytes(readShort().toInt())
|
||||
reserveHashArr = readBytes(readShort().toInt())
|
||||
}
|
||||
val startTimeMS: Long = currentTimeMillis()
|
||||
costTimeMS = 0
|
||||
recursiveDepth = 0
|
||||
if (hashType == 1.toByte()) {
|
||||
bot.logger.info("Calculating type $algorithmType PoW, it can take some time....")
|
||||
when (algorithmType.toInt()) {
|
||||
1 -> calcType1(inputBigNumArr, maxIndex)
|
||||
2 -> calcType2(inputBigNumArr, targetHashArr)
|
||||
else -> {
|
||||
failed = true
|
||||
bot.logger.warning("Unsupported tlv546 algorithm type:${algorithmType}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
failed = true
|
||||
bot.logger.warning("Unsupported tlv546 hash type:${hashType}")
|
||||
}
|
||||
if (!failed) {
|
||||
costTimeMS = (currentTimeMillis() - startTimeMS).toInt()
|
||||
bot.logger.info("Got PoW result, cost: $costTimeMS ms")
|
||||
this.t547 = buildPacket {
|
||||
writeByte(version)
|
||||
writeByte(algorithmType)
|
||||
writeByte(hashType)
|
||||
writeByte(1) //resultStatus
|
||||
writeShort(maxIndex)
|
||||
writeFully(reserveBytes)
|
||||
writeShortLVByteArray(inputBigNumArr)
|
||||
writeShortLVByteArray(targetHashArr)
|
||||
writeShortLVByteArray(reserveHashArr)
|
||||
writeShortLVByteArray(resultArr)
|
||||
writeInt(costTimeMS)
|
||||
writeInt(recursiveDepth)
|
||||
}.readBytes()
|
||||
} else {
|
||||
bot.logger.warning("Failed to get PoW result, login may fail with error 0x6!")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun QQAndroidClient.analysisTlv161(t161: ByteArray) {
|
||||
val tlv = t161.toReadPacket().apply { discardExact(2) }.withUse { _readTLVMap() }
|
||||
|
||||
|
@ -23,6 +23,7 @@ class CoreShadowRelocationTest : AbstractTest() {
|
||||
private const val KtorOkHttp = "io.ktor.client.engine.okhttp.OkHttp"
|
||||
private const val OkHttp = "okhttp3.OkHttp"
|
||||
private const val OkIO = "okio.ByteString"
|
||||
private const val BigInteger = "com.ionspin.kotlin.bignum.integer.BigInteger"
|
||||
|
||||
fun relocated(string: String): String {
|
||||
return "net.mamoe.mirai.internal.deps.$string"
|
||||
@ -38,6 +39,7 @@ class CoreShadowRelocationTest : AbstractTest() {
|
||||
-both(`ktor-client-okhttp`)
|
||||
-both(`okhttp3-okhttp`)
|
||||
-both(okio)
|
||||
-both(`kt-bignum`)
|
||||
}
|
||||
applyCodeFragment(fragment)
|
||||
buildFile.appendText(
|
||||
@ -59,6 +61,7 @@ class CoreShadowRelocationTest : AbstractTest() {
|
||||
-both(`ktor-client-okhttp`)
|
||||
-both(`okhttp3-okhttp`)
|
||||
-both(okio)
|
||||
-both(`kt-bignum`)
|
||||
+relocated(`ExternalResource-input`)
|
||||
}
|
||||
applyCodeFragment(fragment)
|
||||
@ -82,6 +85,7 @@ class CoreShadowRelocationTest : AbstractTest() {
|
||||
+relocated(`okhttp3-okhttp`)
|
||||
+relocated(okio)
|
||||
+relocated(`ExternalResource-input`)
|
||||
+relocated(`kt-bignum`)
|
||||
}
|
||||
applyCodeFragment(fragment)
|
||||
buildFile.appendText(
|
||||
@ -106,6 +110,7 @@ class CoreShadowRelocationTest : AbstractTest() {
|
||||
+relocated(`ktor-client-okhttp`)
|
||||
+relocated(`okhttp3-okhttp`)
|
||||
+relocated(okio)
|
||||
+relocated(`kt-bignum`)
|
||||
}
|
||||
applyCodeFragment(fragment)
|
||||
buildFile.appendText(
|
||||
@ -130,6 +135,7 @@ class CoreShadowRelocationTest : AbstractTest() {
|
||||
-both(`ktor-client-okhttp`)
|
||||
-both(`okhttp3-okhttp`)
|
||||
-both(okio)
|
||||
-both(`kt-bignum`)
|
||||
// +relocated(`ExternalResource-input`) // Will fail with no class def found error because there is no runtime ktor-io
|
||||
}
|
||||
applyCodeFragment(fragment)
|
||||
@ -155,6 +161,7 @@ class CoreShadowRelocationTest : AbstractTest() {
|
||||
+relocated(`okhttp3-okhttp`)
|
||||
+relocated(okio)
|
||||
+relocated(`ExternalResource-input`)
|
||||
+relocated(`kt-bignum`)
|
||||
}
|
||||
applyCodeFragment(fragment)
|
||||
|
||||
@ -231,6 +238,7 @@ class CoreShadowRelocationTest : AbstractTest() {
|
||||
val `ktor-client-okhttp` = ClassTestCase("ktor-client-core OkHttp", KtorOkHttp)
|
||||
val `okhttp3-okhttp` = ClassTestCase("okhttp3 OkHttp", OkHttp)
|
||||
val okio = ClassTestCase("okio ByteString", OkIO)
|
||||
val `kt-bignum` = ClassTestCase("kt-bignum BigInteger", BigInteger)
|
||||
val `ExternalResource-input` =
|
||||
FunctionTestCase(
|
||||
"ExternalResource_input",
|
||||
|
Loading…
Reference in New Issue
Block a user