mirror of
https://github.com/corda/corda.git
synced 2025-06-17 14:48:16 +00:00
[CORDA-941]: Add NetworkParameters contract implementation whitelist. (#2580)
This commit is contained in:
committed by
GitHub
parent
977836f4eb
commit
5be0e4b39e
@ -49,7 +49,7 @@ class AttachmentLoadingTests {
|
||||
@JvmField
|
||||
val testSerialization = SerializationEnvironmentRule()
|
||||
private val attachments = MockAttachmentStorage()
|
||||
private val provider = CordappProviderImpl(CordappLoader.createDevMode(listOf(isolatedJAR)), MockCordappConfigProvider(), attachments)
|
||||
private val provider = CordappProviderImpl(CordappLoader.createDevMode(listOf(isolatedJAR)), MockCordappConfigProvider(), attachments, testNetworkParameters().whitelistedContractImplementations)
|
||||
private val cordapp get() = provider.cordapps.first()
|
||||
private val attachmentId get() = provider.getCordappAttachmentId(cordapp)!!
|
||||
private val appContext get() = provider.getAppContext(cordapp)
|
||||
|
@ -48,7 +48,7 @@ class BFTNotaryServiceTests {
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
mockNet = InternalMockNetwork(emptyList())
|
||||
mockNet = InternalMockNetwork(listOf("net.corda.testing.contracts"))
|
||||
}
|
||||
|
||||
@After
|
||||
|
@ -556,7 +556,7 @@ abstract class AbstractNode(val configuration: NodeConfiguration,
|
||||
checkpointStorage = DBCheckpointStorage()
|
||||
val metrics = MetricRegistry()
|
||||
attachments = NodeAttachmentService(metrics, configuration.attachmentContentCacheSizeBytes, configuration.attachmentCacheBound)
|
||||
val cordappProvider = CordappProviderImpl(cordappLoader, CordappConfigFileProvider(), attachments)
|
||||
val cordappProvider = CordappProviderImpl(cordappLoader, CordappConfigFileProvider(), attachments, networkParameters.whitelistedContractImplementations)
|
||||
val keyManagementService = makeKeyManagementService(identityService, keyPairs)
|
||||
_services = ServiceHubInternalImpl(
|
||||
identityService,
|
||||
|
@ -13,6 +13,7 @@ import net.corda.core.identity.AbstractParty
|
||||
import net.corda.core.identity.CordaX500Name
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.internal.FlowStateMachine
|
||||
import net.corda.core.internal.RPC_UPLOADER
|
||||
import net.corda.core.internal.sign
|
||||
import net.corda.core.messaging.*
|
||||
import net.corda.core.node.NodeInfo
|
||||
@ -189,7 +190,7 @@ internal class CordaRPCOpsImpl(
|
||||
override fun uploadAttachment(jar: InputStream): SecureHash {
|
||||
// TODO: this operation should not require an explicit transaction
|
||||
return database.transaction {
|
||||
services.attachments.importAttachment(jar)
|
||||
services.attachments.importAttachment(jar, RPC_UPLOADER, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,12 @@
|
||||
package net.corda.node.internal.cordapp
|
||||
|
||||
import com.google.common.collect.HashBiMap
|
||||
import net.corda.core.contracts.ContractAttachment
|
||||
import net.corda.core.contracts.ContractClassName
|
||||
import net.corda.core.cordapp.Cordapp
|
||||
import net.corda.core.cordapp.CordappContext
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.internal.DEPLOYED_CORDAPP_UPLOADER
|
||||
import net.corda.core.internal.cordapp.CordappConfigProvider
|
||||
import net.corda.core.internal.createCordappContext
|
||||
import net.corda.core.node.services.AttachmentId
|
||||
@ -17,7 +19,10 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
/**
|
||||
* Cordapp provider and store. For querying CorDapps for their attachment and vice versa.
|
||||
*/
|
||||
open class CordappProviderImpl(private val cordappLoader: CordappLoader, private val cordappConfigProvider: CordappConfigProvider, attachmentStorage: AttachmentStorage) : SingletonSerializeAsToken(), CordappProviderInternal {
|
||||
open class CordappProviderImpl(private val cordappLoader: CordappLoader,
|
||||
private val cordappConfigProvider: CordappConfigProvider,
|
||||
attachmentStorage: AttachmentStorage,
|
||||
private val whitelistedContractImplementations: Map<String, List<AttachmentId>>) : SingletonSerializeAsToken(), CordappProviderInternal {
|
||||
|
||||
companion object {
|
||||
private val log = loggerFor<CordappProviderImpl>()
|
||||
@ -25,6 +30,34 @@ open class CordappProviderImpl(private val cordappLoader: CordappLoader, private
|
||||
|
||||
private val contextCache = ConcurrentHashMap<Cordapp, CordappContext>()
|
||||
|
||||
/**
|
||||
* Current known CorDapps loaded on this node
|
||||
*/
|
||||
override val cordapps get() = cordappLoader.cordapps
|
||||
private val cordappAttachments = HashBiMap.create(loadContractsIntoAttachmentStore(attachmentStorage))
|
||||
|
||||
init {
|
||||
verifyInstalledCordapps(attachmentStorage)
|
||||
}
|
||||
|
||||
private fun verifyInstalledCordapps(attachmentStorage: AttachmentStorage) {
|
||||
|
||||
if (whitelistedContractImplementations.isEmpty()) {
|
||||
log.warn("The network parameters don't specify any whitelisted contract implementations. Please contact your zone operator. See https://docs.corda.net/network-map.html")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify that the installed contract classes correspond with the whitelist hash
|
||||
// And warn if node is not using latest CorDapp
|
||||
cordappAttachments.keys.map(attachmentStorage::openAttachment).mapNotNull { it as? ContractAttachment }.forEach { attch ->
|
||||
(attch.allContracts intersect whitelistedContractImplementations.keys).forEach { contractClassName ->
|
||||
when {
|
||||
attch.id !in whitelistedContractImplementations[contractClassName]!! -> log.error("Contract $contractClassName found in attachment ${attch.id} is not whitelisted in the network parameters. If this is a production node contact your zone operator. See https://docs.corda.net/network-map.html")
|
||||
attch.id != whitelistedContractImplementations[contractClassName]!!.last() -> log.warn("You are not using the latest CorDapp version for contract: $contractClassName. Please contact your zone operator.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAppContext(): CordappContext {
|
||||
// TODO: Use better supported APIs in Java 9
|
||||
@ -42,11 +75,6 @@ open class CordappProviderImpl(private val cordappLoader: CordappLoader, private
|
||||
return getCordappForClass(contractClassName)?.let(this::getCordappAttachmentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Current known CorDapps loaded on this node
|
||||
*/
|
||||
override val cordapps get() = cordappLoader.cordapps
|
||||
private val cordappAttachments = HashBiMap.create(loadContractsIntoAttachmentStore(attachmentStorage))
|
||||
/**
|
||||
* Gets the attachment ID of this CorDapp. Only CorDapps with contracts have an attachment ID
|
||||
*
|
||||
@ -55,11 +83,16 @@ open class CordappProviderImpl(private val cordappLoader: CordappLoader, private
|
||||
*/
|
||||
fun getCordappAttachmentId(cordapp: Cordapp): SecureHash? = cordappAttachments.inverse().get(cordapp.jarPath)
|
||||
|
||||
private fun loadContractsIntoAttachmentStore(attachmentStorage: AttachmentStorage): Map<SecureHash, URL> {
|
||||
val cordappsWithAttachments = cordapps.filter { !it.contractClassNames.isEmpty() }.map { it.jarPath }
|
||||
val attachmentIds = cordappsWithAttachments.map { it.openStream().use { attachmentStorage.importOrGetAttachment(it) } }
|
||||
return attachmentIds.zip(cordappsWithAttachments).toMap()
|
||||
}
|
||||
private fun loadContractsIntoAttachmentStore(attachmentStorage: AttachmentStorage): Map<SecureHash, URL> =
|
||||
cordapps.filter { !it.contractClassNames.isEmpty() }.map {
|
||||
it.jarPath.openStream().use { stream ->
|
||||
try {
|
||||
attachmentStorage.importAttachment(stream, DEPLOYED_CORDAPP_UPLOADER, null)
|
||||
} catch (faee: java.nio.file.FileAlreadyExistsException) {
|
||||
AttachmentId.parse(faee.message!!)
|
||||
}
|
||||
} to it.jarPath
|
||||
}.toMap()
|
||||
|
||||
/**
|
||||
* Get the current cordapp context for the given CorDapp
|
||||
|
@ -8,8 +8,11 @@ import com.google.common.hash.HashingInputStream
|
||||
import com.google.common.io.CountingInputStream
|
||||
import net.corda.core.CordaRuntimeException
|
||||
import net.corda.core.contracts.Attachment
|
||||
import net.corda.core.contracts.ContractAttachment
|
||||
import net.corda.core.contracts.ContractClassName
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.internal.AbstractAttachment
|
||||
import net.corda.core.internal.UNKNOWN_UPLOADER
|
||||
import net.corda.core.internal.VisibleForTesting
|
||||
import net.corda.core.node.services.AttachmentId
|
||||
import net.corda.core.node.services.AttachmentStorage
|
||||
@ -24,6 +27,7 @@ import net.corda.node.utilities.NonInvalidatingWeightBasedCache
|
||||
import net.corda.node.utilities.defaultCordaCacheConcurrencyLevel
|
||||
import net.corda.nodeapi.internal.persistence.NODE_DATABASE_PREFIX
|
||||
import net.corda.nodeapi.internal.persistence.currentDBSession
|
||||
import net.corda.nodeapi.internal.withContractsInJar
|
||||
import java.io.*
|
||||
import java.nio.file.Paths
|
||||
import java.time.Instant
|
||||
@ -85,7 +89,14 @@ class NodeAttachmentService(
|
||||
var uploader: String? = null,
|
||||
|
||||
@Column(name = "filename", updatable = false)
|
||||
var filename: String? = null
|
||||
var filename: String? = null,
|
||||
|
||||
@ElementCollection
|
||||
@Column(name = "contract_class_name")
|
||||
@CollectionTable(name = "node_attachments_contract_class_name", joinColumns = arrayOf(
|
||||
JoinColumn(name = "att_id", referencedColumnName = "att_id")),
|
||||
foreignKey = ForeignKey(name = "FK__ctr_class__attachments"))
|
||||
var contractClassNames: List<ContractClassName>? = null
|
||||
) : Serializable
|
||||
|
||||
@VisibleForTesting
|
||||
@ -196,23 +207,31 @@ class NodeAttachmentService(
|
||||
// If repeatedly looking for non-existing attachments becomes a performance issue, this is either indicating a
|
||||
// a problem somewhere else or this needs to be revisited.
|
||||
|
||||
private val attachmentContentCache = NonInvalidatingWeightBasedCache<SecureHash, Optional<ByteArray>>(
|
||||
private val attachmentContentCache = NonInvalidatingWeightBasedCache<SecureHash, Optional<Pair<Attachment, ByteArray>>>(
|
||||
maxWeight = attachmentContentCacheSize,
|
||||
concurrencyLevel = defaultCordaCacheConcurrencyLevel,
|
||||
weigher = object : Weigher<SecureHash, Optional<ByteArray>> {
|
||||
override fun weigh(key: SecureHash, value: Optional<ByteArray>): Int {
|
||||
return key.size + if (value.isPresent) value.get().size else 0
|
||||
weigher = object : Weigher<SecureHash, Optional<Pair<Attachment, ByteArray>>> {
|
||||
override fun weigh(key: SecureHash, value: Optional<Pair<Attachment, ByteArray>>): Int {
|
||||
return key.size + if (value.isPresent) value.get().second.size else 0
|
||||
}
|
||||
},
|
||||
loadFunction = { Optional.ofNullable(loadAttachmentContent(it)) }
|
||||
)
|
||||
|
||||
private fun loadAttachmentContent(id: SecureHash): ByteArray? {
|
||||
private fun loadAttachmentContent(id: SecureHash): Pair<Attachment, ByteArray>? {
|
||||
val attachment = currentDBSession().get(NodeAttachmentService.DBAttachment::class.java, id.toString())
|
||||
return attachment?.content
|
||||
?: return null
|
||||
val attachmentImpl = AttachmentImpl(id, { attachment.content }, checkAttachmentsOnLoad).let {
|
||||
val contracts = attachment.contractClassNames
|
||||
if (contracts != null && contracts.isNotEmpty()) {
|
||||
ContractAttachment(it, contracts.first(), contracts.drop(1).toSet(), attachment.uploader)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
return Pair(attachmentImpl, attachment.content)
|
||||
}
|
||||
|
||||
|
||||
private val attachmentCache = NonInvalidatingCache<SecureHash, Optional<Attachment>>(
|
||||
attachmentCacheBound,
|
||||
defaultCordaCacheConcurrencyLevel,
|
||||
@ -222,16 +241,7 @@ class NodeAttachmentService(
|
||||
private fun createAttachment(key: SecureHash): Attachment? {
|
||||
val content = attachmentContentCache.get(key)
|
||||
if (content.isPresent) {
|
||||
return AttachmentImpl(
|
||||
key,
|
||||
{
|
||||
attachmentContentCache
|
||||
.get(key)
|
||||
.orElseThrow {
|
||||
IllegalArgumentException("No attachement impl should have been created for non existent content")
|
||||
}
|
||||
},
|
||||
checkAttachmentsOnLoad)
|
||||
return content.get().first
|
||||
}
|
||||
// if no attachement has been found, we don't want to cache that - it might arrive later
|
||||
attachmentContentCache.invalidate(key)
|
||||
@ -248,10 +258,10 @@ class NodeAttachmentService(
|
||||
}
|
||||
|
||||
override fun importAttachment(jar: InputStream): AttachmentId {
|
||||
return import(jar, null, null)
|
||||
return import(jar, UNKNOWN_UPLOADER, null)
|
||||
}
|
||||
|
||||
override fun importAttachment(jar: InputStream, uploader: String, filename: String): AttachmentId {
|
||||
override fun importAttachment(jar: InputStream, uploader: String, filename: String?): AttachmentId {
|
||||
return import(jar, uploader, filename)
|
||||
}
|
||||
|
||||
@ -263,47 +273,39 @@ class NodeAttachmentService(
|
||||
return Pair(id, bytes)
|
||||
}
|
||||
|
||||
override fun hasAttachment(attachmentId: AttachmentId): Boolean {
|
||||
val session = currentDBSession()
|
||||
val criteriaBuilder = session.criteriaBuilder
|
||||
val criteriaQuery = criteriaBuilder.createQuery(Long::class.java)
|
||||
val attachments = criteriaQuery.from(NodeAttachmentService.DBAttachment::class.java)
|
||||
criteriaQuery.select(criteriaBuilder.count(criteriaQuery.from(NodeAttachmentService.DBAttachment::class.java)))
|
||||
criteriaQuery.where(criteriaBuilder.equal(attachments.get<String>(DBAttachment::attId.name), attachmentId.toString()))
|
||||
return (session.createQuery(criteriaQuery).singleResult > 0)
|
||||
}
|
||||
override fun hasAttachment(attachmentId: AttachmentId): Boolean =
|
||||
currentDBSession().find(NodeAttachmentService.DBAttachment::class.java, attachmentId.toString()) != null
|
||||
|
||||
// TODO: PLT-147: The attachment should be randomised to prevent brute force guessing and thus privacy leaks.
|
||||
private fun import(jar: InputStream, uploader: String?, filename: String?): AttachmentId {
|
||||
require(jar !is JarInputStream)
|
||||
return withContractsInJar(jar) { contractClassNames, inputStream ->
|
||||
require(inputStream !is JarInputStream)
|
||||
|
||||
// Read the file into RAM, hashing it to find the ID as we go. The attachment must fit into memory.
|
||||
// TODO: Switch to a two-phase insert so we can handle attachments larger than RAM.
|
||||
// To do this we must pipe stream into the database without knowing its hash, which we will learn only once
|
||||
// the insert/upload is complete. We can then query to see if it's a duplicate and if so, erase, and if not
|
||||
// set the hash field of the new attachment record.
|
||||
// Read the file into RAM, hashing it to find the ID as we go. The attachment must fit into memory.
|
||||
// TODO: Switch to a two-phase insert so we can handle attachments larger than RAM.
|
||||
// To do this we must pipe stream into the database without knowing its hash, which we will learn only once
|
||||
// the insert/upload is complete. We can then query to see if it's a duplicate and if so, erase, and if not
|
||||
// set the hash field of the new attachment record.
|
||||
|
||||
val (id, bytes) = getAttachmentIdAndBytes(jar)
|
||||
if (!hasAttachment(id)) {
|
||||
checkIsAValidJAR(ByteArrayInputStream(bytes))
|
||||
val session = currentDBSession()
|
||||
val attachment = NodeAttachmentService.DBAttachment(attId = id.toString(), content = bytes, uploader = uploader, filename = filename)
|
||||
session.save(attachment)
|
||||
attachmentCount.inc()
|
||||
log.info("Stored new attachment $id")
|
||||
return id
|
||||
} else {
|
||||
throw java.nio.file.FileAlreadyExistsException(id.toString())
|
||||
val (id, bytes) = getAttachmentIdAndBytes(inputStream)
|
||||
if (!hasAttachment(id)) {
|
||||
checkIsAValidJAR(ByteArrayInputStream(bytes))
|
||||
val session = currentDBSession()
|
||||
val attachment = NodeAttachmentService.DBAttachment(attId = id.toString(), content = bytes, uploader = uploader, filename = filename, contractClassNames = contractClassNames)
|
||||
session.save(attachment)
|
||||
attachmentCount.inc()
|
||||
log.info("Stored new attachment $id")
|
||||
id
|
||||
} else {
|
||||
throw java.nio.file.FileAlreadyExistsException(id.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun importOrGetAttachment(jar: InputStream): AttachmentId {
|
||||
try {
|
||||
return importAttachment(jar)
|
||||
}
|
||||
catch (faee: java.nio.file.FileAlreadyExistsException) {
|
||||
return AttachmentId.parse(faee.message!!)
|
||||
}
|
||||
override fun importOrGetAttachment(jar: InputStream): AttachmentId = try {
|
||||
importAttachment(jar)
|
||||
} catch (faee: java.nio.file.FileAlreadyExistsException) {
|
||||
AttachmentId.parse(faee.message!!)
|
||||
}
|
||||
|
||||
override fun queryAttachments(criteria: AttachmentQueryCriteria, sorting: AttachmentSort?): List<AttachmentId> {
|
||||
@ -328,5 +330,4 @@ class NodeAttachmentService(
|
||||
return results.map { AttachmentId.parse(it.attId) }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -2,14 +2,18 @@ package net.corda.node.internal.cordapp
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import junit.framework.Assert.assertNull
|
||||
import net.corda.core.internal.cordapp.CordappConfigProvider
|
||||
import net.corda.core.node.services.AttachmentStorage
|
||||
import net.corda.testing.common.internal.testNetworkParameters
|
||||
import net.corda.testing.internal.MockCordappConfigProvider
|
||||
import net.corda.testing.services.MockAttachmentStorage
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.net.URL
|
||||
|
||||
class CordappProviderImplTests {
|
||||
private companion object {
|
||||
@ -25,6 +29,7 @@ class CordappProviderImplTests {
|
||||
}
|
||||
|
||||
private lateinit var attachmentStore: AttachmentStorage
|
||||
private val whitelistedContractImplementations = testNetworkParameters().whitelistedContractImplementations
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
@ -33,44 +38,40 @@ class CordappProviderImplTests {
|
||||
|
||||
@Test
|
||||
fun `isolated jar is loaded into the attachment store`() {
|
||||
val loader = CordappLoader.createDevMode(listOf(isolatedJAR))
|
||||
val provider = CordappProviderImpl(loader, stubConfigProvider, attachmentStore)
|
||||
val provider = newCordappProvider(isolatedJAR)
|
||||
val maybeAttachmentId = provider.getCordappAttachmentId(provider.cordapps.first())
|
||||
|
||||
Assert.assertNotNull(maybeAttachmentId)
|
||||
Assert.assertNotNull(attachmentStore.openAttachment(maybeAttachmentId!!))
|
||||
assertNotNull(maybeAttachmentId)
|
||||
assertNotNull(attachmentStore.openAttachment(maybeAttachmentId!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty jar is not loaded into the attachment store`() {
|
||||
val loader = CordappLoader.createDevMode(listOf(emptyJAR))
|
||||
val provider = CordappProviderImpl(loader, stubConfigProvider, attachmentStore)
|
||||
Assert.assertNull(provider.getCordappAttachmentId(provider.cordapps.first()))
|
||||
val provider = newCordappProvider(emptyJAR)
|
||||
assertNull(provider.getCordappAttachmentId(provider.cordapps.first()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test that we find a cordapp class that is loaded into the store`() {
|
||||
val loader = CordappLoader.createDevMode(listOf(isolatedJAR))
|
||||
val provider = CordappProviderImpl(loader, stubConfigProvider, attachmentStore)
|
||||
val provider = newCordappProvider(isolatedJAR)
|
||||
val className = "net.corda.finance.contracts.isolated.AnotherDummyContract"
|
||||
|
||||
val expected = provider.cordapps.first()
|
||||
val actual = provider.getCordappForClass(className)
|
||||
|
||||
Assert.assertNotNull(actual)
|
||||
Assert.assertEquals(expected, actual)
|
||||
assertNotNull(actual)
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test that we find an attachment for a cordapp contract class`() {
|
||||
val loader = CordappLoader.createDevMode(listOf(isolatedJAR))
|
||||
val provider = CordappProviderImpl(loader, stubConfigProvider, attachmentStore)
|
||||
fun `test that we find an attachment for a cordapp contrat class`() {
|
||||
val provider = newCordappProvider(isolatedJAR)
|
||||
val className = "net.corda.finance.contracts.isolated.AnotherDummyContract"
|
||||
val expected = provider.getAppContext(provider.cordapps.first()).attachmentId
|
||||
val actual = provider.getContractAttachmentID(className)
|
||||
|
||||
Assert.assertNotNull(actual)
|
||||
Assert.assertEquals(actual!!, expected)
|
||||
assertNotNull(actual)
|
||||
assertEquals(actual!!, expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -78,10 +79,15 @@ class CordappProviderImplTests {
|
||||
val configProvider = MockCordappConfigProvider()
|
||||
configProvider.cordappConfigs.put(isolatedCordappName, validConfig)
|
||||
val loader = CordappLoader.createDevMode(listOf(isolatedJAR))
|
||||
val provider = CordappProviderImpl(loader, configProvider, attachmentStore)
|
||||
val provider = CordappProviderImpl(loader, configProvider, attachmentStore, whitelistedContractImplementations)
|
||||
|
||||
val expected = provider.getAppContext(provider.cordapps.first()).config
|
||||
|
||||
assertThat(expected.getString("key")).isEqualTo("value")
|
||||
}
|
||||
|
||||
private fun newCordappProvider(vararg urls: URL): CordappProviderImpl {
|
||||
val loader = CordappLoader.createDevMode(urls.toList())
|
||||
return CordappProviderImpl(loader, stubConfigProvider, attachmentStore, whitelistedContractImplementations)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user