mirror of
https://github.com/oxen-io/session-android.git
synced 2024-12-25 01:07:47 +00:00
feat: file server v2 and syncing open groups v2 in config messages
This commit is contained in:
parent
facd3616fb
commit
e8bac5005e
@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import okhttp3.HttpUrl
|
||||||
import org.session.libsession.messaging.StorageProtocol
|
import org.session.libsession.messaging.StorageProtocol
|
||||||
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
import org.session.libsession.messaging.jobs.AttachmentUploadJob
|
||||||
import org.session.libsession.messaging.jobs.Job
|
import org.session.libsession.messaging.jobs.Job
|
||||||
@ -543,8 +544,23 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
|
|||||||
return DatabaseFactory.getLokiThreadDatabase(context).getAllV2OpenGroups()
|
return DatabaseFactory.getLokiThreadDatabase(context).getAllV2OpenGroups()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addOpenGroup(server: String, channel: Long) {
|
override fun addOpenGroup(serverUrl: String, channel: Long) {
|
||||||
OpenGroupUtilities.addGroup(context, server, channel)
|
val httpUrl = HttpUrl.parse(serverUrl) ?: return
|
||||||
|
if (httpUrl.queryParameterNames().contains("public_key")) {
|
||||||
|
// open group v2
|
||||||
|
val server = HttpUrl.Builder().scheme(httpUrl.scheme()).host(httpUrl.host()).apply {
|
||||||
|
if (httpUrl.port() != 80 || httpUrl.port() != 443) {
|
||||||
|
// non-standard port, add to server
|
||||||
|
this.port(httpUrl.port())
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
val room = httpUrl.pathSegments().firstOrNull() ?: return
|
||||||
|
val publicKey = httpUrl.queryParameter("public_key") ?: return
|
||||||
|
|
||||||
|
OpenGroupUtilities.addGroup(context, server.toString().removeSuffix("/"), room, publicKey)
|
||||||
|
} else {
|
||||||
|
OpenGroupUtilities.addGroup(context, serverUrl, channel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAllGroups(): List<GroupRecord> {
|
override fun getAllGroups(): List<GroupRecord> {
|
||||||
|
@ -63,7 +63,7 @@ interface StorageProtocol {
|
|||||||
|
|
||||||
// Open Groups
|
// Open Groups
|
||||||
fun getThreadID(openGroupID: String): String?
|
fun getThreadID(openGroupID: String): String?
|
||||||
fun addOpenGroup(server: String, channel: Long)
|
fun addOpenGroup(serverUrl: String, channel: Long)
|
||||||
fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean)
|
fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean)
|
||||||
fun getQuoteServerID(quoteID: Long, publicKey: String): Long?
|
fun getQuoteServerID(quoteID: Long, publicKey: String): Long?
|
||||||
|
|
||||||
|
@ -0,0 +1,108 @@
|
|||||||
|
package org.session.libsession.messaging.file_server
|
||||||
|
|
||||||
|
import nl.komponents.kovenant.Promise
|
||||||
|
import nl.komponents.kovenant.functional.bind
|
||||||
|
import nl.komponents.kovenant.functional.map
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.MediaType
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
|
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
|
||||||
|
import org.session.libsession.snode.OnionRequestAPI
|
||||||
|
import org.session.libsignal.service.loki.HTTP
|
||||||
|
import org.session.libsignal.service.loki.utilities.retryIfNeeded
|
||||||
|
import org.session.libsignal.utilities.Base64
|
||||||
|
import org.session.libsignal.utilities.JsonUtil
|
||||||
|
import org.session.libsignal.utilities.logging.Log
|
||||||
|
|
||||||
|
object FileServerAPIV2 {
|
||||||
|
|
||||||
|
const val DEFAULT_SERVER = "http://88.99.175.227"
|
||||||
|
private const val DEFAULT_SERVER_PUBLIC_KEY = "7cb31905b55cd5580c686911debf672577b3fb0bff81df4ce2d5c4cb3a7aaa69"
|
||||||
|
|
||||||
|
sealed class Error : Exception() {
|
||||||
|
object PARSING_FAILED : Error()
|
||||||
|
object INVALID_URL : Error()
|
||||||
|
|
||||||
|
fun errorDescription() = when (this) {
|
||||||
|
PARSING_FAILED -> "Invalid response."
|
||||||
|
INVALID_URL -> "Invalid URL."
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Request(
|
||||||
|
val verb: HTTP.Verb,
|
||||||
|
val endpoint: String,
|
||||||
|
val queryParameters: Map<String, String> = mapOf(),
|
||||||
|
val parameters: Any? = null,
|
||||||
|
val headers: Map<String, String> = mapOf(),
|
||||||
|
// Always `true` under normal circumstances. You might want to disable
|
||||||
|
// this when running over Lokinet.
|
||||||
|
val useOnionRouting: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createBody(parameters: Any?): RequestBody? {
|
||||||
|
if (parameters == null) return null
|
||||||
|
|
||||||
|
val parametersAsJSON = JsonUtil.toJson(parameters)
|
||||||
|
return RequestBody.create(MediaType.get("application/json"), parametersAsJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun send(request: Request): Promise<Map<*, *>, Exception> {
|
||||||
|
val parsed = HttpUrl.parse(DEFAULT_SERVER) ?: return Promise.ofFail(OpenGroupAPIV2.Error.INVALID_URL)
|
||||||
|
val urlBuilder = HttpUrl.Builder()
|
||||||
|
.scheme(parsed.scheme())
|
||||||
|
.host(parsed.host())
|
||||||
|
.port(parsed.port())
|
||||||
|
.addPathSegments(request.endpoint)
|
||||||
|
|
||||||
|
if (request.verb == HTTP.Verb.GET) {
|
||||||
|
for ((key, value) in request.queryParameters) {
|
||||||
|
urlBuilder.addQueryParameter(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestBuilder = okhttp3.Request.Builder()
|
||||||
|
.url(urlBuilder.build())
|
||||||
|
.headers(Headers.of(request.headers))
|
||||||
|
when (request.verb) {
|
||||||
|
HTTP.Verb.GET -> requestBuilder.get()
|
||||||
|
HTTP.Verb.PUT -> requestBuilder.put(createBody(request.parameters)!!)
|
||||||
|
HTTP.Verb.POST -> requestBuilder.post(createBody(request.parameters)!!)
|
||||||
|
HTTP.Verb.DELETE -> requestBuilder.delete(createBody(request.parameters))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.useOnionRouting) {
|
||||||
|
val publicKey = MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(DEFAULT_SERVER)
|
||||||
|
?: return Promise.ofFail(OpenGroupAPIV2.Error.NO_PUBLIC_KEY)
|
||||||
|
return OnionRequestAPI.sendOnionRequest(requestBuilder.build(), DEFAULT_SERVER, publicKey)
|
||||||
|
.fail { e ->
|
||||||
|
Log.e("Loki", "FileServerV2 failed with error",e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests."))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// region Sending
|
||||||
|
fun upload(file: ByteArray): Promise<Long, Exception> {
|
||||||
|
val base64EncodedFile = Base64.encodeBytes(file)
|
||||||
|
val parameters = mapOf("file" to base64EncodedFile)
|
||||||
|
val request = Request(verb = HTTP.Verb.POST, endpoint = "files", parameters = parameters)
|
||||||
|
return send(request).map { json ->
|
||||||
|
json["result"] as? Long ?: throw OpenGroupAPIV2.Error.PARSING_FAILED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun download(file: Long): Promise<ByteArray, Exception> {
|
||||||
|
val request = Request(verb = HTTP.Verb.GET, endpoint = "files/$file")
|
||||||
|
return send(request).map { json ->
|
||||||
|
val base64EncodedFile = json["result"] as? String ?: throw Error.PARSING_FAILED
|
||||||
|
Base64.decode(base64EncodedFile) ?: throw Error.PARSING_FAILED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -3,9 +3,11 @@ package org.session.libsession.messaging.jobs
|
|||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.file_server.FileServerAPI
|
import org.session.libsession.messaging.file_server.FileServerAPI
|
||||||
|
import org.session.libsession.messaging.file_server.FileServerAPIV2
|
||||||
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
|
import org.session.libsession.messaging.open_groups.OpenGroupAPIV2
|
||||||
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState
|
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState
|
||||||
import org.session.libsession.messaging.utilities.DotNetAPI
|
import org.session.libsession.messaging.utilities.DotNetAPI
|
||||||
|
import org.session.libsession.utilities.DownloadUtilities
|
||||||
import org.session.libsignal.service.api.crypto.AttachmentCipherInputStream
|
import org.session.libsignal.service.api.crypto.AttachmentCipherInputStream
|
||||||
import org.session.libsignal.utilities.Base64
|
import org.session.libsignal.utilities.Base64
|
||||||
import org.session.libsignal.utilities.logging.Log
|
import org.session.libsignal.utilities.logging.Log
|
||||||
@ -58,10 +60,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long)
|
|||||||
val openGroupV2 = MessagingModuleConfiguration.shared.storage.getV2OpenGroup(threadId.toString())
|
val openGroupV2 = MessagingModuleConfiguration.shared.storage.getV2OpenGroup(threadId.toString())
|
||||||
|
|
||||||
val stream = if (openGroupV2 == null) {
|
val stream = if (openGroupV2 == null) {
|
||||||
FileServerAPI.shared.downloadFile(tempFile, attachment.url, null)
|
DownloadUtilities.downloadFile(tempFile, attachment.url, FileServerAPI.maxFileSize, null)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Assume we're retrieving an attachment for an open group server if the digest is not set
|
// Assume we're retrieving an attachment for an open group server if the digest is not set
|
||||||
if (attachment.digest?.size ?: 0 == 0 || attachment.key.isNullOrEmpty()) FileInputStream(tempFile)
|
if (attachment.digest?.size ?: 0 == 0 || attachment.key.isNullOrEmpty()) FileInputStream(tempFile)
|
||||||
else AttachmentCipherInputStream.createForAttachment(tempFile, attachment.size, Base64.decode(attachment.key), attachment.digest)
|
else AttachmentCipherInputStream.createForAttachment(tempFile, attachment.size, Base64.decode(attachment.key), attachment.digest)
|
||||||
|
@ -113,8 +113,11 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups:
|
|||||||
}
|
}
|
||||||
if (groupRecord.isOpenGroup) {
|
if (groupRecord.isOpenGroup) {
|
||||||
val threadID = storage.getThreadID(groupRecord.encodedId) ?: continue
|
val threadID = storage.getThreadID(groupRecord.encodedId) ?: continue
|
||||||
val openGroup = storage.getOpenGroup(threadID) ?: continue
|
val openGroup = storage.getOpenGroup(threadID)
|
||||||
openGroups.add(openGroup.server)
|
val openGroupV2 = storage.getV2OpenGroup(threadID)
|
||||||
|
|
||||||
|
val shareUrl = openGroup?.server ?: openGroupV2?.toJoinUrl() ?: continue
|
||||||
|
openGroups.add(shareUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +45,16 @@ object OpenGroupAPIV2 {
|
|||||||
object SIGNING_FAILED : Error()
|
object SIGNING_FAILED : Error()
|
||||||
object INVALID_URL : Error()
|
object INVALID_URL : Error()
|
||||||
object NO_PUBLIC_KEY : Error()
|
object NO_PUBLIC_KEY : Error()
|
||||||
|
|
||||||
|
fun errorDescription() = when (this) {
|
||||||
|
Error.GENERIC -> "An error occurred."
|
||||||
|
Error.PARSING_FAILED -> "Invalid response."
|
||||||
|
Error.DECRYPTION_FAILED -> "Couldn't decrypt response."
|
||||||
|
Error.SIGNING_FAILED -> "Couldn't sign message."
|
||||||
|
Error.INVALID_URL -> "Invalid URL."
|
||||||
|
Error.NO_PUBLIC_KEY -> "Couldn't find server public key."
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class DefaultGroup(val id: String,
|
data class DefaultGroup(val id: String,
|
||||||
@ -494,12 +504,3 @@ object OpenGroupAPIV2 {
|
|||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Error.errorDescription() = when (this) {
|
|
||||||
Error.GENERIC -> "An error occurred."
|
|
||||||
Error.PARSING_FAILED -> "Invalid response."
|
|
||||||
Error.DECRYPTION_FAILED -> "Couldn't decrypt response."
|
|
||||||
Error.SIGNING_FAILED -> "Couldn't sign message."
|
|
||||||
Error.INVALID_URL -> "Invalid URL."
|
|
||||||
Error.NO_PUBLIC_KEY -> "Couldn't find server public key."
|
|
||||||
}
|
|
@ -39,6 +39,8 @@ data class OpenGroupV2(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun toJoinUrl(): String = "$server/$id?public_key=$publicKey"
|
||||||
|
|
||||||
fun toJson(): Map<String,String> = mapOf(
|
fun toJson(): Map<String,String> = mapOf(
|
||||||
"room" to room,
|
"room" to room,
|
||||||
"server" to server,
|
"server" to server,
|
||||||
|
@ -287,7 +287,7 @@ object MessageSender {
|
|||||||
val userPublicKey = storage.getUserPublicKey()!!
|
val userPublicKey = storage.getUserPublicKey()!!
|
||||||
val messageId = storage.getMessageIdInDatabase(message.sentTimestamp!!, message.sender?:userPublicKey) ?: return
|
val messageId = storage.getMessageIdInDatabase(message.sentTimestamp!!, message.sender?:userPublicKey) ?: return
|
||||||
// Ignore future self-sends
|
// Ignore future self-sends
|
||||||
storage.addReceivedMessageTimestamp(message.sentTimestamp!!)
|
// storage.addReceivedMessageTimestamp(message.sentTimestamp!!)
|
||||||
// Track the open group server message ID
|
// Track the open group server message ID
|
||||||
if (message.openGroupServerMessageID != null && destination is Destination.OpenGroupV2) {
|
if (message.openGroupServerMessageID != null && destination is Destination.OpenGroupV2) {
|
||||||
val encoded = GroupUtil.getEncodedOpenGroupID("${destination.server}.${destination.room}".toByteArray())
|
val encoded = GroupUtil.getEncodedOpenGroupID("${destination.server}.${destination.room}".toByteArray())
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package org.session.libsession.messaging.sending_receiving
|
package org.session.libsession.messaging.sending_receiving
|
||||||
|
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
|
import okhttp3.HttpUrl
|
||||||
import org.session.libsession.messaging.MessagingModuleConfiguration
|
import org.session.libsession.messaging.MessagingModuleConfiguration
|
||||||
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
|
||||||
import org.session.libsession.messaging.jobs.JobQueue
|
import org.session.libsession.messaging.jobs.JobQueue
|
||||||
@ -125,10 +126,9 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) {
|
|||||||
handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, closeGroup.publicKey, closeGroup.name, closeGroup.encryptionKeyPair!!, closeGroup.members, closeGroup.admins, message.sentTimestamp!!)
|
handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, closeGroup.publicKey, closeGroup.name, closeGroup.encryptionKeyPair!!, closeGroup.members, closeGroup.admins, message.sentTimestamp!!)
|
||||||
}
|
}
|
||||||
val allOpenGroups = storage.getAllOpenGroups().map { it.value.server }
|
val allOpenGroups = storage.getAllOpenGroups().map { it.value.server }
|
||||||
val allV2OpenGroups = storage.getAllV2OpenGroups().map { it.value.server }
|
val allV2OpenGroups = storage.getAllV2OpenGroups().map { it.value.toJoinUrl() }
|
||||||
for (openGroup in message.openGroups) {
|
for (openGroup in message.openGroups) {
|
||||||
if (allOpenGroups.contains(openGroup)) continue
|
if (allOpenGroups.contains(openGroup) || allV2OpenGroups.contains(openGroup)) continue
|
||||||
// TODO: add in v2
|
|
||||||
storage.addOpenGroup(openGroup, 1)
|
storage.addOpenGroup(openGroup, 1)
|
||||||
}
|
}
|
||||||
if (message.displayName.isNotEmpty()) {
|
if (message.displayName.isNotEmpty()) {
|
||||||
|
@ -97,6 +97,7 @@ class OpenGroupV2Poller(private val openGroups: List<OpenGroupV2>, private val e
|
|||||||
builder.source = senderPublicKey
|
builder.source = senderPublicKey
|
||||||
builder.sourceDevice = 1
|
builder.sourceDevice = 1
|
||||||
builder.content = message.toProto().toByteString()
|
builder.content = message.toProto().toByteString()
|
||||||
|
builder.serverTimestamp = message.serverID ?: 0
|
||||||
builder.timestamp = message.sentTimestamp
|
builder.timestamp = message.sentTimestamp
|
||||||
val envelope = builder.build()
|
val envelope = builder.build()
|
||||||
val job = MessageReceiveJob(envelope.toByteArray(), isBackgroundPoll, message.serverID, serverRoomId)
|
val job = MessageReceiveJob(envelope.toByteArray(), isBackgroundPoll, message.serverID, serverRoomId)
|
||||||
|
@ -203,7 +203,7 @@ open class DotNetAPI {
|
|||||||
/**
|
/**
|
||||||
* Blocks the calling thread.
|
* Blocks the calling thread.
|
||||||
*/
|
*/
|
||||||
fun downloadFile(outputStream: OutputStream, url: String, listener: SignalServiceAttachment.ProgressListener?) {
|
private fun downloadFile(outputStream: OutputStream, url: String, listener: SignalServiceAttachment.ProgressListener?) {
|
||||||
// We need to throw a PushNetworkException or NonSuccessfulResponseCodeException
|
// We need to throw a PushNetworkException or NonSuccessfulResponseCodeException
|
||||||
// because the underlying Signal logic requires these to work correctly
|
// because the underlying Signal logic requires these to work correctly
|
||||||
val oldPrefixedHost = "https://" + HttpUrl.get(url).host()
|
val oldPrefixedHost = "https://" + HttpUrl.get(url).host()
|
||||||
|
@ -3,7 +3,9 @@ package org.session.libsession.utilities
|
|||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.session.libsession.messaging.file_server.FileServerAPI
|
import org.session.libsession.messaging.file_server.FileServerAPI
|
||||||
|
import org.session.libsession.messaging.file_server.FileServerAPIV2
|
||||||
import org.session.libsession.snode.OnionRequestAPI
|
import org.session.libsession.snode.OnionRequestAPI
|
||||||
|
import org.session.libsignal.service.api.crypto.AttachmentCipherInputStream
|
||||||
import org.session.libsignal.utilities.logging.Log
|
import org.session.libsignal.utilities.logging.Log
|
||||||
import org.session.libsignal.service.api.messages.SignalServiceAttachment
|
import org.session.libsignal.service.api.messages.SignalServiceAttachment
|
||||||
import org.session.libsignal.service.api.push.exceptions.NonSuccessfulResponseCodeException
|
import org.session.libsignal.service.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||||
@ -39,6 +41,19 @@ object DownloadUtilities {
|
|||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun downloadFile(outputStream: OutputStream, url: String, maxSize: Int, listener: SignalServiceAttachment.ProgressListener?) {
|
fun downloadFile(outputStream: OutputStream, url: String, maxSize: Int, listener: SignalServiceAttachment.ProgressListener?) {
|
||||||
|
|
||||||
|
if (url.contains(FileServerAPIV2.DEFAULT_SERVER)) {
|
||||||
|
val httpUrl = HttpUrl.parse(url)!!
|
||||||
|
val fileId = httpUrl.pathSegments().last()
|
||||||
|
try {
|
||||||
|
FileServerAPIV2.download(fileId.toLong()).get().let {
|
||||||
|
outputStream.write(it)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Loki", "Couln't download attachment due to error",e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// We need to throw a PushNetworkException or NonSuccessfulResponseCodeException
|
// We need to throw a PushNetworkException or NonSuccessfulResponseCodeException
|
||||||
// because the underlying Signal logic requires these to work correctly
|
// because the underlying Signal logic requires these to work correctly
|
||||||
val oldPrefixedHost = "https://" + HttpUrl.get(url).host()
|
val oldPrefixedHost = "https://" + HttpUrl.get(url).host()
|
||||||
@ -81,8 +96,9 @@ object DownloadUtilities {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d("Loki", "Couldn't download attachment due to error: $e.")
|
Log.e("Loki", "Couldn't download attachment due to error", e)
|
||||||
throw if (e is NonSuccessfulResponseCodeException) e else PushNetworkException(e)
|
throw if (e is NonSuccessfulResponseCodeException) e else PushNetworkException(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user