mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3512 from vitorpamplona/claude/nostr-blocked-relays-review-7m5o3t
Enforce blocked relays centrally via decorator client
This commit is contained in:
@@ -26,6 +26,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
|
||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
@@ -503,7 +504,23 @@ class AppModules(
|
||||
// Provides a relay pool. The caching decoder skips re-parsing EVENT frames
|
||||
// that arrive again via another subscription or relay (14-57% of frames in
|
||||
// production measurements).
|
||||
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope, CachingEventDecoder())
|
||||
//
|
||||
// Wrapped in BlockedRelayFilteringClient so the active account's NIP-51
|
||||
// kind:10006 blocked relay list is enforced centrally on every REQ, COUNT
|
||||
// and publish (relay targeting is otherwise distributed across dozens of
|
||||
// feed/loader/finder/broadcast sites, most of which don't subtract it).
|
||||
// The blocked set is read per-call from the logged-in account.
|
||||
val client: INostrClient =
|
||||
BlockedRelayFilteringClient(
|
||||
NostrClient(websocketBuilder, applicationIOScope, CachingEventDecoder()),
|
||||
blockedRelays = {
|
||||
sessionManager
|
||||
.loggedInAccount()
|
||||
?.blockedRelayList
|
||||
?.flow
|
||||
?.value ?: emptySet()
|
||||
},
|
||||
)
|
||||
|
||||
// Self-heals the "Tor Active but every circuit dead" state the lifecycle watchdogs can't
|
||||
// see (they only arm while Connecting). Watches Tor-routed relay outcomes and, when enough
|
||||
|
||||
+8
-1
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
|
||||
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
|
||||
@@ -313,7 +314,13 @@ class AccountViewModel(
|
||||
|
||||
// Provides a relay pool. Crawls hit many relays with overlapping
|
||||
// filters, so the duplicate-frame decoder pays off most here.
|
||||
val newClient = NostrClient(Amethyst.instance.websocketBuilder, customScope, CachingEventDecoder())
|
||||
// Wrapped so crawls (Event Sync, Cashu discovery) never contact this
|
||||
// account's NIP-51 kind:10006 blocked relays either.
|
||||
val newClient =
|
||||
BlockedRelayFilteringClient(
|
||||
NostrClient(Amethyst.instance.websocketBuilder, customScope, CachingEventDecoder()),
|
||||
blockedRelays = { account.blockedRelayList.flow.value },
|
||||
)
|
||||
|
||||
// Authenticates with relays (registers itself with the client).
|
||||
RelayAuthenticator(
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.amethyst.commons.relayClient
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* A single, central enforcement point for the NIP-51 kind:10006 blocked relay list.
|
||||
*
|
||||
* Relay targeting in this app is fully distributed: every feed, loader, finder, and
|
||||
* publish path builds its own `Map<relay, filters>` (for REQ/COUNT) or relay set (for
|
||||
* publish) and hands it to [INostrClient]. Only a subset of those sites remembered to
|
||||
* subtract the blocked list, so blocked relays still leaked in through event/thread
|
||||
* loaders, the user-metadata finder, DM targeting, and the broadcast/publish path.
|
||||
*
|
||||
* Rather than patch every selection site (and re-open the same gap with every new
|
||||
* assembler), this decorator wraps the real client and strips blocked relays from the
|
||||
* three operations that open a socket — [subscribe], [count] and [publish] — right
|
||||
* before they reach the pool. Because the one-shot fetch helpers ([fetchAll],
|
||||
* [fetchFirst], [fetchAllPages], NIP-45 count, …) are extension functions that route
|
||||
* through [subscribe]/[count], wrapping the client covers them for free.
|
||||
*
|
||||
* [blockedRelays] is read on every call so the currently-active account's list always
|
||||
* applies, with no caching to invalidate on account switch.
|
||||
*
|
||||
* Note: the NIP-77 negentropy paths drive a single socket directly through
|
||||
* [getOrCreateRelay] with a caller-chosen relay; that escape hatch is delegated
|
||||
* unchanged, so callers that use it must keep filtering blocked relays themselves.
|
||||
*/
|
||||
class BlockedRelayFilteringClient(
|
||||
private val delegate: INostrClient,
|
||||
private val blockedRelays: () -> Set<NormalizedRelayUrl>,
|
||||
) : INostrClient by delegate {
|
||||
override fun subscribe(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
) {
|
||||
delegate.subscribe(subId, filters.withoutBlocked(), listener)
|
||||
}
|
||||
|
||||
override fun count(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
) {
|
||||
delegate.count(subId, filters.withoutBlocked())
|
||||
}
|
||||
|
||||
override fun publish(
|
||||
event: Event,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val blocked = blockedRelays()
|
||||
delegate.publish(event, if (blocked.isEmpty()) relayList else relayList - blocked)
|
||||
}
|
||||
|
||||
private fun Map<NormalizedRelayUrl, List<Filter>>.withoutBlocked(): Map<NormalizedRelayUrl, List<Filter>> {
|
||||
val blocked = blockedRelays()
|
||||
if (blocked.isEmpty()) return this
|
||||
return filterKeys { it !in blocked }
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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.amethyst.commons.relayClient
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class BlockedRelayFilteringClientTest {
|
||||
private val good = NormalizedRelayUrl("wss://good.example/")
|
||||
private val alsoGood = NormalizedRelayUrl("wss://also-good.example/")
|
||||
private val blocked = NormalizedRelayUrl("wss://blocked.example/")
|
||||
|
||||
private fun event() =
|
||||
Event(
|
||||
id = "a".repeat(64),
|
||||
pubKey = "b".repeat(64),
|
||||
createdAt = 1000,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "c".repeat(64),
|
||||
)
|
||||
|
||||
/** Records the relay maps/sets that actually reach the underlying client. */
|
||||
private class RecordingClient : INostrClient by EmptyNostrClient() {
|
||||
var subscribedFilters: Map<NormalizedRelayUrl, List<Filter>>? = null
|
||||
var countFilters: Map<NormalizedRelayUrl, List<Filter>>? = null
|
||||
var publishedRelays: Set<NormalizedRelayUrl>? = null
|
||||
|
||||
override fun subscribe(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
) {
|
||||
subscribedFilters = filters
|
||||
}
|
||||
|
||||
override fun count(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
) {
|
||||
countFilters = filters
|
||||
}
|
||||
|
||||
override fun publish(
|
||||
event: Event,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
publishedRelays = relayList
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscribeDropsBlockedRelaysKeepsOthers() {
|
||||
val inner = RecordingClient()
|
||||
val client = BlockedRelayFilteringClient(inner) { setOf(blocked) }
|
||||
|
||||
client.subscribe(
|
||||
"sub",
|
||||
mapOf(good to listOf(Filter()), blocked to listOf(Filter()), alsoGood to listOf(Filter())),
|
||||
null,
|
||||
)
|
||||
|
||||
assertEquals(setOf(good, alsoGood), inner.subscribedFilters?.keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countDropsBlockedRelays() {
|
||||
val inner = RecordingClient()
|
||||
val client = BlockedRelayFilteringClient(inner) { setOf(blocked) }
|
||||
|
||||
client.count("cnt", mapOf(good to listOf(Filter()), blocked to listOf(Filter())))
|
||||
|
||||
assertEquals(setOf(good), inner.countFilters?.keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishDropsBlockedRelays() {
|
||||
val inner = RecordingClient()
|
||||
val client = BlockedRelayFilteringClient(inner) { setOf(blocked) }
|
||||
|
||||
client.publish(event(), setOf(good, blocked, alsoGood))
|
||||
|
||||
assertEquals(setOf(good, alsoGood), inner.publishedRelays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyBlockListPassesEverythingThrough() {
|
||||
val inner = RecordingClient()
|
||||
val client = BlockedRelayFilteringClient(inner) { emptySet() }
|
||||
|
||||
val filters = mapOf(good to listOf(Filter()), blocked to listOf(Filter()))
|
||||
client.subscribe("sub", filters, null)
|
||||
client.publish(event(), setOf(good, blocked))
|
||||
|
||||
assertEquals(setOf(good, blocked), inner.subscribedFilters?.keys)
|
||||
assertEquals(setOf(good, blocked), inner.publishedRelays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockingEveryRelayYieldsEmptyTargets() {
|
||||
val inner = RecordingClient()
|
||||
val client = BlockedRelayFilteringClient(inner) { setOf(good, blocked) }
|
||||
|
||||
client.subscribe("sub", mapOf(good to listOf(Filter()), blocked to listOf(Filter())), null)
|
||||
client.publish(event(), setOf(good, blocked))
|
||||
|
||||
assertEquals(emptySet(), inner.subscribedFilters?.keys)
|
||||
assertEquals(emptySet(), inner.publishedRelays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockSetIsReadPerCallSoLaterChangesApply() {
|
||||
val inner = RecordingClient()
|
||||
var blockedSet = emptySet<NormalizedRelayUrl>()
|
||||
val client = BlockedRelayFilteringClient(inner) { blockedSet }
|
||||
|
||||
client.subscribe("sub", mapOf(good to listOf(Filter()), blocked to listOf(Filter())), null)
|
||||
assertEquals(setOf(good, blocked), inner.subscribedFilters?.keys)
|
||||
|
||||
// user blocks a relay after the client was built
|
||||
blockedSet = setOf(blocked)
|
||||
client.subscribe("sub", mapOf(good to listOf(Filter()), blocked to listOf(Filter())), null)
|
||||
assertEquals(setOf(good), inner.subscribedFilters?.keys)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user