diff --git a/src/components/RelayIcon/index.tsx b/src/components/RelayIcon/index.tsx index 3dcc7e20..ce0a3460 100644 --- a/src/components/RelayIcon/index.tsx +++ b/src/components/RelayIcon/index.tsx @@ -3,12 +3,13 @@ import { useFetchRelayInfo } from '@/hooks' import { useRelaySessionStrikeActive } from '@/hooks/useRelaySessionStrikeActive' import { getRelayIconFallbackGlyph, + getRelayIconLucideFallback, getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source' import { cn } from '@/lib/utils' import type { TRelayInfo } from '@/types' -import { Server } from 'lucide-react' +import { Home, Search, Server } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' /** @@ -59,8 +60,10 @@ export default function RelayIcon({ useEffect(() => { setIconLoadFailed(false) }, [url, relayInfo?.icon]) + const lucideFallback = useMemo(() => getRelayIconLucideFallback(url), [url]) + const iconUrl = useMemo(() => { - if (!url) return undefined + if (!url || lucideFallback) return undefined const override = getRelayIconOverrideSrc(url) if (override) { @@ -75,7 +78,7 @@ export default function RelayIcon({ } return undefined - }, [url, relayInfo]) + }, [url, relayInfo, lucideFallback]) const fallbackColors = useMemo(() => relayUrlFingerprintColors(url), [url]) const fallbackGlyph = useMemo(() => getRelayIconFallbackGlyph(url), [url]) @@ -95,7 +98,11 @@ export default function RelayIcon({ className="bg-transparent" style={{ backgroundColor: fallbackColors.background, color: fallbackColors.color }} > - {fallbackGlyph ? ( + {lucideFallback === 'search' ? ( + + ) : lucideFallback === 'home' ? ( + + ) : fallbackGlyph ? ( { + it('fails on nostr.land-style empty full document', () => { + const base = { isVerified: false, nip05Name: 'silberengel', nip05Domain: 'nostr.land' } + const out = verifyNip05AgainstWellKnown( + { names: {}, relays: {} }, + 'silberengel', + SILBERENGEL_HEX, + base + ) + expect(out.isVerified).toBe(false) + }) + + it('verifies with name-scoped document (nostr.land ?name= response)', () => { + const base = { isVerified: false, nip05Name: 'silberengel', nip05Domain: 'nostr.land' } + const out = verifyNip05AgainstWellKnown( + { + names: { silberengel: SILBERENGEL_HEX }, + relays: { silberengel: ['wss://nostr.land'] } + }, + 'silberengel', + SILBERENGEL_HEX, + base + ) + expect(out.isVerified).toBe(true) + expect(out.relays).toEqual(['wss://nostr.land']) + }) +}) + +describe('getWellKnownNip05Url', () => { + it('appends name query per NIP-05', () => { + expect(getWellKnownNip05Url('nostr.land', 'silberengel')).toBe( + 'https://nostr.land/.well-known/nostr.json?name=silberengel' + ) + }) +}) + describe('parseNip05NamePubkeysFromWellKnownJson', () => { it('parses theforest.nostr1.com well-known names', () => { const rows = parseNip05NamePubkeysFromWellKnownJson(THEFOREST_WELL_KNOWN) diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts index 12656bf2..056d14c6 100644 --- a/src/lib/nip05.ts +++ b/src/lib/nip05.ts @@ -18,7 +18,7 @@ type TVerifyNip05Result = { } /** Bumps when verification rules change so LRU does not serve stale false negatives. */ -const VERIFY_CACHE_SCHEMA = 7 +const VERIFY_CACHE_SCHEMA = 8 /** Bumps when well-known fetch/parse rules change so stale empty cache entries are not reused. */ const WELL_KNOWN_CACHE_SCHEMA = 5 @@ -239,23 +239,19 @@ const verifyNip05ResultCache = new LRUCache({ } }) -async function _verifyNip05(nip05: string, pubkey: string): Promise { - const nip05Str = asNip05LookupString(nip05).trim() - const split = splitNip05Identifier(nip05Str) - const nip05Name = split?.name ?? '' - const nip05Domain = split?.domain ?? '' - const result: TVerifyNip05Result = { isVerified: false, nip05Name, nip05Domain } - const userHex = normalizePubkeyForNip05Lookup(pubkey) - if (!split || !userHex) return result - - const json = await fetchWellKnownNostrJson(nip05Domain, nip05Name) - if (!json) return result +export function verifyNip05AgainstWellKnown( + json: Record | null, + nip05Name: string, + userHex: string, + base: TVerifyNip05Result +): TVerifyNip05Result { + if (!json) return base const names = json.names as Record | undefined - if (!names || typeof names !== 'object') return result + if (!names || typeof names !== 'object') return base const resolved = resolveNamesEntry(names, nip05Name, userHex) - if (!resolved || !hexPubkeysEqual(resolved.pubkey, userHex)) return result + if (!resolved || !hexPubkeysEqual(resolved.pubkey, userHex)) return base const relays = json.relays as Record | undefined const relayList = pickRelayListForPubkey( @@ -266,12 +262,34 @@ async function _verifyNip05(nip05: string, pubkey: string): Promise { + const nip05Str = asNip05LookupString(nip05).trim() + const split = splitNip05Identifier(nip05Str) + const nip05Name = split?.name ?? '' + const nip05Domain = split?.domain ?? '' + const result: TVerifyNip05Result = { isVerified: false, nip05Name, nip05Domain } + const userHex = normalizePubkeyForNip05Lookup(pubkey) + if (!split || !userHex) return result + + const fullDoc = await getOrFetchWellKnownJsonForDomain(nip05Domain) + let verified = verifyNip05AgainstWellKnown(fullDoc, nip05Name, userHex, result) + if (verified.isVerified) return verified + + // NIP-05: dynamic hosts (e.g. nostr.land) only include the user when `?name=` is set. + // Do not cache these responses — they are not a full domain listing. + if (nip05Name) { + const scoped = await fetchWellKnownNostrJsonOnce(nip05Domain, nip05Name) + verified = verifyNip05AgainstWellKnown(scoped, nip05Name, userHex, result) + } + return verified +} + export async function verifyNip05(nip05: string, pubkey: string): Promise { const nip05Str = asNip05LookupString(nip05).trim() const pubkeyNorm = normalizePubkeyForNip05Lookup(pubkey) ?? pubkey.trim() @@ -406,7 +424,7 @@ async function fetchWellKnownNostrJsonOnce( return null } -/** Always fetch the full domain document (never `?name=` — partial responses must not poison domain cache). */ +/** Full domain document for listing/cache (no `?name=` — partial responses must not poison domain cache). */ async function fetchWellKnownFullDocument(domain: string): Promise | null> { return fetchWellKnownNostrJsonOnce(domain, undefined) } @@ -437,8 +455,8 @@ async function getOrFetchWellKnownJsonForDomain( return inflight } -/** Fetch `/.well-known/nostr.json` for a domain (full document; `name` is resolved locally). */ -async function fetchWellKnownNostrJson(domain: string, _name?: string): Promise | null> { +/** Fetch full `/.well-known/nostr.json` for a domain (domain listing / relay discovery). */ +async function fetchWellKnownNostrJson(domain: string): Promise | null> { return getOrFetchWellKnownJsonForDomain(domain) } diff --git a/src/lib/relay-icon-source.test.ts b/src/lib/relay-icon-source.test.ts index 6f427d03..897dbd91 100644 --- a/src/lib/relay-icon-source.test.ts +++ b/src/lib/relay-icon-source.test.ts @@ -2,7 +2,9 @@ import { NOSTR_ARCHIVES_SEARCH_RELAY_URL } from '@/constants' import { describe, expect, it } from 'vitest' import { getRelayIconFallbackGlyph, + getRelayIconLucideFallback, getRelayIconOverrideSrc, + isLoopbackRelayUrl, NOSTRARCHIVES_SITE_ICON_SRC } from '@/lib/relay-icon-source' @@ -20,4 +22,18 @@ describe('relay icon branding', () => { it('uses nostrarchives favicon for search relay (same as trending)', () => { expect(getRelayIconOverrideSrc(NOSTR_ARCHIVES_SEARCH_RELAY_URL)).toBe(NOSTRARCHIVES_SITE_ICON_SRC) }) + + it('uses search lucide fallback for search.nos.today', () => { + expect(getRelayIconLucideFallback('wss://search.nos.today/')).toBe('search') + expect(getRelayIconFallbackGlyph('wss://search.nos.today/')).toBeUndefined() + }) + + it('uses home lucide fallback for loopback relays', () => { + expect(isLoopbackRelayUrl('ws://localhost:4869/')).toBe(true) + expect(isLoopbackRelayUrl('ws://127.0.0.1:4869/')).toBe(true) + expect(getRelayIconLucideFallback('ws://localhost:4869/')).toBe('home') + expect(getRelayIconLucideFallback('ws://127.0.0.1:4869/')).toBe('home') + expect(isLoopbackRelayUrl('wss://192.168.0.5/')).toBe(false) + expect(getRelayIconLucideFallback('wss://192.168.0.5/')).toBeUndefined() + }) }) diff --git a/src/lib/relay-icon-source.ts b/src/lib/relay-icon-source.ts index 23eb9c67..4ed6ddff 100644 --- a/src/lib/relay-icon-source.ts +++ b/src/lib/relay-icon-source.ts @@ -36,6 +36,8 @@ export function isNostrArchivesBrandedRelayUrl(url: string | undefined): boolean ) } +export type RelayIconLucideFallback = 'search' | 'home' + function parseRelayHostname(url: string): string | undefined { const raw = (normalizeUrl(url) || url).trim() const forParse = raw.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://') @@ -66,11 +68,31 @@ export function getRelayIconOverrideSrc(url: string | undefined): string | undef return undefined } +/** Loopback dev/cache relays (localhost, 127.0.0.1, ::1) — not broader LAN ranges. */ +export function isLoopbackRelayUrl(url: string | undefined): boolean { + const host = parseRelayHostname(url ?? '') + if (!host) return false + return host === 'localhost' || host === '127.0.0.1' || host === '::1' +} + +/** + * Lucide icon for relays that should not use NIP-11 / favicon (shown in {@link RelayIcon}). + * Takes precedence over {@link getRelayIconOverrideSrc} and NIP-11 `icon`. + */ +export function getRelayIconLucideFallback(url: string | undefined): RelayIconLucideFallback | undefined { + const host = parseRelayHostname(url ?? '') + if (!host) return undefined + if (host === 'search.nos.today') return 'search' + if (isLoopbackRelayUrl(url)) return 'home' + return undefined +} + /** * Unicode fallback when NIP-11 / favicon is missing or failed to load (shown in {@link RelayIcon}). * Sovbit hosts use {@link getRelayIconOverrideSrc} favicons instead; purplepag uses the purple circle. */ export function getRelayIconFallbackGlyph(url: string | undefined): string | undefined { + if (getRelayIconLucideFallback(url)) return undefined const host = parseRelayHostname(url ?? '') if (!host) return undefined if (host === 'purplepag.es') return '🟣'