Browse Source

bug-fix nip05

imwald
Silberengel 2 weeks ago
parent
commit
d87578b458
  1. 15
      src/components/RelayIcon/index.tsx
  2. 44
      src/lib/nip05-well-known.test.ts
  3. 54
      src/lib/nip05.ts
  4. 16
      src/lib/relay-icon-source.test.ts
  5. 22
      src/lib/relay-icon-source.ts

15
src/components/RelayIcon/index.tsx

@ -3,12 +3,13 @@ import { useFetchRelayInfo } from '@/hooks'
import { useRelaySessionStrikeActive } from '@/hooks/useRelaySessionStrikeActive' import { useRelaySessionStrikeActive } from '@/hooks/useRelaySessionStrikeActive'
import { import {
getRelayIconFallbackGlyph, getRelayIconFallbackGlyph,
getRelayIconLucideFallback,
getRelayIconOverrideSrc, getRelayIconOverrideSrc,
relayUrlFingerprintColors relayUrlFingerprintColors
} from '@/lib/relay-icon-source' } from '@/lib/relay-icon-source'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { TRelayInfo } from '@/types' import type { TRelayInfo } from '@/types'
import { Server } from 'lucide-react' import { Home, Search, Server } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
/** /**
@ -59,8 +60,10 @@ export default function RelayIcon({
useEffect(() => { useEffect(() => {
setIconLoadFailed(false) setIconLoadFailed(false)
}, [url, relayInfo?.icon]) }, [url, relayInfo?.icon])
const lucideFallback = useMemo(() => getRelayIconLucideFallback(url), [url])
const iconUrl = useMemo(() => { const iconUrl = useMemo(() => {
if (!url) return undefined if (!url || lucideFallback) return undefined
const override = getRelayIconOverrideSrc(url) const override = getRelayIconOverrideSrc(url)
if (override) { if (override) {
@ -75,7 +78,7 @@ export default function RelayIcon({
} }
return undefined return undefined
}, [url, relayInfo]) }, [url, relayInfo, lucideFallback])
const fallbackColors = useMemo(() => relayUrlFingerprintColors(url), [url]) const fallbackColors = useMemo(() => relayUrlFingerprintColors(url), [url])
const fallbackGlyph = useMemo(() => getRelayIconFallbackGlyph(url), [url]) const fallbackGlyph = useMemo(() => getRelayIconFallbackGlyph(url), [url])
@ -95,7 +98,11 @@ export default function RelayIcon({
className="bg-transparent" className="bg-transparent"
style={{ backgroundColor: fallbackColors.background, color: fallbackColors.color }} style={{ backgroundColor: fallbackColors.background, color: fallbackColors.color }}
> >
{fallbackGlyph ? ( {lucideFallback === 'search' ? (
<Search size={iconSize} className="opacity-95" aria-hidden />
) : lucideFallback === 'home' ? (
<Home size={iconSize} className="opacity-95" aria-hidden />
) : fallbackGlyph ? (
<span <span
className="leading-none select-none" className="leading-none select-none"
style={{ fontSize: Math.max(12, iconSize + 4) }} style={{ fontSize: Math.max(12, iconSize + 4) }}

44
src/lib/nip05-well-known.test.ts

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { parseNip05NamePubkeysFromWellKnownJson } from '@/lib/nip05' import {
getWellKnownNip05Url,
parseNip05NamePubkeysFromWellKnownJson,
verifyNip05AgainstWellKnown
} from '@/lib/nip05'
const THEFOREST_WELL_KNOWN = { const THEFOREST_WELL_KNOWN = {
names: { names: {
@ -28,6 +32,44 @@ const THEFOREST_WELL_KNOWN = {
} }
} as const } as const
const SILBERENGEL_HEX = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1'
describe('verifyNip05AgainstWellKnown', () => {
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', () => { describe('parseNip05NamePubkeysFromWellKnownJson', () => {
it('parses theforest.nostr1.com well-known names', () => { it('parses theforest.nostr1.com well-known names', () => {
const rows = parseNip05NamePubkeysFromWellKnownJson(THEFOREST_WELL_KNOWN) const rows = parseNip05NamePubkeysFromWellKnownJson(THEFOREST_WELL_KNOWN)

54
src/lib/nip05.ts

@ -18,7 +18,7 @@ type TVerifyNip05Result = {
} }
/** Bumps when verification rules change so LRU does not serve stale false negatives. */ /** 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. */ /** Bumps when well-known fetch/parse rules change so stale empty cache entries are not reused. */
const WELL_KNOWN_CACHE_SCHEMA = 5 const WELL_KNOWN_CACHE_SCHEMA = 5
@ -239,23 +239,19 @@ const verifyNip05ResultCache = new LRUCache<string, TVerifyNip05Result>({
} }
}) })
async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05Result> { export function verifyNip05AgainstWellKnown(
const nip05Str = asNip05LookupString(nip05).trim() json: Record<string, unknown> | null,
const split = splitNip05Identifier(nip05Str) nip05Name: string,
const nip05Name = split?.name ?? '' userHex: string,
const nip05Domain = split?.domain ?? '' base: TVerifyNip05Result
const result: TVerifyNip05Result = { isVerified: false, nip05Name, nip05Domain } ): TVerifyNip05Result {
const userHex = normalizePubkeyForNip05Lookup(pubkey) if (!json) return base
if (!split || !userHex) return result
const json = await fetchWellKnownNostrJson(nip05Domain, nip05Name)
if (!json) return result
const names = json.names as Record<string, unknown> | undefined const names = json.names as Record<string, unknown> | undefined
if (!names || typeof names !== 'object') return result if (!names || typeof names !== 'object') return base
const resolved = resolveNamesEntry(names, nip05Name, userHex) 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<string, unknown> | undefined const relays = json.relays as Record<string, unknown> | undefined
const relayList = pickRelayListForPubkey( const relayList = pickRelayListForPubkey(
@ -266,12 +262,34 @@ async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05
names names
) )
return { return {
...result, ...base,
isVerified: true, isVerified: true,
relays: Array.isArray(relayList) ? (relayList as string[]) : undefined relays: Array.isArray(relayList) ? (relayList as string[]) : undefined
} }
} }
async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05Result> {
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<TVerifyNip05Result> { export async function verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05Result> {
const nip05Str = asNip05LookupString(nip05).trim() const nip05Str = asNip05LookupString(nip05).trim()
const pubkeyNorm = normalizePubkeyForNip05Lookup(pubkey) ?? pubkey.trim() const pubkeyNorm = normalizePubkeyForNip05Lookup(pubkey) ?? pubkey.trim()
@ -406,7 +424,7 @@ async function fetchWellKnownNostrJsonOnce(
return null 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<Record<string, unknown> | null> { async function fetchWellKnownFullDocument(domain: string): Promise<Record<string, unknown> | null> {
return fetchWellKnownNostrJsonOnce(domain, undefined) return fetchWellKnownNostrJsonOnce(domain, undefined)
} }
@ -437,8 +455,8 @@ async function getOrFetchWellKnownJsonForDomain(
return inflight return inflight
} }
/** Fetch `/.well-known/nostr.json` for a domain (full document; `name` is resolved locally). */ /** Fetch full `/.well-known/nostr.json` for a domain (domain listing / relay discovery). */
async function fetchWellKnownNostrJson(domain: string, _name?: string): Promise<Record<string, unknown> | null> { async function fetchWellKnownNostrJson(domain: string): Promise<Record<string, unknown> | null> {
return getOrFetchWellKnownJsonForDomain(domain) return getOrFetchWellKnownJsonForDomain(domain)
} }

16
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 { describe, expect, it } from 'vitest'
import { import {
getRelayIconFallbackGlyph, getRelayIconFallbackGlyph,
getRelayIconLucideFallback,
getRelayIconOverrideSrc, getRelayIconOverrideSrc,
isLoopbackRelayUrl,
NOSTRARCHIVES_SITE_ICON_SRC NOSTRARCHIVES_SITE_ICON_SRC
} from '@/lib/relay-icon-source' } from '@/lib/relay-icon-source'
@ -20,4 +22,18 @@ describe('relay icon branding', () => {
it('uses nostrarchives favicon for search relay (same as trending)', () => { it('uses nostrarchives favicon for search relay (same as trending)', () => {
expect(getRelayIconOverrideSrc(NOSTR_ARCHIVES_SEARCH_RELAY_URL)).toBe(NOSTRARCHIVES_SITE_ICON_SRC) 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()
})
}) })

22
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 { function parseRelayHostname(url: string): string | undefined {
const raw = (normalizeUrl(url) || url).trim() const raw = (normalizeUrl(url) || url).trim()
const forParse = raw.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://') 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 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}). * 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. * Sovbit hosts use {@link getRelayIconOverrideSrc} favicons instead; purplepag uses the purple circle.
*/ */
export function getRelayIconFallbackGlyph(url: string | undefined): string | undefined { export function getRelayIconFallbackGlyph(url: string | undefined): string | undefined {
if (getRelayIconLucideFallback(url)) return undefined
const host = parseRelayHostname(url ?? '') const host = parseRelayHostname(url ?? '')
if (!host) return undefined if (!host) return undefined
if (host === 'purplepag.es') return '🟣' if (host === 'purplepag.es') return '🟣'

Loading…
Cancel
Save