Files
sovereign_browser_rust/src/nostr_publish.rs
T

249 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shared helpers for creating, signing, encrypting, storing and publishing
//! the user's own Nostr events (agent conversations, skills, deletions).
//!
//! Port of the C `nostr_create_and_sign_event_with_signer` +
//! `synchronous_publish_event_with_progress` usage in agent_conversations.c
//! and agent_skills.c. All functions are blocking: call them from a worker
//! thread (never the GTK main thread) since remote signers and relay
//! publishing can take seconds.
use nostr_core::types::{Event, Kind, PublicKey, Tag};
/// Maximum number of bootstrap relays to publish to.
const MAX_RELAYS: usize = 32;
/// Per-publish connect timeout.
const PUBLISH_CONNECT_TIMEOUT_MS: u64 = 10_000;
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn parse_pubkey(hex_str: &str) -> Result<PublicKey, String> {
let bytes = hex::decode(hex_str).map_err(|_| "Invalid pubkey hex".to_string())?;
let arr: [u8; 32] = bytes.try_into().map_err(|_| "Invalid pubkey length".to_string())?;
Ok(PublicKey::from_bytes(arr))
}
/// The signed-in identity, or an error if there is no signer (read-only
/// / no-login mode).
fn identity() -> Result<(std::sync::Arc<dyn nostr_signer::traits::NostrSigner>, String), String> {
let signer = crate::menu::app_get_signer().ok_or("No signer available (read-only or not logged in)")?;
let pk = crate::menu::app_get_pubkey_hex();
if pk.is_empty() {
return Err("Not logged in".to_string());
}
Ok((signer, pk))
}
/// Whether a signer is available for publishing / encryption.
pub fn have_signer() -> bool {
identity().is_ok()
}
/// The signed-in user's pubkey hex (empty when not logged in).
pub fn my_pubkey() -> String {
crate::menu::app_get_pubkey_hex()
}
/// NIP-44 encrypt `plaintext` to self; returns base64 ciphertext.
pub fn nip44_encrypt_self(plaintext: &str) -> Result<String, String> {
let (signer, pk_hex) = identity()?;
let pk = parse_pubkey(&pk_hex)?;
let ct = signer
.nip44_encrypt(&pk, plaintext)
.map_err(|e| format!("NIP-44 encrypt failed: {}", e))?;
Ok(nostr_core::util::base64_encode(&ct))
}
/// NIP-44 decrypt a base64 ciphertext encrypted to self.
pub fn nip44_decrypt_self(ciphertext_b64: &str) -> Result<String, String> {
let (signer, pk_hex) = identity()?;
let pk = parse_pubkey(&pk_hex)?;
let raw = nostr_core::util::base64_decode(ciphertext_b64)
.map_err(|e| format!("Invalid base64 ciphertext: {}", e))?;
let pt = signer
.nip44_decrypt(&pk, &raw)
.map_err(|e| format!("NIP-44 decrypt failed: {}", e))?;
String::from_utf8(pt).map_err(|_| "Decrypted content is not UTF-8".to_string())
}
/// Build and sign an event of `kind` with the given tags and content.
pub fn sign_event(kind: u64, tags: Vec<Vec<String>>, content: &str) -> Result<Event, String> {
let (signer, pk_hex) = identity()?;
let pk = parse_pubkey(&pk_hex)?;
let event = Event::new(
pk,
now_secs(),
Kind::from_u64(kind),
tags.into_iter().map(Tag).collect(),
content,
);
signer.sign_event(&event).map_err(|e| format!("Signing failed: {}", e))
}
/// Store a signed event in the local SQLite cache. Returns the event id.
pub fn store_event(event: &Event) -> Result<String, String> {
let id = event.id.as_ref().map(|i| i.to_hex()).ok_or("Signed event has no id")?;
let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".into());
crate::db::db_store_event(
&id,
&event.pubkey.to_hex(),
event.kind.as_u64(),
event.created_at,
&event.content,
&tags_json,
event.sig.as_ref().map(|s| s.to_hex()).as_deref(),
None,
)
.map_err(|e| format!("Failed to store event: {}", e))?;
Ok(id)
}
/// Delete a locally cached event by id (used after publishing a kind 5).
pub fn delete_local_event(id: &str) {
let _ = crate::db::db_delete_event(id);
}
/// Publish a signed event to the bootstrap relays. Returns the number of
/// relays the event was sent to. Never fails hard: offline use still
/// works because events are stored locally first.
pub fn publish_event(event: &Event) -> usize {
let relays: Vec<String> = crate::settings::settings_get_bootstrap_relays()
.into_iter()
.filter(|u| {
// Skip .onion relays unless Tor is reachable.
!u.contains(".onion")
|| crate::net_services::net_services_get_tor_socks_endpoint().is_some()
})
.take(MAX_RELAYS)
.collect();
if relays.is_empty() {
println!("[nostr-publish] No relays configured; event stored locally only");
return 0;
}
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
eprintln!("[nostr-publish] Failed to create runtime: {}", e);
return 0;
}
};
let sent = rt.block_on(async {
use nostr_relay::pool::{ReconnectConfig, RelayPool};
let pool = RelayPool::new(Some(ReconnectConfig::default()));
for url in &relays {
let _ = pool.add_relay(url).await;
}
pool.connect_all_with_timeout(PUBLISH_CONNECT_TIMEOUT_MS).await;
let connected = pool.connected_relay_urls().await;
if connected.is_empty() {
pool.disconnect_all().await;
return 0;
}
if let Err(e) = pool.publish_async(&connected, event).await {
eprintln!("[nostr-publish] Publish failed: {:?}", e);
}
// Give the relays a moment to receive the frame before closing.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
pool.disconnect_all().await;
connected.len()
});
println!(
"[nostr-publish] kind {} published to {}/{} relays",
event.kind.as_u64(),
sent,
relays.len()
);
sent
}
/// Sign, store locally and publish. Returns the event id.
pub fn sign_store_publish(kind: u64, tags: Vec<Vec<String>>, content: &str) -> Result<String, String> {
let event = sign_event(kind, tags, content)?;
let id = store_event(&event)?;
publish_event(&event);
Ok(id)
}
/// Return the first value of tag `name` in a cached event JSON.
pub fn tag_value<'a>(event: &'a serde_json::Value, name: &str) -> Option<&'a str> {
event["tags"].as_array()?.iter().find_map(|t| {
let a = t.as_array()?;
if a.len() >= 2 && a[0].as_str() == Some(name) {
a[1].as_str()
} else {
None
}
})
}
/// All values of tag `name` in a cached event JSON.
pub fn tag_values(event: &serde_json::Value, name: &str) -> Vec<String> {
event["tags"]
.as_array()
.map(|tags| {
tags.iter()
.filter_map(|t| {
let a = t.as_array()?;
if a.len() >= 2 && a[0].as_str() == Some(name) {
a[1].as_str().map(String::from)
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}
/// Whether a cached event has tag `[name, value]`.
pub fn has_tag(event: &serde_json::Value, name: &str, value: &str) -> bool {
tag_values(event, name).iter().any(|v| v == value)
}
/// For addressable events (30000–39999), keep only the newest event per
/// (pubkey, d-tag). Input need not be sorted.
pub fn latest_per_d_tag(events: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
let mut best: std::collections::HashMap<(String, String), serde_json::Value> =
std::collections::HashMap::new();
for ev in events {
let key = (
ev["pubkey"].as_str().unwrap_or("").to_string(),
tag_value(&ev, "d").unwrap_or("").to_string(),
);
let newer = best
.get(&key)
.map(|cur| ev["created_at"].as_u64() > cur["created_at"].as_u64())
.unwrap_or(true);
if newer {
best.insert(key, ev);
}
}
let mut out: Vec<serde_json::Value> = best.into_values().collect();
out.sort_by(|a, b| b["created_at"].as_u64().cmp(&a["created_at"].as_u64()));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tag_helpers_and_dedup() {
let a = serde_json::json!({"pubkey":"p","created_at":1,"tags":[["d","x"],["t","a"],["t","b"]]});
let b = serde_json::json!({"pubkey":"p","created_at":5,"tags":[["d","x"]]});
let c = serde_json::json!({"pubkey":"p","created_at":3,"tags":[["d","y"]]});
assert_eq!(tag_value(&a, "d"), Some("x"));
assert_eq!(tag_values(&a, "t"), vec!["a", "b"]);
assert!(has_tag(&a, "t", "b"));
let out = latest_per_d_tag(vec![a, b, c]);
assert_eq!(out.len(), 2);
assert_eq!(out[0]["created_at"], 5);
assert_eq!(out[1]["created_at"], 3);
}
}