notecrumbs

a nostr opengraph server build on nostrdb and egui
git clone git://jb55.com/notecrumbs
Log | Files | Refs | README | LICENSE

commit fffaa9e2af82ee80fe9829c9735cc0615e04fc35
parent 01bea06d20cbe186690d0e8c0358dce25b0ca027
Author: alltheseas <64376233+alltheseas@users.noreply.github.com>
Date:   Thu, 18 Dec 2025 23:36:43 -0600

feat: integrate rust-nostr PR #1172 relay provenance tracking

Integrate relay provenance tracking from rust-nostr PR #1172 to enable
proper NIP-19 bech32 links with relay hints for better content discoverability.

Changes:
- Update nostr-sdk to alltheseas/rust-nostr relay-provenance-tracking branch
- RelayPool::stream_events returns BoxedStream<RelayEvent> with source relay URL
- NoteAndProfileRenderData stores source_relays captured during fetch
- Generate bech32 links with relay hints for all event types (notes, articles, highlights)
- Filter profile (kind 0) relays from note hints
- Prioritize default relays in source_relays for reliability
- Preserve author/kind fields when rebuilding nevent bech32
- Graceful fallback to original nip19 on encoding failure with metric
- Add bech32_with_relays() helper with 8 unit tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
MCargo.lock | 246+++++++++++++++++++++++++------------------------------------------------------
MCargo.toml | 7+++++--
Msrc/html.rs | 62++++++++++++++++++++++++++++++++------------------------------
Msrc/main.rs | 1-
Msrc/nip19.rs | 208++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/relay_pool.rs | 26++++++++++++--------------
Msrc/render.rs | 176+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
7 files changed, 438 insertions(+), 288 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -104,21 +104,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] name = "async-utility" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a349201d80b4aa18d17a34a182bdd7f8ddf845e9e57d2ea130a12e10ef1e3a47" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" dependencies = [ "futures-util", "gloo-timers", @@ -128,9 +117,9 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.10.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d50cb541e6d09e119e717c64c46ed33f49be7fa592fa805d56c11d6a7ff093c" +checksum = "9a7d8c7d34a225ba919dd9ba44d4b9106d20142da545e086be8ae21d1897e043" dependencies = [ "async-utility", "futures", @@ -147,12 +136,9 @@ dependencies = [ [[package]] name = "atomic-destructor" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d919cb60ba95c87ba42777e9e246c4e8d658057299b437b7512531ce0a09a23" -dependencies = [ - "tracing", -] +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" [[package]] name = "atomic-waker" @@ -167,16 +153,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "base58ck" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" -dependencies = [ - "bitcoin-internals", - "bitcoin_hashes", -] - -[[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -269,49 +245,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] -name = "bitcoin" -version = "0.32.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" -dependencies = [ - "base58ck", - "bech32", - "bitcoin-internals", - "bitcoin-io", - "bitcoin-units", - "bitcoin_hashes", - "hex-conservative", - "hex_lit", - "secp256k1", - "serde", -] - -[[package]] -name = "bitcoin-internals" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" -dependencies = [ - "serde", -] - -[[package]] name = "bitcoin-io" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" [[package]] -name = "bitcoin-units" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" -dependencies = [ - "bitcoin-internals", - "serde", -] - -[[package]] name = "bitcoin_hashes" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -353,6 +292,12 @@ dependencies = [ ] [[package]] +name = "btreecap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6160c957d8aa33d0a8ba1dbab98e3cb57023ad9374c501441e88559f99e6c4c9" + +[[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -543,7 +488,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", "typenum", ] @@ -848,12 +792,6 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" @@ -1007,10 +945,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -1043,9 +979,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "gloo-timers" -version = "0.2.6" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" dependencies = [ "futures-channel", "futures-core", @@ -1094,24 +1030,13 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1142,12 +1067,6 @@ dependencies = [ ] [[package]] -name = "hex_lit" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" - -[[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1491,9 +1410,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", ] [[package]] @@ -1626,7 +1542,7 @@ dependencies = [ "minisign-verify", "pkg-config", "tar", - "ureq 3.1.4", + "ureq 3.2.0", "vcpkg", "zip", ] @@ -1675,12 +1591,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.12.5" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] +checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" [[package]] name = "mac" @@ -1856,15 +1769,9 @@ dependencies = [ [[package]] name = "negentropy" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e664971378a3987224f7a0e10059782035e89899ae403718ee07de85bec42afe" - -[[package]] -name = "negentropy" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a88da9dd148bbcdce323dd6ac47d369b4769d4a3b78c6c52389b9269f77932" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" [[package]] name = "new_debug_unreachable" @@ -1890,24 +1797,22 @@ dependencies = [ [[package]] name = "nostr" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8aad4b767bbed24ac5eb4465bfb83bc1210522eb99d67cf4e547ec2ec7e47786" +version = "0.44.1" +source = "git+https://github.com/alltheseas/rust-nostr.git?branch=relay-provenance-tracking#2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5" dependencies = [ - "async-trait", "base64 0.22.1", "bech32", "bip39", - "bitcoin", + "bitcoin_hashes", "cbc", "chacha20", "chacha20poly1305", - "getrandom 0.2.16", + "hex", "instant", - "negentropy 0.3.1", - "negentropy 0.4.3", "once_cell", + "rand 0.9.2", "scrypt", + "secp256k1", "serde", "serde_json", "unicode-normalization", @@ -1916,49 +1821,50 @@ dependencies = [ [[package]] name = "nostr-database" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23696338d51e45cd44e061823847f4b0d1d362eca80d5033facf9c184149f72f" +version = "0.44.0" +source = "git+https://github.com/alltheseas/rust-nostr.git?branch=relay-provenance-tracking#2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5" dependencies = [ - "async-trait", + "btreecap", "lru", "nostr", - "thiserror 1.0.69", "tokio", - "tracing", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "git+https://github.com/alltheseas/rust-nostr.git?branch=relay-provenance-tracking#2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5" +dependencies = [ + "nostr", ] [[package]] name = "nostr-relay-pool" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15fcc6e3f0ca54d0fc779009bc5f2684cea9147be3b6aa68a7d301ea590f95f5" +version = "0.44.0" +source = "git+https://github.com/alltheseas/rust-nostr.git?branch=relay-provenance-tracking#2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5" dependencies = [ "async-utility", "async-wsocket", "atomic-destructor", - "negentropy 0.3.1", - "negentropy 0.4.3", + "hex", + "lru", + "negentropy", "nostr", "nostr-database", - "thiserror 1.0.69", "tokio", - "tokio-stream", "tracing", ] [[package]] name = "nostr-sdk" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "491221fc89b1aa189a0de640127127d68b4e7c5c1d44371b04d9a6d10694b5af" +version = "0.44.1" +source = "git+https://github.com/alltheseas/rust-nostr.git?branch=relay-provenance-tracking#2ea3dbea4d89697e1c2f2bd4dea1bb68432df6f5" dependencies = [ "async-utility", - "atomic-destructor", "nostr", "nostr-database", + "nostr-gossip", "nostr-relay-pool", - "thiserror 1.0.69", "tokio", "tracing", ] @@ -2133,7 +2039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2186,7 +2092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -2365,19 +2271,27 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "libc", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.3", ] [[package]] name = "rand_chacha" -version = "0.3.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -2390,6 +2304,15 @@ dependencies = [ ] [[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] name = "raw-cpuid" version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2653,8 +2576,6 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes", - "rand", "secp256k1-sys", "serde", ] @@ -2733,7 +2654,6 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap", "itoa", "memchr", "ryu", @@ -3196,21 +3116,10 @@ dependencies = [ ] [[package]] -name = "tokio-stream" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", @@ -3327,21 +3236,20 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ - "byteorder", "bytes", "data-encoding", "http 1.4.0", "httparse", "log", - "rand", + "rand 0.9.2", "rustls", "rustls-pki-types", "sha1", - "thiserror 1.0.69", + "thiserror 2.0.17", "utf-8", ] @@ -3418,9 +3326,9 @@ dependencies = [ [[package]] name = "ureq" -version = "3.1.4" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" dependencies = [ "base64 0.22.1", "log", diff --git a/Cargo.toml b/Cargo.toml @@ -17,8 +17,11 @@ nostrdb = "0.9.0" #nostrdb = { path = "/home/jb55/src/rust/nostrdb-rs" } #nostrdb = "0.1.6" #nostr-sdk = { git = "https://github.com/damus-io/nostr-sdk.git", rev = "fc0dc7b38f5060f171228b976b9700c0135245d3" } -nostr-sdk = "0.37.0" -nostr = "0.37.0" +#nostr-sdk = "0.37.0" +#nostr = "0.37.0" +# PR #1172: relay provenance tracking - https://github.com/rust-nostr/nostr/pull/1172 +nostr-sdk = { git = "https://github.com/alltheseas/rust-nostr.git", branch = "relay-provenance-tracking" } +nostr = { git = "https://github.com/alltheseas/rust-nostr.git", branch = "relay-provenance-tracking" } hex = "0.4.3" egui = "0.23.0" egui_extras = { version = "0.23.0", features = ["image", "svg"] } diff --git a/src/html.rs b/src/html.rs @@ -511,11 +511,7 @@ fn extract_quote_refs_from_content(note: &Note, blocks: &Blocks) -> Vec<QuoteRef let bech32_str = block.as_str(); // Parse to get relay hints from nevent if let Ok(Nip19::Event(ev)) = Nip19::from_bech32(bech32_str) { - let relays: Vec<RelayUrl> = ev - .relays - .iter() - .filter_map(|s| RelayUrl::parse(s).ok()) - .collect(); + let relays: Vec<RelayUrl> = ev.relays.to_vec(); quotes.push(QuoteRef::Event { id: *ev.event_id.as_bytes(), bech32: Some(bech32_str.to_string()), @@ -596,11 +592,7 @@ fn extract_quote_refs_from_tags(note: &Note) -> Vec<QuoteRef> { match nip19 { Nip19::Event(ev) => { // Combine relays from nevent with q tag relay hint - let mut relays: Vec<RelayUrl> = ev - .relays - .iter() - .filter_map(|s| RelayUrl::parse(s).ok()) - .collect(); + let mut relays: Vec<RelayUrl> = ev.relays.to_vec(); if let Some(hint) = &tag_relay_hint { if !relays.contains(hint) { relays.push(hint.clone()); @@ -752,10 +744,10 @@ fn build_quote_link(quote_ref: &QuoteRef) -> String { if let Some(b) = bech32 { return format!("/{}", b); } - if let Ok(eid) = EventId::from_slice(id) { - if let Ok(b) = eid.to_bech32() { - return format!("/{}", b); - } + if let Ok(b) = + EventId::from_slice(id).map(|eid| eid.to_bech32().expect("infallible apparently")) + { + return format!("/{}", b); } } QuoteRef::Article { addr, bech32, .. } => { @@ -1093,10 +1085,8 @@ fn build_note_content_html( base_url, ); let timestamp_attr = note.created_at().to_string(); - let nevent = Nip19Event::new( - EventId::from_byte_array(note.id().to_owned()), - relays.iter().map(|r| r.to_string()), - ); + let nevent = Nip19Event::new(EventId::from_byte_array(note.id().to_owned())) + .relays(relays.iter().cloned()); let note_id = nevent.to_bech32().unwrap(); // Extract quote refs from q tags and inline mentions @@ -1370,9 +1360,7 @@ fn build_note_source_link(event_id: &[u8; 32]) -> String { let Ok(id) = EventId::from_slice(event_id) else { return String::new(); }; - let Ok(nevent) = id.to_bech32() else { - return String::new(); - }; + let nevent = id.to_bech32().expect("infallible"); let href_raw = format!("/{nevent}"); let href = html_escape::encode_double_quoted_attribute(&href_raw); @@ -2067,7 +2055,22 @@ pub fn serve_note_html( profile_record, ); - let note_bech32 = nip19.to_bech32().unwrap(); + // Generate bech32 with source relay hints for better discoverability. + // This applies to all event types (notes, articles, highlights). + // Falls back to original nip19 encoding if relay-enhanced encoding fails. + let note_bech32 = match crate::nip19::bech32_with_relays(nip19, &note_rd.source_relays) { + Some(bech32) => bech32, + None => { + warn!( + "failed to encode bech32 with relays for nip19: {:?}, falling back to original", + nip19 + ); + metrics::counter!("bech32_encode_fallback_total", 1); + nip19 + .to_bech32() + .map_err(|e| Error::Generic(format!("failed to encode nip19: {}", e)))? + } + }; let base_url = get_base_url(); let canonical_url = format!("{}/{}", base_url, note_bech32); let fallback_image_url = format!("{}/{}.png", base_url, note_bech32); @@ -2171,14 +2174,13 @@ pub fn serve_note_html( ) } else { // Regular notes (kind 1, etc.) - build_note_content_html( - app, - &note, - &txn, - &base_url, - &profile, - &crate::nip19::nip19_relays(nip19), - ) + // Use source relays from fetch if available, otherwise fall back to nip19 relay hints + let relays = if note_rd.source_relays.is_empty() { + crate::nip19::nip19_relays(nip19) + } else { + note_rd.source_relays.clone() + }; + build_note_content_html(app, &note, &txn, &base_url, &profile, &relays) }; if og_description_raw.is_empty() { diff --git a/src/main.rs b/src/main.rs @@ -423,7 +423,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { RelayPool::new( keys.clone(), &["wss://relay.damus.io", "wss://nostr.wine", "wss://nos.lol"], - timeout, ) .await?, ); diff --git a/src/nip19.rs b/src/nip19.rs @@ -1,17 +1,213 @@ -use nostr::nips::nip19::Nip19; +use nostr::nips::nip19::{Nip19, Nip19Coordinate, Nip19Event}; use nostr_sdk::prelude::*; /// Do we have relays for this request? If so we can use these when /// looking for missing data pub fn nip19_relays(nip19: &Nip19) -> Vec<RelayUrl> { match nip19 { - Nip19::Event(ev) => ev - .relays - .iter() - .filter_map(|r| RelayUrl::parse(r).ok()) - .collect(), + Nip19::Event(ev) => ev.relays.clone(), Nip19::Coordinate(coord) => coord.relays.clone(), Nip19::Profile(p) => p.relays.clone(), _ => vec![], } } + +/// Generate a bech32 string with source relay hints. +/// If source_relays is empty, uses the original nip19 relays. +/// Otherwise, replaces the relays with source_relays. +/// Preserves author/kind fields when present. +pub fn bech32_with_relays(nip19: &Nip19, source_relays: &[RelayUrl]) -> Option<String> { + // If no source relays, use original + if source_relays.is_empty() { + return nip19.to_bech32().ok(); + } + + match nip19 { + Nip19::Event(ev) => { + // Preserve author and kind from original nevent + let mut new_event = Nip19Event::new(ev.event_id).relays(source_relays.iter().cloned()); + if let Some(author) = ev.author { + new_event = new_event.author(author); + } + if let Some(kind) = ev.kind { + new_event = new_event.kind(kind); + } + new_event + .to_bech32() + .ok() + .or_else(|| nip19.to_bech32().ok()) + } + Nip19::Coordinate(coord) => { + let new_coord = + Nip19Coordinate::new(coord.coordinate.clone(), source_relays.iter().cloned()); + new_coord + .to_bech32() + .ok() + .or_else(|| nip19.to_bech32().ok()) + } + // For other types (note, pubkey), just use original - they don't support relays + _ => nip19.to_bech32().ok(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::nips::nip01::Coordinate; + use nostr::prelude::Keys; + + #[test] + fn bech32_with_relays_adds_relay_to_nevent() { + let event_id = EventId::from_slice(&[1u8; 32]).unwrap(); + let nip19_event = Nip19Event::new(event_id); + let nip19 = Nip19::Event(nip19_event); + + let source_relays = vec![RelayUrl::parse("wss://relay.damus.io").unwrap()]; + let result = bech32_with_relays(&nip19, &source_relays).expect("should encode"); + + // Result should be longer than original (includes relay hint) + let original = nip19.to_bech32().unwrap(); + assert!( + result.len() > original.len(), + "bech32 with relay should be longer" + ); + + // Decode and verify relay is included + let decoded = Nip19::from_bech32(&result).unwrap(); + match decoded { + Nip19::Event(ev) => { + assert!(!ev.relays.is_empty(), "should have relay hints"); + assert!(ev.relays[0].to_string().contains("relay.damus.io")); + } + _ => panic!("expected Nip19::Event"), + } + } + + #[test] + fn bech32_with_relays_adds_relay_to_naddr() { + let keys = Keys::generate(); + let coordinate = + Coordinate::new(Kind::LongFormTextNote, keys.public_key()).identifier("test-article"); + let nip19_coord = Nip19Coordinate::new(coordinate.clone(), Vec::<RelayUrl>::new()); + let nip19 = Nip19::Coordinate(nip19_coord); + + let source_relays = vec![RelayUrl::parse("wss://nostr.wine").unwrap()]; + let result = bech32_with_relays(&nip19, &source_relays).expect("should encode"); + + // Result should be longer than original (includes relay hint) + let original = nip19.to_bech32().unwrap(); + assert!( + result.len() > original.len(), + "bech32 with relay should be longer" + ); + + // Decode and verify relay is included + let decoded = Nip19::from_bech32(&result).unwrap(); + match decoded { + Nip19::Coordinate(coord) => { + assert!(!coord.relays.is_empty(), "should have relay hints"); + assert!(coord.relays[0].to_string().contains("nostr.wine")); + } + _ => panic!("expected Nip19::Coordinate"), + } + } + + #[test] + fn bech32_with_relays_empty_returns_original() { + let event_id = EventId::from_slice(&[2u8; 32]).unwrap(); + let relay = RelayUrl::parse("wss://original.relay").unwrap(); + let nip19_event = Nip19Event::new(event_id).relays([relay.clone()]); + let nip19 = Nip19::Event(nip19_event); + + // Empty source_relays should preserve original + let result = bech32_with_relays(&nip19, &[]).expect("should encode"); + let original = nip19.to_bech32().unwrap(); + + assert_eq!( + result, original, + "empty source_relays should return original bech32" + ); + } + + #[test] + fn bech32_with_relays_replaces_existing_relays() { + let event_id = EventId::from_slice(&[3u8; 32]).unwrap(); + let original_relay = RelayUrl::parse("wss://original.relay").unwrap(); + let nip19_event = Nip19Event::new(event_id).relays([original_relay]); + let nip19 = Nip19::Event(nip19_event); + + let new_relay = RelayUrl::parse("wss://new.relay").unwrap(); + let result = bech32_with_relays(&nip19, &[new_relay.clone()]).expect("should encode"); + + // Decode and verify new relay replaced original + let decoded = Nip19::from_bech32(&result).unwrap(); + match decoded { + Nip19::Event(ev) => { + assert_eq!(ev.relays.len(), 1, "should have exactly one relay"); + assert!(ev.relays[0].to_string().contains("new.relay")); + } + _ => panic!("expected Nip19::Event"), + } + } + + #[test] + fn bech32_with_relays_preserves_author_and_kind() { + let event_id = EventId::from_slice(&[5u8; 32]).unwrap(); + let keys = Keys::generate(); + let nip19_event = Nip19Event::new(event_id) + .author(keys.public_key()) + .kind(Kind::TextNote); + let nip19 = Nip19::Event(nip19_event); + + let source_relays = vec![RelayUrl::parse("wss://test.relay").unwrap()]; + let result = bech32_with_relays(&nip19, &source_relays).expect("should encode"); + + // Decode and verify author/kind are preserved + let decoded = Nip19::from_bech32(&result).unwrap(); + match decoded { + Nip19::Event(ev) => { + assert!(ev.author.is_some(), "author should be preserved"); + assert_eq!(ev.author.unwrap(), keys.public_key()); + assert!(ev.kind.is_some(), "kind should be preserved"); + assert_eq!(ev.kind.unwrap(), Kind::TextNote); + assert!(!ev.relays.is_empty(), "should have relay"); + } + _ => panic!("expected Nip19::Event"), + } + } + + #[test] + fn nip19_relays_extracts_from_event() { + let event_id = EventId::from_slice(&[4u8; 32]).unwrap(); + let relay = RelayUrl::parse("wss://test.relay").unwrap(); + let nip19_event = Nip19Event::new(event_id).relays([relay.clone()]); + let nip19 = Nip19::Event(nip19_event); + + let relays = nip19_relays(&nip19); + assert_eq!(relays.len(), 1); + assert!(relays[0].to_string().contains("test.relay")); + } + + #[test] + fn nip19_relays_extracts_from_coordinate() { + let keys = Keys::generate(); + let coordinate = + Coordinate::new(Kind::LongFormTextNote, keys.public_key()).identifier("article"); + let relay = RelayUrl::parse("wss://coord.relay").unwrap(); + let nip19_coord = Nip19Coordinate::new(coordinate, [relay.clone()]); + let nip19 = Nip19::Coordinate(nip19_coord); + + let relays = nip19_relays(&nip19); + assert_eq!(relays.len(), 1); + assert!(relays[0].to_string().contains("coord.relay")); + } + + #[test] + fn nip19_relays_returns_empty_for_pubkey() { + let keys = Keys::generate(); + let nip19 = Nip19::Pubkey(keys.public_key()); + + let relays = nip19_relays(&nip19); + assert!(relays.is_empty(), "pubkey nip19 should have no relays"); + } +} diff --git a/src/relay_pool.rs b/src/relay_pool.rs @@ -1,6 +1,6 @@ use crate::Error; use nostr::prelude::RelayUrl; -use nostr_sdk::prelude::{Client, Event, Filter, Keys, ReceiverStream}; +use nostr_sdk::prelude::{BoxedStream, Client, Filter, Keys, RelayEvent}; use std::collections::HashSet; use std::sync::Arc; use tokio::sync::Mutex; @@ -21,16 +21,11 @@ pub struct RelayPool { client: Client, known_relays: Arc<Mutex<HashSet<String>>>, default_relays: Arc<[RelayUrl]>, - connect_timeout: Duration, stats: Arc<Mutex<RelayStats>>, } impl RelayPool { - pub async fn new( - keys: Keys, - default_relays: &[&str], - connect_timeout: Duration, - ) -> Result<Self, Error> { + pub async fn new(keys: Keys, default_relays: &[&str]) -> Result<Self, Error> { let client = Client::builder().signer(keys).build(); let parsed_defaults: Vec<RelayUrl> = default_relays .iter() @@ -48,7 +43,6 @@ impl RelayPool { client, known_relays: Arc::new(Mutex::new(HashSet::new())), default_relays: default_relays.clone(), - connect_timeout, stats: Arc::new(Mutex::new(RelayStats::default())), }; @@ -110,7 +104,7 @@ impl RelayPool { } if had_new { - self.client.connect_with_timeout(self.connect_timeout).await; + self.client.connect().await; let mut stats = self.stats.lock().await; stats.ensure_calls += 1; @@ -147,19 +141,23 @@ impl RelayPool { Ok(()) } + /// Stream events from relays, returning RelayEvent which includes source relay URL. + /// Takes a single Filter - callers should combine filters before calling. pub async fn stream_events( &self, - filters: Vec<Filter>, + filter: Filter, relays: &[RelayUrl], timeout: Duration, - ) -> Result<ReceiverStream<Event>, Error> { + ) -> Result<BoxedStream<RelayEvent>, Error> { if relays.is_empty() { - Ok(self.client.stream_events(filters, Some(timeout)).await?) + Ok(self + .client + .stream_events_with_source(filter, timeout) + .await?) } else { - let urls: Vec<String> = relays.iter().map(|r| r.to_string()).collect(); Ok(self .client - .stream_events_from(urls, filters, Some(timeout)) + .stream_events_from_with_source(relays.to_vec(), filter, timeout) .await?) } } diff --git a/src/render.rs b/src/render.rs @@ -71,6 +71,9 @@ impl NoteRenderData { pub struct NoteAndProfileRenderData { pub note_rd: NoteRenderData, pub profile_rd: Option<ProfileRenderData>, + /// Source relay URL(s) where the note was fetched from. + /// Used for generating bech32 links with relay hints. + pub source_relays: Vec<RelayUrl>, } impl NoteAndProfileRenderData { @@ -78,6 +81,13 @@ impl NoteAndProfileRenderData { Self { note_rd, profile_rd, + source_relays: Vec::new(), + } + } + + pub fn add_source_relay(&mut self, relay: RelayUrl) { + if !self.source_relays.contains(&relay) { + self.source_relays.push(relay); } } } @@ -313,12 +323,15 @@ fn query_note_by_address<'a>( } } +/// Fetches notes from relays and returns the source relay URLs. +/// The source relays are used to generate bech32 links with relay hints. +/// Prioritizes default relays in the returned list for better reliability. pub async fn find_note( relay_pool: Arc<RelayPool>, ndb: Ndb, filters: Vec<nostr::Filter>, nip19: &Nip19, -) -> Result<()> { +) -> Result<Vec<RelayUrl>> { use nostr_sdk::JsonUtil; let mut relay_targets = nip19::nip19_relays(nip19); @@ -330,35 +343,57 @@ pub async fn find_note( debug!("finding note(s) with filters: {:?}", filters); - let expected_events = filters.len(); + let mut all_source_relays = Vec::new(); + let default_relays = relay_pool.default_relays(); + + for filter in filters { + let mut streamed_events = relay_pool + .stream_events( + filter, + &relay_targets, + std::time::Duration::from_millis(2000), + ) + .await?; + + // Collect all responding relays, then prioritize after stream exhausts. + while let Some(relay_event) = streamed_events.next().await { + if let Err(err) = ensure_relay_hints(&relay_pool, &relay_event.event).await { + warn!("failed to apply relay hints: {err}"); + } - let mut streamed_events = relay_pool - .stream_events( - filters, - &relay_targets, - std::time::Duration::from_millis(2000), - ) - .await?; + debug!("processing event {:?}", relay_event.event); + if let Err(err) = ndb.process_event(&relay_event.event.as_json()) { + error!("error processing event: {err}"); + } - let mut num_loops = 0; - while let Some(event) = streamed_events.next().await { - if let Err(err) = ensure_relay_hints(&relay_pool, &event).await { - warn!("failed to apply relay hints: {err}"); - } + // Skip profile events - their relays shouldn't be used as note hints + if relay_event.event.kind == Kind::Metadata { + continue; + } - debug!("processing event {:?}", event); - if let Err(err) = ndb.process_event(&event.as_json()) { - error!("error processing event: {err}"); - } + let Some(relay_url) = relay_event.relay_url() else { + continue; + }; - num_loops += 1; + if all_source_relays.contains(relay_url) { + continue; + } - if num_loops == expected_events { - break; + all_source_relays.push(relay_url.clone()); } } - Ok(()) + // Sort relays: default relays first (more reliable), then others. + // Take up to 3 for the final result. + const MAX_SOURCE_RELAYS: usize = 3; + all_source_relays.sort_by(|a, b| { + let a_is_default = default_relays.contains(a); + let b_is_default = default_relays.contains(b); + b_is_default.cmp(&a_is_default) // true > false, so defaults come first + }); + all_source_relays.truncate(MAX_SOURCE_RELAYS); + + Ok(all_source_relays) } /// Fetch the latest profile metadata (kind 0) from relays and update nostrdb. @@ -391,7 +426,7 @@ async fn fetch_profile_metadata( }; let stream = relay_pool - .stream_events(vec![filter], &relays, Duration::from_millis(2000)) + .stream_events(filter, &relays, Duration::from_millis(2000)) .await; let mut stream = match stream { @@ -583,10 +618,17 @@ impl RenderData { } } - // Wait for primary fetch to complete - // Note: unknowns collection happens in main.rs after complete() returns + // Capture source relay URLs from the fetch task match fetch_handle.await { - Ok(Ok(())) => Ok(()), + Ok(Ok(source_relays)) => { + // Store source relays in the render data for bech32 link generation + if let RenderData::Note(ref mut note_data) = self { + for relay in source_relays { + note_data.add_source_relay(relay); + } + } + Ok(()) + } Ok(Err(err)) => Err(err), Err(join_err) => Err(Error::Generic(format!( "relay fetch task failed: {}", @@ -660,13 +702,15 @@ pub async fn fetch_unknowns( ); // Stream with shorter timeout since these are secondary fetches - let mut stream = relay_pool - .stream_events(nostr_filters, &relay_targets, Duration::from_millis(1500)) - .await?; - - while let Some(event) = stream.next().await { - if let Err(err) = ndb.process_event(&event.as_json()) { - warn!("error processing quoted event: {err}"); + for filter in nostr_filters { + let mut stream = relay_pool + .stream_events(filter, &relay_targets, Duration::from_millis(1500)) + .await?; + + while let Some(event) = stream.next().await { + if let Err(err) = ndb.process_event(&event.as_json()) { + warn!("error processing quoted event: {err}"); + } } } @@ -751,34 +795,34 @@ async fn collect_profile_relays( .build(), ); - let mut stream = relay_pool - .stream_events( - vec![relay_filter, contact_filter], - &[], - Duration::from_millis(2000), - ) - .await?; - while let Some(event) = stream.next().await { - if let Err(err) = ndb.process_event(&event.as_json()) { - error!("error processing relay discovery event: {err}"); - } + // Process each filter separately since stream_events now takes a single filter + for filter in [relay_filter, contact_filter] { + let mut stream = relay_pool + .stream_events(filter, &[], Duration::from_millis(2000)) + .await?; - let hints = collect_relay_hints(&event); - if hints.is_empty() { - continue; - } + while let Some(relay_event) = stream.next().await { + if let Err(err) = ndb.process_event(&relay_event.event.as_json()) { + error!("error processing relay discovery event: {err}"); + } - let mut fresh = Vec::new(); - for hint in hints { - let key = hint.to_string(); - if known.insert(key) { - targets.push(hint.clone()); - fresh.push(hint); + let hints = collect_relay_hints(&relay_event.event); + if hints.is_empty() { + continue; } - } - if !fresh.is_empty() { - relay_pool.ensure_relays(fresh).await?; + let mut fresh = Vec::new(); + for hint in hints { + let key = hint.to_string(); + if known.insert(key) { + targets.push(hint.clone()); + fresh.push(hint); + } + } + + if !fresh.is_empty() { + relay_pool.ensure_relays(fresh).await?; + } } } @@ -806,16 +850,16 @@ async fn stream_profile_feed_once( convert_filter(&builder.build()) }; let mut stream = relay_pool - .stream_events(vec![filter], relays, Duration::from_millis(2000)) + .stream_events(filter, &relays, Duration::from_millis(2000)) .await?; let mut fetched = 0usize; - while let Some(event) = stream.next().await { - if let Err(err) = ensure_relay_hints(&relay_pool, &event).await { + while let Some(relay_event) = stream.next().await { + if let Err(err) = ensure_relay_hints(&relay_pool, &relay_event.event).await { warn!("failed to apply relay hints: {err}"); } - if let Err(err) = ndb.process_event(&event.as_json()) { + if let Err(err) = ndb.process_event(&relay_event.event.as_json()) { error!("error processing profile feed event: {err}"); } else { fetched += 1; @@ -835,7 +879,7 @@ pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<Re let pk = if let Some(pk) = m_note.as_ref().map(|note| note.pubkey()) { Some(*pk) } else { - nevent.author.map(|a| a.serialize()) + nevent.author.map(|a| a.to_bytes()) }; let profile_rd = pk.as_ref().map(|pubkey| { @@ -878,7 +922,7 @@ pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<Re } Nip19::Coordinate(coordinate) => { - let author = coordinate.public_key.serialize(); + let author = coordinate.public_key.to_bytes(); let kind: u64 = u16::from(coordinate.kind) as u64; let identifier = coordinate.identifier.clone(); @@ -912,7 +956,7 @@ pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<Re } Nip19::Profile(nprofile) => { - let pubkey = nprofile.public_key.serialize(); + let pubkey = nprofile.public_key.to_bytes(); let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) { ProfileRenderData::Profile(profile_key) } else { @@ -923,7 +967,7 @@ pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<Re } Nip19::Pubkey(public_key) => { - let pubkey = public_key.serialize(); + let pubkey = public_key.to_bytes(); let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) { ProfileRenderData::Profile(profile_key) } else { @@ -1304,7 +1348,7 @@ mod tests { let coordinate = Coordinate::new(Kind::LongFormTextNote, keys.public_key()) .identifier(identifier_with_a); let event_with_a_only = EventBuilder::long_form_text_note("content with a tag only") - .tags([Tag::coordinate(coordinate)]) + .tags([Tag::coordinate(coordinate, None)]) .sign_with_keys(&keys) .expect("sign long-form event with coordinate tag");