From d65dcf671cb10491109b4e3694e5bf24e0f8fc55 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 13 May 2026 15:33:37 +0200 Subject: [PATCH] bug-fixes --- src/components/Nip05List/index.tsx | 25 ++- src/i18n/locales/cs.ts | 5 +- src/i18n/locales/de.ts | 7 +- src/i18n/locales/en.ts | 5 +- src/i18n/locales/es.ts | 5 +- src/i18n/locales/fr.ts | 5 +- src/i18n/locales/nl.ts | 5 +- src/i18n/locales/pl.ts | 5 +- src/i18n/locales/ru.ts | 5 +- src/i18n/locales/tr.ts | 5 +- src/i18n/locales/zh.ts | 5 +- src/lib/nip05.ts | 207 +++++++++++++++---- src/lib/profile-relay-search-filters.ts | 62 ++++++ src/providers/NostrProvider/index.tsx | 195 ++++++++++------- src/providers/NostrProvider/nip-07.signer.ts | 5 +- src/services/client-query.service.ts | 4 +- src/services/client.service.ts | 81 ++++++-- 17 files changed, 467 insertions(+), 164 deletions(-) create mode 100644 src/lib/profile-relay-search-filters.ts diff --git a/src/components/Nip05List/index.tsx b/src/components/Nip05List/index.tsx index 5b19e39d..0cf657c0 100644 --- a/src/components/Nip05List/index.tsx +++ b/src/components/Nip05List/index.tsx @@ -1,5 +1,5 @@ import { Skeleton } from '@/components/ui/skeleton' -import { verifyNip05 } from '@/lib/nip05' +import { splitNip05Identifier, verifyNip05 } from '@/lib/nip05' import { toNoteList } from '@/lib/link' import { SecondaryPageLink } from '@/PageManager' import { BadgeAlert, BadgeCheck } from 'lucide-react' @@ -24,13 +24,13 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[]; const newVerifications = new Map() // Initialize all as fetching - nip05List.forEach(nip05 => { - const [nip05Name, nip05Domain] = nip05.split('@') + nip05List.forEach((nip05) => { + const parts = splitNip05Identifier(nip05.trim()) newVerifications.set(nip05, { nip05, isVerified: false, - nip05Name: nip05Name || '', - nip05Domain: nip05Domain || '', + nip05Name: parts?.name ?? '', + nip05Domain: parts?.domain ?? '', isFetching: true }) }) @@ -43,11 +43,12 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[]; const result = await verifyNip05(nip05, pubkey) setVerifications(prev => { const updated = new Map(prev) + const fb = splitNip05Identifier(nip05.trim()) updated.set(nip05, { nip05, isVerified: result.isVerified, - nip05Name: result.nip05Name || nip05.split('@')[0] || '', - nip05Domain: result.nip05Domain || nip05.split('@')[1] || '', + nip05Name: result.nip05Name || fb?.name || '', + nip05Domain: result.nip05Domain || fb?.domain || '', isFetching: false }) return updated @@ -55,11 +56,12 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[]; } catch (error) { setVerifications(prev => { const updated = new Map(prev) + const fb = splitNip05Identifier(nip05.trim()) const existing = updated.get(nip05) || { nip05, isVerified: false, - nip05Name: nip05.split('@')[0] || '', - nip05Domain: nip05.split('@')[1] || '', + nip05Name: fb?.name || '', + nip05Domain: fb?.domain || '', isFetching: false } updated.set(nip05, { ...existing, isFetching: false }) @@ -81,8 +83,9 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[]; const verification = verifications.get(nip05) const isFetching = verification?.isFetching ?? true const isVerified = verification?.isVerified ?? false - const nip05Name = verification?.nip05Name || nip05.split('@')[0] || '' - const nip05Domain = verification?.nip05Domain || nip05.split('@')[1] || '' + const fb = splitNip05Identifier(nip05.trim()) + const nip05Name = verification?.nip05Name || fb?.name || '' + const nip05Domain = verification?.nip05Domain || fb?.domain || '' if (isFetching) { return ( diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index a904c411..b7e9d806 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -1539,8 +1539,11 @@ export default { "Location (optional)": "Location (optional)", "Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).", "Login failed": "Login failed", - "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.", + "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.", "nip07.reloadPage": "Reload page", + "nip07.useExtensionIdentity": "Use extension identity", + "nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.", + "nip07.adoptExtensionFailed": "Could not switch to extension identity", "Login to configure RSS feeds": "Login to configure RSS feeds", "Long-form Article": "Long-form Article", "Mailbox relays saved": "Mailbox relays saved", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index b7ab8e5e..773595d5 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1539,8 +1539,11 @@ export default { "Location (optional)": "Location (optional)", "Log in to run this spell (it uses $me or $contacts).": "Zum Ausführen anmelden (verwendet $me oder $contacts).", "Login failed": "Login failed", - "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.", - "nip07.reloadPage": "Reload page", + "nip07.extensionKeyMismatch": "Ihre Browser-Erweiterung verwendet auf diesem Tab einen anderen Schlüssel. Wechseln Sie in der Erweiterung zum passenden Schlüssel, laden Sie die Seite neu, um die aktuelle Erweiterungsauswahl zu übernehmen, oder nutzen Sie die andere Aktion in dieser Meldung, um sich mit dem in der Erweiterung gewählten Schlüssel anzumelden.", + "nip07.reloadPage": "Seite neu laden", + "nip07.useExtensionIdentity": "Erweiterungs-Identität verwenden", + "nip07.switchedToExtensionIdentity": "Auf die aktuelle Identität Ihrer Erweiterung umgestellt.", + "nip07.adoptExtensionFailed": "Wechsel zur Erweiterungs-Identität fehlgeschlagen", "Login to configure RSS feeds": "Login to configure RSS feeds", "Long-form Article": "Long-form Article", "Mailbox relays saved": "Mailbox relays saved", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 78b9057e..1bfef33d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1574,8 +1574,11 @@ export default { "Location (optional)": "Location (optional)", "Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).", "Login failed": "Login failed", - "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.", + "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.", "nip07.reloadPage": "Reload page", + "nip07.useExtensionIdentity": "Use extension identity", + "nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.", + "nip07.adoptExtensionFailed": "Could not switch to extension identity", "Login to configure RSS feeds": "Login to configure RSS feeds", "Long-form Article": "Long-form Article", "Mailbox relays saved": "Mailbox relays saved", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 6f6e2c28..1e77c401 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -1539,8 +1539,11 @@ export default { "Location (optional)": "Location (optional)", "Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).", "Login failed": "Login failed", - "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.", + "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.", "nip07.reloadPage": "Reload page", + "nip07.useExtensionIdentity": "Use extension identity", + "nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.", + "nip07.adoptExtensionFailed": "Could not switch to extension identity", "Login to configure RSS feeds": "Login to configure RSS feeds", "Long-form Article": "Long-form Article", "Mailbox relays saved": "Mailbox relays saved", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index a0c485b8..3e613675 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -1539,8 +1539,11 @@ export default { "Location (optional)": "Location (optional)", "Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).", "Login failed": "Login failed", - "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.", + "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.", "nip07.reloadPage": "Reload page", + "nip07.useExtensionIdentity": "Use extension identity", + "nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.", + "nip07.adoptExtensionFailed": "Could not switch to extension identity", "Login to configure RSS feeds": "Login to configure RSS feeds", "Long-form Article": "Long-form Article", "Mailbox relays saved": "Mailbox relays saved", diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index 9845b0b9..1ef5b600 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -1539,8 +1539,11 @@ export default { "Location (optional)": "Location (optional)", "Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).", "Login failed": "Login failed", - "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.", + "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.", "nip07.reloadPage": "Reload page", + "nip07.useExtensionIdentity": "Use extension identity", + "nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.", + "nip07.adoptExtensionFailed": "Could not switch to extension identity", "Login to configure RSS feeds": "Login to configure RSS feeds", "Long-form Article": "Long-form Article", "Mailbox relays saved": "Mailbox relays saved", diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index bcb7eea6..140bc871 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -1539,8 +1539,11 @@ export default { "Location (optional)": "Location (optional)", "Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).", "Login failed": "Login failed", - "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.", + "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.", "nip07.reloadPage": "Reload page", + "nip07.useExtensionIdentity": "Use extension identity", + "nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.", + "nip07.adoptExtensionFailed": "Could not switch to extension identity", "Login to configure RSS feeds": "Login to configure RSS feeds", "Long-form Article": "Long-form Article", "Mailbox relays saved": "Mailbox relays saved", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 2174ffc2..11bd3fdf 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -1539,8 +1539,11 @@ export default { "Location (optional)": "Location (optional)", "Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).", "Login failed": "Login failed", - "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.", + "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.", "nip07.reloadPage": "Reload page", + "nip07.useExtensionIdentity": "Use extension identity", + "nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.", + "nip07.adoptExtensionFailed": "Could not switch to extension identity", "Login to configure RSS feeds": "Login to configure RSS feeds", "Long-form Article": "Long-form Article", "Mailbox relays saved": "Mailbox relays saved", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index e8d4b007..8559d111 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -1539,8 +1539,11 @@ export default { "Location (optional)": "Location (optional)", "Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).", "Login failed": "Login failed", - "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.", + "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.", "nip07.reloadPage": "Reload page", + "nip07.useExtensionIdentity": "Use extension identity", + "nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.", + "nip07.adoptExtensionFailed": "Could not switch to extension identity", "Login to configure RSS feeds": "Login to configure RSS feeds", "Long-form Article": "Long-form Article", "Mailbox relays saved": "Mailbox relays saved", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 3511b668..0d86aa25 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -1539,8 +1539,11 @@ export default { "Location (optional)": "Location (optional)", "Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).", "Login failed": "Login failed", - "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.", + "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.", "nip07.reloadPage": "Reload page", + "nip07.useExtensionIdentity": "Use extension identity", + "nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.", + "nip07.adoptExtensionFailed": "Could not switch to extension identity", "Login to configure RSS feeds": "Login to configure RSS feeds", "Long-form Article": "Long-form Article", "Mailbox relays saved": "Mailbox relays saved", diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts index d428fe60..5db9e5a7 100644 --- a/src/lib/nip05.ts +++ b/src/lib/nip05.ts @@ -1,6 +1,7 @@ import { LRUCache } from 'lru-cache' +import { nip19 } from 'nostr-tools' import { buildViteProxySitesFetchUrl } from '@/lib/vite-proxy-url' -import { isValidPubkey } from './pubkey' +import { hexPubkeysEqual, isValidPubkey, normalizeHexPubkey } from './pubkey' import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import logger from '@/lib/logger' @@ -11,6 +12,9 @@ type TVerifyNip05Result = { relays?: string[] } +/** Bumps when verification rules change so LRU does not serve stale false negatives. */ +const VERIFY_CACHE_SCHEMA = 4 + function asNip05LookupString(value: unknown): string { if (typeof value === 'string') return value if (value == null) return '' @@ -23,43 +27,146 @@ function asNip05LookupString(value: unknown): string { return String(value) } +/** + * Split `local@domain` on the **first** `@` only (NIP-05 local part must not contain `@`). + * `split('@')` breaks for domains like `user@sub.example.com` → wrong domain. + */ +export function splitNip05Identifier(nip05Str: string): { name: string; domain: string } | null { + const s = nip05Str.trim() + const at = s.indexOf('@') + if (at <= 0 || at >= s.length - 1) return null + const name = s.slice(0, at).trim() + const domain = s.slice(at + 1).trim().replace(/\.$/, '') + if (!name || !domain) return null + return { name, domain: domain.toLowerCase() } +} + +/** Normalize a `names` entry: hex (any case) or `npub1…` → lowercase hex, else null. */ +function pubkeyHexFromWellKnownNamesValue(v: unknown): string | null { + if (typeof v !== 'string') return null + let t = v.trim() + if (t.startsWith('0x') || t.startsWith('0X')) t = t.slice(2).trim() + if (/^[0-9a-f]{64}$/i.test(t)) return t.toLowerCase() + if (t.startsWith('npub1')) { + try { + const { type, data } = nip19.decode(t) + if (type === 'npub' && typeof data === 'string' && isValidPubkey(data)) return data.toLowerCase() + } catch { + return null + } + } + return null +} + +function getNamesEntryRaw(names: Record, nip05Name: string): string | undefined { + if (!nip05Name || typeof names !== 'object' || names == null) return undefined + const asTrimmedString = (x: unknown): string | undefined => + typeof x === 'string' && x.trim() ? x.trim() : undefined + const direct = asTrimmedString(names[nip05Name]) + if (direct !== undefined) return direct + const want = nip05Name.toLowerCase() + for (const k of Object.keys(names)) { + if (k.toLowerCase() === want) return asTrimmedString(names[k]) + } + return undefined +} + +function pickRelayListForPubkey( + relays: Record | undefined, + userPubkeyHex: string, + listedRaw: string, + nip05LocalName: string +): unknown { + if (!relays || typeof relays !== 'object') return undefined + const user = normalizeHexPubkey(userPubkeyHex) + const keysToTry = new Set() + keysToTry.add(user) + keysToTry.add(userPubkeyHex.trim()) + keysToTry.add(listedRaw.trim()) + const listedHex = pubkeyHexFromWellKnownNamesValue(listedRaw) + if (listedHex) { + keysToTry.add(listedHex) + try { + keysToTry.add(nip19.npubEncode(listedHex)) + } catch { + /* ignore */ + } + } + for (const k of keysToTry) { + if (!k) continue + const v = relays[k] + if (Array.isArray(v)) return v + } + for (const k of Object.keys(relays)) { + if (pubkeyHexFromWellKnownNamesValue(k) === user) { + const v = relays[k] + if (Array.isArray(v)) return v + } + } + /** Some hosts key `relays` by NIP-05 local part (non-standard); NIP recommends pubkey keys. */ + const local = nip05LocalName.trim() + if (local) { + const want = local.toLowerCase() + for (const k of Object.keys(relays)) { + if (k.toLowerCase() === want) { + const v = relays[k] + if (Array.isArray(v)) return v + } + } + } + return undefined +} + const verifyNip05ResultCache = new LRUCache({ max: 1000, fetchMethod: (key) => { - const { nip05, pubkey } = JSON.parse(key) as { nip05?: unknown; pubkey?: unknown } - return _verifyNip05(asNip05LookupString(nip05), typeof pubkey === 'string' ? pubkey : '') + const parsed = JSON.parse(key) as { s?: number; nip05?: unknown; pubkey?: unknown } + const nip05 = asNip05LookupString(parsed.nip05).trim() + const pubkey = typeof parsed.pubkey === 'string' ? parsed.pubkey.trim() : '' + return _verifyNip05(nip05, pubkey) } }) async function _verifyNip05(nip05: string, pubkey: string): Promise { - const nip05Str = asNip05LookupString(nip05) - const parts = nip05Str ? nip05Str.split('@') : [] - const nip05Name = parts[0] - const nip05Domain = parts[1] + const nip05Str = asNip05LookupString(nip05).trim() + const split = splitNip05Identifier(nip05Str) + const nip05Name = split?.name ?? '' + const nip05Domain = split?.domain ?? '' const result: TVerifyNip05Result = { isVerified: false, nip05Name, nip05Domain } - if (!nip05Name || !nip05Domain || !pubkey) return result + if (!split || !pubkey || !isValidPubkey(pubkey)) return result + const userHex = normalizeHexPubkey(pubkey) const json = await fetchWellKnownNostrJson(nip05Domain, nip05Name) - if (json) { - const names = json.names as Record | undefined - if (names?.[nip05Name] === pubkey) { - const relays = json.relays as Record | undefined - const relayList = relays?.[pubkey] - return { ...result, isVerified: true, relays: Array.isArray(relayList) ? relayList : undefined } - } - } - return result + if (!json) return result + + const names = json.names as Record | undefined + if (!names || typeof names !== 'object') return result + + const listedRaw = getNamesEntryRaw(names, nip05Name) + if (listedRaw == null) return result + + const listedHex = pubkeyHexFromWellKnownNamesValue(listedRaw) + if (!listedHex || !hexPubkeysEqual(listedHex, userHex)) return result + + const relays = json.relays as Record | undefined + const relayList = pickRelayListForPubkey(relays, userHex, listedRaw, nip05Name) + return { ...result, isVerified: true, relays: Array.isArray(relayList) ? (relayList as string[]) : undefined } } export async function verifyNip05(nip05: string, pubkey: string): Promise { - const nip05Str = asNip05LookupString(nip05) - const pubkeyStr = typeof pubkey === 'string' ? pubkey : '' - const result = await verifyNip05ResultCache.fetch(JSON.stringify({ nip05: nip05Str, pubkey: pubkeyStr })) - if (result) { - return result + const nip05Str = asNip05LookupString(nip05).trim() + const pubkeyStr = typeof pubkey === 'string' ? pubkey.trim() : '' + const pubkeyNorm = isValidPubkey(pubkeyStr) ? normalizeHexPubkey(pubkeyStr) : pubkeyStr + const cached = await verifyNip05ResultCache.fetch( + JSON.stringify({ s: VERIFY_CACHE_SCHEMA, nip05: nip05Str, pubkey: pubkeyNorm }) + ) + if (cached) return cached + const split = splitNip05Identifier(nip05Str) + return { + isVerified: false, + nip05Name: split?.name ?? '', + nip05Domain: split?.domain ?? '' } - const parts = nip05Str ? nip05Str.split('@') : [] - return { isVerified: false, nip05Name: parts[0], nip05Domain: parts[1] } } export function getWellKnownNip05Url(domain: string, name?: string): string { @@ -74,17 +181,23 @@ export function getWellKnownNip05Url(domain: string, name?: string): string { * Fetch `/.well-known/nostr.json` in the browser without tripping third-party CORS: * when `VITE_PROXY_SERVER` is set (production), use same-origin `/sites/?url=…` like OG preview. */ -async function fetchWellKnownNostrJson(domain: string, name?: string): Promise | null> { - const targetUrl = getWellKnownNip05Url(domain, name) +async function fetchWellKnownNostrJsonOnce( + domain: string, + nameInQuery: string | undefined +): Promise | null> { + const targetUrl = getWellKnownNip05Url(domain, nameInQuery) const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim() const fetchUrl = proxyServer ? buildViteProxySitesFetchUrl(targetUrl, proxyServer) : targetUrl try { const res = await fetchWithTimeout(fetchUrl, { credentials: 'omit', - headers: { Accept: 'application/json, text/plain;q=0.9,*/*;q=0.8' }, + headers: { + Accept: 'application/nostr+json, application/json;q=0.9, text/plain;q=0.8, */*;q=0.1' + }, timeoutMs: 15_000 }) - if (!res.ok) return null + /** NIP-05: well-known MUST NOT redirect; following redirects can land on unrelated JSON. */ + if (res.redirected || !res.ok) return null const data: unknown = await res.json() return data && typeof data === 'object' && !Array.isArray(data) ? (data as Record) : null } catch { @@ -92,21 +205,39 @@ async function fetchWellKnownNostrJson(domain: string, name?: string): Promise | null> { + const trimmedName = typeof name === 'string' ? name.trim() : '' + const withQuery = + trimmedName.length > 0 ? await fetchWellKnownNostrJsonOnce(domain, trimmedName) : null + if (withQuery && typeof withQuery.names === 'object' && withQuery.names != null) { + if (getNamesEntryRaw(withQuery.names as Record, trimmedName) != null) { + return withQuery + } + } + const full = await fetchWellKnownNostrJsonOnce(domain, undefined) + if (full && trimmedName && typeof full.names === 'object' && full.names != null) { + if (getNamesEntryRaw(full.names as Record, trimmedName) != null) { + return full + } + } + return withQuery ?? full +} + export async function fetchPubkeysFromDomain(domain: string): Promise { try { const json = await fetchWellKnownNostrJson(domain) if (!json) return [] const pubkeySet = new Set() - return Object.values((json.names as Record) || {}).filter((pubkey) => { - if (typeof pubkey !== 'string' || !isValidPubkey(pubkey)) { - return false - } - if (pubkeySet.has(pubkey)) { - return false - } - pubkeySet.add(pubkey) - return true - }) as string[] + const out: string[] = [] + for (const v of Object.values((json.names as Record) || {})) { + const hex = pubkeyHexFromWellKnownNamesValue(v) + if (!hex || !isValidPubkey(hex)) continue + if (pubkeySet.has(hex)) continue + pubkeySet.add(hex) + out.push(hex) + } + return out } catch (error) { logger.error('Error fetching pubkeys from domain', { error, domain }) return [] diff --git a/src/lib/profile-relay-search-filters.ts b/src/lib/profile-relay-search-filters.ts new file mode 100644 index 00000000..23aad8b3 --- /dev/null +++ b/src/lib/profile-relay-search-filters.ts @@ -0,0 +1,62 @@ +import type { Filter } from 'nostr-tools' +import { kinds } from 'nostr-tools' +import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' + +/** + * REQ filters for kind 0 profile discovery: NIP-50 `search` plus tag-shaped queries + * (`#nip05`, `#name`, `#display_name`) that profile mirrors often index. + * When the query is a hex pubkey, `npub`, or `nprofile`, relays are queried by `authors` + * (NIP-50 text search does not match raw hex or bech32). + * Multiple filters are OR‑merged by relays. + */ +export function buildProfileKind0SearchFilters(opts: { + search: string + limit: number + until?: number +}): Filter[] { + const search = opts.search.trim() + if (!search) return [] + + const limit = Math.max(1, Math.min(opts.limit ?? 50, 500)) + const time = + typeof opts.until === 'number' && opts.until > 0 ? ({ until: opts.until } as Pick) : {} + const k = [kinds.Metadata] as number[] + + const pubkeyHex = decodeProfileSearchQueryToPubkeyHex(search) + if (pubkeyHex) { + return [{ kinds: k, authors: [pubkeyHex], limit, ...time }] + } + + const seen = new Set() + const out: Filter[] = [] + const add = (f: Filter) => { + const key = JSON.stringify(f) + if (seen.has(key)) return + seen.add(key) + out.push(f) + } + + add({ kinds: k, search, limit, ...time }) + + if (search.includes('@')) { + const firstToken = search.split(/\s+/)[0] ?? search + const nipLower = firstToken.trim().toLowerCase() + if (nipLower) add({ kinds: k, '#nip05': [nipLower], limit, ...time }) + const nipExact = firstToken.trim() + if (nipExact && nipExact !== nipLower) add({ kinds: k, '#nip05': [nipExact], limit, ...time }) + } + + const token = search.startsWith('@') ? search.slice(1).trim() : search.trim() + if ( + token && + !/\s/.test(token) && + token.length <= 80 && + /^[a-zA-Z0-9._-]+$/.test(token) && + !token.includes('@') + ) { + add({ kinds: k, '#name': [token], limit, ...time }) + add({ kinds: k, '#display_name': [token], limit, ...time }) + } + + return out +} diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 14572769..a84f2257 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1005,81 +1005,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { void customEmojiService.init(userEmojiListEvent, account.pubkey, profileEvent) }, [userEmojiListEvent, account?.pubkey, profileEvent]) - /** - * If session restore temporarily fell back to read-only (`npub`) while the stored - * account is still `nip-07`, periodically retry reconnecting the extension signer. - */ - useEffect(() => { - if (!account || account.signerType !== 'npub') return - const preferred = storage.getCurrentAccount() - if (!preferred || preferred.signerType !== 'nip-07') return - if (preferred.pubkey !== account.pubkey) return - - let cancelled = false - let timer: ReturnType | null = null - let attempts = 0 - const maxAttempts = 10 - - const schedule = (ms: number) => { - if (cancelled) return - if (timer) clearTimeout(timer) - timer = setTimeout(() => { - void tryRecover() - }, ms) - } - - const tryRecover = async () => { - if (cancelled || attempts >= maxAttempts) return - attempts += 1 - try { - const nip07Signer = new Nip07Signer() - await nip07Signer.init() - const pubkey = await nip07Signer.getPublicKey() - if (pubkey.toLowerCase() !== preferred.pubkey.toLowerCase()) { - throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG) - } - login(nip07Signer, preferred) - logger.info('[NostrProvider] Recovered NIP-07 signer from read-only fallback', { - pubkeySlice: pubkey.slice(0, 12), - attempts - }) - return - } catch (error) { - if (isNip07SignerPubkeyMismatchError(error)) { - logger.info('[NostrProvider] NIP-07 recovery: extension key mismatch on attempt', { - attempts, - wantedPubkey: preferred.pubkey.slice(0, 12) - }) - if (!nip07KeyMismatchToastShownRef.current) { - nip07KeyMismatchToastShownRef.current = true - toast.error(t('nip07.extensionKeyMismatch'), { - duration: 20_000, - action: { label: t('nip07.reloadPage'), onClick: () => window.location.reload() } - }) - } - // Keep retrying — the extension may update its approved key after a moment. - schedule(3_000) - return - } - logger.info('[NostrProvider] NIP-07 recovery retry failed', { - pubkeySlice: preferred.pubkey.slice(0, 12), - attempts, - error: error instanceof Error ? error.message : String(error) - }) - } - schedule(Math.min(10_000, attempts * 1_500)) - } - - schedule(1_200) - return () => { - cancelled = true - if (timer) clearTimeout(timer) - } - // nip07RecoveryBump is incremented by switchAccount after it updates storage following an - // npub fallback, so the loop re-fires with the correct preferred account. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [account, nip07RecoveryBump]) - const hasNostrLoginHash = () => { return window.location.hash && window.location.hash.startsWith('#nostr-login') } @@ -1350,11 +1275,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { (isNip07SignerPubkeyMismatchError(err) || isNip07SignerPubkeyMismatchError(lastNip07Err)) && !nip07KeyMismatchToastShownRef.current ) { - nip07KeyMismatchToastShownRef.current = true - toast.error(t('nip07.extensionKeyMismatch'), { - duration: 20_000, - action: { label: t('nip07.reloadPage'), onClick: () => window.location.reload() } - }) + fireNip07ExtensionKeyMismatchToast() } return fallbackToReadOnlyNpub(storedAccount.pubkey, err) } @@ -1391,6 +1312,120 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { return null } + /** + * Stored NIP-07 account pubkey no longer matches the extension (user switched keys). + * Drop the stale stored NIP-07 row and sign in with whatever pubkey the extension returns now. + */ + const adoptCurrentExtensionNip07Identity = useEventCallback(async () => { + try { + const nip07Signer = new Nip07Signer() + await nip07Signer.init() + const extPubkey = await nip07Signer.getPublicKey() + if (!extPubkey?.trim()) { + throw new Error('Empty pubkey from extension') + } + const preferred = storage.getCurrentAccount() + if ( + preferred?.signerType === 'nip-07' && + preferred.pubkey.toLowerCase() !== extPubkey.toLowerCase() + ) { + removeAccount(preferred) + } + const existing = storage + .getAccounts() + .find((a) => a.pubkey.toLowerCase() === extPubkey.toLowerCase() && a.signerType === 'nip-07') + const act: TAccount = existing ?? { pubkey: extPubkey, signerType: 'nip-07' } + login(nip07Signer, act) + toast.success(t('nip07.switchedToExtensionIdentity')) + } catch (e) { + toast.error(`${t('nip07.adoptExtensionFailed')}: ${e instanceof Error ? e.message : String(e)}`) + } + }) + + const fireNip07ExtensionKeyMismatchToast = useCallback(() => { + if (nip07KeyMismatchToastShownRef.current) return + nip07KeyMismatchToastShownRef.current = true + toast.error(t('nip07.extensionKeyMismatch'), { + duration: 35_000, + action: { label: t('nip07.reloadPage'), onClick: () => window.location.reload() }, + cancel: { + label: t('nip07.useExtensionIdentity'), + onClick: () => { + void adoptCurrentExtensionNip07Identity() + } + } + }) + }, [t, adoptCurrentExtensionNip07Identity]) + + /** + * If session restore temporarily fell back to read-only (`npub`) while the stored + * account is still `nip-07`, periodically retry reconnecting the extension signer. + */ + useEffect(() => { + if (!account || account.signerType !== 'npub') return + const preferred = storage.getCurrentAccount() + if (!preferred || preferred.signerType !== 'nip-07') return + if (preferred.pubkey !== account.pubkey) return + + let cancelled = false + let timer: ReturnType | null = null + let attempts = 0 + const maxAttempts = 10 + + const schedule = (ms: number) => { + if (cancelled) return + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + void tryRecover() + }, ms) + } + + const tryRecover = async () => { + if (cancelled || attempts >= maxAttempts) return + attempts += 1 + try { + const nip07Signer = new Nip07Signer() + await nip07Signer.init() + const pubkey = await nip07Signer.getPublicKey() + if (pubkey.toLowerCase() !== preferred.pubkey.toLowerCase()) { + throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG) + } + login(nip07Signer, preferred) + logger.info('[NostrProvider] Recovered NIP-07 signer from read-only fallback', { + pubkeySlice: pubkey.slice(0, 12), + attempts + }) + return + } catch (error) { + if (isNip07SignerPubkeyMismatchError(error)) { + logger.info('[NostrProvider] NIP-07 recovery: extension key mismatch on attempt', { + attempts, + wantedPubkey: preferred.pubkey.slice(0, 12) + }) + fireNip07ExtensionKeyMismatchToast() + // Keep retrying — the extension may update its approved key after a moment. + schedule(3_000) + return + } + logger.info('[NostrProvider] NIP-07 recovery retry failed', { + pubkeySlice: preferred.pubkey.slice(0, 12), + attempts, + error: error instanceof Error ? error.message : String(error) + }) + } + schedule(Math.min(10_000, attempts * 1_500)) + } + + schedule(1_200) + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + // nip07RecoveryBump is incremented by switchAccount after it updates storage following an + // npub fallback, so the loop re-fires with the correct preferred account. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [account, nip07RecoveryBump, fireNip07ExtensionKeyMismatchToast]) + const normalizeDraftEventTags = ( draftEvent: TDraftEvent, options?: { addClientTag?: boolean } diff --git a/src/providers/NostrProvider/nip-07.signer.ts b/src/providers/NostrProvider/nip-07.signer.ts index 617fed3e..f230d7c3 100644 --- a/src/providers/NostrProvider/nip-07.signer.ts +++ b/src/providers/NostrProvider/nip-07.signer.ts @@ -27,9 +27,8 @@ export class Nip07Signer implements ISigner { if (!this.signer) { throw new Error('Should call init() first') } - if (!this.pubkey) { - this.pubkey = await this.signer.getPublicKey() - } + /** Always ask the extension — it may change the active key without a full page reload. */ + this.pubkey = await this.signer.getPublicKey() return this.pubkey } diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 2a01d5c0..ac48d585 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -7,6 +7,7 @@ import { SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_CONCURRENT_SUBS_PER_RELAY, + PROFILE_RELAY_URLS, RELAY_FILTER_MAX_KINDS_PER_OBJECT, RELAY_REQ_MAX_FILTERS_PER_MESSAGE, RELAY_POOL_CONNECTION_TIMEOUT_MS, @@ -623,7 +624,8 @@ export class QueryService { const searchableSet = new Set([ ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), - ...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u) + ...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u), + ...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) ]) const groupedRequests = Array.from(grouped.entries()).map(([url, f]) => { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 3cc01023..4db2ba09 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -29,6 +29,7 @@ import { DEFAULT_FAVORITE_RELAYS, NIP66_DISCOVERY_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, + PROFILE_RELAY_URLS, READ_ONLY_RELAY_URLS, NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS, SEARCHABLE_RELAY_URLS @@ -174,6 +175,7 @@ import indexedDb from './indexed-db.service' import { invalidateArchiveFootprintCache } from './event-archive.service' import { notifyLiveActivitiesPrewarmComplete } from './live-activities-prewarm-bridge' import nip66Service from './nip66.service' +import { buildProfileKind0SearchFilters } from '@/lib/profile-relay-search-filters' import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure' import { compactFilterForRelayLog, @@ -3357,36 +3359,75 @@ class ClientService extends EventTarget { const normalizedAll = dedupeNormalizeRelayUrlsOrdered( relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) ) + const profileRelayLayer = dedupeNormalizeRelayUrlsOrdered( + PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) + ) + const searchableSet = new Set([ + ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), + ...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u), + ...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) + ]) let urls = normalizedAll if (searchStr.length > 0) { - const searchableSet = new Set([ - ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), - ...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u) - ]) const searchCapable = normalizedAll.filter( (u) => searchableSet.has(u) || nip66Service.isRelaySearchable(u) ) - if (searchCapable.length > 0) { - urls = searchCapable + urls = dedupeNormalizeRelayUrlsOrdered([...searchCapable, ...profileRelayLayer]) + if (urls.length === 0) { + urls = normalizedAll } } - const events = await this.queryService.query( - urls, - { - ...filter, - kinds: [kinds.Metadata] - }, - undefined, - { - replaceableRace: true, - // Search spans many relays; sub-second EOSE was cutting off almost all index relays. - eoseTimeout: 4500, - globalTimeout: 9000 + const limitCap = Math.max(1, Math.min(filter.limit ?? 100, 500)) + const queryFilter: Filter | Filter[] = + searchStr.length > 0 + ? (() => { + const built = buildProfileKind0SearchFilters({ + search: searchStr, + limit: limitCap, + until: filter.until + }) + return built.length > 0 ? built : [{ ...filter, kinds: [kinds.Metadata] }] + })() + : { ...filter, kinds: [kinds.Metadata] } + + const events = await this.queryService.query(urls, queryFilter, undefined, { + replaceableRace: false, + eoseTimeout: 4500, + globalTimeout: 9000, + relayOpSource: 'ClientService.searchProfiles' + }) + + /** Which relays actually delivered each kind-0 id (for tuning SEARCHABLE_RELAY_URLS). DEBUG only. */ + if (searchStr.length > 0) { + const relayHitCounts = new Map() + for (const e of events) { + if (e.kind !== kinds.Metadata) continue + for (const u of this.queryService.getSeenEventRelayUrls(e.id)) { + const n = (normalizeUrl(u) || u).trim() + if (!n) continue + relayHitCounts.set(n, (relayHitCounts.get(n) ?? 0) + 1) + } } - ) + if (relayHitCounts.size > 0) { + const relayHits = [...relayHitCounts.entries()].sort((a, b) => b[1] - a[1]) + logger.debug('[ClientService.searchProfiles] kind=0 deliveries by relay URL (count = events relay sent for this query)', { + searchPreview: searchStr.slice(0, 80), + totalKind0Events: events.filter((e) => e.kind === kinds.Metadata).length, + relayHits: Object.fromEntries(relayHits) + }) + } + } - const profileEvents = events.sort((a, b) => b.created_at - a.created_at) + const byPk = new Map() + for (const e of events) { + if (e.kind !== kinds.Metadata) continue + const prev = byPk.get(e.pubkey) + if (!prev || e.created_at > prev.created_at) { + byPk.set(e.pubkey, e) + } + } + const profileEvents = [...byPk.values()].sort((a, b) => b.created_at - a.created_at).slice(0, limitCap) await Promise.allSettled(profileEvents.map((profile) => this.addUsernameToIndex(profile))) profileEvents.forEach((profile) => this.updateProfileEventCache(profile)) return profileEvents.map((profileEvent) => getProfileFromEvent(profileEvent))