From 95698813c6faf97f7730b4e3d228f4e5613429a0 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Wed, 3 Sep 2025 15:29:57 -0700 Subject: [PATCH] Monitor relay connections for restricted responses and show error to user --- src/app/components/ModalContainer.svelte | 22 +++--- src/app/components/SpaceAccessRequest.svelte | 35 +++++---- src/app/components/SpaceJoinConfirm.svelte | 11 ++- src/app/core/commands.ts | 31 ++++++-- src/app/core/state.ts | 55 ++++++++++++++ src/app/util/modal.ts | 7 +- .../components/SocketStatusIndicator.svelte | 7 +- src/routes/+layout.svelte | 75 ++++++++++++++++++- src/routes/spaces/[relay]/+layout.svelte | 58 +++++++------- 9 files changed, 226 insertions(+), 75 deletions(-) diff --git a/src/app/components/ModalContainer.svelte b/src/app/components/ModalContainer.svelte index 45cb61e..6ec5d0e 100644 --- a/src/app/components/ModalContainer.svelte +++ b/src/app/components/ModalContainer.svelte @@ -1,8 +1,7 @@ -{#if modal?.options?.drawer} - - {#key modal.id} - +{#if m?.options?.drawer} + + {#key m.id} + {/key} -{:else if modal} - - {#key modal.id} - +{:else if m} + + {#key m.id} + {/key} {/if} diff --git a/src/app/components/SpaceAccessRequest.svelte b/src/app/components/SpaceAccessRequest.svelte index ebbe457..bf70151 100644 --- a/src/app/components/SpaceAccessRequest.svelte +++ b/src/app/components/SpaceAccessRequest.svelte @@ -1,6 +1,7 @@ diff --git a/src/app/core/commands.ts b/src/app/core/commands.ts index 6ea4b0f..a4eaa43 100644 --- a/src/app/core/commands.ts +++ b/src/app/core/commands.ts @@ -246,11 +246,7 @@ export const checkRelayAccess = async (url: string, claim = "") => { await attemptAuth(url) - const thunk = publishThunk({ - event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}), - relays: [url], - }) - + const thunk = publishJoinRequest({url, claim}) const error = await getThunkError(thunk) if (error) { @@ -296,7 +292,7 @@ export const checkRelayConnection = async (url: string) => { } } -export const checkRelayAuth = async (url: string, timeout = 3000) => { +export const checkRelayAuth = async (url: string) => { const socket = Pool.get().get(url) const okStatuses = [AuthStatus.None, AuthStatus.Ok] @@ -325,7 +321,7 @@ export const attemptRelayAccess = async (url: string, claim = "") => { } } -// Actions +// Deletions export type DeleteParams = { protect: boolean @@ -351,6 +347,8 @@ export const makeDelete = ({protect, event, tags = []}: DeleteParams) => { export const publishDelete = ({relays, ...params}: DeleteParams & {relays: string[]}) => publishThunk({event: makeDelete(params), relays}) +// Reports + export type ReportParams = { event: TrustedEvent content: string @@ -374,6 +372,8 @@ export const publishReport = ({ }: ReportParams & {relays: string[]}) => publishThunk({event: makeReport({event, reason, content}), relays}) +// Reactions + export type ReactionParams = { protect: boolean event: TrustedEvent @@ -399,6 +399,8 @@ export const makeReaction = ({protect, content, event, tags: paramTags = []}: Re export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) => publishThunk({event: makeReaction(params), relays}) +// Comments + export type CommentParams = { event: TrustedEvent content: string @@ -411,6 +413,8 @@ export const makeComment = ({event, content, tags = []}: CommentParams) => export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) => publishThunk({event: makeComment(params), relays}) +// Alerts + export type AlertParams = { feed: Feed description: string @@ -494,6 +498,19 @@ export const addTrustedRelay = async (url: string) => export const removeTrustedRelay = async (url: string) => publishSettings({trusted_relays: remove(url, userSettingsValues.get().trusted_relays)}) +// Join request + +export type JoinRequestParams = { + url: string + claim: string +} + +export const makeJoinRequest = (params: JoinRequestParams) => + makeEvent(AUTH_JOIN, {tags: [["claim", params.claim]]}) + +export const publishJoinRequest = (params: JoinRequestParams) => + publishThunk({event: makeJoinRequest(params), relays: [params.url]}) + // Lightning export const getWebLn = () => (window as any).webln diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 40bc7a3..0da5b15 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -70,6 +70,7 @@ import { getTagValue, getTagValues, verifyEvent, + makeEvent, } from "@welshman/util" import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util" import {Nip59, decrypt} from "@welshman/signer" @@ -92,6 +93,8 @@ import { signer, makeOutboxLoader, appContext, + getThunkError, + publishThunk, } from "@welshman/app" import type {Thunk, Relay} from "@welshman/app" @@ -368,6 +371,10 @@ export const { export const relaysPendingTrust = writable([]) +// Relays that mostly send restricted responses to requests and events + +export const relaysMostlyRestricted = writable>({}) + // Alerts export type Alert = { @@ -738,3 +745,51 @@ export const deriveSocket = (url: string) => return () => subs.forEach(call) }) + +export const deriveTimeout = (timeout: number) => { + const store = writable(false) + + setTimeout(() => store.set(true), timeout) + + return derived(store, identity) +} + +export const deriveRelayAuthError = (url: string, claim = "") => { + const $signer = signer.get() + const socket = Pool.get().get(url) + const stripPrefix = (m: string) => m.replace(/^\w+: /, "") + + // Kick off the auth process + socket.auth.attemptAuth($signer.sign) + + // Attempt to join the relay + const thunk = publishThunk({ + event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}), + relays: [url], + }) + + return derived( + [relaysMostlyRestricted, deriveSocket(url)], + ([$relaysMostlyRestricted, $socket]) => { + if ($socket.auth.details) { + return stripPrefix($socket.auth.details) + } + + if ($relaysMostlyRestricted[url]) { + return stripPrefix($relaysMostlyRestricted[url]) + } + + const error = getThunkError(thunk) + + if (error) { + const isIgnored = error.startsWith("mute: ") + const isEmptyInvite = !claim && error.includes("invite code") + const isStrictNip29Relay = error.includes("missing group (`h`) tag") + + if (!isStrictNip29Relay && !isIgnored && !isEmptyInvite && !isStrictNip29Relay) { + return stripPrefix(error) || "join request rejected" + } + } + }, + ) +} diff --git a/src/app/util/modal.ts b/src/app/util/modal.ts index 0175586..9ffeee2 100644 --- a/src/app/util/modal.ts +++ b/src/app/util/modal.ts @@ -1,7 +1,8 @@ import type {Component} from "svelte" -import {writable} from "svelte/store" +import {derived, writable} from "svelte/store" import {randomId, always, assoc, Emitter} from "@welshman/lib" import {goto} from "$app/navigation" +import {page} from "$app/stores" export type ModalOptions = { drawer?: boolean @@ -21,6 +22,10 @@ export const emitter = new Emitter() export const modals = writable>({}) +export const modal = derived([page, modals], ([$page, $modals]) => { + return $modals[$page.url.hash.slice(1)] +}) + export const pushModal = ( component: Component, props: Record = {}, diff --git a/src/lib/components/SocketStatusIndicator.svelte b/src/lib/components/SocketStatusIndicator.svelte index f7e0608..ae2b7be 100644 --- a/src/lib/components/SocketStatusIndicator.svelte +++ b/src/lib/components/SocketStatusIndicator.svelte @@ -1,14 +1,15 @@ {#if $socket.status === SocketStatus.Open} @@ -22,7 +23,7 @@ Failed to Authenticate {:else if $socket.auth.status === AuthStatus.PendingResponse} Authenticating - {:else if $socket.auth.status === AuthStatus.Forbidden} + {:else if $socket.auth.status === AuthStatus.Forbidden || $relaysMostlyRestricted[url]} Access Denied {:else if $socket.auth.status === AuthStatus.Ok} Connected diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f153da5..d78b5aa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -20,6 +20,8 @@ ago, WEEK, TaskQueue, + assoc, + dissoc, } from "@welshman/lib" import type {TrustedEvent, StampedEvent} from "@welshman/util" import { @@ -40,13 +42,18 @@ getRelaysFromList, } from "@welshman/util" import {Nip46Broker, makeSecret} from "@welshman/signer" - import type {Socket, RelayMessage} from "@welshman/net" + import type {Socket, RelayMessage, ClientMessage} from "@welshman/net" import { request, defaultSocketPolicies, makeSocketPolicyAuth, SocketEvent, isRelayEvent, + isRelayOk, + isRelayClosed, + isClientReq, + isClientEvent, + isClientClose, } from "@welshman/net" import { loadRelay, @@ -87,6 +94,7 @@ ensureUnwrapped, canDecrypt, getSetting, + relaysMostlyRestricted, } from "@app/core/state" import {loadUserData, listenForNotifications} from "@app/core/requests" import {theme} from "@app/util/theme" @@ -296,6 +304,71 @@ }), ] + return () => { + unsubscribers.forEach(call) + } + }, + function monitorRestrictedResponses(socket: Socket) { + let total = 0 + let restricted = 0 + let error = "" + + const pending = new Set() + + const updateStatus = () => + relaysMostlyRestricted.update( + restricted > total / 2 ? assoc(socket.url, error) : dissoc(socket.url), + ) + + const unsubscribers = [ + on(socket, SocketEvent.Receive, (message: RelayMessage) => { + if (isRelayOk(message)) { + const [_, id, ok, details = ""] = message + + if (pending.has(id)) { + pending.delete(id) + + if (!ok && details.startsWith("restricted: ")) { + restricted++ + error = details + updateStatus() + } + } + } + + if (isRelayClosed(message)) { + const [_, id, details = ""] = message + + if (pending.has(id)) { + pending.delete(id) + + if (details.startsWith("restricted: ")) { + restricted++ + error = details + updateStatus() + } + } + } + }), + on(socket, SocketEvent.Send, (message: ClientMessage) => { + if (isClientReq(message)) { + total++ + pending.add(message[1]) + updateStatus() + } + + if (isClientEvent(message)) { + total++ + pending.add(message[1].id) + updateStatus() + } + + if (isClientClose(message)) { + pending.delete(message[1]) + } + }), + ] + return () => { unsubscribers.forEach(call) } diff --git a/src/routes/spaces/[relay]/+layout.svelte b/src/routes/spaces/[relay]/+layout.svelte index 96e4226..5c2d0e4 100644 --- a/src/routes/spaces/[relay]/+layout.svelte +++ b/src/routes/spaces/[relay]/+layout.svelte @@ -2,8 +2,9 @@ import type {Snippet} from "svelte" import {onMount} from "svelte" import {page} from "$app/stores" - import {ago, MONTH} from "@welshman/lib" - import {ROOM_META, EVENT_TIME, THREAD, COMMENT, MESSAGE} from "@welshman/util" + import {ago, sleep, once, MONTH} from "@welshman/lib" + import {ROOM_META, EVENT_TIME, THREAD, COMMENT, MESSAGE, displayRelayUrl} from "@welshman/util" + import {SocketStatus} from "@welshman/net" import Page from "@lib/components/Page.svelte" import Dialog from "@lib/components/Dialog.svelte" import SecondaryNav from "@lib/components/SecondaryNav.svelte" @@ -13,8 +14,13 @@ import {pushToast} from "@app/util/toast" import {pushModal} from "@app/util/modal" import {setChecked} from "@app/util/notifications" - import {checkRelayConnection, checkRelayAuth, checkRelayAccess} from "@app/core/commands" - import {decodeRelay, userRoomsByUrl, relaysPendingTrust} from "@app/core/state" + import { + decodeRelay, + deriveRelayAuthError, + relaysPendingTrust, + deriveSocket, + userRoomsByUrl, + } from "@app/core/state" import {pullConservatively} from "@app/core/requests" import {notifications} from "@app/util/notifications" @@ -28,21 +34,11 @@ const rooms = Array.from($userRoomsByUrl.get(url) || []) - const checkConnection = async (signal: AbortSignal) => { - const connectionError = await checkRelayConnection(url) + const socket = deriveSocket(url) - if (connectionError) { - return pushToast({theme: "error", message: connectionError}) - } + const authError = deriveRelayAuthError(url) - const [authError, accessError] = await Promise.all([checkRelayAuth(url), checkRelayAccess(url)]) - - const error = authError || accessError - - if (error && !signal.aborted) { - pushModal(SpaceAuthError, {url, error}) - } - } + const showAuthError = once(() => pushModal(SpaceAuthError, {url, error: $authError})) // We have to watch this one, since on mobile the badge will be visible when active $effect(() => { @@ -51,17 +47,29 @@ } }) - onMount(() => { - const relays = [url] - const since = ago(MONTH) - const controller = new AbortController() + // Watch for relay errors and notify the user + $effect(() => { + if ($authError) { + showAuthError() + } + }) - checkConnection(controller.signal) + onMount(() => { + const since = ago(MONTH) + + sleep(2000).then(() => { + if ($socket.status !== SocketStatus.Open) { + pushToast({ + theme: "error", + message: `Failed to connect to ${displayRelayUrl(url)}`, + }) + } + }) // Load group meta, threads, calendar events, comments, and recent messages // for user rooms to help with a quick page transition pullConservatively({ - relays, + relays: [url], filters: [ {kinds: [ROOM_META]}, {kinds: [THREAD, EVENT_TIME, MESSAGE], since}, @@ -69,10 +77,6 @@ ...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since})), ], }) - - return () => { - controller.abort() - } })