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