Improves RelayHint normalization to make sure the NormalizedRelayUrls are indeed relays

This commit is contained in:
Vitor Pamplona
2025-07-03 15:45:46 -04:00
parent 64bad2b476
commit e4ebca29f4
5 changed files with 199 additions and 96 deletions

View File

@@ -20,8 +20,12 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.normalizer
import android.util.Log
import android.util.Log.e
import androidx.collection.LruCache
import kotlinx.coroutines.CancellationException
import org.czeal.rfc3986.URIReference
import kotlin.contracts.ExperimentalContracts
val normalizedUrls = LruCache<String, NormalizedRelayUrl>(5000)
@@ -37,6 +41,27 @@ class RelayUrlNormalizer {
fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/")
fun isRelaySchemePrefix(url: String) = url.length > 6 && url[0] == 'w' && url[1] == 's'
fun isRelaySchemePrefixSecure(url: String) = url[2] == 's' && url[3] == ':' && url[4] == '/' && url[5] == '/' && url[6] != '/'
fun isRelaySchemePrefixInsecure(url: String) = url[2] == ':' && url[3] == '/' && url[4] == '/' && url[5] != '/'
fun isRelayUrl(url: String): Boolean {
val trimmed = url.trim().ifEmpty { return false }
// fast
if (isRelaySchemePrefix(trimmed)) {
if (isRelaySchemePrefixSecure(trimmed)) {
return true
} else if (isRelaySchemePrefixInsecure(trimmed)) {
return true
}
}
return false
}
private fun norm(url: String) =
NormalizedRelayUrl(
URIReference
@@ -46,19 +71,20 @@ class RelayUrlNormalizer {
.intern(),
)
fun fix(url: String): String {
val trimmed = url.trim()
@OptIn(ExperimentalContracts::class)
fun fix(url: String): String? {
val trimmed = url.trim().ifEmpty { return null }
// fast
if (trimmed.length > 4 && trimmed[0] == 'w' && trimmed[1] == 's') {
if (trimmed[2] == 's' && trimmed[3] == ':' && trimmed[4] == '/' && trimmed[5] == '/') {
return trimmed
} else if (trimmed[2] == ':' && trimmed[3] == '/' && trimmed[4] == '/') {
if (trimmed.isEmpty()) return null
// fast for good wss:// urls
if (isRelaySchemePrefix(trimmed)) {
if (isRelaySchemePrefixSecure(trimmed) || isRelaySchemePrefixInsecure(trimmed)) {
return trimmed
}
}
// fast
// fast for good https:// urls
if (trimmed.length > 8 && trimmed[0] == 'h' && trimmed[1] == 't' && trimmed[2] == 't' && trimmed[3] == 'p') {
if (trimmed[4] == 's' && trimmed[5] == ':' && trimmed[6] == '/' && trimmed[7] == '/') {
// https://
@@ -69,6 +95,12 @@ class RelayUrlNormalizer {
}
}
if (trimmed.contains("://")) {
// some other scheme we cannot connect to.
Log.w("RelayUrlNormalizer", "Rejected relay URL: $url")
return null
}
return if (isOnion(trimmed) || isLocalHost(trimmed)) {
"ws://$trimmed"
} else {
@@ -80,7 +112,8 @@ class RelayUrlNormalizer {
normalizedUrls.get(url)?.let { return it }
return try {
val normalized = norm(fix(url))
val fixed = fix(url) ?: return NormalizedRelayUrl(url)
val normalized = norm(fixed)
normalizedUrls.put(url, normalized)
normalized
} catch (e: Exception) {
@@ -89,13 +122,22 @@ class RelayUrlNormalizer {
}
fun normalizeOrNull(url: String): NormalizedRelayUrl? {
normalizedUrls.get(url)?.let { return it }
if (url.isEmpty()) return null
normalizedUrls[url]?.let { return it }
return try {
val normalized = norm(fix(url))
normalizedUrls.put(url, normalized)
normalized
val fixed = fix(url)
if (fixed != null) {
val normalized = norm(fixed)
normalizedUrls.put(url, normalized)
return normalized
} else {
Log.w("NormalizedRelayUrl", "Rejected Error $url")
null
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("NormalizedRelayUrl", "Rejected Error $url")
null
}
}

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.tags.events
import android.R.attr.tag
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
@@ -72,8 +73,7 @@ data class ETag(
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
return ETag(tag[1], hint, tag.getOrNull(3))
return ETag(tag[1], pickRelayHint(tag), pickAuthor(tag))
}
@JvmStatic
@@ -84,6 +84,32 @@ data class ETag(
return tag[1]
}
// simple case ["e", "id", "relay"]
// empty tags ["e", "id", "relay", ""]
// current root ["e", "id", "relay", "marker"]
// current root ["e", "id", "relay", "marker", "pubkey"]
// empty tags ["e", "id", "relay", "", "pubkey"]
// pubkey marker ["e", "id", "relay", "pubkey"]
// pubkey marker ["e", "id", "relay", "pubkey", "marker"]
// pubkey marker ["e", "id", "pubkey"] // incorrect
// current root ["e", "id", "marker"] // incorrect
@JvmStatic
private fun pickRelayHint(tag: Array<String>): NormalizedRelayUrl? {
if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2])
if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3])
if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4])
return null
}
@JvmStatic
private fun pickAuthor(tag: Array<String>): HexKey? {
if (tag.has(2) && tag[2].length == 64) return tag[2]
if (tag.has(3) && tag[3].length == 64) return tag[3]
if (tag.has(4) && tag[4].length == 64) return tag[4]
return null
}
@JvmStatic
fun parseAsHint(tag: Array<String>): EventIdHint? {
ensure(tag.has(2)) { return null }
@@ -91,7 +117,8 @@ data class ETag(
ensure(tag[1].length == 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val hint = RelayUrlNormalizer.normalizeOrNull(tag[2])
val hint = pickRelayHint(tag)
ensure(hint != null) { return null }
return EventIdHint(tag[1], hint)

View File

@@ -66,11 +66,16 @@ data class PTag(
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
val hint = pickRelayHint(tag)
return PTag(tag[1], hint)
}
private fun pickRelayHint(tag: Array<String>): NormalizedRelayUrl? {
if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2])
return null
}
@JvmStatic
fun parseKey(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
@@ -89,10 +94,11 @@ data class PTag(
ensure(tag[1].length == 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val normalized = RelayUrlNormalizer.normalizeOrNull(tag[2])
ensure(normalized != null) { return null }
val hint = pickRelayHint(tag)
return PubKeyHint(tag[1], normalized)
ensure(hint != null) { return null }
return PubKeyHint(tag[1], hint)
}
@JvmStatic

View File

@@ -20,12 +20,14 @@
*/
package com.vitorpamplona.quartz.nip10Notes.tags
import android.R.attr.tag
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer.Companion.isRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.utils.arrayOfNotNull
@@ -87,67 +89,71 @@ data class MarkedETag(
@JvmStatic
fun parse(tag: Array<String>): MarkedETag? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
return MarkedETag(
tag[ORDER_EVT_ID],
RelayUrlNormalizer.normalizeOrNull(tag[ORDER_RELAY]),
MARKER.parse(tag[ORDER_MARKER]),
tag.getOrNull(ORDER_PUBKEY),
eventId = tag[1],
relayHint = pickRelayHint(tag),
marker = pickMarker(tag),
authorPubKeyHex = pickAuthor(tag),
)
}
@JvmStatic
fun parseId(tag: Array<String>): HexKey? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[ORDER_EVT_ID]
return tag[1]
}
// simple case ["e", "id", "relay"]
// empty tags ["e", "id", "relay", ""]
// current root ["e", "id", "relay", "marker"]
// current root ["e", "id", "relay", "marker", "pubkey"]
// empty tags ["e", "id", "relay", "", "pubkey"]
// pubkey marker ["e", "id", "relay", "pubkey"]
// pubkey marker ["e", "id", "relay", "pubkey", "marker"]
// pubkey marker ["e", "id", "pubkey"] // incorrect
// current root ["e", "id", "marker"] // incorrect
@JvmStatic
private fun pickRelayHint(tag: Array<String>): NormalizedRelayUrl? {
if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2])
if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3])
if (tag.has(4) && tag[4].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[4])) return RelayUrlNormalizer.normalizeOrNull(tag[4])
return null
}
@JvmStatic
private fun pickAuthor(tag: Array<String>): HexKey? {
if (tag.has(3) && tag[3].length == 64) return tag[3]
if (tag.has(4) && tag[4].length == 64) return tag[4]
if (tag.has(2) && tag[2].length == 64) return tag[2]
return null
}
@JvmStatic
private fun pickMarker(tag: Array<String>): MARKER? {
if (tag.has(3)) MARKER.parse(tag[3])?.let { return it }
if (tag.has(4)) MARKER.parse(tag[4])?.let { return it }
if (tag.has(2)) MARKER.parse(tag[2])?.let { return it }
return null
}
@JvmStatic
fun parseAllThreadTags(tag: Array<String>): MarkedETag? =
if (tag.size >= 2 && tag[0] == TAG_NAME) {
if (tag.size <= 3) {
// simple case ["e", "id", "relay"]
MarkedETag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, null, null)
} else if (tag.size == 4) {
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2])
if (tag[3].isEmpty()) {
// empty tags ["e", "id", "relay", ""]
MarkedETag(tag[1], relayHint, null, null)
} else if (tag[3].length == 64) {
// updated case with pubkey instead of marker ["e", "id", "relay", "pubkey"]
MarkedETag(tag[1], relayHint, null, tag[3])
} else if (tag[3] == MARKER.ROOT.code) {
// corrent root ["e", "id", "relay", "root"]
MarkedETag(tag[1], relayHint, MARKER.ROOT)
} else if (tag[3] == MARKER.REPLY.code) {
// correct reply ["e", "id", "relay", "reply"]
MarkedETag(tag[1], relayHint, MARKER.REPLY)
} else {
// ignore "mention" and "fork" markers
null
}
} else {
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2])
// tag.size >= 5
if (tag[3].isEmpty()) {
// empty tags ["e", "id", "relay", "", "pubkey"]
MarkedETag(tag[1], relayHint, null, tag[4])
} else if (tag[3].length == 64) {
// updated case with pubkey instead of marker ["e", "id", "relay", "pubkey"]
MarkedETag(tag[1], relayHint, null, tag[3])
} else if (tag[3] == MARKER.ROOT.code) {
// corrent root ["e", "id", "relay", "root"]
MarkedETag(tag[1], relayHint, MARKER.ROOT, tag[4])
} else if (tag[3] == MARKER.REPLY.code) {
// correct reply ["e", "id", "relay", "reply"]
MarkedETag(tag[1], relayHint, MARKER.REPLY, tag[4])
} else {
// ignore "mention" and "fork" markers
null
}
}
MarkedETag(
eventId = tag[1],
relayHint = pickRelayHint(tag),
marker = pickMarker(tag),
authorPubKeyHex = pickAuthor(tag),
)
} else {
null
}
@@ -187,9 +193,8 @@ data class MarkedETag(
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val hint = RelayUrlNormalizer.normalizeOrNull(tag[2])
val hint = pickRelayHint(tag)
ensure(hint != null) { return null }
return EventIdHint(tag[1], hint)
@@ -197,15 +202,18 @@ data class MarkedETag(
@JvmStatic
fun parseRoot(tag: Array<String>): MarkedETag? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
if (tag[ORDER_MARKER] != MARKER.ROOT.code) return null
ensure(tag.has(3)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val marker = pickMarker(tag)
ensure(marker == MARKER.ROOT) { return null }
// ["e", id hex, relay hint, marker, pubkey]
return MarkedETag(
eventId = tag[ORDER_EVT_ID],
relayHint = RelayUrlNormalizer.normalizeOrNull(tag[ORDER_RELAY]),
marker = MARKER.ROOT,
authorPubKeyHex = tag.getOrNull(ORDER_PUBKEY),
eventId = tag[1],
relayHint = pickRelayHint(tag),
marker = marker,
authorPubKeyHex = pickAuthor(tag),
)
}
@@ -215,23 +223,25 @@ data class MarkedETag(
@JvmStatic
fun parseUnmarkedRoot(tag: Array<String>): MarkedETag? =
if (tag.size in 2..3 && tag[0] == TAG_NAME) {
MarkedETag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, MARKER.ROOT)
MarkedETag(tag[1], pickRelayHint(tag), MARKER.ROOT)
} else {
null
}
@JvmStatic
fun parseReply(tag: Array<String>): MarkedETag? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
if (tag[ORDER_MARKER] != MARKER.REPLY.code) return null
// ["e", id hex, relay hint, marker, pubkey]
ensure(tag.has(3)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val marker = pickMarker(tag)
ensure(marker == MARKER.REPLY) { return null }
return MarkedETag(
tag[ORDER_EVT_ID],
RelayUrlNormalizer.normalizeOrNull(tag[ORDER_RELAY]),
MARKER.REPLY,
tag.getOrNull(
ORDER_PUBKEY,
),
eventId = tag[1],
relayHint = pickRelayHint(tag),
marker = marker,
authorPubKeyHex = pickAuthor(tag),
)
}
@@ -241,17 +251,21 @@ data class MarkedETag(
@JvmStatic
fun parseUnmarkedReply(tag: Array<String>): MarkedETag? =
if (tag.size in 2..3 && tag[0] == TAG_NAME) {
MarkedETag(tag[1], tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }, MARKER.REPLY)
MarkedETag(tag[1], pickRelayHint(tag), MARKER.REPLY)
} else {
null
}
@JvmStatic
fun parseRootId(tag: Array<String>): HexKey? {
if (tag.size < TAG_SIZE || tag[0] != TAG_NAME) return null
if (tag[ORDER_MARKER] != MARKER.ROOT.code) return null
// ["e", id hex, relay hint, marker, pubkey]
return tag[ORDER_EVT_ID]
ensure(tag.has(3)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val marker = pickMarker(tag)
ensure(marker == MARKER.ROOT) { return null }
return tag[1]
}
@JvmStatic

View File

@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.quartz.nip18Reposts.quotes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.utils.ensure
@@ -38,16 +40,28 @@ interface QTag {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
val relayHint = pickRelayHint(tag)
return if (tag[1].length == 64) {
QEventTag(tag[1], relayHint, tag.getOrNull(3))
QEventTag(tag[1], relayHint, pickAuthor(tag))
} else {
val address = Address.parse(tag[1]) ?: return null
QAddressableTag(address, relayHint)
}
}
private fun pickRelayHint(tag: Array<String>): NormalizedRelayUrl? {
if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) return RelayUrlNormalizer.normalizeOrNull(tag[2])
if (tag.has(3) && tag[3].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[3])) return RelayUrlNormalizer.normalizeOrNull(tag[3])
return null
}
private fun pickAuthor(tag: Array<String>): HexKey? {
if (tag.has(2) && tag[2].length == 64) return tag[2]
if (tag.has(3) && tag[3].length == 64) return tag[3]
return null
}
@JvmStatic
fun parseKey(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
@@ -62,7 +76,7 @@ interface QTag {
ensure(tag[1].length == 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2])
val relayHint = pickRelayHint(tag)
ensure(relayHint != null) { return null }
return EventIdHint(tag[1], relayHint)
@@ -76,7 +90,7 @@ interface QTag {
ensure(tag[2].isNotEmpty()) { return null }
ensure(!tag[1].contains(':')) { return null }
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2])
val relayHint = pickRelayHint(tag)
ensure(relayHint != null) { return null }
return AddressHint(tag[1], relayHint)