mirror of
https://github.com/ViaVersion/VIAaaS.git
synced 2024-11-16 11:05:15 +01:00
use completablefuture with coroutines, autodetector fallback to -1
This commit is contained in:
parent
8130cb8ede
commit
9107ca0bd6
@ -66,7 +66,7 @@ Example address: ```server.example.com._p25565._v1_12_2._of._uBACKUSERNAME.viaaa
|
|||||||
Address parts:
|
Address parts:
|
||||||
- ```server.example.com```: backend server address
|
- ```server.example.com```: backend server address
|
||||||
- ```_p```: backend port
|
- ```_p```: backend port
|
||||||
- ```_v```: backend version ([protocol id](https://wiki.vg/Protocol_version_numbers) or name with underline instead of dots). ```AUTO``` is default and 1.8 is fallback if it fails.
|
- ```_v```: backend version ([protocol id](https://wiki.vg/Protocol_version_numbers) or name with underline instead of dots). ```AUTO``` is default and ``-1`` is fallback if it fails.
|
||||||
- ```_o```: ```t``` to force online mode in frontend, ```f``` to disable online mode in frontend. If not set, it will be based on backend online mode.
|
- ```_o```: ```t``` to force online mode in frontend, ```f``` to disable online mode in frontend. If not set, it will be based on backend online mode.
|
||||||
- ```_u```: username to use in backend connection
|
- ```_u```: username to use in backend connection
|
||||||
- ```viaaas.example.com```: hostname suffix (defined in config)
|
- ```viaaas.example.com```: hostname suffix (defined in config)
|
||||||
|
@ -11,7 +11,8 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
application {
|
application {
|
||||||
mainClassName = "com.viaversion.aas.VIAaaSKt"
|
mainClass.set("com.viaversion.aas.VIAaaSKt")
|
||||||
|
mainClassName = mainClass.get()
|
||||||
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=true")
|
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=true")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,16 +25,13 @@ class ProtocolDetectorHandler(val connectionData: ConnectionData) : ChannelDuple
|
|||||||
if (address is InetSocketAddress) {
|
if (address is InetSocketAddress) {
|
||||||
val timeoutRun = ctx.executor().schedule({
|
val timeoutRun = ctx.executor().schedule({
|
||||||
mcLogger.warn("Timeout protocol A.D. $address")
|
mcLogger.warn("Timeout protocol A.D. $address")
|
||||||
hold = false
|
|
||||||
drainQueue(ctx)
|
|
||||||
ctx.pipeline().remove(this)
|
ctx.pipeline().remove(this)
|
||||||
}, 10, TimeUnit.SECONDS)
|
}, 10, TimeUnit.SECONDS)
|
||||||
ProtocolDetector.detectVersion(address)
|
ProtocolDetector.detectVersion(address).whenComplete { protocol, _ ->
|
||||||
.whenComplete { protocol, _ ->
|
|
||||||
if (protocol != null && protocol.version != -1) {
|
if (protocol != null && protocol.version != -1) {
|
||||||
connectionData.viaBackServerVer = protocol.version
|
connectionData.viaBackServerVer = protocol.version
|
||||||
} else {
|
} else {
|
||||||
connectionData.viaBackServerVer = 47 // fallback
|
connectionData.viaBackServerVer = -1 // fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.pipeline().remove(this)
|
ctx.pipeline().remove(this)
|
||||||
|
@ -8,6 +8,7 @@ import com.viaversion.aas.packet.Packet
|
|||||||
import com.viaversion.aas.packet.handshake.Handshake
|
import com.viaversion.aas.packet.handshake.Handshake
|
||||||
import com.google.common.cache.CacheBuilder
|
import com.google.common.cache.CacheBuilder
|
||||||
import com.google.common.cache.CacheLoader
|
import com.google.common.cache.CacheLoader
|
||||||
|
import com.google.common.net.HostAndPort
|
||||||
import com.google.common.util.concurrent.RateLimiter
|
import com.google.common.util.concurrent.RateLimiter
|
||||||
import com.viaversion.aas.util.StacklessException
|
import com.viaversion.aas.util.StacklessException
|
||||||
import io.netty.channel.ChannelHandlerContext
|
import io.netty.channel.ChannelHandlerContext
|
||||||
@ -64,7 +65,7 @@ class HandshakeState : MinecraftConnectionState {
|
|||||||
(handler.data.state as? LoginState)?.also {
|
(handler.data.state as? LoginState)?.also {
|
||||||
it.frontOnline = frontOnline
|
it.frontOnline = frontOnline
|
||||||
it.backName = parsed.username
|
it.backName = parsed.username
|
||||||
it.backAddress = packet.address to packet.port
|
it.backAddress = HostAndPort.fromParts(packet.address, packet.port)
|
||||||
}
|
}
|
||||||
|
|
||||||
val playerAddr = handler.data.frontHandler.endRemoteAddress
|
val playerAddr = handler.data.frontHandler.endRemoteAddress
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package com.viaversion.aas.handler.state
|
package com.viaversion.aas.handler.state
|
||||||
|
|
||||||
|
import com.google.common.net.HostAndPort
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.viaversion.aas.*
|
import com.viaversion.aas.*
|
||||||
import com.viaversion.aas.codec.CompressionCodec
|
import com.viaversion.aas.codec.CompressionCodec
|
||||||
@ -13,18 +14,20 @@ import io.netty.channel.Channel
|
|||||||
import io.netty.channel.ChannelHandlerContext
|
import io.netty.channel.ChannelHandlerContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.future.await
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import us.myles.ViaVersion.packets.State
|
import us.myles.ViaVersion.packets.State
|
||||||
|
import java.util.*
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
|
|
||||||
class LoginState : MinecraftConnectionState {
|
class LoginState : MinecraftConnectionState {
|
||||||
val callbackPlayerId = CompletableFuture<String>()
|
val callbackPlayerId = CompletableFuture<UUID>()
|
||||||
lateinit var frontToken: ByteArray
|
lateinit var frontToken: ByteArray
|
||||||
lateinit var frontServerId: String
|
lateinit var frontServerId: String
|
||||||
var frontOnline: Boolean? = null
|
var frontOnline: Boolean? = null
|
||||||
lateinit var frontName: String
|
lateinit var frontName: String
|
||||||
lateinit var backAddress: Pair<String, Int>
|
lateinit var backAddress: HostAndPort
|
||||||
var backName: String? = null
|
var backName: String? = null
|
||||||
var started = false
|
var started = false
|
||||||
override val state: State
|
override val state: State
|
||||||
@ -64,7 +67,6 @@ class LoginState : MinecraftConnectionState {
|
|||||||
|
|
||||||
if (threshold != -1) {
|
if (threshold != -1) {
|
||||||
pipe.addAfter("frame", "compress", CompressionCodec(threshold))
|
pipe.addAfter("frame", "compress", CompressionCodec(threshold))
|
||||||
// todo viarewind backend compression
|
|
||||||
} else if (pipe.get("compress") != null) {
|
} else if (pipe.get("compress") != null) {
|
||||||
pipe.remove("compress")
|
pipe.remove("compress")
|
||||||
}
|
}
|
||||||
@ -91,43 +93,34 @@ class LoginState : MinecraftConnectionState {
|
|||||||
if (frontOnline == null) {
|
if (frontOnline == null) {
|
||||||
authenticateOnlineFront(handler.data.frontChannel)
|
authenticateOnlineFront(handler.data.frontChannel)
|
||||||
}
|
}
|
||||||
|
val frontHandler = handler.data.frontHandler
|
||||||
|
val backChan = handler.data.backChannel!!
|
||||||
|
|
||||||
callbackPlayerId.whenComplete { playerId, e ->
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
if (e != null) return@whenComplete
|
try {
|
||||||
val frontHandler = handler.data.frontHandler
|
val playerId = callbackPlayerId.await()
|
||||||
val backChan = handler.data.backChannel!!
|
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
val backKey = generate128Bits()
|
||||||
try {
|
val backHash = generateServerHash(backServerId, backKey, backPublicKey)
|
||||||
val backKey = generate128Bits()
|
|
||||||
val backHash = generateServerHash(backServerId, backKey, backPublicKey)
|
|
||||||
|
|
||||||
viaWebServer.requestSessionJoin(
|
viaWebServer.requestSessionJoin(
|
||||||
parseUndashedId(playerId),
|
playerId,
|
||||||
backName!!,
|
backName!!,
|
||||||
backHash,
|
backHash,
|
||||||
frontHandler.endRemoteAddress,
|
frontHandler.endRemoteAddress,
|
||||||
handler.data.backHandler!!.endRemoteAddress
|
handler.data.backHandler!!.endRemoteAddress
|
||||||
).whenCompleteAsync({ _, throwable ->
|
).await()
|
||||||
if (throwable != null) {
|
|
||||||
frontHandler.data.frontChannel.pipeline()
|
|
||||||
.fireExceptionCaught(throwable)
|
|
||||||
return@whenCompleteAsync
|
|
||||||
}
|
|
||||||
|
|
||||||
val cryptoResponse = CryptoResponse()
|
val cryptoResponse = CryptoResponse()
|
||||||
cryptoResponse.encryptedKey = encryptRsa(backPublicKey, backKey)
|
cryptoResponse.encryptedKey = encryptRsa(backPublicKey, backKey)
|
||||||
cryptoResponse.encryptedToken = encryptRsa(backPublicKey, backToken)
|
cryptoResponse.encryptedToken = encryptRsa(backPublicKey, backToken)
|
||||||
forward(frontHandler, cryptoResponse, true)
|
val backAesEn = mcCfb8(backKey, Cipher.ENCRYPT_MODE)
|
||||||
|
val backAesDe = mcCfb8(backKey, Cipher.DECRYPT_MODE)
|
||||||
|
|
||||||
val backAesEn = mcCfb8(backKey, Cipher.ENCRYPT_MODE)
|
forward(frontHandler, cryptoResponse, true)
|
||||||
val backAesDe = mcCfb8(backKey, Cipher.DECRYPT_MODE)
|
backChan.pipeline().addBefore("frame", "crypto", CryptoCodec(backAesDe, backAesEn))
|
||||||
backChan.pipeline().addBefore("frame", "crypto", CryptoCodec(backAesDe, backAesEn))
|
} catch (e: Exception) {
|
||||||
}, backChan.eventLoop())
|
frontHandler.data.frontChannel.pipeline().fireExceptionCaught(e)
|
||||||
} catch (e: Exception) {
|
|
||||||
frontHandler.data.frontChannel.pipeline()
|
|
||||||
.fireExceptionCaught(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -152,7 +145,7 @@ class LoginState : MinecraftConnectionState {
|
|||||||
val profile = hasJoined(frontName, frontHash)
|
val profile = hasJoined(frontName, frontHash)
|
||||||
val id = profile.get("id")!!.asString
|
val id = profile.get("id")!!.asString
|
||||||
|
|
||||||
callbackPlayerId.complete(id)
|
callbackPlayerId.complete(parseUndashedId(id))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callbackPlayerId.completeExceptionally(e)
|
callbackPlayerId.completeExceptionally(e)
|
||||||
}
|
}
|
||||||
@ -169,7 +162,7 @@ class LoginState : MinecraftConnectionState {
|
|||||||
backName = backName ?: frontName
|
backName = backName ?: frontName
|
||||||
|
|
||||||
val connect = {
|
val connect = {
|
||||||
connectBack(handler, backAddress.first, backAddress.second, State.LOGIN) {
|
connectBack(handler, backAddress.host, backAddress.port, State.LOGIN) {
|
||||||
loginStart.username = backName!!
|
loginStart.username = backName!!
|
||||||
send(handler.data.backChannel!!, loginStart, true)
|
send(handler.data.backChannel!!, loginStart, true)
|
||||||
}
|
}
|
||||||
@ -187,7 +180,7 @@ class LoginState : MinecraftConnectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
when (frontOnline) {
|
when (frontOnline) {
|
||||||
false -> callbackPlayerId.complete(generateOfflinePlayerUuid(frontName).toString().replace("-", ""))
|
false -> callbackPlayerId.complete(generateOfflinePlayerUuid(frontName))
|
||||||
true -> authenticateOnlineFront(handler.data.frontChannel) // forced
|
true -> authenticateOnlineFront(handler.data.frontChannel) // forced
|
||||||
null -> connect() // Connect then authenticate
|
null -> connect() // Connect then authenticate
|
||||||
}
|
}
|
||||||
|
@ -28,8 +28,8 @@ data class WebClient(
|
|||||||
return server.listeners.put(uuid, this)
|
return server.listeners.put(uuid, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unlistenId(uuid: UUID) {
|
fun unlistenId(uuid: UUID): Boolean {
|
||||||
server.listeners.remove(uuid, this)
|
server.listeners.remove(uuid, this)
|
||||||
listenedIds.remove(uuid)
|
return listenedIds.remove(uuid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ import io.ktor.client.request.*
|
|||||||
import io.ktor.http.cio.websocket.*
|
import io.ktor.http.cio.websocket.*
|
||||||
import io.ktor.websocket.*
|
import io.ktor.websocket.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.future.asCompletableFuture
|
||||||
import kotlinx.coroutines.time.delay
|
import kotlinx.coroutines.time.delay
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.SocketAddress
|
import java.net.SocketAddress
|
||||||
@ -45,7 +46,7 @@ class WebDashboardServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun checkToken(token: String): UUID? {
|
fun checkToken(token: String): UUID? {
|
||||||
try {
|
return try {
|
||||||
val verified = JWT.require(jwtAlgorithm)
|
val verified = JWT.require(jwtAlgorithm)
|
||||||
.withSubject("mc_account")
|
.withSubject("mc_account")
|
||||||
.withClaim("can_listen", true)
|
.withClaim("can_listen", true)
|
||||||
@ -53,9 +54,9 @@ class WebDashboardServer {
|
|||||||
.build()
|
.build()
|
||||||
.verify(token)
|
.verify(token)
|
||||||
|
|
||||||
return UUID.fromString(verified.getClaim("mc_uuid").asString())
|
UUID.fromString(verified.getClaim("mc_uuid").asString())
|
||||||
} catch (e: JWTVerificationException) {
|
} catch (e: JWTVerificationException) {
|
||||||
return null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,13 +69,11 @@ class WebDashboardServer {
|
|||||||
)
|
)
|
||||||
val usernameIdCache = CacheBuilder.newBuilder()
|
val usernameIdCache = CacheBuilder.newBuilder()
|
||||||
.expireAfterWrite(1, TimeUnit.HOURS)
|
.expireAfterWrite(1, TimeUnit.HOURS)
|
||||||
.build<String, UUID>(CacheLoader.from { name ->
|
.build<String, CompletableFuture<UUID?>>(CacheLoader.from { name ->
|
||||||
runBlocking {
|
GlobalScope.async(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.IO) {
|
httpClient.get<JsonObject?>("https://api.mojang.com/users/profiles/minecraft/$name")
|
||||||
httpClient.get<JsonObject?>("https://api.mojang.com/users/profiles/minecraft/$name")
|
?.get("id")?.asString?.let { parseUndashedId(it) }
|
||||||
?.get("id")?.asString?.let { parseUndashedId(it) }
|
}.asCompletableFuture()
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
val sessionHashCallbacks = CacheBuilder.newBuilder()
|
val sessionHashCallbacks = CacheBuilder.newBuilder()
|
||||||
|
@ -10,6 +10,7 @@ import com.viaversion.aas.webLogger
|
|||||||
import io.ktor.client.request.forms.*
|
import io.ktor.client.request.forms.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.http.cio.websocket.*
|
import io.ktor.http.cio.websocket.*
|
||||||
|
import kotlinx.coroutines.future.await
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@ -24,7 +25,6 @@ class WebLogin : WebState {
|
|||||||
|
|
||||||
when (obj.getAsJsonPrimitive("action").asString) {
|
when (obj.getAsJsonPrimitive("action").asString) {
|
||||||
"offline_login" -> {
|
"offline_login" -> {
|
||||||
// todo add some spam check
|
|
||||||
val username = obj.get("username").asString.trim()
|
val username = obj.get("username").asString.trim()
|
||||||
val uuid = generateOfflinePlayerUuid(username)
|
val uuid = generateOfflinePlayerUuid(username)
|
||||||
|
|
||||||
@ -51,7 +51,8 @@ class WebLogin : WebState {
|
|||||||
if (check.getAsJsonPrimitive("valid").asBoolean) {
|
if (check.getAsJsonPrimitive("valid").asBoolean) {
|
||||||
val mcIdUser = check.get("username").asString
|
val mcIdUser = check.get("username").asString
|
||||||
val uuid = check.get("uuid")?.asString?.let { parseUndashedId(it.replace("-", "")) }
|
val uuid = check.get("uuid")?.asString?.let { parseUndashedId(it.replace("-", "")) }
|
||||||
?: webClient.server.usernameIdCache.get(mcIdUser)
|
?: webClient.server.usernameIdCache.get(mcIdUser).await()
|
||||||
|
?: throw StacklessException("Failed to get UUID from minecraft.id")
|
||||||
|
|
||||||
val token = webClient.server.generateToken(uuid)
|
val token = webClient.server.generateToken(uuid)
|
||||||
webClient.ws.send(JsonObject().also {
|
webClient.ws.send(JsonObject().also {
|
||||||
@ -90,7 +91,13 @@ class WebLogin : WebState {
|
|||||||
}
|
}
|
||||||
"unlisten_login_requests" -> {
|
"unlisten_login_requests" -> {
|
||||||
val uuid = UUID.fromString(obj.getAsJsonPrimitive("uuid").asString)
|
val uuid = UUID.fromString(obj.getAsJsonPrimitive("uuid").asString)
|
||||||
webClient.unlistenId(uuid)
|
webLogger.info("Unlisten: ${webClient.id}: $uuid")
|
||||||
|
val response = JsonObject().also {
|
||||||
|
it.addProperty("action", "unlisten_login_requests_result")
|
||||||
|
it.addProperty("uuid", uuid.toString())
|
||||||
|
it.addProperty("success", webClient.unlistenId(uuid))
|
||||||
|
}
|
||||||
|
webClient.ws.send(response.toString())
|
||||||
}
|
}
|
||||||
"session_hash_response" -> {
|
"session_hash_response" -> {
|
||||||
val hash = obj.get("session_hash").asString
|
val hash = obj.get("session_hash").asString
|
||||||
|
@ -8,9 +8,13 @@ let urlParams = new URLSearchParams();
|
|||||||
window.location.hash.substr(1).split("?").map(it => new URLSearchParams(it).forEach((a, b) => urlParams.append(b, a)));
|
window.location.hash.substr(1).split("?").map(it => new URLSearchParams(it).forEach((a, b) => urlParams.append(b, a)));
|
||||||
var mcIdUsername = urlParams.get("username");
|
var mcIdUsername = urlParams.get("username");
|
||||||
var mcauth_code = urlParams.get("mcauth_code");
|
var mcauth_code = urlParams.get("mcauth_code");
|
||||||
if (urlParams.get("mcauth_success") == "false") {
|
var mcauth_success = urlParams.get("mcauth_success");
|
||||||
|
if (mcauth_success == "false") {
|
||||||
addToast("Couldn't authenticate with Minecraft.ID", urlParams.get("mcauth_msg"));
|
addToast("Couldn't authenticate with Minecraft.ID", urlParams.get("mcauth_msg"));
|
||||||
}
|
}
|
||||||
|
if (mcauth_code != null) {
|
||||||
|
history.replaceState(null, null, "#");
|
||||||
|
}
|
||||||
|
|
||||||
// WS url
|
// WS url
|
||||||
function defaultWs() {
|
function defaultWs() {
|
||||||
@ -271,9 +275,25 @@ function addAction(text, onClick) {
|
|||||||
actions.appendChild(p);
|
actions.appendChild(p);
|
||||||
}
|
}
|
||||||
function addListeningList(user) {
|
function addListeningList(user) {
|
||||||
let msg = document.createElement("p");
|
let p = document.createElement("p");
|
||||||
msg.innerText = "Listening to login: " + user;
|
let head = document.createElement("img");
|
||||||
listening.appendChild(msg);
|
let n = document.createElement("span");
|
||||||
|
let remove = document.createElement("a");
|
||||||
|
n.innerText = " " + user + " ";
|
||||||
|
remove.innerText = "Unlisten";
|
||||||
|
remove.href = "javascript:";
|
||||||
|
remove.onclick = () => {
|
||||||
|
// todo remove the token
|
||||||
|
listening.removeChild(p);
|
||||||
|
unlisten(user);
|
||||||
|
};
|
||||||
|
head.className = "account_head";
|
||||||
|
head.alt = user + "'s head";
|
||||||
|
head.src = "https://crafthead.net/helm/" + user;
|
||||||
|
p.append(head);
|
||||||
|
p.append(n);
|
||||||
|
p.append(remove);
|
||||||
|
listening.appendChild(p);
|
||||||
}
|
}
|
||||||
function addToast(title, msg) {
|
function addToast(title, msg) {
|
||||||
let toast = document.createElement("div");
|
let toast = document.createElement("div");
|
||||||
@ -345,6 +365,9 @@ new BroadcastChannel("viaaas-notification").addEventListener("message", handleSW
|
|||||||
function listen(token) {
|
function listen(token) {
|
||||||
socket.send(JSON.stringify({"action": "listen_login_requests", "token": token}));
|
socket.send(JSON.stringify({"action": "listen_login_requests", "token": token}));
|
||||||
}
|
}
|
||||||
|
function unlisten(id) {
|
||||||
|
socket.send(JSON.stringify({"action": "unlisten_login_requests", "uuid": id}));
|
||||||
|
}
|
||||||
function confirmJoin(hash) {
|
function confirmJoin(hash) {
|
||||||
socket.send(JSON.stringify({action: "session_hash_response", session_hash: hash}));
|
socket.send(JSON.stringify({action: "session_hash_response", session_hash: hash}));
|
||||||
}
|
}
|
||||||
|
@ -63,7 +63,7 @@
|
|||||||
<p>CORS Proxy status: <span id="cors_status" class="text-white bg-dark">?</span></p>
|
<p>CORS Proxy status: <span id="cors_status" class="text-white bg-dark">?</span></p>
|
||||||
<hr>
|
<hr>
|
||||||
<p><span id="actions"></span></p>
|
<p><span id="actions"></span></p>
|
||||||
<p><span id="listening"></span></p>
|
<p>Listening to logins from: <span id="listening"></span></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="settings" class="tab-pane fade" aria-labelledby="settings-tab">
|
<div id="settings" class="tab-pane fade" aria-labelledby="settings-tab">
|
||||||
|
Loading…
Reference in New Issue
Block a user