import { LRUCache } from 'lru-cache' import { sha256 } from '@noble/hashes/sha2' import { nip19 } from 'nostr-tools' import logger from '@/lib/logger' /** 64-char lowercase hex for identicon math; hashes arbitrary strings (e.g. bad npub paste). */ function stableHexSeedForIdenticon(input: string): string { const t = input.trim() if (/^[0-9a-f]{64}$/i.test(t)) return t.toLowerCase() const bytes = sha256(new TextEncoder().encode(t)) return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') } export function formatPubkey(pubkey: string) { const npub = pubkeyToNpub(pubkey) if (npub) { return formatNpub(npub) } return pubkey.slice(0, 4) + '...' + pubkey.slice(-4) } export function formatNpub(npub: string, length = 12) { if (length < 12) { length = 12 } if (length >= 63) { return npub } const prefixLength = Math.floor((length - 5) / 2) + 5 const suffixLength = length - prefixLength return npub.slice(0, prefixLength) + '...' + npub.slice(-suffixLength) } export function formatUserId(userId: string) { if (userId.startsWith('npub1')) { return formatNpub(userId) } return formatPubkey(userId) } export function pubkeyToNpub(pubkey: string) { try { return nip19.npubEncode(pubkey) } catch { return null } } export function userIdToPubkey(userId: string) { const trimmed = userId.trim() if (!trimmed) return '' if (trimmed.startsWith('npub1') || trimmed.startsWith('nprofile1')) { try { const { type, data } = nip19.decode(trimmed) if (type === 'npub' && typeof data === 'string' && isValidPubkey(data)) { return data.toLowerCase() } if (type === 'nprofile' && data && typeof data.pubkey === 'string' && isValidPubkey(data.pubkey)) { return data.pubkey.toLowerCase() } } catch { // Wrong-length or bad-checksum bech32 — do not pass the literal npub string downstream as "hex pubkey" logger.debug('userIdToPubkey: nip19 decode failed', { len: trimmed.length, prefix: trimmed.slice(0, 12) }) } return '' } if (/^[0-9a-f]{64}$/i.test(trimmed)) { return trimmed.toLowerCase() } return trimmed } /** Lowercase 64-char hex pubkeys for stable Maps, REQ filters, and tag comparison. */ export function normalizeHexPubkey(pubkey: string): string { const t = pubkey.trim() return /^[0-9a-f]{64}$/i.test(t) ? t.toLowerCase() : t } export function hexPubkeysEqual(a: string, b: string): boolean { if (a === b) return true const na = normalizeHexPubkey(a) const nb = normalizeHexPubkey(b) return ( na.length === 64 && nb.length === 64 && /^[0-9a-f]{64}$/.test(na) && na === nb ) } export function isValidPubkey(pubkey: string) { return /^[0-9a-f]{64}$/i.test(pubkey) } /** Hex pubkey from pasted npub / nprofile / hex / `nostr:` URL (e.g. invite lists). */ export function inviteInputToHexPubkey(raw: string): string | null { const t = raw.trim().replace(/^nostr:/i, '').trim() if (!t) return null const pk = userIdToPubkey(t) return isValidPubkey(pk) ? pk.toLowerCase() : null } const pubkeyImageCache = new LRUCache({ max: 1000 }) // Version identifier to force cache invalidation when algorithm changes const CACHE_VERSION = 'v2' export function generateImageByPubkey(pubkey: string): string { const seed = stableHexSeedForIdenticon(pubkey) const cacheKey = `${CACHE_VERSION}:${seed}` if (pubkeyImageCache.has(cacheKey)) { return pubkeyImageCache.get(cacheKey)! } const paddedPubkey = seed.padEnd(66, '0') /** XML/HTML id tokens must not contain `nevent1…` or other punctuation from pasted ids */ const svgIdSafe = `g${seed.slice(0, 20)}` // Split into 3 parts for colors and the rest for control points const colors: string[] = [] const controlPoints: string[] = [] for (let i = 0; i < 11; i++) { const part = paddedPubkey.slice(i * 6, (i + 1) * 6) if (i < 3) { colors.push(`#${part}`) } else { controlPoints.push(part) } } // Generate SVG with multiple radial gradients const gradients = controlPoints .map((point, index) => { const cx = parseInt(point.slice(0, 2), 16) % 100 const cy = parseInt(point.slice(2, 4), 16) % 100 const r = (parseInt(point.slice(4, 6), 16) % 35) + 30 const c = colors[index % (colors.length - 1)] return ` ` }) .join('') const image = ` ${gradients} ` const imageData = `data:image/svg+xml;base64,${btoa(image)}` pubkeyImageCache.set(cacheKey, imageData) return imageData }