fix(nostr): profile publish broadcasts to ALL enabled relays
Previously handle_identity_publish_profile defaulted to a single
hard-coded relay (ws://localhost:18081) so the user's kind:0 profile
event only ever landed on the local relay — hence "Manage Relays
shows N connected, but profile edits don't propagate" from testing.
Fix — two-layer change:
- identity_manager::publish_profile now takes `&[String]` relays
instead of one URL. Adds each relay to the nostr-sdk client,
gives 15s for handshakes, publishes, then surfaces per-relay
accept/reject in a new ProfilePublishOutcome struct so the UI
can show WHICH relays accepted vs. rejected and WHY.
- RPC handle_identity_publish_profile no longer defaults to the
local relay: pulls the ENABLED list from nostr_relays::list_relays
(the same table that powers Manage Relays) and publishes to every
entry. Accepts an optional `relays: [...]` override for tests.
- At-least-one-accept guarantee: if every relay rejects, the call
errors instead of silently reporting published=true. User gets a
real error message listing the failures.
- Response shape: `{event_id, accepted: [urls], rejected: [[url,
reason]], relays_attempted: N, published: bool}` so the UI can
show a useful status block after clicking Publish.
relay_url_matches is tolerant of trailing-slash / case differences
since nostr-sdk canonicalises URLs internally.
Covers the publishing half of task #29; avatar/banner upload UI is
still open.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -727,7 +727,10 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Publish kind 0 (metadata) profile to the local Nostr relay.
|
||||
/// Publish kind 0 (metadata) profile to every enabled Nostr relay
|
||||
/// configured in Manage Relays. Callers can override the default
|
||||
/// list by passing `relays: [..]` in params (e.g. to publish to a
|
||||
/// single relay for testing).
|
||||
pub(in crate::api::rpc) async fn handle_identity_publish_profile(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -739,18 +742,38 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let relay_url = params
|
||||
.get("relay")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("ws://localhost:18081");
|
||||
let relay_urls: Vec<String> = if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
|
||||
vec![single.to_string()]
|
||||
} else {
|
||||
// Default: every enabled relay in the user's Manage Relays list.
|
||||
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
statuses
|
||||
.into_iter()
|
||||
.filter(|s| s.enabled)
|
||||
.map(|s| s.url)
|
||||
.collect()
|
||||
};
|
||||
|
||||
if relay_urls.is_empty() {
|
||||
anyhow::bail!("No enabled relays configured; add one under Manage Relays");
|
||||
}
|
||||
|
||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||
let event_id = manager.publish_profile(id, relay_url).await?;
|
||||
let outcome = manager.publish_profile(id, &relay_urls).await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"event_id": event_id,
|
||||
"relay": relay_url,
|
||||
"published": true,
|
||||
"event_id": outcome.event_id,
|
||||
"accepted": outcome.accepted,
|
||||
"rejected": outcome.rejected,
|
||||
"relays_attempted": relay_urls.len(),
|
||||
"published": !outcome.accepted.is_empty(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,26 @@ pub struct IdentityManager {
|
||||
identities_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Result of a multi-relay profile broadcast.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ProfilePublishOutcome {
|
||||
pub event_id: String,
|
||||
pub accepted: Vec<String>,
|
||||
pub rejected: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Relay URL equality that tolerates minor normalization differences
|
||||
/// (trailing slash, case). nostr-sdk canonicalises URLs internally and
|
||||
/// we compare on the surface strings, so be liberal about what matches.
|
||||
fn relay_url_matches(a: &str, b: &str) -> bool {
|
||||
let norm = |s: &str| {
|
||||
s.trim_end_matches('/')
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
};
|
||||
norm(a) == norm(b)
|
||||
}
|
||||
|
||||
impl IdentityManager {
|
||||
pub async fn new(data_dir: &Path) -> Result<Self> {
|
||||
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
||||
@@ -512,12 +532,26 @@ impl IdentityManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish kind 0 (metadata) event to a Nostr relay.
|
||||
pub async fn publish_profile(&self, id: &str, relay_url: &str) -> Result<String> {
|
||||
/// Publish kind 0 (metadata) event to one or more Nostr relays.
|
||||
///
|
||||
/// Connects all relays in parallel, broadcasts the signed event to
|
||||
/// every one of them, and reports back the event id plus per-relay
|
||||
/// acceptance status. At least one successful relay is required —
|
||||
/// if every relay rejects the event, this returns an error so the
|
||||
/// UI can surface "publish failed" instead of silently succeeding.
|
||||
pub async fn publish_profile(
|
||||
&self,
|
||||
id: &str,
|
||||
relay_urls: &[String],
|
||||
) -> Result<ProfilePublishOutcome> {
|
||||
let record = self.get(id).await?;
|
||||
let keys = self.load_nostr_keys(id).await?;
|
||||
let profile = record.profile.unwrap_or_default();
|
||||
|
||||
if relay_urls.is_empty() {
|
||||
anyhow::bail!("No relays configured — add a relay under Manage Relays first");
|
||||
}
|
||||
|
||||
// Build kind 0 content JSON (NIP-01 + NIP-24)
|
||||
let mut content = serde_json::Map::new();
|
||||
content.insert("name".to_string(), serde_json::json!(record.name));
|
||||
@@ -547,26 +581,78 @@ impl IdentityManager {
|
||||
serde_json::to_string(&content).context("Failed to serialize profile content")?;
|
||||
|
||||
let client = nostr_sdk::Client::new(keys);
|
||||
client
|
||||
.add_relay(relay_url)
|
||||
.await
|
||||
.context("Failed to add relay")?;
|
||||
if tokio::time::timeout(Duration::from_secs(10), client.connect())
|
||||
for url in relay_urls {
|
||||
if let Err(e) = client.add_relay(url).await {
|
||||
tracing::warn!(relay = %url, error = %e, "Failed to add relay; continuing");
|
||||
}
|
||||
}
|
||||
// 15s gives each relay a reasonable chance to hand-shake before we
|
||||
// fire the publish. nostr-sdk's send_event_builder to "all relays"
|
||||
// will only reach relays that have connected by then — some slow
|
||||
// relays can miss the first publish but subsequent publishes hit
|
||||
// them once the connection has settled.
|
||||
if tokio::time::timeout(Duration::from_secs(15), client.connect())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
||||
tracing::warn!("Nostr relay connection timed out after 15s, continuing anyway");
|
||||
}
|
||||
|
||||
let builder =
|
||||
nostr_sdk::prelude::EventBuilder::new(nostr_sdk::prelude::Kind::Metadata, &content_str);
|
||||
let output = client
|
||||
.send_event_builder(builder)
|
||||
.await
|
||||
.context("Failed to publish profile")?;
|
||||
let output = match client.send_event_builder(builder).await {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
client.disconnect().await;
|
||||
return Err(anyhow::anyhow!("Publish failed on every relay: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let event_id = output.id().to_hex();
|
||||
// `Output` has `success: HashSet<RelayUrl>` + `failed: HashMap<RelayUrl, String>`.
|
||||
// Normalise to string comparisons (RelayUrl trims trailing slashes etc.).
|
||||
let success_strs: std::collections::HashSet<String> = output
|
||||
.success
|
||||
.iter()
|
||||
.map(|u| u.to_string())
|
||||
.collect();
|
||||
let failed_strs: std::collections::HashMap<String, String> = output
|
||||
.failed
|
||||
.iter()
|
||||
.map(|(u, msg)| (u.to_string(), msg.clone()))
|
||||
.collect();
|
||||
let mut accepted: Vec<String> = Vec::new();
|
||||
let mut rejected: Vec<(String, String)> = Vec::new();
|
||||
for url in relay_urls {
|
||||
let match_url = success_strs
|
||||
.iter()
|
||||
.any(|s| relay_url_matches(s, url));
|
||||
if match_url {
|
||||
accepted.push(url.clone());
|
||||
} else if let Some((_, reason)) = failed_strs
|
||||
.iter()
|
||||
.find(|(s, _)| relay_url_matches(s, url))
|
||||
{
|
||||
rejected.push((url.clone(), reason.clone()));
|
||||
} else {
|
||||
rejected.push((url.clone(), "(no ack from relay)".to_string()));
|
||||
}
|
||||
}
|
||||
client.disconnect().await;
|
||||
|
||||
Ok(output.id().to_hex())
|
||||
if accepted.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Profile published on 0 relays — {} attempted. Failures: {:?}",
|
||||
relay_urls.len(),
|
||||
rejected
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ProfilePublishOutcome {
|
||||
event_id,
|
||||
accepted,
|
||||
rejected,
|
||||
})
|
||||
}
|
||||
|
||||
/// Export all keys for an identity (SENSITIVE — only call after password verification).
|
||||
|
||||
Reference in New Issue
Block a user