diff --git a/.changeset/rotten-icons-end.md b/.changeset/rotten-icons-end.md new file mode 100644 index 000000000..8522b9b5a --- /dev/null +++ b/.changeset/rotten-icons-end.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Show relay authentication requests diff --git a/package.json b/package.json index f3f11eb7d..d85a9d379 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "nanoid": "^5.0.4", "ngeohash": "^0.6.3", "nostr-idb": "^2.1.4", - "nostr-tools": "^2.5.2", + "nostr-tools": "2.5.2", "nostr-wasm": "^0.1.0", "prettier": "^3.2.5", "react": "^18.2.0", diff --git a/src/classes/chunked-request.ts b/src/classes/chunked-request.ts index 0b3a57f76..1c0cba8b4 100644 --- a/src/classes/chunked-request.ts +++ b/src/classes/chunked-request.ts @@ -54,6 +54,13 @@ export default class ChunkedRequest { async loadNextChunk() { if (this.loading) return; + + // check if its possible to subscribe to this relay + if (!relayPoolService.canSubscribe(this.relay)) { + this.log("Cant subscribe to relay, aborting"); + return; + } + this.loading = true; if (!this.relay.connected) { @@ -93,6 +100,9 @@ export default class ChunkedRequest { this.process.active = false; res(gotEvents); }, + onclose: (reason) => { + relayPoolService.handleRelayNotice(this.relay, reason); + }, }); }); } diff --git a/src/classes/multi-subscription.ts b/src/classes/multi-subscription.ts index 7637e2a29..0d5128dee 100644 --- a/src/classes/multi-subscription.ts +++ b/src/classes/multi-subscription.ts @@ -76,7 +76,6 @@ export default class MultiSubscription { this.relays.add(relay); } - this.process.relays = new Set(this.relays); this.updateSubscriptions(); } diff --git a/src/classes/persistent-subscription.ts b/src/classes/persistent-subscription.ts index 265ba27a0..ee44bb740 100644 --- a/src/classes/persistent-subscription.ts +++ b/src/classes/persistent-subscription.ts @@ -44,6 +44,9 @@ export default class PersistentSubscription { // this.subscription.close(); // } + // check if its possible to subscribe to this relay + if (!relayPoolService.canSubscribe(this.relay)) return; + this.closed = false; this.eosed = false; this.process.active = true; @@ -59,7 +62,9 @@ export default class PersistentSubscription { onclose: (reason) => { if (!this.closed) { // unexpected close, reconnect? - console.log("Unexpected closed", this.relay, reason); + // console.log("Unexpected closed", this.relay, reason); + + relayPoolService.handleRelayNotice(this.relay, reason); this.closed = true; this.process.active = false; diff --git a/src/classes/relay-pool.ts b/src/classes/relay-pool.ts index f119b9f00..76d1d8630 100644 --- a/src/classes/relay-pool.ts +++ b/src/classes/relay-pool.ts @@ -8,6 +8,8 @@ import Subject, { PersistentSubject } from "./subject"; import verifyEventMethod from "../services/verify-event"; import SuperMap from "./super-map"; import processManager from "../services/process-manager"; +import signingService from "../services/signing"; +import accountService from "../services/account"; export type Notice = { message: string; @@ -15,6 +17,8 @@ export type Notice = { relay: AbstractRelay; }; +export type RelayAuthMode = "always" | "ask" | "never"; + export default class RelayPool { relays = new Map(); onRelayCreated = new Subject(); @@ -25,9 +29,12 @@ export default class RelayPool { connectionErrors = new SuperMap(() => []); connecting = new SuperMap>(() => new PersistentSubject(false)); + challenges = new SuperMap>(() => new Subject()); authForPublish = new SuperMap>(() => new Subject()); authForSubscribe = new SuperMap>(() => new Subject()); + authenticated = new SuperMap>(() => new Subject()); + log = logger.extend("RelayPool"); getRelay(relayOrUrl: string | URL | AbstractRelay) { @@ -62,7 +69,10 @@ export default class RelayPool { const key = url.toString(); if (!this.relays.has(key)) { const r = new AbstractRelay(key, { verifyEvent: verifyEventMethod }); - r._onauth = (challenge) => this.onRelayChallenge.next([r, challenge]); + r._onauth = (challenge) => { + this.onRelayChallenge.next([r, challenge]); + this.challenges.get(r).next(challenge); + }; r.onnotice = (notice) => this.handleRelayNotice(r, notice); this.relays.set(key, r); @@ -112,12 +122,94 @@ export default class RelayPool { } } + getRelayAuthStorageKey(relayOrUrl: string | URL | AbstractRelay) { + let relay = this.getRelay(relayOrUrl); + return `${relay!.url}-auth-mode`; + } + getRelayAuthMode(relayOrUrl: string | URL | AbstractRelay): RelayAuthMode | undefined { + let relay = this.getRelay(relayOrUrl); + if (!relay) return; + + const defaultMode = (localStorage.getItem(`default-auth-mode`) as RelayAuthMode) ?? undefined; + const mode = (localStorage.getItem(this.getRelayAuthStorageKey(relay)) as RelayAuthMode) ?? undefined; + + return mode || defaultMode; + } + setRelayAuthMode(relayOrUrl: string | URL | AbstractRelay, mode: RelayAuthMode) { + let relay = this.getRelay(relayOrUrl); + if (!relay) return; + + localStorage.setItem(this.getRelayAuthStorageKey(relay), mode); + } + + pendingAuth = new Map>(); + async authenticate( + relayOrUrl: string | URL | AbstractRelay, + sign: Parameters[0], + quite = true, + ) { + let relay = this.getRelay(relayOrUrl); + if (!relay) return; + + const pending = this.pendingAuth.get(relay); + if (pending) return pending; + + if (this.getRelayAuthMode(relay) === "never") throw new Error("Auth disabled for relay"); + + if (!relay.connected) throw new Error("Not connected"); + + const promise = new Promise(async (res) => { + if (!relay) return; + + try { + const message = await relay.auth(sign); + this.authenticated.get(relay).next(true); + res(message); + } catch (e) { + e = e || new Error("Unknown error"); + if (e instanceof Error) { + this.log(`Failed to authenticate to ${relay.url}`, e.message); + } + this.authenticated.get(relay).next(false); + if (!quite) throw e; + } + + this.pendingAuth.delete(relay); + }); + + this.pendingAuth.set(relay, promise); + + return await promise; + } + + canSubscribe(relayOrUrl: string | URL | AbstractRelay) { + let relay = this.getRelay(relayOrUrl); + if (!relay) return false; + + return this.authForSubscribe.get(relay).value !== false; + } + handleRelayNotice(relay: AbstractRelay, message: string) { const subject = this.notices.get(relay); subject.next([...subject.value, { message, date: dayjs().unix(), relay }]); - const authForSubscribe = this.authForSubscribe.get(relay); - if (!authForSubscribe.value) authForSubscribe.next(true); + if (message.includes("auth-required")) { + const authForSubscribe = this.authForSubscribe.get(relay); + if (!authForSubscribe.value) authForSubscribe.next(true); + + const account = accountService.current.value; + if (account) { + const authMode = this.getRelayAuthMode(relay); + + if (authMode === "always") { + this.authenticate(relay, (draft) => { + return signingService.requestSignature(draft, account); + }).then(() => { + this.log(`Automatically authenticated to ${relay.url}`); + }); + } + } + } } disconnectFromUnused() { diff --git a/src/components/external-embeds/types/image.tsx b/src/components/external-embeds/types/image.tsx index cf8c9f923..aad326228 100644 --- a/src/components/external-embeds/types/image.tsx +++ b/src/components/external-embeds/types/image.tsx @@ -133,7 +133,7 @@ export function EmbeddedImage({ src, event, imageProps, ...props }: EmbeddedImag ); } -export const GalleryImage = forwardRef( +export const GalleryImage = forwardRef( ({ src, event, imageProps, ...props }, ref) => { const thumbnail = useImageThumbnail(src); diff --git a/src/components/layout/desktop-side-nav.tsx b/src/components/layout/desktop-side-nav.tsx index 658e713e2..a4a3104c2 100644 --- a/src/components/layout/desktop-side-nav.tsx +++ b/src/components/layout/desktop-side-nav.tsx @@ -12,7 +12,7 @@ import useSubject from "../../hooks/use-subject"; import { offlineMode } from "../../services/offline-mode"; import WifiOff from "../icons/wifi-off"; import { useTaskManagerContext } from "../../views/task-manager/provider"; -import TaskManagerButton from "./task-manager-button"; +import TaskManagerButtons from "./task-manager-buttons"; const hideScrollbar = css` -ms-overflow-style: none; @@ -89,7 +89,7 @@ export default function DesktopSideNav(props: Omit) { )} - + ); } diff --git a/src/components/layout/mobile-side-drawer.tsx b/src/components/layout/mobile-side-drawer.tsx index 27a409db6..23da7226d 100644 --- a/src/components/layout/mobile-side-drawer.tsx +++ b/src/components/layout/mobile-side-drawer.tsx @@ -15,7 +15,7 @@ import { Link as RouterLink } from "react-router-dom"; import AccountSwitcher from "./account-switcher"; import useCurrentAccount from "../../hooks/use-current-account"; import NavItems from "./nav-items"; -import TaskManagerButton from "./task-manager-button"; +import TaskManagerButtons from "./task-manager-buttons"; export default function MobileSideDrawer({ ...props }: Omit) { const account = useCurrentAccount(); @@ -40,7 +40,7 @@ export default function MobileSideDrawer({ ...props }: Omit )} - + diff --git a/src/components/layout/task-manager-button.tsx b/src/components/layout/task-manager-button.tsx deleted file mode 100644 index 9d04fadc3..000000000 --- a/src/components/layout/task-manager-button.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useContext } from "react"; -import { Button, ButtonProps } from "@chakra-ui/react"; - -import { PublishContext } from "../../providers/global/publish-provider"; -import { useTaskManagerContext } from "../../views/task-manager/provider"; -import PublishActionStatusTag from "../../views/task-manager/publish-log/action-status-tag"; - -export default function TaskManagerButton({ ...props }: Omit) { - const { log } = useContext(PublishContext); - const { openTaskManager } = useTaskManagerContext(); - - return ( - - ); -} diff --git a/src/components/layout/task-manager-buttons.tsx b/src/components/layout/task-manager-buttons.tsx new file mode 100644 index 000000000..92b3e06a0 --- /dev/null +++ b/src/components/layout/task-manager-buttons.tsx @@ -0,0 +1,46 @@ +import { useContext } from "react"; +import { Button, Flex, FlexProps, IconButton } from "@chakra-ui/react"; + +import { PublishContext } from "../../providers/global/publish-provider"; +import { useTaskManagerContext } from "../../views/task-manager/provider"; +import PublishActionStatusTag from "../../views/task-manager/publish-log/action-status-tag"; +import PasscodeLock from "../icons/passcode-lock"; +import relayPoolService from "../../services/relay-pool"; + +export default function TaskManagerButtons({ ...props }: Omit) { + const { log } = useContext(PublishContext); + const { openTaskManager } = useTaskManagerContext(); + + const pendingAuth = Array.from(relayPoolService.challenges.entries()).filter( + ([r, c]) => r.connected && !!c.value && !relayPoolService.authenticated.get(r).value, + ); + + return ( + + + {pendingAuth.length > 0 && ( + + )} + + ); +} diff --git a/src/components/relays/relay-auth-button.tsx b/src/components/relays/relay-auth-button.tsx index e9adb1a5a..55c3ba3be 100644 --- a/src/components/relays/relay-auth-button.tsx +++ b/src/components/relays/relay-auth-button.tsx @@ -1,43 +1,83 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; +import { + Button, + ButtonProps, + IconButton, + IconButtonProps, + useForceUpdate, + useInterval, + useToast, +} from "@chakra-ui/react"; import { AbstractRelay } from "nostr-tools"; -import { Button, useToast } from "@chakra-ui/react"; import relayPoolService from "../../services/relay-pool"; import { useSigningContext } from "../../providers/global/signing-provider"; +import PasscodeLock from "../icons/passcode-lock"; +import useSubject from "../../hooks/use-subject"; +import CheckCircleBroken from "../icons/check-circle-broken"; -export default function RelayAuthButton({ relay }: { relay: string | URL | AbstractRelay }) { +export function useRelayChallenge(relay: AbstractRelay) { + return useSubject(relayPoolService.challenges.get(relay)); +} + +export function useRelayAuthMethod(relay: AbstractRelay) { const toast = useToast(); const { requestSignature } = useSigningContext(); - const r = relayPoolService.getRelay(relay); - if (!r) return null; + const challenge = useRelayChallenge(relay); - // @ts-expect-error - const [challenge, setChallenge] = useState(r.challenge ?? ""); - useEffect(() => { - const sub = relayPoolService.onRelayChallenge.subscribe(([relay, challenge]) => { - if (r === relay) setChallenge(challenge); - }); - - return () => sub.unsubscribe(); - }, [r]); + const authenticated = useSubject(relayPoolService.authenticated.get(relay)); const [loading, setLoading] = useState(false); const auth = useCallback(async () => { setLoading(true); try { - const message = await r.auth(requestSignature); + const message = await relayPoolService.authenticate(relay, requestSignature, false); toast({ description: message || "Success", status: "success" }); } catch (error) { if (error instanceof Error) toast({ status: "error", description: error.message }); } setLoading(false); - }, [r, requestSignature]); + }, [relay, requestSignature]); - if (challenge) + return { loading, auth, challenge, authenticated }; +} + +export function IconRelayAuthButton({ + relay, + ...props +}: { relay: string | URL | AbstractRelay } & Omit) { + const r = relayPoolService.getRelay(relay); + if (!r) return null; + + const update = useForceUpdate(); + useInterval(update, 500); + + const { challenge, auth, loading, authenticated } = useRelayAuthMethod(r); + + if (authenticated) { return ( - + } + aria-label="Authenticated" + title="Authenticated" + colorScheme="green" + {...props} + /> ); + } + + if (r.connected && challenge) { + return ( + } + onClick={auth} + isLoading={loading} + aria-label="Authenticate with relay" + title="Authenticate" + {...props} + /> + ); + } + return null; } diff --git a/src/components/relays/relay-connect-switch.tsx b/src/components/relays/relay-connect-switch.tsx new file mode 100644 index 000000000..c0c270092 --- /dev/null +++ b/src/components/relays/relay-connect-switch.tsx @@ -0,0 +1,36 @@ +import { ChangeEventHandler } from "react"; +import { Switch, useForceUpdate, useInterval, useToast } from "@chakra-ui/react"; +import { AbstractRelay } from "nostr-tools"; + +import relayPoolService from "../../services/relay-pool"; +import useSubject from "../../hooks/use-subject"; + +export default function RelayConnectSwitch({ relay }: { relay: string | URL | AbstractRelay }) { + const toast = useToast(); + + const r = relayPoolService.getRelay(relay); + if (!r) return null; + + const update = useForceUpdate(); + useInterval(update, 500); + + const connecting = useSubject(relayPoolService.connecting.get(r)); + + const onChange: ChangeEventHandler = async (e) => { + try { + if (e.target.checked && !r.connected) await relayPoolService.requestConnect(r); + else if (r.connected) r.close(); + } catch (error) { + if (error instanceof Error) toast({ status: "error", description: error.message }); + } + }; + + return ( + + ); +} diff --git a/src/components/relay-status.tsx b/src/components/relays/relay-status.tsx similarity index 91% rename from src/components/relay-status.tsx rename to src/components/relays/relay-status.tsx index 17c917fdc..94d910829 100644 --- a/src/components/relay-status.tsx +++ b/src/components/relays/relay-status.tsx @@ -1,9 +1,9 @@ import { Badge, useForceUpdate } from "@chakra-ui/react"; import { useInterval } from "react-use"; -import relayPoolService from "../services/relay-pool"; +import relayPoolService from "../../services/relay-pool"; import { AbstractRelay } from "nostr-tools"; -import useSubject from "../hooks/use-subject"; +import useSubject from "../../hooks/use-subject"; const getStatusText = (relay: AbstractRelay, connecting = false) => { if (connecting) return "Connecting..."; diff --git a/src/components/router/back-button.tsx b/src/components/router/back-button.tsx index 09518a359..6c5e6119b 100644 --- a/src/components/router/back-button.tsx +++ b/src/components/router/back-button.tsx @@ -6,7 +6,7 @@ import { ChevronLeftIcon } from "../icons"; export default function BackButton({ ...props }: Omit) { const navigate = useNavigate(); return ( - } aria-label="Back" {...props} onClick={() => navigate(-1)}> + } aria-label="Back" {...props} onClick={() => navigate(-1)}> Back ); @@ -14,5 +14,7 @@ export default function BackButton({ ...props }: Omit) { const navigate = useNavigate(); - return } aria-label="Back" {...props} onClick={() => navigate(-1)} />; + return ( + } aria-label="Back" {...props} onClick={() => navigate(-1)} /> + ); } diff --git a/src/helpers/nostr/wiki.ts b/src/helpers/nostr/wiki.ts index 183c77f97..8b08ae708 100644 --- a/src/helpers/nostr/wiki.ts +++ b/src/helpers/nostr/wiki.ts @@ -15,7 +15,7 @@ export function getPageTopic(page: NostrEvent) { export function getPageSummary(page: NostrEvent, fallback = true) { const summary = page.tags.find((t) => t[0] === "summary")?.[1]; - return summary || (fallback ? page.content.split("\n")[0] : ''); + return summary || (fallback ? page.content.split("\n")[0] : ""); } export function getPageForks(page: NostrEvent) { diff --git a/src/helpers/relay.ts b/src/helpers/relay.ts index 5439ffba6..d086c170d 100644 --- a/src/helpers/relay.ts +++ b/src/helpers/relay.ts @@ -97,27 +97,6 @@ export function splitQueryByPubkeys(query: NostrQuery, relayPubkeyMap: Record((res) => { - const events: NostrEvent[] = []; - const sub = relay.subscribe(filters, { - ...opts, - onevent: (e) => events.push(e), - oneose: () => { - sub.close(); - res(events); - }, - onclose: () => res(events), - }); - }); -} - // copied from nostr-tools, SimplePool#subscribeMany export function subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser { const _knownIds = new Set(); diff --git a/src/hooks/use-router-marker.ts b/src/hooks/use-router-marker.ts index 41895a4b3..31a3912e2 100644 --- a/src/hooks/use-router-marker.ts +++ b/src/hooks/use-router-marker.ts @@ -1,20 +1,20 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { createMemoryRouter } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { createMemoryRouter } from "react-router-dom"; type Router = ReturnType; export default function useRouterMarker(router: Router) { - const index = useRef(null); - const set = useCallback((v = 0) => (index.current = v), []); - const reset = useCallback(() => (index.current = null), []); + const index = useRef(null); + const set = useCallback((v = 0) => (index.current = v), []); + const reset = useCallback(() => (index.current = null), []); - useEffect(() => { - return router.subscribe((event) => { - if (index.current === null) return; - if (event.historyAction === 'PUSH') index.current++; - else if (event.historyAction === 'POP') index.current--; - }); - }, [router]); + useEffect(() => { + return router.subscribe((event) => { + if (index.current === null) return; + if (event.historyAction === "PUSH") index.current++; + else if (event.historyAction === "POP") index.current--; + }); + }, [router]); - return useMemo(() => ({ index, set, reset }), [index, set, reset]); + return useMemo(() => ({ index, set, reset }), [index, set, reset]); } diff --git a/src/lib/fix-image-orientation/file.ts b/src/lib/fix-image-orientation/file.ts index 8b1378917..e69de29bb 100644 --- a/src/lib/fix-image-orientation/file.ts +++ b/src/lib/fix-image-orientation/file.ts @@ -1 +0,0 @@ - diff --git a/src/views/relays/cache/index.tsx b/src/views/relays/cache/index.tsx index e01a337b4..74156dc3a 100644 --- a/src/views/relays/cache/index.tsx +++ b/src/views/relays/cache/index.tsx @@ -129,11 +129,11 @@ function NostrRelayTray() { )} - + A cool little app that runs a local relay in your systems tray Maximum capacity: Unlimited Performance: As fast as your computer - + ); } @@ -171,11 +171,11 @@ function CitrineRelay() { )} - + A cool little app that runs a local relay in your phone Maximum capacity: Unlimited Performance: As fast as your phone - + ); } @@ -302,13 +302,7 @@ export default function CacheRelayView() { {WasmRelay.SUPPORTED && } - { - navigator.userAgent.includes("Android") ? ( - - ) : ( - - ) - } + {navigator.userAgent.includes("Android") ? : } {window.satellite && } {window.CACHE_RELAY_ENABLED && } - + + @@ -84,9 +68,9 @@ export default function InspectRelayView() { ))} - {notices.map((notice) => ( - - [] {notice.message} + {notices.map((notice, i) => ( + + {notice.message} ))} diff --git a/yarn.lock b/yarn.lock index 797f34216..4d7a794de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6218,6 +6218,20 @@ nostr-idb@^2.1.4: idb "^8.0.0" nostr-tools "^2.1.3" +nostr-tools@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.5.2.tgz#54b445380ac2a7740ad90ed3b044bca93ecf23bd" + integrity sha512-Ls2FKh694eudBye6q89yJ5JhXjQle1MWp1yD2sBZ5j9M3IOBEW8ia9IED5W6daSAjlT/Z/pV77yTkdF45c1Rbg== + dependencies: + "@noble/ciphers" "^0.5.1" + "@noble/curves" "1.2.0" + "@noble/hashes" "1.3.1" + "@scure/base" "1.1.1" + "@scure/bip32" "1.3.1" + "@scure/bip39" "1.2.1" + optionalDependencies: + nostr-wasm v0.1.0 + nostr-tools@^1.17.0: version "1.17.0" resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.17.0.tgz#b6f62e32fedfd9e68ec0a7ce57f74c44fc768e8c" @@ -6258,20 +6272,6 @@ nostr-tools@^2.3.2: optionalDependencies: nostr-wasm v0.1.0 -nostr-tools@^2.5.2: - version "2.5.2" - resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.5.2.tgz#54b445380ac2a7740ad90ed3b044bca93ecf23bd" - integrity sha512-Ls2FKh694eudBye6q89yJ5JhXjQle1MWp1yD2sBZ5j9M3IOBEW8ia9IED5W6daSAjlT/Z/pV77yTkdF45c1Rbg== - dependencies: - "@noble/ciphers" "^0.5.1" - "@noble/curves" "1.2.0" - "@noble/hashes" "1.3.1" - "@scure/base" "1.1.1" - "@scure/bip32" "1.3.1" - "@scure/bip39" "1.2.1" - optionalDependencies: - nostr-wasm v0.1.0 - nostr-wasm@^0.1.0, nostr-wasm@v0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/nostr-wasm/-/nostr-wasm-0.1.0.tgz#17af486745feb2b7dd29503fdd81613a24058d94"