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:
parent
2d78c2ef2b
commit
f1c982bc95
@ -727,7 +727,10 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "ok": true }))
|
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(
|
pub(in crate::api::rpc) async fn handle_identity_publish_profile(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
@ -739,18 +742,38 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||||
validate_identity_id(id)?;
|
validate_identity_id(id)?;
|
||||||
|
|
||||||
let relay_url = params
|
let relay_urls: Vec<String> = if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
|
||||||
.get("relay")
|
arr.iter()
|
||||||
.and_then(|v| v.as_str())
|
.filter_map(|v| v.as_str())
|
||||||
.unwrap_or("ws://localhost:18081");
|
.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 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!({
|
Ok(serde_json::json!({
|
||||||
"event_id": event_id,
|
"event_id": outcome.event_id,
|
||||||
"relay": relay_url,
|
"accepted": outcome.accepted,
|
||||||
"published": true,
|
"rejected": outcome.rejected,
|
||||||
|
"relays_attempted": relay_urls.len(),
|
||||||
|
"published": !outcome.accepted.is_empty(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -99,6 +99,26 @@ pub struct IdentityManager {
|
|||||||
identities_dir: PathBuf,
|
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 {
|
impl IdentityManager {
|
||||||
pub async fn new(data_dir: &Path) -> Result<Self> {
|
pub async fn new(data_dir: &Path) -> Result<Self> {
|
||||||
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
let identities_dir = data_dir.join(IDENTITIES_DIR);
|
||||||
@ -512,12 +532,26 @@ impl IdentityManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish kind 0 (metadata) event to a Nostr relay.
|
/// Publish kind 0 (metadata) event to one or more Nostr relays.
|
||||||
pub async fn publish_profile(&self, id: &str, relay_url: &str) -> Result<String> {
|
///
|
||||||
|
/// 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 record = self.get(id).await?;
|
||||||
let keys = self.load_nostr_keys(id).await?;
|
let keys = self.load_nostr_keys(id).await?;
|
||||||
let profile = record.profile.unwrap_or_default();
|
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)
|
// Build kind 0 content JSON (NIP-01 + NIP-24)
|
||||||
let mut content = serde_json::Map::new();
|
let mut content = serde_json::Map::new();
|
||||||
content.insert("name".to_string(), serde_json::json!(record.name));
|
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")?;
|
serde_json::to_string(&content).context("Failed to serialize profile content")?;
|
||||||
|
|
||||||
let client = nostr_sdk::Client::new(keys);
|
let client = nostr_sdk::Client::new(keys);
|
||||||
client
|
for url in relay_urls {
|
||||||
.add_relay(relay_url)
|
if let Err(e) = client.add_relay(url).await {
|
||||||
.await
|
tracing::warn!(relay = %url, error = %e, "Failed to add relay; continuing");
|
||||||
.context("Failed to add relay")?;
|
}
|
||||||
if tokio::time::timeout(Duration::from_secs(10), client.connect())
|
}
|
||||||
|
// 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
|
.await
|
||||||
.is_err()
|
.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 =
|
let builder =
|
||||||
nostr_sdk::prelude::EventBuilder::new(nostr_sdk::prelude::Kind::Metadata, &content_str);
|
nostr_sdk::prelude::EventBuilder::new(nostr_sdk::prelude::Kind::Metadata, &content_str);
|
||||||
let output = client
|
let output = match client.send_event_builder(builder).await {
|
||||||
.send_event_builder(builder)
|
Ok(o) => o,
|
||||||
.await
|
Err(e) => {
|
||||||
.context("Failed to publish profile")?;
|
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;
|
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).
|
/// Export all keys for an identity (SENSITIVE — only call after password verification).
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user