helpers.js raw

   1  // helpers.js — nostr utils, crypto helpers, content parser
   2  
   3  import { decode as nip19Decode } from 'https://esm.sh/nostr-tools@2.17.0/nip19'
   4  import { schnorr } from 'https://esm.sh/@noble/curves@1.8.2/secp256k1'
   5  import { bytesToHex } from 'https://esm.sh/@noble/hashes@1.7.2/utils'
   6  
   7  // ─── SW communication ───────────────────────────────────────────────
   8  
   9  export function send(msg) {
  10    if (navigator.serviceWorker.controller) {
  11      navigator.serviceWorker.controller.postMessage(msg)
  12    }
  13  }
  14  
  15  // ─── extension mode crypto bridge ───────────────────────────────────
  16  
  17  export async function handleCryptoRequest(type, reqId, pubkey, text) {
  18    if (!window.nostr) {
  19      send(['CRYPTO_RESULT', reqId, null, 'no extension'])
  20      return
  21    }
  22    try {
  23      let result
  24      if (type === 'DECRYPT_NIP04') result = await window.nostr.nip04.decrypt(pubkey, text)
  25      else if (type === 'ENCRYPT_NIP04') result = await window.nostr.nip04.encrypt(pubkey, text)
  26      else if (type === 'DECRYPT_NIP44') result = await window.nostr.nip44.decrypt(pubkey, text)
  27      else if (type === 'ENCRYPT_NIP44') result = await window.nostr.nip44.encrypt(pubkey, text)
  28      send(['CRYPTO_RESULT', reqId, result, null])
  29    } catch (err) {
  30      send(['CRYPTO_RESULT', reqId, null, err.message])
  31    }
  32  }
  33  
  34  // ─── nsec encryption (PBKDF2 + AES-256-GCM) ────────────────────────
  35  
  36  async function deriveKeyPBKDF2(password, salt) {
  37    const enc = new TextEncoder()
  38    const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
  39    return crypto.subtle.deriveKey(
  40      { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
  41      keyMaterial,
  42      { name: 'AES-GCM', length: 256 },
  43      false,
  44      ['encrypt', 'decrypt']
  45    )
  46  }
  47  
  48  export async function encryptNsec(nsec, password) {
  49    const salt = crypto.getRandomValues(new Uint8Array(32))
  50    const iv = crypto.getRandomValues(new Uint8Array(12))
  51    const key = await deriveKeyPBKDF2(password, salt)
  52    const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(nsec))
  53    const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength)
  54    combined.set(salt, 0)
  55    combined.set(iv, salt.length)
  56    combined.set(new Uint8Array(encrypted), salt.length + iv.length)
  57    let binary = ''
  58    for (let i = 0; i < combined.length; i++) binary += String.fromCharCode(combined[i])
  59    return btoa(binary)
  60  }
  61  
  62  export async function decryptNsec(encryptedData, password) {
  63    const combined = new Uint8Array(atob(encryptedData).split('').map((c) => c.charCodeAt(0)))
  64    const salt = combined.slice(0, 32)
  65    const iv = combined.slice(32, 44)
  66    const ciphertext = combined.slice(44)
  67    const key = await deriveKeyPBKDF2(password, salt)
  68    const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
  69    return new TextDecoder().decode(decrypted)
  70  }
  71  
  72  export function decodeNsec(nsec) {
  73    const decoded = nip19Decode(nsec)
  74    if (decoded.type !== 'nsec') throw new Error('not an nsec')
  75    return decoded.data
  76  }
  77  
  78  export function pubkeyFromSecret(secretKeyBytes) {
  79    return bytesToHex(schnorr.getPublicKey(secretKeyBytes))
  80  }
  81  
  82  // ─── nostr helpers ──────────────────────────────────────────────────
  83  
  84  export const PROFILE_RELAYS = [
  85    'wss://relay.damus.io', 'wss://relay.nostr.net', 'wss://nos.lol',
  86    'wss://purplepag.es', 'wss://relay.snort.social', 'wss://relay.primal.net',
  87    'wss://offchain.pub', 'wss://nostr.wine', 'wss://relay.noswhere.com',
  88    'wss://nostr-pub.wellorder.net',
  89  ]
  90  
  91  export const DEFAULT_RELAYS = ['wss://relay.orly.dev', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band']
  92  
  93  export function profileRelays(userRelays) {
  94    return [...new Set([...userRelays.slice(0, 3), ...PROFILE_RELAYS])]
  95  }
  96  
  97  export function shortId(hex) {
  98    if (!hex) return '?'
  99    return hex.slice(0, 8) + '...' + hex.slice(-4)
 100  }
 101  
 102  export function relativeTime(ts) {
 103    const diff = Math.floor(Date.now() / 1000) - ts
 104    if (diff < 60) return 'now'
 105    if (diff < 3600) return Math.floor(diff / 60) + 'm'
 106    if (diff < 86400) return Math.floor(diff / 3600) + 'h'
 107    return Math.floor(diff / 86400) + 'd'
 108  }
 109  
 110  export function parseProfile(event) {
 111    try { return JSON.parse(event.content) } catch { return {} }
 112  }
 113  
 114  // ─── content parser ─────────────────────────────────────────────────
 115  
 116  const IMAGE_RE = /\.(jpe?g|png|gif|webp|svg)(\?[^\s]*)?$/i
 117  const VIDEO_RE = /\.(mp4|webm|mov)(\?[^\s]*)?$/i
 118  
 119  export function parseContent(text) {
 120    if (!text) return [{ t: 'text', v: '' }]
 121    const TOKEN = /(https?:\/\/[^\s<>"]+)|(nostr:(npub1|note1|nevent1|nprofile1|naddr1)[a-z0-9]+)/gi
 122    const segments = []
 123    let last = 0, m
 124    while ((m = TOKEN.exec(text)) !== null) {
 125      if (m.index > last) segments.push({ t: 'text', v: text.slice(last, m.index) })
 126      if (m[1]) {
 127        const url = m[1].replace(/[.,;:!?)]+$/, '')
 128        segments.push(IMAGE_RE.test(url) ? { t: 'image', url } : VIDEO_RE.test(url) ? { t: 'video', url } : { t: 'link', url })
 129        last = m.index + url.length
 130        TOKEN.lastIndex = last
 131      } else if (m[2]) {
 132        const raw = m[2], bech32 = raw.slice(6)
 133        try {
 134          const d = nip19Decode(bech32)
 135          if (d.type === 'npub') segments.push({ t: 'mention', pubkey: d.data })
 136          else if (d.type === 'nprofile') segments.push({ t: 'mention', pubkey: d.data.pubkey, relays: d.data.relays })
 137          else if (d.type === 'note') segments.push({ t: 'noteref', id: d.data })
 138          else if (d.type === 'nevent') segments.push({ t: 'noteref', id: d.data.id, relays: d.data.relays })
 139          else if (d.type === 'naddr') segments.push({ t: 'addrref', kind: d.data.kind, pubkey: d.data.pubkey, d: d.data.identifier, relays: d.data.relays })
 140          else segments.push({ t: 'text', v: raw })
 141        } catch { segments.push({ t: 'text', v: raw }) }
 142        last = m.index + raw.length
 143        TOKEN.lastIndex = last
 144      }
 145    }
 146    if (last < text.length) segments.push({ t: 'text', v: text.slice(last) })
 147    return segments
 148  }
 149  
 150  // ─── HTML escaping ──────────────────────────────────────────────────
 151  
 152  export function esc(str) {
 153    if (!str) return ''
 154    return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
 155  }
 156