1mod identity;
47mod repo_metadata;
48
49use crate::runtime::block_on_result;
50use anyhow::{Context, Result};
51use futures::{SinkExt, StreamExt};
52use hashtree_blossom::BlossomClient;
53use hashtree_core::{decode_tree_node, decrypt_chk, Cid, LinkType, TreeNode};
54use nostr_sdk::prelude::*;
55use serde::Deserialize;
56use std::collections::HashMap;
57use std::time::Duration;
58use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
59use tracing::{debug, info, warn};
60
61pub use identity::{
62 load_key_lists, load_keys, resolve_identity, resolve_self_identity, StoredKey, StoredKeyLists,
63};
64use repo_metadata::{
65 append_repo_discovery_labels, build_git_repo_list_filter, build_repo_announcement_filter,
66 build_repo_event_filter, extract_repo_announcement_euc, latest_repo_announcement_created_at,
67 latest_repo_event_created_at, latest_trusted_pr_status_kinds, list_git_repo_announcements,
68 next_replaceable_created_at, pick_latest_event, pick_latest_repo_event,
69 validate_repo_publish_relays,
70};
71
72pub const KIND_APP_DATA: u16 = 30078;
74
75pub const KIND_PULL_REQUEST: u16 = 1618;
77pub const KIND_STATUS_OPEN: u16 = 1630;
78pub const KIND_STATUS_APPLIED: u16 = 1631;
79pub const KIND_STATUS_CLOSED: u16 = 1632;
80pub const KIND_STATUS_DRAFT: u16 = 1633;
81pub const KIND_REPO_ANNOUNCEMENT: u16 = 30617;
82
83pub const LABEL_HASHTREE: &str = "hashtree";
85pub const LABEL_GIT: &str = "git";
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum PullRequestState {
90 Open,
91 Applied,
92 Closed,
93 Draft,
94}
95
96impl PullRequestState {
97 pub fn as_str(self) -> &'static str {
98 match self {
99 PullRequestState::Open => "open",
100 PullRequestState::Applied => "applied",
101 PullRequestState::Closed => "closed",
102 PullRequestState::Draft => "draft",
103 }
104 }
105
106 fn from_status_kind(status_kind: u16) -> Option<Self> {
107 match status_kind {
108 KIND_STATUS_OPEN => Some(PullRequestState::Open),
109 KIND_STATUS_APPLIED => Some(PullRequestState::Applied),
110 KIND_STATUS_CLOSED => Some(PullRequestState::Closed),
111 KIND_STATUS_DRAFT => Some(PullRequestState::Draft),
112 _ => None,
113 }
114 }
115
116 fn from_latest_status_kind(status_kind: Option<u16>) -> Self {
117 status_kind
118 .and_then(Self::from_status_kind)
119 .unwrap_or(PullRequestState::Open)
120 }
121}
122
123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125pub enum PullRequestStateFilter {
126 #[default]
127 Open,
128 Applied,
129 Closed,
130 Draft,
131 All,
132}
133
134impl PullRequestStateFilter {
135 pub fn as_str(self) -> &'static str {
136 match self {
137 PullRequestStateFilter::Open => "open",
138 PullRequestStateFilter::Applied => "applied",
139 PullRequestStateFilter::Closed => "closed",
140 PullRequestStateFilter::Draft => "draft",
141 PullRequestStateFilter::All => "all",
142 }
143 }
144
145 fn includes(self, state: PullRequestState) -> bool {
146 match self {
147 PullRequestStateFilter::All => true,
148 PullRequestStateFilter::Open => state == PullRequestState::Open,
149 PullRequestStateFilter::Applied => state == PullRequestState::Applied,
150 PullRequestStateFilter::Closed => state == PullRequestState::Closed,
151 PullRequestStateFilter::Draft => state == PullRequestState::Draft,
152 }
153 }
154}
155
156#[derive(Debug, Clone)]
158pub struct PullRequestListItem {
159 pub event_id: String,
160 pub author_pubkey: String,
161 pub state: PullRequestState,
162 pub subject: Option<String>,
163 pub commit_tip: Option<String>,
164 pub branch: Option<String>,
165 pub target_branch: Option<String>,
166 pub created_at: u64,
167}
168
169async fn fetch_events_via_raw_relay_query(
170 relays: &[String],
171 filter: Filter,
172 timeout: Duration,
173) -> Vec<Event> {
174 let request_json = ClientMessage::req(SubscriptionId::generate(), vec![filter]).as_json();
175 let mut events_by_id = HashMap::<String, Event>::new();
176
177 for relay_url in relays {
178 let relay_events = match tokio::time::timeout(timeout, async {
179 let (mut ws, _) = connect_async(relay_url).await?;
180 ws.send(WsMessage::Text(request_json.clone())).await?;
181
182 let mut relay_events = Vec::new();
183 while let Some(message) = ws.next().await {
184 let message = message?;
185 let WsMessage::Text(text) = message else {
186 continue;
187 };
188
189 match RelayMessage::from_json(text.as_str()) {
190 Ok(RelayMessage::Event { event, .. }) => relay_events.push(event.into_owned()),
191 Ok(RelayMessage::EndOfStoredEvents(_)) => break,
192 Ok(RelayMessage::Closed { message, .. }) => {
193 debug!("Raw relay PR query closed by {}: {}", relay_url, message);
194 break;
195 }
196 Ok(_) => {}
197 Err(err) => {
198 debug!(
199 "Failed to parse raw relay response from {}: {}",
200 relay_url, err
201 );
202 }
203 }
204 }
205
206 let _ = ws.close(None).await;
207 Ok::<Vec<Event>, anyhow::Error>(relay_events)
208 })
209 .await
210 {
211 Ok(Ok(events)) => events,
212 Ok(Err(err)) => {
213 debug!("Raw relay PR query failed for {}: {}", relay_url, err);
214 continue;
215 }
216 Err(_) => {
217 debug!("Raw relay PR query timed out for {}", relay_url);
218 continue;
219 }
220 };
221
222 for event in relay_events {
223 events_by_id.insert(event.id.to_hex(), event);
224 }
225 }
226
227 events_by_id.into_values().collect()
228}
229
230async fn connected_relay_count(client: &Client) -> (usize, usize) {
231 let relays = client.relays().await;
232 let total = relays.len();
233 let mut connected = 0;
234 for relay in relays.values() {
235 if relay.is_connected() {
236 connected += 1;
237 }
238 }
239 (connected, total)
240}
241
242async fn wait_for_any_connected_relay(client: &Client, timeout: Duration) -> bool {
243 let start = std::time::Instant::now();
244 loop {
245 if connected_relay_count(client).await.0 > 0 {
246 return true;
247 }
248 if start.elapsed() > timeout {
249 return false;
250 }
251 tokio::time::sleep(Duration::from_millis(50)).await;
252 }
253}
254
255type FetchedRefs = (HashMap<String, String>, Option<String>, Option<[u8; 32]>);
256
257#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
258enum RootResolveSource {
259 #[default]
260 Relay,
261 LocalDaemon,
262}
263
264#[derive(Debug, Clone)]
265struct ResolvedRoot {
266 root_hash: Option<String>,
267 encryption_key: Option<[u8; 32]>,
268 source: Option<RootResolveSource>,
269}
270use hashtree_config::Config;
271
272#[derive(Debug, Clone)]
274pub struct RelayResult {
275 #[allow(dead_code)]
277 pub configured: Vec<String>,
278 pub connected: Vec<String>,
280 pub failed: Vec<String>,
282}
283
284#[derive(Debug, Clone, Default, PartialEq, Eq)]
286pub struct RepoAnnouncementOptions {
287 pub earliest_unique_commit: Option<String>,
289 pub personal_fork: bool,
291 pub forked_from: Option<String>,
293}
294
295#[derive(Debug, Clone)]
297pub struct BlossomResult {
298 #[allow(dead_code)]
300 pub configured: Vec<String>,
301 pub succeeded: Vec<String>,
303 pub failed: Vec<String>,
305 pub local_complete: bool,
307 pub degraded: bool,
309}
310
311pub struct NostrClient {
313 pubkey: String,
314 keys: Option<Keys>,
316 relays: Vec<String>,
317 blossom: BlossomClient,
318 cached_refs: HashMap<String, HashMap<String, String>>,
320 cached_root_hash: HashMap<String, String>,
322 cached_encryption_key: HashMap<String, [u8; 32]>,
324 cached_root_source: HashMap<String, RootResolveSource>,
326 url_secret: Option<[u8; 32]>,
329 is_private: bool,
331 local_daemon_url: Option<String>,
333}
334
335#[derive(Debug, Clone, Default)]
336struct RootEventData {
337 root_hash: String,
338 encryption_key: Option<[u8; 32]>,
339 key_tag_name: Option<String>,
340 self_encrypted_ciphertext: Option<String>,
341 source: RootResolveSource,
342 daemon_source: Option<String>,
343 event_created_at: Option<u64>,
344 event_id: Option<String>,
345}
346
347#[derive(Debug, Deserialize)]
348struct DaemonResolveResponse {
349 hash: Option<String>,
350 #[serde(default)]
351 cid: Option<String>,
352 #[serde(default, rename = "key_tag")]
353 key: Option<String>,
354 #[serde(default, rename = "encryptedKey")]
355 encrypted_key: Option<String>,
356 #[serde(default, rename = "selfEncryptedKey")]
357 self_encrypted_key: Option<String>,
358 #[serde(default)]
359 source: Option<String>,
360 #[serde(default)]
361 created_at: Option<u64>,
362 #[serde(default)]
363 event_id: Option<String>,
364}
365
366impl NostrClient {
367 pub fn new(
369 pubkey: &str,
370 secret_key: Option<String>,
371 url_secret: Option<[u8; 32]>,
372 is_private: bool,
373 config: &Config,
374 ) -> Result<Self> {
375 let _ = rustls::crypto::ring::default_provider().install_default();
377
378 let secret_key = secret_key.or_else(|| std::env::var("NOSTR_SECRET_KEY").ok());
380
381 let keys = if let Some(ref secret_hex) = secret_key {
383 let secret_bytes = hex::decode(secret_hex).context("Invalid secret key hex")?;
384 let secret = nostr::SecretKey::from_slice(&secret_bytes)
385 .map_err(|e| anyhow::anyhow!("Invalid secret key: {}", e))?;
386 Some(Keys::new(secret))
387 } else {
388 None
389 };
390
391 let blossom_keys = keys.clone().unwrap_or_else(Keys::generate);
394 let mut read_servers = config.blossom.all_read_servers();
395 if let Some(local_url) =
396 hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
397 {
398 if !read_servers.iter().any(|server| server == &local_url) {
399 read_servers.insert(0, local_url);
400 }
401 }
402 let blossom = BlossomClient::new_empty(blossom_keys)
403 .with_read_servers(read_servers)
404 .with_write_servers(config.blossom.all_write_servers())
405 .with_timeout(Duration::from_secs(120));
406
407 tracing::info!(
408 "BlossomClient created with read_servers: {:?}, write_servers: {:?}",
409 blossom.read_servers(),
410 blossom.write_servers()
411 );
412
413 let relays = hashtree_config::resolve_relays(
414 &config.nostr.relays,
415 Some(config.server.bind_address.as_str()),
416 );
417 let local_daemon_url =
418 hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
419 .or_else(|| {
420 config
421 .blossom
422 .read_servers
423 .iter()
424 .find(|url| {
425 url.starts_with("http://127.0.0.1:")
426 || url.starts_with("http://localhost:")
427 })
428 .cloned()
429 });
430
431 Ok(Self {
432 pubkey: pubkey.to_string(),
433 keys,
434 relays,
435 blossom,
436 cached_refs: HashMap::new(),
437 cached_root_hash: HashMap::new(),
438 cached_encryption_key: HashMap::new(),
439 cached_root_source: HashMap::new(),
440 url_secret,
441 is_private,
442 local_daemon_url,
443 })
444 }
445
446 fn format_repo_author(pubkey_hex: &str) -> String {
447 PublicKey::from_hex(pubkey_hex)
448 .ok()
449 .and_then(|pk| pk.to_bech32().ok())
450 .unwrap_or_else(|| pubkey_hex.to_string())
451 }
452
453 fn daemon_pubkey_identifier(&self) -> String {
454 Self::format_repo_author(&self.pubkey)
455 }
456
457 #[allow(dead_code)]
459 pub fn can_sign(&self) -> bool {
460 self.keys.is_some()
461 }
462
463 pub fn list_repos(&self) -> Result<Vec<String>> {
464 block_on_result(self.list_repos_async())
465 }
466
467 pub async fn list_repos_async(&self) -> Result<Vec<String>> {
468 let client = Client::default();
469
470 for relay in &self.relays {
471 if let Err(e) = client.add_relay(relay).await {
472 warn!("Failed to add relay {}: {}", relay, e);
473 }
474 }
475 client.connect().await;
476
477 if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
478 let _ = client.disconnect().await;
479 return Err(anyhow::anyhow!(
480 "Failed to connect to any relay while listing repos"
481 ));
482 }
483
484 let author = PublicKey::from_hex(&self.pubkey)
485 .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
486 let filter = build_git_repo_list_filter(author);
487
488 let events = match tokio::time::timeout(
489 Duration::from_secs(3),
490 client.fetch_events(filter, Duration::from_secs(3)),
491 )
492 .await
493 {
494 Ok(Ok(events)) => events,
495 Ok(Err(e)) => {
496 let _ = client.disconnect().await;
497 return Err(anyhow::anyhow!(
498 "Failed to fetch git repo events from relays: {}",
499 e
500 ));
501 }
502 Err(_) => {
503 let _ = client.disconnect().await;
504 return Err(anyhow::anyhow!(
505 "Timed out fetching git repo events from relays"
506 ));
507 }
508 };
509
510 let _ = client.disconnect().await;
511 let events = events.to_vec();
512
513 Ok(list_git_repo_announcements(&events)
514 .into_iter()
515 .map(|repo| repo.repo_name)
516 .collect())
517 }
518
519 pub fn fetch_refs(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
522 let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 10)?;
523 Ok(refs)
524 }
525
526 #[allow(dead_code)]
529 pub fn fetch_refs_quick(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
530 let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 3)?;
531 Ok(refs)
532 }
533
534 #[allow(dead_code)]
537 pub fn fetch_refs_with_root(&mut self, repo_name: &str) -> Result<FetchedRefs> {
538 self.fetch_refs_with_timeout(repo_name, 10)
539 }
540
541 pub(crate) fn refetch_refs_without_local_daemon(
542 &mut self,
543 repo_name: &str,
544 timeout_secs: u64,
545 ) -> Result<FetchedRefs> {
546 self.clear_cached_remote_state(repo_name);
547 self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
548 }
549
550 fn clear_cached_remote_state(&mut self, repo_name: &str) {
551 self.cached_refs.remove(repo_name);
552 self.cached_root_hash.remove(repo_name);
553 self.cached_encryption_key.remove(repo_name);
554 self.cached_root_source.remove(repo_name);
555 }
556
557 fn cache_resolved_root(&mut self, repo_name: &str, resolved: &ResolvedRoot) {
558 if let Some(ref root) = resolved.root_hash {
559 self.cached_root_hash
560 .insert(repo_name.to_string(), root.clone());
561 } else {
562 self.cached_root_hash.remove(repo_name);
563 }
564
565 if let Some(key) = resolved.encryption_key {
566 self.cached_encryption_key
567 .insert(repo_name.to_string(), key);
568 } else {
569 self.cached_encryption_key.remove(repo_name);
570 }
571
572 if let Some(source) = resolved.source {
573 self.cached_root_source
574 .insert(repo_name.to_string(), source);
575 } else {
576 self.cached_root_source.remove(repo_name);
577 }
578 }
579
580 fn fetch_refs_with_timeout(
582 &mut self,
583 repo_name: &str,
584 timeout_secs: u64,
585 ) -> Result<FetchedRefs> {
586 debug!(
587 "Fetching refs for {} from {} (timeout {}s)",
588 repo_name, self.pubkey, timeout_secs
589 );
590
591 if let Some(refs) = self.cached_refs.get(repo_name) {
593 let root = self.cached_root_hash.get(repo_name).cloned();
594 let key = self.cached_encryption_key.get(repo_name).cloned();
595 return Ok((refs.clone(), root, key));
596 }
597
598 self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, true)
599 }
600
601 fn fetch_refs_with_timeout_uncached(
602 &mut self,
603 repo_name: &str,
604 timeout_secs: u64,
605 allow_local_daemon: bool,
606 ) -> Result<FetchedRefs> {
607 let resolved = block_on_result(self.resolve_root_async_with_timeout(
608 repo_name,
609 timeout_secs,
610 allow_local_daemon,
611 ))?;
612
613 match self.fetch_refs_for_resolved_root(repo_name, &resolved) {
614 Ok(fetched) => Ok(fetched),
615 Err(err)
616 if resolved.source == Some(RootResolveSource::LocalDaemon)
617 && allow_local_daemon =>
618 {
619 warn!(
620 "Local daemon root for {} could not be read as a valid git tree: {}. Retrying via relays.",
621 repo_name, err
622 );
623 self.clear_cached_remote_state(repo_name);
624 self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
625 }
626 Err(err) => Err(err),
627 }
628 }
629
630 fn fetch_refs_for_resolved_root(
631 &mut self,
632 repo_name: &str,
633 resolved: &ResolvedRoot,
634 ) -> Result<FetchedRefs> {
635 if resolved.source != Some(RootResolveSource::LocalDaemon) {
636 self.cache_resolved_root(repo_name, resolved);
637 }
638
639 let refs = if let Some(ref root) = resolved.root_hash {
640 block_on_result(self.fetch_refs_from_hashtree(root, resolved.encryption_key.as_ref()))?
641 } else {
642 HashMap::new()
643 };
644
645 self.cache_resolved_root(repo_name, resolved);
646 self.cached_refs.insert(repo_name.to_string(), refs.clone());
647 Ok((refs, resolved.root_hash.clone(), resolved.encryption_key))
648 }
649
650 fn parse_root_event_data_from_event(event: &Event) -> RootEventData {
651 let root_hash = event
652 .tags
653 .iter()
654 .find(|t| t.as_slice().len() >= 2 && t.as_slice()[0].as_str() == "hash")
655 .map(|t| t.as_slice()[1].to_string())
656 .unwrap_or_else(|| event.content.to_string());
657
658 let (encryption_key, key_tag_name, self_encrypted_ciphertext) = event
659 .tags
660 .iter()
661 .find_map(|t| {
662 let slice = t.as_slice();
663 if slice.len() < 2 {
664 return None;
665 }
666 let tag_name = slice[0].as_str();
667 let tag_value = slice[1].to_string();
668 if tag_name == "selfEncryptedKey" {
669 return Some((None, Some(tag_name.to_string()), Some(tag_value)));
670 }
671 if tag_name == "key" || tag_name == "encryptedKey" {
672 if let Ok(bytes) = hex::decode(&tag_value) {
673 if bytes.len() == 32 {
674 let mut key = [0u8; 32];
675 key.copy_from_slice(&bytes);
676 return Some((Some(key), Some(tag_name.to_string()), None));
677 }
678 }
679 }
680 None
681 })
682 .unwrap_or((None, None, None));
683
684 RootEventData {
685 root_hash,
686 encryption_key,
687 key_tag_name,
688 self_encrypted_ciphertext,
689 source: RootResolveSource::Relay,
690 daemon_source: None,
691 event_created_at: Some(event.created_at.as_secs()),
692 event_id: Some(event.id.to_hex()),
693 }
694 }
695
696 fn parse_daemon_response_to_root_data(
697 response: DaemonResolveResponse,
698 ) -> Option<RootEventData> {
699 let parsed_cid = response.cid.as_deref().and_then(|cid| Cid::parse(cid).ok());
700 let daemon_source = response.source.clone();
701 let event_created_at = response.created_at;
702 let event_id = response.event_id.clone();
703 let root_hash = response
704 .hash
705 .or_else(|| parsed_cid.as_ref().map(|cid| hex::encode(cid.hash)))?;
706 if root_hash.is_empty() {
707 return None;
708 }
709
710 let mut data = RootEventData {
711 root_hash,
712 encryption_key: parsed_cid.and_then(|cid| cid.key),
713 key_tag_name: None,
714 self_encrypted_ciphertext: None,
715 source: RootResolveSource::LocalDaemon,
716 daemon_source,
717 event_created_at,
718 event_id,
719 };
720
721 if let Some(ciphertext) = response.self_encrypted_key {
722 data.key_tag_name = Some("selfEncryptedKey".to_string());
723 data.self_encrypted_ciphertext = Some(ciphertext);
724 return Some(data);
725 }
726
727 let (tag_name, tag_value) = if let Some(v) = response.encrypted_key {
728 ("encryptedKey", v)
729 } else if let Some(v) = response.key {
730 ("key", v)
731 } else {
732 return Some(data);
733 };
734
735 if let Ok(bytes) = hex::decode(&tag_value) {
736 if bytes.len() == 32 {
737 let mut key = [0u8; 32];
738 key.copy_from_slice(&bytes);
739 data.encryption_key = Some(key);
740 data.key_tag_name = Some(tag_name.to_string());
741 }
742 }
743
744 Some(data)
745 }
746
747 async fn fetch_root_from_local_daemon(
748 &self,
749 repo_name: &str,
750 timeout: Duration,
751 ) -> Option<RootEventData> {
752 let base = self.local_daemon_url.as_ref()?;
753 let pubkey = self.daemon_pubkey_identifier();
754 let url = format!(
755 "{}/api/nostr/resolve/{}/{}?refresh=1",
756 base.trim_end_matches('/'),
757 pubkey,
758 repo_name
759 );
760
761 let client = reqwest::Client::builder().timeout(timeout).build().ok()?;
762 let response = client.get(&url).send().await.ok()?;
763 if !response.status().is_success() {
764 return None;
765 }
766
767 let payload: DaemonResolveResponse = response.json().await.ok()?;
768 let source = payload
769 .source
770 .clone()
771 .unwrap_or_else(|| "unknown".to_string());
772 let parsed = Self::parse_daemon_response_to_root_data(payload)?;
773 debug!(
774 "Resolved repo {} via local daemon source={}",
775 repo_name, source
776 );
777 Some(parsed)
778 }
779
780 fn daemon_root_needs_relay_confirmation(data: &RootEventData) -> bool {
781 if data.source != RootResolveSource::LocalDaemon {
782 return false;
783 }
784
785 !matches!(data.daemon_source.as_deref(), Some("nostr-relay" | "nostr"))
786 }
787
788 fn root_event_is_newer(left: &RootEventData, right: &RootEventData) -> bool {
789 match (left.event_created_at, right.event_created_at) {
790 (Some(left_time), Some(right_time)) => {
791 (left_time, left.event_id.as_deref().unwrap_or(""))
792 > (right_time, right.event_id.as_deref().unwrap_or(""))
793 }
794 (Some(_), None) => true,
795 _ => false,
796 }
797 }
798
799 fn choose_newer_root_data(fallback: RootEventData, relay: RootEventData) -> RootEventData {
800 if Self::root_event_is_newer(&fallback, &relay) {
801 fallback
802 } else {
803 relay
804 }
805 }
806
807 async fn cache_public_root_in_local_daemon(
808 &self,
809 repo_name: &str,
810 root_hash: &str,
811 encryption_key: Option<(&[u8; 32], bool, bool)>,
812 ) {
813 let Some(base) = self.local_daemon_url.as_ref() else {
814 return;
815 };
816
817 let key = match encryption_key {
818 Some((key, false, false)) => Some(hex::encode(key)),
819 Some((_key, _is_link_visible, _is_self_private)) => return,
820 None => None,
821 };
822
823 let url = format!("{}/api/cache-tree-root", base.trim_end_matches('/'));
824 let pubkey = self.daemon_pubkey_identifier();
825 let payload = serde_json::json!({
826 "npub": pubkey,
827 "treeName": repo_name,
828 "hash": root_hash,
829 "key": key,
830 "visibility": "public",
831 });
832
833 let client = match reqwest::Client::builder()
834 .timeout(Duration::from_secs(2))
835 .build()
836 {
837 Ok(client) => client,
838 Err(err) => {
839 debug!("Could not build local daemon cache client: {}", err);
840 return;
841 }
842 };
843
844 match client.post(url).json(&payload).send().await {
845 Ok(response) if response.status().is_success() => {
846 debug!("Cached repo root for {} in local daemon", repo_name);
847 }
848 Ok(response) => {
849 debug!(
850 "Local daemon root cache returned status {} for {}",
851 response.status(),
852 repo_name
853 );
854 }
855 Err(err) => {
856 debug!("Could not cache repo root in local daemon: {}", err);
857 }
858 }
859 }
860
861 async fn resolve_root_async_with_timeout(
862 &self,
863 repo_name: &str,
864 timeout_secs: u64,
865 allow_local_daemon: bool,
866 ) -> Result<ResolvedRoot> {
867 let client = Client::default();
869
870 for relay in &self.relays {
872 if let Err(e) = client.add_relay(relay).await {
873 warn!("Failed to add relay {}: {}", relay, e);
874 }
875 }
876
877 client.connect().await;
879
880 let connect_timeout = Duration::from_secs(2);
881 let query_timeout = Duration::from_secs(timeout_secs.saturating_sub(2).max(3));
882 let local_daemon_timeout = Duration::from_secs(4);
883 let retry_delay = Duration::from_millis(300);
884 let max_attempts = 2;
885
886 let start = std::time::Instant::now();
887
888 let author = PublicKey::from_hex(&self.pubkey)
890 .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
891
892 let filter = build_repo_event_filter(author, repo_name);
893
894 debug!("Querying relays for repo {} events", repo_name);
895
896 let mut root_data = None;
897 let mut daemon_fallback: Option<RootEventData> = None;
898 for attempt in 1..=max_attempts {
899 if allow_local_daemon {
900 if let Some(data) = self
901 .fetch_root_from_local_daemon(repo_name, local_daemon_timeout)
902 .await
903 {
904 if Self::daemon_root_needs_relay_confirmation(&data) {
905 debug!(
906 "Local daemon resolved {} via {}; checking relays for fresher root",
907 repo_name,
908 data.daemon_source.as_deref().unwrap_or("unknown")
909 );
910 daemon_fallback = Some(data);
911 } else {
912 root_data = Some(data);
913 break;
914 }
915 }
916 }
917
918 if !allow_local_daemon && attempt == 1 {
919 debug!(
920 "Skipping local daemon while resolving {} because relay retry was requested",
921 repo_name
922 );
923 }
924
925 if allow_local_daemon && daemon_fallback.is_none() {
926 debug!(
927 "Local daemon did not resolve {}; querying relays",
928 repo_name
929 );
930 }
931
932 let connect_start = std::time::Instant::now();
935 let mut last_log = std::time::Instant::now();
936 let mut has_connected_relay = false;
937 loop {
938 let (connected, total) = connected_relay_count(&client).await;
939 if connected > 0 {
940 debug!(
941 "Connected to {}/{} relay(s) in {:?} (attempt {}/{})",
942 connected,
943 total,
944 start.elapsed(),
945 attempt,
946 max_attempts
947 );
948 has_connected_relay = true;
949 break;
950 }
951 if last_log.elapsed() > Duration::from_millis(500) {
952 debug!(
953 "Connecting to relays... (0/{} after {:?}, attempt {}/{})",
954 total,
955 start.elapsed(),
956 attempt,
957 max_attempts
958 );
959 last_log = std::time::Instant::now();
960 }
961 if connect_start.elapsed() > connect_timeout {
962 debug!(
963 "Timeout waiting for relay connections - continuing with local-daemon fallback"
964 );
965 break;
966 }
967 tokio::time::sleep(Duration::from_millis(50)).await;
968 }
969
970 if !has_connected_relay {
974 if let Some(data) = daemon_fallback.take() {
975 debug!(
976 "Using local daemon root for {} because no relay connected",
977 repo_name
978 );
979 root_data = Some(data);
980 break;
981 }
982 }
983
984 let events = if has_connected_relay {
985 match client.fetch_events(filter.clone(), query_timeout).await {
986 Ok(events) => events.to_vec(),
987 Err(e) => {
988 warn!("Failed to fetch events: {}", e);
989 vec![]
990 }
991 }
992 } else {
993 vec![]
994 };
995
996 debug!(
997 "Got {} events from relays on attempt {}/{}",
998 events.len(),
999 attempt,
1000 max_attempts
1001 );
1002 let relay_event = pick_latest_repo_event(events.iter(), repo_name);
1003
1004 if let Some(event) = relay_event {
1005 debug!(
1006 "Found relay event with root hash: {}",
1007 &event.content[..12.min(event.content.len())]
1008 );
1009 let relay_data = Self::parse_root_event_data_from_event(event);
1010 root_data = Some(match daemon_fallback.take() {
1011 Some(fallback) => Self::choose_newer_root_data(fallback, relay_data),
1012 None => relay_data,
1013 });
1014 break;
1015 }
1016
1017 if attempt < max_attempts {
1018 debug!(
1019 "No relay hashtree event found for {} on attempt {}/{}; retrying",
1020 repo_name, attempt, max_attempts
1021 );
1022 tokio::time::sleep(retry_delay).await;
1023 } else if let Some(data) = daemon_fallback.take() {
1024 debug!(
1025 "Using local daemon root for {} after relay lookup returned no event",
1026 repo_name
1027 );
1028 root_data = Some(data);
1029 break;
1030 }
1031 }
1032
1033 let _ = client.disconnect().await;
1035
1036 let root_data = match root_data {
1037 Some(data) => data,
1038 None => {
1039 anyhow::bail!(
1040 "Repository '{}' not found (no hashtree event published by {})",
1041 repo_name,
1042 Self::format_repo_author(&self.pubkey)
1043 );
1044 }
1045 };
1046
1047 let root_hash = root_data.root_hash;
1048
1049 if root_hash.is_empty() {
1050 debug!("Empty root hash in event");
1051 return Ok(ResolvedRoot {
1052 root_hash: None,
1053 encryption_key: None,
1054 source: None,
1055 });
1056 }
1057
1058 let encryption_key = root_data.encryption_key;
1059 let key_tag_name = root_data.key_tag_name;
1060 let self_encrypted_ciphertext = root_data.self_encrypted_ciphertext;
1061 let root_source = root_data.source;
1062
1063 let unmasked_key = match key_tag_name.as_deref() {
1065 Some("encryptedKey") => {
1066 if let (Some(masked), Some(secret)) = (encryption_key, self.url_secret) {
1068 let mut unmasked = [0u8; 32];
1069 for i in 0..32 {
1070 unmasked[i] = masked[i] ^ secret[i];
1071 }
1072 Some(unmasked)
1073 } else {
1074 anyhow::bail!(
1075 "This repo is link-visible and requires a secret key.\n\
1076 Use: htree://.../{repo_name}#k=<secret>\n\
1077 Ask the repo owner for the full URL with the secret."
1078 );
1079 }
1080 }
1081 Some("selfEncryptedKey") => {
1082 if !self.is_private {
1084 anyhow::bail!(
1085 "This repo is private (author-only).\n\
1086 Use: htree://.../{repo_name}#private\n\
1087 Only the author can access this repo."
1088 );
1089 }
1090
1091 if let Some(keys) = &self.keys {
1093 if let Some(ciphertext) = self_encrypted_ciphertext {
1094 let pubkey = keys.public_key();
1096 match nip44::decrypt(keys.secret_key(), &pubkey, &ciphertext) {
1097 Ok(key_hex) => {
1098 let key_bytes =
1099 hex::decode(&key_hex).context("Invalid decrypted key hex")?;
1100 if key_bytes.len() != 32 {
1101 anyhow::bail!("Decrypted key wrong length");
1102 }
1103 let mut key = [0u8; 32];
1104 key.copy_from_slice(&key_bytes);
1105 Some(key)
1106 }
1107 Err(e) => {
1108 anyhow::bail!(
1109 "Failed to decrypt private repo: {}\n\
1110 The repo may be corrupted or published with a different key.",
1111 e
1112 );
1113 }
1114 }
1115 } else {
1116 anyhow::bail!("selfEncryptedKey tag has invalid format");
1117 }
1118 } else {
1119 anyhow::bail!(
1120 "Cannot access this private repo.\n\
1121 Private repos can only be accessed by their author.\n\
1122 You don't have the secret key for this repo's owner."
1123 );
1124 }
1125 }
1126 Some("key") | None => {
1127 encryption_key
1129 }
1130 Some(other) => {
1131 warn!("Unknown key tag type: {}", other);
1132 encryption_key
1133 }
1134 };
1135
1136 info!(
1137 "Found root hash {} for {} (encrypted: {}, link_visible: {})",
1138 &root_hash[..12.min(root_hash.len())],
1139 repo_name,
1140 unmasked_key.is_some(),
1141 self.url_secret.is_some()
1142 );
1143
1144 Ok(ResolvedRoot {
1145 root_hash: Some(root_hash),
1146 encryption_key: unmasked_key,
1147 source: Some(root_source),
1148 })
1149 }
1150
1151 fn decrypt_and_decode(&self, data: &[u8], key: Option<&[u8; 32]>) -> Result<TreeNode> {
1153 let decrypted_data: Vec<u8>;
1154 let data_to_decode = if let Some(k) = key {
1155 decrypted_data = decrypt_chk(data, k).context("Decryption failed")?;
1156 &decrypted_data
1157 } else {
1158 data
1159 };
1160
1161 decode_tree_node(data_to_decode).context("Failed to decode tree node")
1162 }
1163
1164 async fn fetch_refs_from_hashtree(
1167 &self,
1168 root_hash: &str,
1169 encryption_key: Option<&[u8; 32]>,
1170 ) -> Result<HashMap<String, String>> {
1171 let mut refs = HashMap::new();
1172 debug!(
1173 "fetch_refs_from_hashtree: downloading root {}",
1174 &root_hash[..12]
1175 );
1176
1177 let root_data = match self.blossom.download(root_hash).await {
1179 Ok(data) => {
1180 debug!("Downloaded {} bytes from blossom", data.len());
1181 data
1182 }
1183 Err(e) => {
1184 anyhow::bail!(
1185 "Failed to download root hash {}: {}",
1186 &root_hash[..12.min(root_hash.len())],
1187 e
1188 );
1189 }
1190 };
1191
1192 let root_node = self
1194 .decrypt_and_decode(&root_data, encryption_key)
1195 .with_context(|| {
1196 format!(
1197 "Failed to decode root node {} (encrypted: {})",
1198 &root_hash[..12.min(root_hash.len())],
1199 encryption_key.is_some()
1200 )
1201 })?;
1202 debug!("Decoded root node with {} links", root_node.links.len());
1203
1204 debug!(
1206 "Root links: {:?}",
1207 root_node
1208 .links
1209 .iter()
1210 .map(|l| l.name.as_deref())
1211 .collect::<Vec<_>>()
1212 );
1213 let git_link = root_node
1214 .links
1215 .iter()
1216 .find(|l| l.name.as_deref() == Some(".git"));
1217 let (git_hash, git_key) = match git_link {
1218 Some(link) => {
1219 debug!("Found .git link with key: {}", link.key.is_some());
1220 (hex::encode(link.hash), link.key)
1221 }
1222 None => {
1223 debug!("No .git directory in hashtree root");
1224 return Ok(refs);
1225 }
1226 };
1227
1228 let git_data = match self.blossom.download(&git_hash).await {
1230 Ok(data) => data,
1231 Err(e) => {
1232 anyhow::bail!(
1233 "Failed to download .git directory ({}): {}",
1234 &git_hash[..12],
1235 e
1236 );
1237 }
1238 };
1239
1240 let git_node = self
1241 .decrypt_and_decode(&git_data, git_key.as_ref())
1242 .with_context(|| {
1243 format!(
1244 "Failed to decode .git directory {} (encrypted: {})",
1245 &git_hash[..12.min(git_hash.len())],
1246 git_key.is_some()
1247 )
1248 })?;
1249 debug!(
1250 "Decoded .git node with {} links: {:?}",
1251 git_node.links.len(),
1252 git_node
1253 .links
1254 .iter()
1255 .map(|l| l.name.as_deref())
1256 .collect::<Vec<_>>()
1257 );
1258
1259 let refs_link = git_node
1261 .links
1262 .iter()
1263 .find(|l| l.name.as_deref() == Some("refs"));
1264 let (refs_hash, refs_key) = match refs_link {
1265 Some(link) => (hex::encode(link.hash), link.key),
1266 None => {
1267 debug!("No refs directory in .git");
1268 return Ok(refs);
1269 }
1270 };
1271
1272 let refs_data =
1274 self.blossom.download(&refs_hash).await.with_context(|| {
1275 format!("Failed to download refs directory {}", &refs_hash[..12])
1276 })?;
1277
1278 let refs_node = self
1279 .decrypt_and_decode(&refs_data, refs_key.as_ref())
1280 .with_context(|| {
1281 format!(
1282 "Failed to decode refs directory {} (encrypted: {})",
1283 &refs_hash[..12.min(refs_hash.len())],
1284 refs_key.is_some()
1285 )
1286 })?;
1287
1288 if let Some(head_link) = git_node
1290 .links
1291 .iter()
1292 .find(|l| l.name.as_deref() == Some("HEAD"))
1293 {
1294 let head_hash = hex::encode(head_link.hash);
1295 let head_data = self
1296 .blossom
1297 .download(&head_hash)
1298 .await
1299 .with_context(|| format!("Failed to download HEAD {}", &head_hash[..12]))?;
1300 let head_content = if let Some(k) = head_link.key.as_ref() {
1302 let decrypted = decrypt_chk(&head_data, k)
1303 .with_context(|| format!("Failed to decrypt HEAD {}", &head_hash[..12]))?;
1304 String::from_utf8_lossy(&decrypted).trim().to_string()
1305 } else {
1306 String::from_utf8_lossy(&head_data).trim().to_string()
1307 };
1308 refs.insert("HEAD".to_string(), head_content);
1309 }
1310
1311 for subdir_link in &refs_node.links {
1313 if subdir_link.link_type != LinkType::Dir {
1314 continue;
1315 }
1316 let subdir_name = match &subdir_link.name {
1317 Some(n) => n.clone(),
1318 None => continue,
1319 };
1320 let subdir_hash = hex::encode(subdir_link.hash);
1321
1322 self.collect_refs_recursive(
1323 &subdir_hash,
1324 subdir_link.key.as_ref(),
1325 &format!("refs/{}", subdir_name),
1326 &mut refs,
1327 )
1328 .await?;
1329 }
1330
1331 debug!("Found {} refs from hashtree", refs.len());
1332 Ok(refs)
1333 }
1334
1335 async fn collect_refs_recursive(
1337 &self,
1338 dir_hash: &str,
1339 dir_key: Option<&[u8; 32]>,
1340 prefix: &str,
1341 refs: &mut HashMap<String, String>,
1342 ) -> Result<()> {
1343 let dir_data = self
1344 .blossom
1345 .download(dir_hash)
1346 .await
1347 .with_context(|| format!("Failed to download refs subtree {}", &dir_hash[..12]))?;
1348
1349 let dir_node = self
1350 .decrypt_and_decode(&dir_data, dir_key)
1351 .with_context(|| {
1352 format!(
1353 "Failed to decode refs subtree {} (encrypted: {})",
1354 &dir_hash[..12.min(dir_hash.len())],
1355 dir_key.is_some()
1356 )
1357 })?;
1358
1359 for link in &dir_node.links {
1360 let name = match &link.name {
1361 Some(n) => n.clone(),
1362 None => continue,
1363 };
1364 let link_hash = hex::encode(link.hash);
1365 let ref_path = format!("{}/{}", prefix, name);
1366
1367 if link.link_type == LinkType::Dir {
1368 Box::pin(self.collect_refs_recursive(
1370 &link_hash,
1371 link.key.as_ref(),
1372 &ref_path,
1373 refs,
1374 ))
1375 .await?;
1376 } else {
1377 let ref_data = self
1379 .blossom
1380 .download(&link_hash)
1381 .await
1382 .with_context(|| format!("Failed to download ref {}", ref_path))?;
1383 let sha = if let Some(k) = link.key.as_ref() {
1385 let decrypted = decrypt_chk(&ref_data, k)
1386 .with_context(|| format!("Failed to decrypt ref {}", ref_path))?;
1387 String::from_utf8_lossy(&decrypted).trim().to_string()
1388 } else {
1389 String::from_utf8_lossy(&ref_data).trim().to_string()
1390 };
1391 if !sha.is_empty() {
1392 debug!("Found ref {} -> {}", ref_path, sha);
1393 refs.insert(ref_path, sha);
1394 }
1395 }
1396 }
1397
1398 Ok(())
1399 }
1400
1401 #[allow(dead_code)]
1403 pub fn update_ref(&mut self, repo_name: &str, ref_name: &str, sha: &str) -> Result<()> {
1404 info!("Updating ref {} -> {} for {}", ref_name, sha, repo_name);
1405
1406 let refs = self.cached_refs.entry(repo_name.to_string()).or_default();
1407 refs.insert(ref_name.to_string(), sha.to_string());
1408
1409 Ok(())
1410 }
1411
1412 pub fn delete_ref(&mut self, repo_name: &str, ref_name: &str) -> Result<()> {
1414 info!("Deleting ref {} for {}", ref_name, repo_name);
1415
1416 if let Some(refs) = self.cached_refs.get_mut(repo_name) {
1417 refs.remove(ref_name);
1418 }
1419
1420 Ok(())
1421 }
1422
1423 pub fn get_cached_root_hash(&self, repo_name: &str) -> Option<&String> {
1425 self.cached_root_hash.get(repo_name)
1426 }
1427
1428 pub fn get_cached_encryption_key(&self, repo_name: &str) -> Option<&[u8; 32]> {
1430 self.cached_encryption_key.get(repo_name)
1431 }
1432
1433 pub(crate) fn cached_root_is_from_local_daemon(&self, repo_name: &str) -> bool {
1434 self.cached_root_source.get(repo_name) == Some(&RootResolveSource::LocalDaemon)
1435 }
1436
1437 pub fn blossom(&self) -> &BlossomClient {
1439 &self.blossom
1440 }
1441
1442 pub fn relay_urls(&self) -> Vec<String> {
1444 self.relays.clone()
1445 }
1446
1447 #[allow(dead_code)]
1449 pub fn pubkey(&self) -> &str {
1450 &self.pubkey
1451 }
1452
1453 pub fn npub(&self) -> String {
1455 PublicKey::from_hex(&self.pubkey)
1456 .ok()
1457 .and_then(|pk| pk.to_bech32().ok())
1458 .unwrap_or_else(|| self.pubkey.clone())
1459 }
1460
1461 pub fn publish_repo(
1469 &self,
1470 repo_name: &str,
1471 root_hash: &str,
1472 encryption_key: Option<(&[u8; 32], bool, bool)>,
1473 ) -> Result<(String, RelayResult)> {
1474 self.publish_repo_with_announcement(repo_name, root_hash, encryption_key, None)
1475 }
1476
1477 pub fn publish_repo_with_announcement(
1478 &self,
1479 repo_name: &str,
1480 root_hash: &str,
1481 encryption_key: Option<(&[u8; 32], bool, bool)>,
1482 repo_announcement: Option<RepoAnnouncementOptions>,
1483 ) -> Result<(String, RelayResult)> {
1484 let keys = self.keys.as_ref().context(format!(
1485 "Cannot push: no secret key for {}. You can only push to your own repos.",
1486 &self.pubkey[..16]
1487 ))?;
1488
1489 info!(
1490 "Publishing repo {} with root hash {} (encrypted: {})",
1491 repo_name,
1492 root_hash,
1493 encryption_key.is_some()
1494 );
1495
1496 block_on_result(self.publish_repo_async(
1498 keys,
1499 repo_name,
1500 root_hash,
1501 encryption_key,
1502 repo_announcement,
1503 ))
1504 }
1505
1506 async fn publish_repo_async(
1507 &self,
1508 keys: &Keys,
1509 repo_name: &str,
1510 root_hash: &str,
1511 encryption_key: Option<(&[u8; 32], bool, bool)>,
1512 repo_announcement: Option<RepoAnnouncementOptions>,
1513 ) -> Result<(String, RelayResult)> {
1514 let client = Client::new(keys.clone());
1516
1517 let configured: Vec<String> = self.relays.clone();
1518 let mut connected: Vec<String> = Vec::new();
1519 let mut failed: Vec<String> = Vec::new();
1520
1521 for relay in &self.relays {
1523 if let Err(e) = client.add_relay(relay).await {
1524 warn!("Failed to add relay {}: {}", relay, e);
1525 failed.push(relay.clone());
1526 }
1527 }
1528
1529 client.connect().await;
1531
1532 let _ = wait_for_any_connected_relay(&client, Duration::from_secs(3)).await;
1534
1535 let publish_created_at = next_replaceable_created_at(
1536 Timestamp::now(),
1537 latest_repo_event_created_at(
1538 &client,
1539 keys.public_key(),
1540 repo_name,
1541 Duration::from_secs(2),
1542 )
1543 .await,
1544 );
1545
1546 let mut tags = vec![
1548 Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1549 Tag::custom(TagKind::custom("l"), vec![LABEL_HASHTREE.to_string()]),
1550 Tag::custom(TagKind::custom("hash"), vec![root_hash.to_string()]),
1551 ];
1552
1553 if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1559 if is_self_private {
1560 let pubkey = keys.public_key();
1562 let key_hex = hex::encode(key);
1563 let encrypted =
1564 nip44::encrypt(keys.secret_key(), &pubkey, &key_hex, nip44::Version::V2)
1565 .map_err(|e| anyhow::anyhow!("NIP-44 encryption failed: {}", e))?;
1566 tags.push(Tag::custom(
1567 TagKind::custom("selfEncryptedKey"),
1568 vec![encrypted],
1569 ));
1570 } else if is_link_visible {
1571 tags.push(Tag::custom(
1573 TagKind::custom("encryptedKey"),
1574 vec![hex::encode(key)],
1575 ));
1576 } else {
1577 tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1579 }
1580 }
1581
1582 append_repo_discovery_labels(&mut tags, repo_name);
1583
1584 let event = EventBuilder::new(Kind::Custom(KIND_APP_DATA), root_hash)
1586 .tags(tags)
1587 .custom_created_at(publish_created_at)
1588 .sign_with_keys(keys)
1589 .map_err(|e| anyhow::anyhow!("Failed to sign event: {}", e))?;
1590
1591 match client.send_event(&event).await {
1593 Ok(output) => {
1594 for url in output.success.iter() {
1596 let url_str = url.to_string();
1597 if !connected.contains(&url_str) {
1598 connected.push(url_str);
1599 }
1600 }
1601 for (url, _err) in output.failed.iter() {
1603 let url_str = url.to_string();
1604 if !failed.contains(&url_str) && !connected.contains(&url_str) {
1605 failed.push(url_str);
1606 }
1607 }
1608 info!(
1609 "Sent event {} to {} relays ({} failed)",
1610 output.id(),
1611 output.success.len(),
1612 output.failed.len()
1613 );
1614 }
1615 Err(e) => {
1616 warn!("Failed to send event: {}", e);
1617 for relay in &self.relays {
1619 if !failed.contains(relay) {
1620 failed.push(relay.clone());
1621 }
1622 }
1623 }
1624 };
1625
1626 let npub_url = keys
1628 .public_key()
1629 .to_bech32()
1630 .map(|npub| format!("htree://{}/{}", npub, repo_name))
1631 .unwrap_or_else(|_| format!("htree://{}/{}", &self.pubkey[..16], repo_name));
1632
1633 let relay_validation = validate_repo_publish_relays(&configured, &connected);
1634
1635 if relay_validation.is_ok() {
1636 if let Some(repo_announcement) = repo_announcement.as_ref() {
1637 if let Err(err) = self
1638 .publish_repo_announcement_async(
1639 &client,
1640 keys,
1641 repo_name,
1642 &npub_url,
1643 repo_announcement,
1644 )
1645 .await
1646 {
1647 warn!("Failed to publish NIP-34 repo announcement: {}", err);
1648 }
1649 }
1650 }
1651
1652 let _ = client.disconnect().await;
1654 tokio::time::sleep(Duration::from_millis(50)).await;
1655
1656 relay_validation?;
1657
1658 self.cache_public_root_in_local_daemon(repo_name, root_hash, encryption_key)
1659 .await;
1660
1661 Ok((
1662 npub_url,
1663 RelayResult {
1664 configured,
1665 connected,
1666 failed,
1667 },
1668 ))
1669 }
1670
1671 fn build_repo_announcement_tags(
1672 repo_name: &str,
1673 clone_url: &str,
1674 relays: &[String],
1675 options: &RepoAnnouncementOptions,
1676 ) -> Vec<Tag> {
1677 let mut tags = vec![
1678 Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1679 Tag::custom(TagKind::custom("name"), vec![repo_name.to_string()]),
1680 Tag::custom(TagKind::custom("clone"), vec![clone_url.to_string()]),
1681 ];
1682
1683 if !relays.is_empty() {
1684 tags.push(Tag::custom(TagKind::custom("relays"), relays.to_vec()));
1685 }
1686
1687 if let Some(euc) = options
1688 .earliest_unique_commit
1689 .as_deref()
1690 .map(str::trim)
1691 .filter(|value| !value.is_empty())
1692 {
1693 tags.push(Tag::custom(
1694 TagKind::custom("r"),
1695 vec![euc.to_string(), "euc".to_string()],
1696 ));
1697 }
1698
1699 if options.personal_fork {
1700 tags.push(Tag::custom(
1701 TagKind::custom("t"),
1702 vec!["personal-fork".to_string()],
1703 ));
1704 }
1705
1706 if let Some(forked_from) = options
1707 .forked_from
1708 .as_deref()
1709 .map(str::trim)
1710 .filter(|value| !value.is_empty())
1711 {
1712 tags.push(Tag::custom(
1713 TagKind::custom("forked-from"),
1714 vec![forked_from.to_string()],
1715 ));
1716 }
1717
1718 tags
1719 }
1720
1721 async fn publish_repo_announcement_async(
1722 &self,
1723 client: &Client,
1724 keys: &Keys,
1725 repo_name: &str,
1726 clone_url: &str,
1727 options: &RepoAnnouncementOptions,
1728 ) -> Result<()> {
1729 let created_at = next_replaceable_created_at(
1730 Timestamp::now(),
1731 latest_repo_announcement_created_at(
1732 client,
1733 keys.public_key(),
1734 repo_name,
1735 Duration::from_secs(2),
1736 )
1737 .await,
1738 );
1739 let tags = Self::build_repo_announcement_tags(repo_name, clone_url, &self.relays, options);
1740 let event = EventBuilder::new(Kind::Custom(KIND_REPO_ANNOUNCEMENT), "")
1741 .tags(tags)
1742 .custom_created_at(created_at)
1743 .sign_with_keys(keys)
1744 .map_err(|e| anyhow::anyhow!("Failed to sign NIP-34 repo announcement: {}", e))?;
1745
1746 let output = client
1747 .send_event(&event)
1748 .await
1749 .map_err(|e| anyhow::anyhow!("Failed to publish NIP-34 repo announcement: {}", e))?;
1750 if output.success.is_empty() {
1751 anyhow::bail!("NIP-34 repo announcement was not confirmed by any relay");
1752 }
1753
1754 info!(
1755 "Published NIP-34 repo announcement {} to {} relays",
1756 output.id(),
1757 output.success.len()
1758 );
1759 Ok(())
1760 }
1761
1762 pub fn fetch_repo_announcement_euc(
1763 &self,
1764 owner_pubkey_hex: &str,
1765 repo_name: &str,
1766 ) -> Result<Option<String>> {
1767 block_on_result(self.fetch_repo_announcement_euc_async(owner_pubkey_hex, repo_name))
1768 }
1769
1770 async fn fetch_repo_announcement_euc_async(
1771 &self,
1772 owner_pubkey_hex: &str,
1773 repo_name: &str,
1774 ) -> Result<Option<String>> {
1775 let owner = PublicKey::from_hex(owner_pubkey_hex)
1776 .map_err(|e| anyhow::anyhow!("Invalid repo owner pubkey: {}", e))?;
1777 let client = Client::default();
1778
1779 for relay in &self.relays {
1780 if let Err(e) = client.add_relay(relay).await {
1781 debug!(
1782 "Failed to add relay {} for repo announcement lookup: {}",
1783 relay, e
1784 );
1785 }
1786 }
1787 client.connect().await;
1788
1789 if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
1790 let _ = client.disconnect().await;
1791 return Ok(None);
1792 }
1793
1794 let filter = build_repo_announcement_filter(owner, repo_name);
1795 let events = match tokio::time::timeout(
1796 Duration::from_secs(3),
1797 client.fetch_events(filter, Duration::from_secs(3)),
1798 )
1799 .await
1800 {
1801 Ok(Ok(events)) => events.to_vec(),
1802 Ok(Err(err)) => {
1803 debug!(
1804 "Failed to fetch NIP-34 repo announcement for {}: {}",
1805 repo_name, err
1806 );
1807 Vec::new()
1808 }
1809 Err(_) => {
1810 debug!(
1811 "Timed out fetching NIP-34 repo announcement for {}",
1812 repo_name
1813 );
1814 Vec::new()
1815 }
1816 };
1817
1818 let _ = client.disconnect().await;
1819 Ok(pick_latest_event(events.iter()).and_then(extract_repo_announcement_euc))
1820 }
1821
1822 pub fn fetch_prs(
1824 &self,
1825 repo_name: &str,
1826 state_filter: PullRequestStateFilter,
1827 ) -> Result<Vec<PullRequestListItem>> {
1828 block_on_result(self.fetch_prs_async(repo_name, state_filter))
1829 }
1830
1831 pub async fn fetch_prs_async(
1832 &self,
1833 repo_name: &str,
1834 state_filter: PullRequestStateFilter,
1835 ) -> Result<Vec<PullRequestListItem>> {
1836 let client = Client::default();
1837
1838 for relay in &self.relays {
1839 if let Err(e) = client.add_relay(relay).await {
1840 warn!("Failed to add relay {}: {}", relay, e);
1841 }
1842 }
1843 client.connect().await;
1844
1845 if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
1847 let _ = client.disconnect().await;
1848 return Err(anyhow::anyhow!(
1849 "Failed to connect to any relay while fetching PRs"
1850 ));
1851 }
1852
1853 let repo_address = format!("{}:{}:{}", KIND_REPO_ANNOUNCEMENT, self.pubkey, repo_name);
1855 let pull_request_filter = Filter::new()
1856 .kind(Kind::Custom(KIND_PULL_REQUEST))
1857 .custom_tag(
1858 SingleLetterTag::lowercase(Alphabet::A),
1859 repo_address.clone(),
1860 );
1861
1862 let mut pr_events = match tokio::time::timeout(
1863 Duration::from_secs(3),
1864 client.fetch_events(pull_request_filter.clone(), Duration::from_secs(3)),
1865 )
1866 .await
1867 {
1868 Ok(Ok(events)) => events.to_vec(),
1869 Ok(Err(e)) => {
1870 let _ = client.disconnect().await;
1871 return Err(anyhow::anyhow!(
1872 "Failed to fetch PR events from relays: {}",
1873 e
1874 ));
1875 }
1876 Err(_) => {
1877 let _ = client.disconnect().await;
1878 return Err(anyhow::anyhow!("Timed out fetching PR events from relays"));
1879 }
1880 };
1881
1882 if pr_events.is_empty() {
1883 let fallback_events = fetch_events_via_raw_relay_query(
1884 &self.relays,
1885 pull_request_filter,
1886 Duration::from_secs(3),
1887 )
1888 .await;
1889 if !fallback_events.is_empty() {
1890 debug!(
1891 "Raw relay fallback recovered {} PR event(s) for {}",
1892 fallback_events.len(),
1893 repo_name
1894 );
1895 pr_events = fallback_events;
1896 }
1897 }
1898
1899 if pr_events.is_empty() {
1900 let _ = client.disconnect().await;
1901 return Ok(Vec::new());
1902 }
1903
1904 let pr_ids: Vec<String> = pr_events.iter().map(|e| e.id.to_hex()).collect();
1906
1907 let status_event_filter = Filter::new()
1909 .kinds(vec![
1910 Kind::Custom(KIND_STATUS_OPEN),
1911 Kind::Custom(KIND_STATUS_APPLIED),
1912 Kind::Custom(KIND_STATUS_CLOSED),
1913 Kind::Custom(KIND_STATUS_DRAFT),
1914 ])
1915 .custom_tags(
1916 SingleLetterTag::lowercase(Alphabet::E),
1917 pr_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1918 );
1919
1920 let mut status_events = match tokio::time::timeout(
1921 Duration::from_secs(3),
1922 client.fetch_events(status_event_filter.clone(), Duration::from_secs(3)),
1923 )
1924 .await
1925 {
1926 Ok(Ok(events)) => events.to_vec(),
1927 Ok(Err(e)) => {
1928 let _ = client.disconnect().await;
1929 return Err(anyhow::anyhow!(
1930 "Failed to fetch PR status events from relays: {}",
1931 e
1932 ));
1933 }
1934 Err(_) => {
1935 let _ = client.disconnect().await;
1936 return Err(anyhow::anyhow!(
1937 "Timed out fetching PR status events from relays"
1938 ));
1939 }
1940 };
1941
1942 if status_events.is_empty() {
1943 let fallback_events = fetch_events_via_raw_relay_query(
1944 &self.relays,
1945 status_event_filter,
1946 Duration::from_secs(3),
1947 )
1948 .await;
1949 if !fallback_events.is_empty() {
1950 debug!(
1951 "Raw relay fallback recovered {} PR status event(s) for {}",
1952 fallback_events.len(),
1953 repo_name
1954 );
1955 status_events = fallback_events;
1956 }
1957 }
1958
1959 let _ = client.disconnect().await;
1960
1961 let latest_status =
1963 latest_trusted_pr_status_kinds(&pr_events, &status_events, &self.pubkey);
1964
1965 let mut prs = Vec::new();
1966 for event in &pr_events {
1967 let pr_id = event.id.to_hex();
1968 let state =
1969 PullRequestState::from_latest_status_kind(latest_status.get(&pr_id).copied());
1970 if !state_filter.includes(state) {
1971 continue;
1972 }
1973
1974 let mut subject = None;
1975 let mut commit_tip = None;
1976 let mut branch = None;
1977 let mut target_branch = None;
1978
1979 for tag in event.tags.iter() {
1980 let slice = tag.as_slice();
1981 if slice.len() >= 2 {
1982 match slice[0].as_str() {
1983 "subject" => subject = Some(slice[1].to_string()),
1984 "c" => commit_tip = Some(slice[1].to_string()),
1985 "branch" => branch = Some(slice[1].to_string()),
1986 "target-branch" => target_branch = Some(slice[1].to_string()),
1987 _ => {}
1988 }
1989 }
1990 }
1991
1992 prs.push(PullRequestListItem {
1993 event_id: pr_id,
1994 author_pubkey: event.pubkey.to_hex(),
1995 state,
1996 subject,
1997 commit_tip,
1998 branch,
1999 target_branch,
2000 created_at: event.created_at.as_secs(),
2001 });
2002 }
2003
2004 prs.sort_by(|left, right| {
2006 right
2007 .created_at
2008 .cmp(&left.created_at)
2009 .then_with(|| right.event_id.cmp(&left.event_id))
2010 });
2011
2012 debug!(
2013 "Found {} PRs for {} (filter: {:?})",
2014 prs.len(),
2015 repo_name,
2016 state_filter
2017 );
2018 Ok(prs)
2019 }
2020
2021 pub fn publish_pr_merged_status(
2023 &self,
2024 pr_event_id: &str,
2025 pr_author_pubkey: &str,
2026 ) -> Result<()> {
2027 let keys = self
2028 .keys
2029 .as_ref()
2030 .context("Cannot publish status: no secret key")?;
2031
2032 block_on_result(self.publish_pr_merged_status_async(keys, pr_event_id, pr_author_pubkey))
2033 }
2034
2035 async fn publish_pr_merged_status_async(
2036 &self,
2037 keys: &Keys,
2038 pr_event_id: &str,
2039 pr_author_pubkey: &str,
2040 ) -> Result<()> {
2041 let client = Client::new(keys.clone());
2042
2043 for relay in &self.relays {
2044 if let Err(e) = client.add_relay(relay).await {
2045 warn!("Failed to add relay {}: {}", relay, e);
2046 }
2047 }
2048 client.connect().await;
2049
2050 if !wait_for_any_connected_relay(&client, Duration::from_secs(3)).await {
2052 anyhow::bail!("Failed to connect to any relay for status publish");
2053 }
2054
2055 let tags = vec![
2056 Tag::custom(TagKind::custom("e"), vec![pr_event_id.to_string()]),
2057 Tag::custom(TagKind::custom("p"), vec![pr_author_pubkey.to_string()]),
2058 ];
2059
2060 let event = EventBuilder::new(Kind::Custom(KIND_STATUS_APPLIED), "")
2061 .tags(tags)
2062 .sign_with_keys(keys)
2063 .map_err(|e| anyhow::anyhow!("Failed to sign status event: {}", e))?;
2064
2065 let publish_result = match client.send_event(&event).await {
2066 Ok(output) => {
2067 if output.success.is_empty() {
2068 Err(anyhow::anyhow!(
2069 "PR merged status was not confirmed by any relay"
2070 ))
2071 } else {
2072 info!(
2073 "Published PR merged status to {} relays",
2074 output.success.len()
2075 );
2076 Ok(())
2077 }
2078 }
2079 Err(e) => Err(anyhow::anyhow!("Failed to publish PR merged status: {}", e)),
2080 };
2081
2082 let _ = client.disconnect().await;
2083 tokio::time::sleep(Duration::from_millis(50)).await;
2084 publish_result
2085 }
2086
2087 #[allow(dead_code)]
2089 pub async fn upload_blob(&self, _hash: &str, data: &[u8]) -> Result<String> {
2090 let hash = self
2091 .blossom
2092 .upload(data)
2093 .await
2094 .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))?;
2095 Ok(hash)
2096 }
2097
2098 #[allow(dead_code)]
2100 pub async fn upload_blob_if_missing(&self, data: &[u8]) -> Result<(String, bool)> {
2101 self.blossom
2102 .upload_if_missing(data)
2103 .await
2104 .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))
2105 }
2106
2107 #[allow(dead_code)]
2109 pub async fn download_blob(&self, hash: &str) -> Result<Vec<u8>> {
2110 self.blossom
2111 .download(hash)
2112 .await
2113 .map_err(|e| anyhow::anyhow!("Blossom download failed: {}", e))
2114 }
2115
2116 #[allow(dead_code)]
2118 pub async fn try_download_blob(&self, hash: &str) -> Option<Vec<u8>> {
2119 self.blossom.try_download(hash).await
2120 }
2121}
2122
2123#[cfg(test)]
2124mod tests;