mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(quartz): add a type-safe NIP-11 relay info builder
Relay operators wiring up a Quartz-based relay had to hand-write the
NIP-11 document as a large JSON string, which is error-prone and drifts
from the model. Add a DSL builder so they can describe the document in
Kotlin instead:
val info = relayInformation {
name = "sot"
description = "NIP-50 profile search ranked by Nostr web-of-trust"
software = "https://github.com/vitorpamplona/sot"
version = "0.1"
supports(1, 11, 42, 50)
}
call.respondText(info.toJson(), ContentType.parse(Nip11RelayInformation.CONTENT_TYPE))
The builder covers every field, with nested `limitation { }` / `fees { }`
DSLs, repeatable list helpers, a `retention(...)` entry adder, and a
`limitation(RelayLimits)` overload that advertises exactly the limits the
relay enforces so the two can't drift.
Also fix FlexibleIntListSerializer to emit numeric `supported_nips` as
JSON integers ([1,11,42,50]) instead of quoted strings, matching the
NIP-11 spec; non-numeric ids still fall back to strings. Geode's default
document now builds via the DSL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C64Yy2d3na7Y28u7GRrHMX
This commit is contained in:
@@ -23,6 +23,7 @@ package com.vitorpamplona.geode
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.relayInformation
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -68,13 +69,13 @@ data class RelayInfo(
|
||||
/** Pre-built default for `RelayEngine(url = ...)` — advertises the supported NIPs. */
|
||||
fun default(url: NormalizedRelayUrl): RelayInfo =
|
||||
RelayInfo(
|
||||
Nip11RelayInformation(
|
||||
name = NAME,
|
||||
description = DESCRIPTION,
|
||||
software = SOFTWARE,
|
||||
version = VERSION,
|
||||
supported_nips = SUPPORTED_NIPS,
|
||||
),
|
||||
relayInformation {
|
||||
name = NAME
|
||||
description = DESCRIPTION
|
||||
software = SOFTWARE
|
||||
version = VERSION
|
||||
supports(*SUPPORTED_NIPS.toTypedArray())
|
||||
},
|
||||
)
|
||||
|
||||
/** Loads a NIP-11 doc from a JSON file (e.g. a relay operator's config). */
|
||||
|
||||
+16
-2
@@ -31,6 +31,7 @@ import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonEncoder
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@@ -87,8 +88,21 @@ object FlexibleIntListSerializer : KSerializer<List<String>?> {
|
||||
) {
|
||||
if (value == null) {
|
||||
encoder.encodeNull()
|
||||
} else {
|
||||
listSerializer.serialize(encoder, value)
|
||||
return
|
||||
}
|
||||
|
||||
require(encoder is JsonEncoder) { "This serializer can only be used with Json format" }
|
||||
|
||||
// NIP-11 expects `supported_nips` as an array of integers ([1, 11, 42]),
|
||||
// so emit numeric entries as JSON numbers. Non-numeric ids (rare, but the
|
||||
// model tolerates them) fall back to JSON strings so nothing is lost.
|
||||
val array =
|
||||
JsonArray(
|
||||
value.map { nip ->
|
||||
val asLong = nip.toLongOrNull()
|
||||
if (asLong != null) JsonPrimitive(asLong) else JsonPrimitive(nip)
|
||||
},
|
||||
)
|
||||
encoder.encodeJsonElement(array)
|
||||
}
|
||||
}
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip11RelayInfo
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFee
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFees
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationLimitation
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationRetentionData
|
||||
|
||||
/**
|
||||
* Scopes the NIP-11 DSL so an inner builder (e.g. `limitation { }`) can't
|
||||
* accidentally reach the outer builder's members.
|
||||
*/
|
||||
@DslMarker annotation class Nip11DslMarker
|
||||
|
||||
/**
|
||||
* Type-safe builder for a [Nip11RelayInformation] document, so a relay operator
|
||||
* never has to hand-write the JSON.
|
||||
*
|
||||
* Instead of the string that smells:
|
||||
* ```
|
||||
* private val NIP11 =
|
||||
* """{"name":"sot","description":"...","supported_nips":[1,11,42,50],"software":"...","version":"0.1"}"""
|
||||
* ```
|
||||
* write:
|
||||
* ```
|
||||
* val info = relayInformation {
|
||||
* name = "sot"
|
||||
* description = "NIP-50 profile search ranked by Nostr web-of-trust"
|
||||
* software = "https://github.com/vitorpamplona/sot"
|
||||
* version = "0.1"
|
||||
* supports(1, 11, 42, 50)
|
||||
* }
|
||||
* ```
|
||||
* and serve it with [Nip11RelayInformation.toJson] under
|
||||
* [Nip11RelayInformation.CONTENT_TYPE]. Null/empty fields are omitted, and
|
||||
* numeric NIP numbers serialize as the JSON integers the spec expects
|
||||
* (`[1,11,42,50]`, not `["1","11","42","50"]`).
|
||||
*/
|
||||
inline fun relayInformation(initializer: Nip11RelayInformationBuilder.() -> Unit): Nip11RelayInformation = Nip11RelayInformationBuilder().apply(initializer).build()
|
||||
|
||||
@Nip11DslMarker
|
||||
class Nip11RelayInformationBuilder {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var description: String? = null
|
||||
var banner: String? = null
|
||||
var icon: String? = null
|
||||
var pubkey: HexKey? = null
|
||||
var self: HexKey? = null
|
||||
var contact: String? = null
|
||||
var software: String? = null
|
||||
var version: String? = null
|
||||
var postingPolicy: String? = null
|
||||
var privacyPolicy: String? = null
|
||||
var termsOfService: String? = null
|
||||
var paymentsUrl: String? = null
|
||||
|
||||
private val supportedNips = mutableListOf<String>()
|
||||
private val supportedNipExtensions = mutableListOf<String>()
|
||||
private val relayCountries = mutableListOf<String>()
|
||||
private val languageTags = mutableListOf<String>()
|
||||
private val tags = mutableListOf<String>()
|
||||
private val nip50Subfeatures = mutableListOf<String>()
|
||||
private val supportedGrasps = mutableListOf<String>()
|
||||
private val retention = mutableListOf<RelayInformationRetentionData>()
|
||||
|
||||
private var limitation: RelayInformationLimitation? = null
|
||||
private var fees: RelayInformationFees? = null
|
||||
|
||||
/** Advertise supported NIP numbers, e.g. `supports(1, 11, 42, 50)`. Repeatable. */
|
||||
fun supports(vararg nips: Int) = apply { nips.forEach { supportedNips.add(it.toString()) } }
|
||||
|
||||
/** Advertise supported NIPs by string id (for non-numeric extensions). Repeatable. */
|
||||
fun supports(vararg nips: String) = apply { supportedNips.addAll(nips) }
|
||||
|
||||
/** Advertise `supported_nip_extensions` (draft/experimental features). Repeatable. */
|
||||
fun supportsExtensions(vararg extensions: String) = apply { supportedNipExtensions.addAll(extensions) }
|
||||
|
||||
/** ISO-3166 country codes the relay serves under (`relay_countries`). Repeatable. */
|
||||
fun countries(vararg codes: String) = apply { relayCountries.addAll(codes) }
|
||||
|
||||
/** IETF language tags of content on the relay (`language_tags`). Repeatable. */
|
||||
fun languages(vararg codes: String) = apply { languageTags.addAll(codes) }
|
||||
|
||||
/** Free-form topic tags describing the relay's focus. Repeatable. */
|
||||
fun tags(vararg values: String) = apply { tags.addAll(values) }
|
||||
|
||||
/** NIP-50 search sub-features the relay implements (the `nip50` field). Repeatable. */
|
||||
fun nip50Features(vararg features: String) = apply { nip50Subfeatures.addAll(features) }
|
||||
|
||||
/** GRASP git-server capabilities the relay implements (`supported_grasps`). Repeatable. */
|
||||
fun grasps(vararg values: String) = apply { supportedGrasps.addAll(values) }
|
||||
|
||||
/** Declare the relay's `limitation` object via a nested DSL. */
|
||||
fun limitation(initializer: LimitationBuilder.() -> Unit) =
|
||||
apply {
|
||||
limitation = LimitationBuilder().apply(initializer).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Advertise the exact limits the relay actually enforces, keeping what is
|
||||
* published in sync with what is applied. See [RelayLimits.toNip11Limitation].
|
||||
*/
|
||||
fun limitation(limits: RelayLimits) = apply { limitation = limits.toNip11Limitation() }
|
||||
|
||||
/** Declare the relay's `fees` object via a nested DSL. */
|
||||
fun fees(initializer: FeesBuilder.() -> Unit) =
|
||||
apply {
|
||||
fees = FeesBuilder().apply(initializer).build()
|
||||
}
|
||||
|
||||
/** Add a data-retention policy entry (`retention`). Call multiple times for multiple entries. */
|
||||
fun retention(
|
||||
kinds: List<Int>? = null,
|
||||
time: Int? = null,
|
||||
count: Int? = null,
|
||||
) = apply {
|
||||
retention.add(RelayInformationRetentionData(kinds?.let { ArrayList(it) }, time, count))
|
||||
}
|
||||
|
||||
fun build(): Nip11RelayInformation =
|
||||
Nip11RelayInformation(
|
||||
id = id,
|
||||
name = name,
|
||||
description = description,
|
||||
banner = banner,
|
||||
icon = icon,
|
||||
pubkey = pubkey,
|
||||
self = self,
|
||||
contact = contact,
|
||||
supported_nips = supportedNips.ifEmpty { null }?.toList(),
|
||||
supported_nip_extensions = supportedNipExtensions.ifEmpty { null }?.toList(),
|
||||
software = software,
|
||||
version = version,
|
||||
limitation = limitation,
|
||||
relay_countries = relayCountries.ifEmpty { null }?.toList(),
|
||||
language_tags = languageTags.ifEmpty { null }?.toList(),
|
||||
tags = tags.ifEmpty { null }?.toList(),
|
||||
posting_policy = postingPolicy,
|
||||
privacy_policy = privacyPolicy,
|
||||
terms_of_service = termsOfService,
|
||||
payments_url = paymentsUrl,
|
||||
retention = retention.ifEmpty { null }?.toList(),
|
||||
fees = fees,
|
||||
nip50 = nip50Subfeatures.ifEmpty { null }?.toList(),
|
||||
supported_grasps = supportedGrasps.ifEmpty { null }?.toList(),
|
||||
)
|
||||
|
||||
@Nip11DslMarker
|
||||
class LimitationBuilder {
|
||||
var maxMessageLength: Int? = null
|
||||
var maxSubscriptions: Int? = null
|
||||
var maxFilters: Int? = null
|
||||
var maxLimit: Int? = null
|
||||
var defaultLimit: Int? = null
|
||||
var maxSubidLength: Int? = null
|
||||
var minPrefix: Int? = null
|
||||
var maxEventTags: Int? = null
|
||||
var maxContentLength: Int? = null
|
||||
var minPowDifficulty: Int? = null
|
||||
var authRequired: Boolean? = null
|
||||
var paymentRequired: Boolean? = null
|
||||
var restrictedWrites: Boolean? = null
|
||||
var createdAtLowerLimit: Int? = null
|
||||
var createdAtUpperLimit: Int? = null
|
||||
|
||||
fun build(): RelayInformationLimitation =
|
||||
RelayInformationLimitation(
|
||||
max_message_length = maxMessageLength,
|
||||
max_subscriptions = maxSubscriptions,
|
||||
max_filters = maxFilters,
|
||||
max_limit = maxLimit,
|
||||
default_limit = defaultLimit,
|
||||
max_subid_length = maxSubidLength,
|
||||
min_prefix = minPrefix,
|
||||
max_event_tags = maxEventTags,
|
||||
max_content_length = maxContentLength,
|
||||
min_pow_difficulty = minPowDifficulty,
|
||||
auth_required = authRequired,
|
||||
payment_required = paymentRequired,
|
||||
restricted_writes = restrictedWrites,
|
||||
created_at_lower_limit = createdAtLowerLimit,
|
||||
created_at_upper_limit = createdAtUpperLimit,
|
||||
)
|
||||
}
|
||||
|
||||
@Nip11DslMarker
|
||||
class FeesBuilder {
|
||||
private val admission = mutableListOf<RelayInformationFee>()
|
||||
private val subscription = mutableListOf<RelayInformationFee>()
|
||||
private val publication = mutableListOf<RelayInformationFee>()
|
||||
|
||||
/** A one-time fee to be allowed to write to the relay. Repeatable. */
|
||||
fun admission(
|
||||
amount: Int? = null,
|
||||
unit: String? = null,
|
||||
period: Int? = null,
|
||||
kinds: List<Int>? = null,
|
||||
) = apply { admission.add(RelayInformationFee(amount, unit, period, kinds)) }
|
||||
|
||||
/** A recurring fee to keep writing to the relay. Repeatable. */
|
||||
fun subscription(
|
||||
amount: Int? = null,
|
||||
unit: String? = null,
|
||||
period: Int? = null,
|
||||
kinds: List<Int>? = null,
|
||||
) = apply { subscription.add(RelayInformationFee(amount, unit, period, kinds)) }
|
||||
|
||||
/** A per-event fee to publish. Repeatable (e.g. one entry per kind group). */
|
||||
fun publication(
|
||||
amount: Int? = null,
|
||||
unit: String? = null,
|
||||
period: Int? = null,
|
||||
kinds: List<Int>? = null,
|
||||
) = apply { publication.add(RelayInformationFee(amount, unit, period, kinds)) }
|
||||
|
||||
fun build(): RelayInformationFees =
|
||||
RelayInformationFees(
|
||||
admission = admission.ifEmpty { null }?.toList(),
|
||||
subscription = subscription.ifEmpty { null }?.toList(),
|
||||
publication = publication.ifEmpty { null }?.toList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip11RelayInfo
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Nip11RelayInformationBuilderTest {
|
||||
@Test
|
||||
fun buildsTheSotExampleWithoutHandWrittenJson() {
|
||||
val info =
|
||||
relayInformation {
|
||||
name = "sot"
|
||||
description = "NIP-50 profile search ranked by Nostr web-of-trust"
|
||||
software = "https://github.com/vitorpamplona/sot"
|
||||
version = "0.1"
|
||||
supports(1, 11, 42, 50)
|
||||
}
|
||||
|
||||
assertEquals("sot", info.name)
|
||||
assertEquals(listOf("1", "11", "42", "50"), info.supported_nips)
|
||||
|
||||
// Numeric NIPs serialize as JSON integers, exactly as the hand-written string had them.
|
||||
val json = info.toJson()
|
||||
assertTrue(json.contains("\"supported_nips\":[1,11,42,50]"), json)
|
||||
assertTrue(json.contains("\"name\":\"sot\""), json)
|
||||
// Unset fields stay out of the document.
|
||||
assertTrue(!json.contains("\"limitation\""), json)
|
||||
assertTrue(!json.contains("\"fees\""), json)
|
||||
|
||||
// Round-trips back to the same document.
|
||||
assertEquals(info, Nip11RelayInformation.fromJson(json))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyBuilderProducesAllNullFields() {
|
||||
val info = relayInformation {}
|
||||
|
||||
assertNull(info.name)
|
||||
assertNull(info.supported_nips)
|
||||
assertNull(info.limitation)
|
||||
assertNull(info.fees)
|
||||
assertEquals("{}", info.toJson())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nestedLimitationDsl() {
|
||||
val info =
|
||||
relayInformation {
|
||||
name = "R"
|
||||
limitation {
|
||||
maxSubscriptions = 20
|
||||
maxFilters = 10
|
||||
maxLimit = 500
|
||||
authRequired = true
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(20, info.limitation?.max_subscriptions)
|
||||
assertEquals(10, info.limitation?.max_filters)
|
||||
assertEquals(500, info.limitation?.max_limit)
|
||||
assertEquals(true, info.limitation?.auth_required)
|
||||
// Untouched limitation fields remain null (and are omitted from JSON).
|
||||
assertNull(info.limitation?.max_message_length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun limitationFromEnforcedRelayLimitsStaysInSync() {
|
||||
val limits = RelayLimits(maxSubscriptions = 20, maxFilters = 10, maxLimit = 500, authRequired = true)
|
||||
val info =
|
||||
relayInformation {
|
||||
name = "R"
|
||||
limitation(limits)
|
||||
}
|
||||
|
||||
assertEquals(limits.toNip11Limitation(), info.limitation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun feesDslAccumulatesEntries() {
|
||||
val info =
|
||||
relayInformation {
|
||||
name = "Paid Relay"
|
||||
fees {
|
||||
admission(amount = 1000, unit = "msats")
|
||||
publication(amount = 100, unit = "msats", kinds = listOf(1, 30023))
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
1000,
|
||||
info.fees
|
||||
?.admission
|
||||
?.first()
|
||||
?.amount,
|
||||
)
|
||||
assertEquals(
|
||||
"msats",
|
||||
info.fees
|
||||
?.admission
|
||||
?.first()
|
||||
?.unit,
|
||||
)
|
||||
assertEquals(
|
||||
listOf(1, 30023),
|
||||
info.fees
|
||||
?.publication
|
||||
?.first()
|
||||
?.kinds,
|
||||
)
|
||||
assertNull(info.fees?.subscription)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retentionEntriesAreRepeatable() {
|
||||
val info =
|
||||
relayInformation {
|
||||
name = "R"
|
||||
retention(kinds = listOf(0, 3), count = 1)
|
||||
retention(time = 3600)
|
||||
}
|
||||
|
||||
assertEquals(2, info.retention?.size)
|
||||
assertEquals(arrayListOf(0, 3), info.retention?.get(0)?.kinds)
|
||||
assertEquals(1, info.retention?.get(0)?.count)
|
||||
assertEquals(3600, info.retention?.get(1)?.time)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listHelpersAreRepeatableAndCollapseWhenEmpty() {
|
||||
val info =
|
||||
relayInformation {
|
||||
name = "R"
|
||||
supports(1)
|
||||
supports(11, 42)
|
||||
supports("custom-ext")
|
||||
countries("US", "CA")
|
||||
languages("en")
|
||||
tags("search")
|
||||
}
|
||||
|
||||
assertEquals(listOf("1", "11", "42", "custom-ext"), info.supported_nips)
|
||||
assertEquals(listOf("US", "CA"), info.relay_countries)
|
||||
assertEquals(listOf("en"), info.language_tags)
|
||||
assertEquals(listOf("search"), info.tags)
|
||||
// Untouched lists collapse to null instead of empty arrays.
|
||||
assertNull(info.nip50)
|
||||
assertNull(info.supported_grasps)
|
||||
|
||||
// Mixed numeric + string ids: numbers stay numbers, the extension stays a string.
|
||||
assertTrue(info.toJson().contains("\"supported_nips\":[1,11,42,\"custom-ext\"]"), info.toJson())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user