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