1700 lines
58 KiB
Rust
1700 lines
58 KiB
Rust
use std::collections::hash_map::DefaultHasher;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::future::Future;
|
|
use std::hash::{Hash, Hasher};
|
|
use std::path::{Path, PathBuf};
|
|
use std::str::FromStr;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{Context as AnyhowContext, Error, anyhow, bail};
|
|
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
|
use nostr::event::IntoEventBuilder;
|
|
use nostr_connect::prelude::*;
|
|
use nostr_sdk::client::SyncSummary;
|
|
use nostr_sdk::prelude::*;
|
|
use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name, repo_addr};
|
|
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
|
|
|
use crate::git_store::GitStore;
|
|
|
|
/// Keyring entry for the user credential.
|
|
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
|
/// Timeout for NIP-46 signer responses.
|
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
|
|
|
/// Relays connected at startup, before any user-specific relay config is known.
|
|
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
|
"wss://relay.primal.net",
|
|
"wss://relay.ditto.pub",
|
|
"wss://index.ngit.dev",
|
|
"wss://profiles.nostr1.com",
|
|
];
|
|
|
|
/// Relays used to index the user's NIP-65 relay list.
|
|
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
|
|
|
/// How long an identical fetch or sync request is suppressed after it started.
|
|
const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum BackendEvent {
|
|
/// User has no signer configured.
|
|
SignerRequired,
|
|
/// The stored identity is NIP-49 encrypted key.
|
|
PassphraseRequired,
|
|
/// The signer changed on login, logout or account switch.
|
|
SignerChanged,
|
|
/// A new event was received from a relay and stored in the database.
|
|
NostrUpdate(Update),
|
|
/// A negentropy sync completed.
|
|
Synced,
|
|
/// A negentropy sync is in flight.
|
|
SyncProgress {
|
|
/// Total events to process.
|
|
total: u64,
|
|
/// Events processed so far.
|
|
current: u64,
|
|
},
|
|
/// An event built locally was signed, broadcast and stored.
|
|
Published(Box<Event>),
|
|
/// An error occurred.
|
|
Error(String),
|
|
}
|
|
|
|
impl BackendEvent {
|
|
pub fn error<T>(error: T) -> Self
|
|
where
|
|
T: Into<String>,
|
|
{
|
|
Self::Error(error.into())
|
|
}
|
|
}
|
|
|
|
/// The global backend entity.
|
|
///
|
|
/// Owns the nostr client, the signer and the notification pump.
|
|
pub struct Backend {
|
|
client: Client,
|
|
signer: UniversalSigner,
|
|
current_user: Option<PublicKey>,
|
|
sync_progress: Option<(u64, u64)>,
|
|
/// True when the stored credential is NIP-49 encrypted.
|
|
passphrase_required: bool,
|
|
/// Fingerprints of recently started fetches and syncs, a relay plus filter set.
|
|
recent_fetches: HashMap<u64, Instant>,
|
|
/// Repositories with a push in flight, mirror or checkout based.
|
|
pushing_repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
|
tasks: Vec<Task<Result<(), Error>>>,
|
|
}
|
|
|
|
struct GlobalBackend(Entity<Backend>);
|
|
|
|
impl Global for GlobalBackend {}
|
|
|
|
/// Removes its repository from the in-flight push set when dropped.
|
|
///
|
|
/// A push task cancelled by its panel closing cannot leave the repository locked.
|
|
struct PushGuard {
|
|
repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
|
addr: RepoAddr,
|
|
}
|
|
|
|
impl Drop for PushGuard {
|
|
fn drop(&mut self) {
|
|
if let Ok(mut repos) = self.repos.lock() {
|
|
repos.remove(&self.addr);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<BackendEvent> for Backend {}
|
|
|
|
impl Backend {
|
|
/// Retrieve the global backend.
|
|
pub fn global(cx: &App) -> Entity<Self> {
|
|
cx.global::<GlobalBackend>().0.clone()
|
|
}
|
|
|
|
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
|
cx.set_global(GlobalBackend(entity));
|
|
}
|
|
|
|
pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context<Self>) -> Self {
|
|
let pump_client = client.clone();
|
|
|
|
let pump = cx.spawn(async move |this, cx| {
|
|
let mut notifications = pump_client.notifications();
|
|
|
|
while let Some(notification) = notifications.next().await {
|
|
let ClientNotification::Event { event, .. } = notification else {
|
|
continue;
|
|
};
|
|
|
|
let update = Update::from_event(&event);
|
|
|
|
if this
|
|
.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(update)))
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
});
|
|
|
|
let mut this = Self {
|
|
client,
|
|
signer,
|
|
current_user: None,
|
|
sync_progress: None,
|
|
passphrase_required: false,
|
|
recent_fetches: HashMap::new(),
|
|
pushing_repos: Arc::new(Mutex::new(HashSet::new())),
|
|
tasks: vec![pump],
|
|
};
|
|
|
|
this.bootstrap(cx);
|
|
this
|
|
}
|
|
|
|
/// Track a spawned task, pruning finished tasks first.
|
|
///
|
|
/// Keeps the store's task list bounded by the number of in-flight tasks.
|
|
fn push_task(&mut self, task: Task<Result<(), Error>>) {
|
|
self.tasks.retain(|task| !task.is_ready());
|
|
self.tasks.push(task);
|
|
}
|
|
|
|
/// Bootstrap the client.
|
|
///
|
|
/// Restore the saved session, if any.
|
|
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
|
let client = self.client.clone();
|
|
|
|
let task = cx.background_spawn(async move {
|
|
for url in BOOTSTRAP_RELAYS {
|
|
client.add_relay(url).await?;
|
|
}
|
|
for url in INDEXER_RELAYS {
|
|
client
|
|
.add_relay(url)
|
|
.capabilities(RelayCapabilities::DISCOVERY)
|
|
.await?;
|
|
}
|
|
client.connect().await;
|
|
Ok::<(), Error>(())
|
|
});
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
match task.await {
|
|
Ok(()) => {
|
|
this.update(cx, |_this, cx| cx.notify())?;
|
|
}
|
|
Err(e) => {
|
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}));
|
|
|
|
self.restore_session(cx);
|
|
}
|
|
|
|
/// Restore the saved session from the keyring.
|
|
///
|
|
/// Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
|
///
|
|
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
|
|
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
|
if cfg!(target_arch = "wasm32") {
|
|
cx.emit(BackendEvent::SignerRequired);
|
|
return;
|
|
}
|
|
|
|
let user = cx.read_credentials(USER_KEYRING);
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
let content = match user.await {
|
|
Ok(Some((_username, secret))) => String::from_utf8(secret)?,
|
|
_ => {
|
|
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let result = async {
|
|
if content.starts_with("nsec1") {
|
|
let keys = Keys::new(SecretKey::parse(&content)?);
|
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
|
} else if content.starts_with("bunker://") {
|
|
let (base, keys) = extract_master_key(&content);
|
|
let uri = NostrConnectUri::parse(base)?;
|
|
let mut signer = NostrConnect::new(
|
|
uri,
|
|
keys,
|
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
|
None,
|
|
)?;
|
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
|
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
|
} else if content.starts_with("ncryptsec1") {
|
|
// Encrypted identity.
|
|
// A passphrase is required to decrypt it before the session can resume.
|
|
log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase");
|
|
this.update(cx, |this, cx| {
|
|
this.passphrase_required = true;
|
|
cx.emit(BackendEvent::PassphraseRequired);
|
|
})?;
|
|
} else {
|
|
this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?;
|
|
}
|
|
|
|
Ok::<_, Error>(())
|
|
}
|
|
.await;
|
|
|
|
if let Err(e) = result {
|
|
this.update(cx, |_, cx| {
|
|
cx.emit(BackendEvent::error(e.to_string()));
|
|
cx.emit(BackendEvent::SignerRequired);
|
|
})?;
|
|
}
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Decrypt the NIP-49 keyring credential with the given passphrase.
|
|
pub fn restore_with_passphrase(
|
|
&mut self,
|
|
password: &str,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<PublicKey, Error>> {
|
|
let password = password.to_owned();
|
|
let user = cx.read_credentials(USER_KEYRING);
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
let content = user
|
|
.await?
|
|
.map(|(_username, secret)| String::from_utf8(secret))
|
|
.transpose()?
|
|
.ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?;
|
|
|
|
if !content.starts_with("ncryptsec1") {
|
|
return Err(anyhow!("stored credential is not passphrase-encrypted"));
|
|
}
|
|
|
|
let decrypt_task = cx.background_spawn(async move {
|
|
let encrypted = EncryptedSecretKey::from_bech32(&content)?;
|
|
let secret = encrypted.decrypt(&password)?;
|
|
Ok::<_, Error>(Keys::new(secret))
|
|
});
|
|
|
|
let keys = decrypt_task.await?;
|
|
let public_key = keys.public_key();
|
|
|
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
|
|
|
Ok(public_key)
|
|
})
|
|
}
|
|
|
|
/// Create a new identity.
|
|
pub fn create_identity(
|
|
&mut self,
|
|
name: &str,
|
|
password: &str,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<PublicKey, Error>> {
|
|
let name = name.trim().to_owned();
|
|
let password = password.to_owned();
|
|
|
|
if name.is_empty() || name.len() > 255 {
|
|
return Task::ready(Err(anyhow!("Name must be 1-255 characters")));
|
|
}
|
|
if password.is_empty() {
|
|
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
|
|
}
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
let job = cx.background_spawn(async move {
|
|
let keys = Keys::generate();
|
|
let encrypted =
|
|
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?;
|
|
let ncryptsec = encrypted.to_bech32()?;
|
|
Ok::<_, Error>((keys, ncryptsec))
|
|
});
|
|
|
|
let (keys, ncryptsec) = job.await?;
|
|
let public_key = keys.public_key();
|
|
|
|
// Persist the encrypted credential.
|
|
let write = cx.update(|cx| {
|
|
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
|
|
});
|
|
write.await?;
|
|
|
|
this.update(cx, |this, cx| {
|
|
// Become the new identity so later publishes are signed with the new keys.
|
|
this.signer.swap_inner(keys);
|
|
this.current_user = Some(public_key);
|
|
this.bootstrap_user(public_key, cx);
|
|
cx.emit(BackendEvent::SignerChanged);
|
|
cx.notify();
|
|
|
|
let relays: Vec<(RelayUrl, Option<RelayMetadata>)> = [
|
|
(
|
|
RelayUrl::parse("wss://relay.primal.net").unwrap(),
|
|
Some(RelayMetadata::Read),
|
|
),
|
|
(
|
|
RelayUrl::parse("wss://relay.ditto.pub").unwrap(),
|
|
Some(RelayMetadata::Read),
|
|
),
|
|
(
|
|
RelayUrl::parse("wss://relay.nostr.net").unwrap(),
|
|
Some(RelayMetadata::Write),
|
|
),
|
|
(
|
|
RelayUrl::parse("wss://nos.lol").unwrap(),
|
|
Some(RelayMetadata::Write),
|
|
),
|
|
]
|
|
.to_vec();
|
|
|
|
this.send_fire_and_forget(RelayList::new(relays).into_event_builder(), cx);
|
|
|
|
let metadata = Metadata::new()
|
|
.name(&name)
|
|
.display_name(&name)
|
|
.into_event_builder();
|
|
|
|
this.send_fire_and_forget(metadata, cx);
|
|
|
|
let grasp_servers: Vec<RelayUrl> = ["wss://gitnostr.com", "wss://relay.ngit.dev"]
|
|
.into_iter()
|
|
.map(|url| RelayUrl::parse(url).expect("valid relay URL"))
|
|
.collect();
|
|
|
|
this.send_fire_and_forget(
|
|
GitUserGraspList { grasp_servers }.into_event_builder(),
|
|
cx,
|
|
);
|
|
})?;
|
|
|
|
Ok(public_key)
|
|
})
|
|
}
|
|
|
|
/// Initialize a local clone with a `main` branch and a `README.md`.
|
|
///
|
|
/// The task yields the announcement and the path of the working copy.
|
|
pub fn create_repository(
|
|
&mut self,
|
|
name: &str,
|
|
description: &str,
|
|
folder: PathBuf,
|
|
grasp_servers: Vec<RelayUrl>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<(Announcement, PathBuf), Error>> {
|
|
let name = name.trim().to_owned();
|
|
let description = description.trim().to_owned();
|
|
|
|
if name.is_empty() {
|
|
return Task::ready(Err(anyhow!("Repository name is required")));
|
|
}
|
|
|
|
if grasp_servers.is_empty() {
|
|
return Task::ready(Err(anyhow!("Add at least one grasp server")));
|
|
}
|
|
|
|
if !folder.is_dir() {
|
|
return Task::ready(Err(anyhow!("Choose a folder for the repository")));
|
|
}
|
|
|
|
let Some(public_key) = self.current_user else {
|
|
return Task::ready(Err(anyhow!("Sign in to create a repository")));
|
|
};
|
|
|
|
// The repository identifier is derived from the name.
|
|
let repo_id = identifier_from_name(&name);
|
|
|
|
if repo_id.is_empty() || repo_id.len() > 100 {
|
|
return Task::ready(Err(anyhow!(
|
|
"Repository name must produce an identifier of 1-100 characters"
|
|
)));
|
|
}
|
|
|
|
if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) {
|
|
return Task::ready(Err(anyhow!(
|
|
"Repository name must contain at least one alphanumeric character"
|
|
)));
|
|
}
|
|
|
|
let addr = repo_addr(public_key, repo_id.clone());
|
|
let cache = GitStore::global(cx).cache().clone();
|
|
let path = cache.repo_path(&addr);
|
|
let owner = public_key.to_bech32().unwrap();
|
|
let servers = grasp_servers.clone();
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
// Initialize the local clone and create the user's working copy from it.
|
|
let work = cx.background_spawn({
|
|
let path = path.clone();
|
|
let folder = folder.clone();
|
|
let name = name.clone();
|
|
let description = description.clone();
|
|
let owner = owner.clone();
|
|
let repo_id = repo_id.clone();
|
|
let servers = servers.clone();
|
|
|
|
async move {
|
|
let parent = path
|
|
.parent()
|
|
.ok_or_else(|| anyhow!("invalid repository path"))?;
|
|
std::fs::create_dir_all(parent)?;
|
|
let commit = signed_git::init_repository(&path, &name, &description)?;
|
|
|
|
// Point `origin` at the first grasp server.
|
|
// Later fetches and pushes have a target, like ngit.
|
|
if let Some(base) = servers.first().and_then(grasp_base_url) {
|
|
let url = format!("{base}/{owner}/{repo_id}.git");
|
|
signed_git::ensure_origin(&path, &url).ok();
|
|
}
|
|
|
|
// A working copy at `<folder>/<name>`, like the header's Clone action.
|
|
// Cloned from the mirror above so it shares the announced history.
|
|
// `origin` is set to the first grasp server, not the mirror path.
|
|
let destination = {
|
|
let dir_name = signed_git::sanitize_path_component(&name);
|
|
let dir_name = if dir_name.is_empty() {
|
|
"repository".to_owned()
|
|
} else {
|
|
dir_name
|
|
};
|
|
folder.join(dir_name)
|
|
};
|
|
|
|
let mirror_url = Url::from_file_path(&path)
|
|
.map_err(|_| anyhow!("invalid mirror path"))?
|
|
.to_string();
|
|
|
|
signed_git::clone_repo(&[mirror_url], &destination).with_context(|| {
|
|
format!(
|
|
"failed to create the working copy at {}",
|
|
destination.display()
|
|
)
|
|
})?;
|
|
|
|
if let Some(base) = servers.first().and_then(grasp_base_url) {
|
|
let url = format!("{base}/{owner}/{repo_id}.git");
|
|
signed_git::set_origin(&destination, &url)?;
|
|
}
|
|
|
|
Ok::<_, Error>((commit, destination))
|
|
}
|
|
});
|
|
|
|
let (commit, checkout_path) = work.await?;
|
|
let commit_sha = Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid id"))?;
|
|
|
|
// The nostr client queues events until each relay is connected.
|
|
this.update(cx, |this, cx| {
|
|
let urls: Vec<String> = servers.iter().map(ToString::to_string).collect();
|
|
this.add_relays(urls, cx);
|
|
})?;
|
|
|
|
// The state event is the push authorization. It must be accepted before the push below.
|
|
let announcement = GitRepositoryAnnouncement {
|
|
id: repo_id.clone(),
|
|
name: Some(name.clone()),
|
|
description: (!description.is_empty()).then_some(description.clone()),
|
|
web: Vec::new(),
|
|
clone: servers
|
|
.iter()
|
|
.filter_map(|relay| grasp_clone_url(relay, &owner, &repo_id))
|
|
.collect(),
|
|
relays: servers.clone(),
|
|
euc: Some(commit_sha),
|
|
maintainers: Vec::new(),
|
|
};
|
|
|
|
let event = this
|
|
.update(cx, |this, cx| {
|
|
this.send(announcement.into_event_builder(), cx)
|
|
})?
|
|
.await?;
|
|
|
|
let state_event = match this
|
|
.update(cx, |this, cx| {
|
|
let builder = build_state(
|
|
&repo_id,
|
|
&[("refs/heads/main".to_owned(), commit)],
|
|
Some("main"),
|
|
);
|
|
this.send(builder, cx)
|
|
})?
|
|
.await
|
|
{
|
|
Ok(state_event) => state_event,
|
|
Err(e) => {
|
|
this.update(cx, |this, cx| {
|
|
this.retract_events(std::slice::from_ref(&event), cx);
|
|
})
|
|
.ok();
|
|
|
|
return Err(e.context(
|
|
"The repository was announced, but its state could not be published. \
|
|
The announcement has been retracted",
|
|
));
|
|
}
|
|
};
|
|
|
|
// Push to every grasp server. Creation fails only when no server accepted it.
|
|
let push = cx.background_spawn({
|
|
let path = path.clone();
|
|
let owner = owner.clone();
|
|
let repo_id = repo_id.clone();
|
|
let servers = servers.clone();
|
|
push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main)
|
|
});
|
|
|
|
if let Err(e) = push.await {
|
|
// The events are already published. Retract them so the repository is not left announced without content.
|
|
this.update(cx, |this, cx| {
|
|
this.retract_events(&[event.clone(), state_event.clone()], cx);
|
|
})
|
|
.ok();
|
|
|
|
return Err(e.context(
|
|
"The repository was announced, but the push to every grasp server failed. \
|
|
The announcement has been retracted",
|
|
));
|
|
}
|
|
|
|
let announcement = Announcement::from_event(&event)
|
|
.ok_or_else(|| anyhow!("failed to parse announcement"))?;
|
|
|
|
Ok((announcement, checkout_path))
|
|
})
|
|
}
|
|
|
|
/// Publish an existing local repository to NIP-34.
|
|
pub fn publish_local_repo(
|
|
&mut self,
|
|
path: PathBuf,
|
|
name: &str,
|
|
description: &str,
|
|
grasp_servers: Vec<RelayUrl>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<Announcement, Error>> {
|
|
let name = name.trim().to_owned();
|
|
let description = description.trim().to_owned();
|
|
|
|
if name.is_empty() {
|
|
return Task::ready(Err(anyhow!("Repository name is required")));
|
|
}
|
|
|
|
if grasp_servers.is_empty() {
|
|
return Task::ready(Err(anyhow!("Add at least one grasp server")));
|
|
}
|
|
|
|
let Some(public_key) = self.current_user else {
|
|
return Task::ready(Err(anyhow!("Sign in to publish a repository")));
|
|
};
|
|
|
|
// The identifier derives from the name, as in [`Self::create_repository`].
|
|
let repo_id = identifier_from_name(&name);
|
|
|
|
if repo_id.is_empty() || repo_id.len() > 100 {
|
|
return Task::ready(Err(anyhow!(
|
|
"Repository name must produce an identifier of 1-100 characters"
|
|
)));
|
|
}
|
|
|
|
if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) {
|
|
return Task::ready(Err(anyhow!(
|
|
"Repository name must contain at least one alphanumeric character"
|
|
)));
|
|
}
|
|
|
|
let owner = public_key.to_bech32().unwrap();
|
|
let servers = grasp_servers.clone();
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
let work = cx.background_spawn({
|
|
let path = path.clone();
|
|
async move {
|
|
let state = signed_git::worktree_ref_state(&path)?;
|
|
let euc = signed_git::root_commit(&path)?;
|
|
Ok::<_, Error>((state, euc))
|
|
}
|
|
});
|
|
let (state, euc) = work.await?;
|
|
|
|
// The nostr client queues events until each relay is connected.
|
|
this.update(cx, |this, cx| {
|
|
let urls: Vec<String> = servers.iter().map(ToString::to_string).collect();
|
|
this.add_relays(urls, cx);
|
|
})?;
|
|
|
|
// The state event is the push authorization. It must be accepted before the push below.
|
|
let announcement = GitRepositoryAnnouncement {
|
|
id: repo_id.clone(),
|
|
name: Some(name.clone()),
|
|
description: (!description.is_empty()).then_some(description.clone()),
|
|
web: Vec::new(),
|
|
clone: servers
|
|
.iter()
|
|
.filter_map(|relay| grasp_clone_url(relay, &owner, &repo_id))
|
|
.collect(),
|
|
relays: servers.clone(),
|
|
euc: euc.and_then(|commit| Sha1Hash::from_str(&commit).ok()),
|
|
maintainers: Vec::new(),
|
|
};
|
|
|
|
let event = this
|
|
.update(cx, |this, cx| {
|
|
this.send(announcement.into_event_builder(), cx)
|
|
})?
|
|
.await?;
|
|
|
|
let refs = state.refs.clone();
|
|
let head = state.head.clone();
|
|
let state_event = match this
|
|
.update(cx, |this, cx| {
|
|
let builder = build_state(&repo_id, &refs, head.as_deref());
|
|
this.send(builder, cx)
|
|
})?
|
|
.await
|
|
{
|
|
Ok(state_event) => state_event,
|
|
Err(e) => {
|
|
this.update(cx, |this, cx| {
|
|
this.retract_events(std::slice::from_ref(&event), cx);
|
|
})
|
|
.ok();
|
|
|
|
return Err(e.context(
|
|
"The repository was announced, but its state could not be published. \
|
|
The announcement has been retracted",
|
|
));
|
|
}
|
|
};
|
|
|
|
// Push every branch and tag to each grasp server. The push fails only when no server accepted it.
|
|
//
|
|
// An empty repository has nothing to push.
|
|
if !refs.is_empty() {
|
|
let push = cx.background_spawn({
|
|
let path = path.clone();
|
|
let owner = owner.clone();
|
|
let repo_id = repo_id.clone();
|
|
let servers = servers.clone();
|
|
push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_all)
|
|
});
|
|
if let Err(e) = push.await {
|
|
this.update(cx, |this, cx| {
|
|
this.retract_events(&[event.clone(), state_event.clone()], cx);
|
|
})
|
|
.ok();
|
|
|
|
return Err(e.context(
|
|
"The repository was announced, but the push to every grasp server failed. \
|
|
The announcement has been retracted",
|
|
));
|
|
}
|
|
}
|
|
|
|
// Point `origin` at the first grasp server so later pushes have a target.
|
|
if let Some(base) = servers.first().and_then(grasp_base_url) {
|
|
let url = format!("{base}/{owner}/{repo_id}.git");
|
|
let path = path.clone();
|
|
cx.background_spawn(async move {
|
|
signed_git::ensure_origin(&path, &url).ok();
|
|
})
|
|
.await;
|
|
}
|
|
|
|
Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement"))
|
|
})
|
|
}
|
|
|
|
/// Re-push the repository's current refs to the grasp servers in its `relays` tag.
|
|
pub fn push_repository(
|
|
&mut self,
|
|
announcement: Announcement,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<(), Error>> {
|
|
let cache = GitStore::global(cx).cache().clone();
|
|
let path = cache.repo_path(&announcement.addr());
|
|
self.push_repo_from(announcement, path, None, cx)
|
|
}
|
|
|
|
/// Push the refs of a local checkout to the grasp servers in its `relays` tag.
|
|
/// The checkout is the working copy of the user's own repository.
|
|
///
|
|
/// Publish a fresh state event, then push every branch and tag of the checkout.
|
|
pub fn push_checkout(
|
|
&mut self,
|
|
announcement: Announcement,
|
|
checkout: PathBuf,
|
|
announced_head: Option<String>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<(), Error>> {
|
|
self.push_repo_from(announcement, checkout, announced_head, cx)
|
|
}
|
|
|
|
/// Shared body of the mirror-based and checkout-based pushes.
|
|
fn push_repo_from(
|
|
&mut self,
|
|
announcement: Announcement,
|
|
path: PathBuf,
|
|
announced_head: Option<String>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<(), Error>> {
|
|
let addr = announcement.addr();
|
|
let guard = {
|
|
let mut pushing = self
|
|
.pushing_repos
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
if !pushing.insert(addr.clone()) {
|
|
return Task::ready(Err(anyhow!(
|
|
"A push to this repository is already in progress"
|
|
)));
|
|
}
|
|
PushGuard {
|
|
repos: self.pushing_repos.clone(),
|
|
addr: addr.clone(),
|
|
}
|
|
};
|
|
|
|
let owner = announcement.owner.to_bech32().unwrap();
|
|
let repo_id = announcement.id.clone();
|
|
let relays = announcement.relays.clone();
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
// Held for the whole task. Dropped on completion, on error and on cancellation alike.
|
|
let _guard = guard;
|
|
|
|
let mut state = {
|
|
let work = cx.background_spawn({
|
|
let path = path.clone();
|
|
async move { signed_git::worktree_ref_state(&path) }
|
|
});
|
|
work.await?
|
|
};
|
|
|
|
// The state event announces the pushed refs.
|
|
// Keep the announced default branch in `HEAD` when it is among the pushed refs.
|
|
//
|
|
// Otherwise `HEAD` stays the checkout's current branch.
|
|
let heads: Vec<&str> = state
|
|
.refs
|
|
.iter()
|
|
.filter_map(|(name, _)| name.strip_prefix("refs/heads/"))
|
|
.collect();
|
|
|
|
if let Some(head) = announced_head
|
|
&& heads.iter().any(|branch| *branch == head)
|
|
{
|
|
state.head = Some(head);
|
|
}
|
|
|
|
// Grasp servers authorize a push by the state they have seen.
|
|
let refs = state.refs.clone();
|
|
let head = state.head.clone();
|
|
|
|
this.update(cx, |this, cx| {
|
|
let builder = build_state(&repo_id, &refs, head.as_deref());
|
|
this.send(builder, cx)
|
|
})?
|
|
.await?;
|
|
|
|
if !refs.is_empty() {
|
|
let push = cx.background_spawn({
|
|
let path = path.clone();
|
|
let owner = owner.clone();
|
|
let repo_id = repo_id.clone();
|
|
let relays = relays.clone();
|
|
async move {
|
|
push_to_grasp_servers(path, owner, repo_id, relays, signed_git::push_all)
|
|
.await
|
|
}
|
|
});
|
|
push.await?;
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Delete the repository from nostr.
|
|
///
|
|
/// Only the repository owner may delete it.
|
|
pub fn delete_repository(
|
|
&mut self,
|
|
addr: RepoAddr,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<(), Error>> {
|
|
let Some(public_key) = self.current_user else {
|
|
return Task::ready(Err(anyhow!("Sign in to delete a repository")));
|
|
};
|
|
if public_key != addr.public_key {
|
|
return Task::ready(Err(anyhow!("Only the repository owner can delete it")));
|
|
}
|
|
|
|
let client = self.client.clone();
|
|
let addr = addr.clone();
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
// Collect every event of the repository from the local database.
|
|
let events = cx.background_spawn(async move {
|
|
let db = client.database();
|
|
let mut events = Vec::new();
|
|
for filter in [
|
|
filters::announcement(&addr),
|
|
filters::state(&addr),
|
|
filters::activity(&addr),
|
|
] {
|
|
events.extend(db.query(filter).await?);
|
|
}
|
|
Ok::<_, Error>(events)
|
|
});
|
|
let events = events.await?;
|
|
|
|
this.update(cx, |this, cx| {
|
|
this.retract_events(&events, cx);
|
|
})
|
|
.ok();
|
|
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Login with an `nsec1...` key or a `bunker://...` URI.
|
|
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
|
|
let credential = credential.trim();
|
|
|
|
if credential.starts_with("nsec1") {
|
|
self.login_with_nsec(credential, cx);
|
|
} else if credential.starts_with("bunker://") {
|
|
self.login_with_bunker(credential, cx);
|
|
} else {
|
|
cx.emit(BackendEvent::error(
|
|
"Unsupported credential, expected nsec1... or bunker://...",
|
|
));
|
|
}
|
|
}
|
|
|
|
/// Create a fresh identity and login with it.
|
|
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
|
|
let nsec = Keys::generate()
|
|
.secret_key()
|
|
.to_bech32()
|
|
.expect("infallible");
|
|
self.login_with_nsec(&nsec, cx);
|
|
}
|
|
|
|
/// Login with an `nsec1...` secret key.
|
|
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
|
|
let keys = match SecretKey::parse(nsec) {
|
|
Ok(secret) => Keys::new(secret),
|
|
Err(e) => {
|
|
cx.emit(BackendEvent::error(e.to_string()));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let nsec = nsec.trim().to_owned();
|
|
let pubkey = keys.public_key().to_hex();
|
|
let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes());
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
if let Err(e) = write.await {
|
|
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
return Ok(());
|
|
}
|
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Login with a `bunker://...` URI, NIP-46.
|
|
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
|
|
let uri_string = uri.trim().to_owned();
|
|
|
|
let connect_uri = match NostrConnectUri::parse(&uri_string) {
|
|
Ok(uri) => uri,
|
|
Err(e) => {
|
|
cx.emit(BackendEvent::error(e.to_string()));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let keys = Keys::generate();
|
|
let credential = with_master_key(&uri_string, &keys);
|
|
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
let result = async {
|
|
let mut signer = NostrConnect::new(
|
|
connect_uri,
|
|
keys,
|
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
|
None,
|
|
)?;
|
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
|
|
|
// Verify the signer before persisting the credential.
|
|
signer.get_public_key_async().await?;
|
|
write.await?;
|
|
|
|
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
|
|
|
Ok::<_, Error>(())
|
|
}
|
|
.await;
|
|
|
|
if let Err(e) = result {
|
|
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Remove the saved credential and reset to an anonymous session.
|
|
pub fn logout(&mut self, cx: &mut Context<Self>) {
|
|
let delete = cx.delete_credentials(USER_KEYRING);
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
delete.await.ok();
|
|
|
|
this.update(cx, |this, cx| {
|
|
this.signer.swap_inner(Keys::generate());
|
|
this.current_user = None;
|
|
this.passphrase_required = false;
|
|
cx.emit(BackendEvent::SignerChanged);
|
|
cx.emit(BackendEvent::SignerRequired);
|
|
cx.notify();
|
|
})?;
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Fetch the user's grasp list and add the listed grasp servers as relays.
|
|
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
|
let client = self.client.clone();
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
let result = async {
|
|
let events: Vec<Event> = client
|
|
.fetch_events(filters::grasp_list(public_key))
|
|
.await?
|
|
.into_iter()
|
|
.collect();
|
|
|
|
for url in latest_grasp_list_servers(events) {
|
|
client.add_relay(url.as_str()).await.ok();
|
|
}
|
|
client.connect().await;
|
|
|
|
Ok::<_, Error>(())
|
|
}
|
|
.await;
|
|
|
|
if let Err(e) = result {
|
|
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Get the nostr client.
|
|
pub fn client(&self) -> Client {
|
|
self.client.clone()
|
|
}
|
|
|
|
/// Get the current signer.
|
|
pub fn signer(&self) -> UniversalSigner {
|
|
self.signer.clone()
|
|
}
|
|
|
|
/// Get the current user's public key.
|
|
pub fn current_user(&self) -> Option<PublicKey> {
|
|
self.current_user
|
|
}
|
|
|
|
/// True when the stored credential is NIP-49 encrypted.
|
|
pub fn passphrase_required(&self) -> bool {
|
|
self.passphrase_required
|
|
}
|
|
|
|
/// Surface an error message through [`BackendEvent::Error`].
|
|
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
|
|
cx.emit(BackendEvent::error(message));
|
|
}
|
|
|
|
/// Progress of the in-flight negentropy sync, if any.
|
|
pub fn sync_progress(&self) -> Option<(u64, u64)> {
|
|
self.sync_progress
|
|
}
|
|
|
|
/// Update the signer.
|
|
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
|
where
|
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
|
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
|
|
<T as AsyncSignEvent>::Error: std::error::Error + Send + Sync + 'static,
|
|
<T as AsyncNip44>::Error: std::error::Error + Send + Sync + 'static,
|
|
{
|
|
let task = cx.spawn(async move |this, cx| {
|
|
match new_signer.get_public_key_async().await {
|
|
Ok(public_key) => {
|
|
this.update(cx, |this, cx| {
|
|
this.signer.swap_inner(new_signer);
|
|
this.current_user = Some(public_key);
|
|
this.passphrase_required = false;
|
|
this.bootstrap_user(public_key, cx);
|
|
cx.emit(BackendEvent::SignerChanged);
|
|
cx.notify();
|
|
})?;
|
|
}
|
|
Err(e) => {
|
|
this.update(cx, |_this, cx| {
|
|
cx.emit(BackendEvent::error(e.to_string()));
|
|
})?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
});
|
|
self.push_task(task);
|
|
}
|
|
|
|
/// Add relays and connect to them.
|
|
pub fn add_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
|
|
let client = self.client.clone();
|
|
|
|
let task = cx.background_spawn(async move {
|
|
for url in urls {
|
|
client.add_relay(&url).await?;
|
|
}
|
|
client.connect().await;
|
|
Ok::<(), Error>(())
|
|
});
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
match task.await {
|
|
Ok(()) => {
|
|
this.update(cx, |_this, cx| cx.notify())?;
|
|
}
|
|
Err(e) => {
|
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent.
|
|
///
|
|
/// Records the fingerprint when returning `false`, pruning expired entries first.
|
|
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
|
|
self.recent_fetches
|
|
.retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW);
|
|
if self.recent_fetches.contains_key(&fingerprint) {
|
|
return true;
|
|
}
|
|
self.recent_fetches.insert(fingerprint, Instant::now());
|
|
false
|
|
}
|
|
|
|
/// Connect to a repository's announced relays, its NIP-34 `relays` tag.
|
|
pub fn connect_repo_relays(
|
|
&mut self,
|
|
relays: Vec<RelayUrl>,
|
|
filters: Vec<Filter>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let relay_strs: Vec<&str> = relays.iter().map(|url| url.as_str()).collect();
|
|
let fingerprint = fetch_fingerprint(&relay_strs, &filters);
|
|
if self.fetch_recently_started(fingerprint) {
|
|
log::debug!("skipping duplicate repo relay fetch");
|
|
return;
|
|
}
|
|
|
|
let client = self.client.clone();
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
if let Err(e) = connect_repo_relays_only(&client, relays, filters).await {
|
|
log::warn!("repo relay fetch failed: {e}");
|
|
// Allow an immediate retry after a failure.
|
|
this.update(cx, |this, _cx| {
|
|
this.recent_fetches.remove(&fingerprint);
|
|
})
|
|
.ok();
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// One-shot subscription on the bootstrap relays only.
|
|
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
|
let client = self.client.clone();
|
|
|
|
let task =
|
|
cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await });
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
if let Err(e) = task.await {
|
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Negentropy-sync the given filter against the bootstrap relays.
|
|
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
|
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
|
|
if self.fetch_recently_started(fingerprint) {
|
|
log::debug!("skipping duplicate bootstrap sync");
|
|
return;
|
|
}
|
|
|
|
let client = self.client.clone();
|
|
|
|
self.sync_progress = Some((0, 0));
|
|
cx.notify();
|
|
|
|
let (tx, mut rx) = SyncProgress::channel();
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
let mut last_percent: u64 = 0;
|
|
|
|
while rx.changed().await.is_ok() {
|
|
let progress = *rx.borrow_and_update();
|
|
let percent = (progress.percentage() * 100.0) as u64;
|
|
|
|
if progress.current > 0 && percent != last_percent {
|
|
last_percent = percent;
|
|
|
|
let alive = this.update(cx, |this, cx| {
|
|
this.sync_progress = Some((progress.total, progress.current));
|
|
cx.emit(BackendEvent::SyncProgress {
|
|
total: progress.total,
|
|
current: progress.current,
|
|
});
|
|
cx.notify();
|
|
});
|
|
|
|
if alive.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}));
|
|
|
|
let task = cx.background_spawn(async move {
|
|
let opts = SyncOptions::default().progress(tx);
|
|
sync_bootstrap_only(&client, filter, opts).await
|
|
});
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
match task.await {
|
|
Ok(summary) => {
|
|
log::debug!(
|
|
"sync done: {} received, {} sent",
|
|
summary.received.len(),
|
|
summary.sent.len()
|
|
);
|
|
this.update(cx, |this, cx| {
|
|
this.sync_progress = None;
|
|
cx.emit(BackendEvent::Synced);
|
|
cx.notify();
|
|
})?;
|
|
}
|
|
Err(e) => {
|
|
this.update(cx, |this, cx| {
|
|
this.sync_progress = None;
|
|
// Allow an immediate retry after a failure.
|
|
this.recent_fetches.remove(&fingerprint);
|
|
cx.emit(BackendEvent::error(e.to_string()))
|
|
})?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Sign, broadcast and locally store an event.
|
|
pub fn send(
|
|
&mut self,
|
|
builder: EventBuilder,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<Event, Error>> {
|
|
let client = self.client.clone();
|
|
let signer = self.signer.clone();
|
|
|
|
self.publish_task(cx, async move {
|
|
// Sign with the current signer, broadcast and save locally.
|
|
// The event is immediately visible to database queries.
|
|
let event = builder.finalize_async(&signer).await?;
|
|
broadcast_event(&client, &event).await
|
|
})
|
|
}
|
|
|
|
/// Broadcast and locally store an already-signed event.
|
|
pub fn publish_event(
|
|
&mut self,
|
|
event: Event,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<Event, Error>> {
|
|
let client = self.client.clone();
|
|
self.publish_task(cx, async move { broadcast_event(&client, &event).await })
|
|
}
|
|
|
|
/// Run `work` in the background, then emit its outcome as a [`BackendEvent`].
|
|
fn publish_task(
|
|
&mut self,
|
|
cx: &mut Context<Self>,
|
|
work: impl Future<Output = Result<Event, Error>> + 'static + Send,
|
|
) -> Task<Result<Event, Error>> {
|
|
cx.spawn(async move |this, cx| {
|
|
let result = cx.background_spawn(work).await;
|
|
|
|
match &result {
|
|
Ok(event) => {
|
|
this.update(cx, |_this, cx| {
|
|
cx.emit(BackendEvent::Published(Box::new(event.clone())));
|
|
})
|
|
.ok();
|
|
}
|
|
Err(e) => {
|
|
this.update(cx, |_this, cx| {
|
|
cx.emit(BackendEvent::error(e.to_string()));
|
|
})
|
|
.ok();
|
|
}
|
|
}
|
|
|
|
result
|
|
})
|
|
}
|
|
|
|
/// Sign, broadcast and store an event without awaiting the result.
|
|
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
|
|
let task = self.send(builder, cx);
|
|
|
|
self.push_task(cx.spawn(async move |this, cx| {
|
|
if let Err(e) = task.await {
|
|
this.update(cx, |_this, cx| {
|
|
cx.emit(BackendEvent::error(e.to_string()));
|
|
})
|
|
.ok();
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Publish NIP-09 deletions for `events`, best-effort.
|
|
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
|
|
if events.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let mut tags: Vec<Tag> = Vec::with_capacity(events.len() * 2);
|
|
|
|
for event in events {
|
|
tags.push(Tag::event(event.id));
|
|
tags.push(Tag::parse(["k", &event.kind.to_string()]).expect("valid kind tag"));
|
|
}
|
|
|
|
let task = self.send(EventBuilder::new(Kind::EventDeletion, "").tags(tags), cx);
|
|
|
|
self.push_task(cx.spawn(async move |_this, _cx| {
|
|
if let Err(e) = task.await {
|
|
log::warn!("failed to retract repository events: {e}");
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
}
|
|
|
|
/// Broadcast an event and fail when no relay accepted it.
|
|
///
|
|
/// The client stores accepted events locally, visible to database queries.
|
|
async fn broadcast_event(client: &Client, event: &Event) -> Result<Event, Error> {
|
|
let output = client.send_event(event).await?;
|
|
|
|
if output.success.is_empty() && !output.failed.is_empty() {
|
|
let reasons = output
|
|
.failed
|
|
.values()
|
|
.cloned()
|
|
.collect::<Vec<String>>()
|
|
.join(", ");
|
|
return Err(anyhow!("event not accepted by any relay: {reasons}"));
|
|
}
|
|
|
|
Ok(event.clone())
|
|
}
|
|
|
|
/// Fingerprint of a relay and filter set, for fetch dedup.
|
|
///
|
|
/// Relays and filters are sorted first, so the fingerprint is order-independent.
|
|
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
|
|
let mut relays: Vec<&str> = relays.to_vec();
|
|
relays.sort_unstable();
|
|
let mut filters: Vec<&Filter> = filters.iter().collect();
|
|
filters.sort_unstable();
|
|
|
|
let mut hasher = DefaultHasher::new();
|
|
relays.hash(&mut hasher);
|
|
filters.hash(&mut hasher);
|
|
hasher.finish()
|
|
}
|
|
|
|
/// Add the given relays, connect and fetch the filters.
|
|
async fn connect_repo_relays_only(
|
|
client: &Client,
|
|
relays: Vec<RelayUrl>,
|
|
filters: Vec<Filter>,
|
|
) -> Result<(), Error> {
|
|
if relays.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut added = false;
|
|
for url in &relays {
|
|
added |= client.add_relay(url).await?;
|
|
}
|
|
|
|
// Connect only when the pool grew.
|
|
if added {
|
|
client.connect().await;
|
|
}
|
|
|
|
let opts = SubscribeAutoCloseOptions::default()
|
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
|
.timeout(Some(Duration::from_secs(10)));
|
|
|
|
let target: HashMap<&str, Vec<Filter>> = relays
|
|
.iter()
|
|
.map(|url| (url.as_str(), filters.clone()))
|
|
.collect();
|
|
client.subscribe(target).close_on(opts).await?;
|
|
|
|
// Sync the filters concurrently.
|
|
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
|
|
let syncs = filters.into_iter().map(|filter| {
|
|
let client = &client;
|
|
let relays = &relays;
|
|
let sync_opts = sync_opts.clone();
|
|
async move {
|
|
if let Err(e) = client
|
|
.sync(filter)
|
|
.with(relays.iter())
|
|
.opts(sync_opts)
|
|
.await
|
|
{
|
|
log::warn!("repo relay negentropy sync failed: {e}");
|
|
}
|
|
}
|
|
});
|
|
|
|
futures::future::join_all(syncs).await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Subscribe only on the bootstrap relays.
|
|
pub(crate) async fn subscribe_bootstrap_only(
|
|
client: &Client,
|
|
filters: Vec<Filter>,
|
|
) -> Result<(), Error> {
|
|
let opts = SubscribeAutoCloseOptions::default()
|
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
|
.timeout(Some(Duration::from_secs(10)));
|
|
|
|
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
|
.iter()
|
|
.map(|relay| (*relay, filters.clone()))
|
|
.collect();
|
|
|
|
client.subscribe(target).close_on(opts).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Negentropy-sync the filter against the bootstrap relays only.
|
|
pub(crate) async fn sync_bootstrap_only(
|
|
client: &Client,
|
|
filter: Filter,
|
|
opts: SyncOptions,
|
|
) -> Result<SyncSummary, Error> {
|
|
let output = client
|
|
.sync(filter)
|
|
.with(BOOTSTRAP_RELAYS)
|
|
.opts(opts)
|
|
.await?;
|
|
Ok(output.value)
|
|
}
|
|
|
|
/// Embed a NIP-46 session key into a bunker URI as `?master=<nsec>`.
|
|
fn with_master_key(uri: &str, keys: &Keys) -> String {
|
|
let separator = if uri.contains('?') { '&' } else { '?' };
|
|
let nsec = keys.secret_key().to_bech32().expect("infallible");
|
|
format!("{uri}{separator}master={nsec}")
|
|
}
|
|
|
|
/// Base URL of a grasp server, `https://<host>`.
|
|
///
|
|
/// `ws://` grasp servers use `http://<host>`, like ngit.
|
|
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
|
// `domain()` drops the port.
|
|
let parsed = Url::parse(relay.as_str()).ok()?;
|
|
let host = parsed.host_str()?;
|
|
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
|
// `ws://` grasp servers, e.g. local dev relays, speak plain HTTP.
|
|
let scheme = if relay.scheme().is_secure() {
|
|
"https"
|
|
} else {
|
|
"http"
|
|
};
|
|
Some(format!("{scheme}://{host}{port}"))
|
|
}
|
|
|
|
/// GRASP clone URL of a repository on a grasp server.
|
|
fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
|
|
let base = grasp_base_url(relay)?;
|
|
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
|
|
}
|
|
|
|
/// GRASP-06 contributor namespace URL of a pull request tip.
|
|
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
|
|
format!("{base_url}/prs/{npub}/{repo_id}.git")
|
|
}
|
|
|
|
/// Assemble the `clone` URLs of a pull request.
|
|
///
|
|
/// The author's GRASP-06 `/prs/` URLs come first.
|
|
pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut urls = Vec::new();
|
|
for url in prs_urls.into_iter().chain(base_clone_urls) {
|
|
if seen.insert(url.to_string()) {
|
|
urls.push(url);
|
|
}
|
|
}
|
|
urls
|
|
}
|
|
|
|
/// The `g` tag servers of one kind-10317 grasp list event, in tag order.
|
|
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
|
event
|
|
.tags
|
|
.iter()
|
|
.filter(|tag| tag.kind() == "g")
|
|
.filter_map(|tag| tag.content())
|
|
.filter_map(|url| RelayUrl::parse(url).ok())
|
|
.collect()
|
|
}
|
|
|
|
/// Grasp servers of the newest kind-10317 grasp list among `events`.
|
|
fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
|
events
|
|
.into_iter()
|
|
.max_by_key(|event| event.created_at)
|
|
.map(|event| grasp_list_servers(&event))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Resolve the user's published grasp servers from the local database.
|
|
pub async fn user_grasp_list_servers(
|
|
client: Client,
|
|
user: PublicKey,
|
|
) -> Result<Vec<RelayUrl>, Error> {
|
|
let events: Vec<Event> = client
|
|
.database()
|
|
.query(filters::grasp_list(user))
|
|
.await?
|
|
.into_iter()
|
|
.collect();
|
|
Ok(latest_grasp_list_servers(events))
|
|
}
|
|
|
|
/// Push the repository at `path` to every grasp server.
|
|
async fn push_to_grasp_servers(
|
|
path: PathBuf,
|
|
owner: String,
|
|
repo_id: String,
|
|
servers: Vec<RelayUrl>,
|
|
push: fn(&Path, &str, &str, &str) -> Result<(), Error>,
|
|
) -> Result<(), Error> {
|
|
let mut failures = Vec::new();
|
|
let mut pushed = 0;
|
|
|
|
for relay in &servers {
|
|
let Some(base_url) = grasp_base_url(relay) else {
|
|
failures.push(format!("{relay}: no domain"));
|
|
continue;
|
|
};
|
|
match push(&path, &base_url, &owner, &repo_id) {
|
|
Ok(()) => pushed += 1,
|
|
Err(e) => failures.push(format!("{relay}: {e}")),
|
|
}
|
|
}
|
|
|
|
if pushed == 0 {
|
|
bail!(
|
|
"could not push the repository to any grasp server: {}",
|
|
failures.join("; ")
|
|
);
|
|
}
|
|
|
|
for failure in failures {
|
|
log::warn!("grasp push failed: {failure}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Split a stored bunker credential into the plain URI and the session key.
|
|
fn extract_master_key(credential: &str) -> (&str, Keys) {
|
|
match credential.split_once("master=") {
|
|
Some((base, nsec)) => {
|
|
let keys = SecretKey::parse(nsec)
|
|
.map(Keys::new)
|
|
.unwrap_or_else(|_| Keys::generate());
|
|
(base.trim_end_matches(['?', '&']), keys)
|
|
}
|
|
None => (credential, Keys::generate()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn grasp_base_url_maps_schemes_like_ngit() {
|
|
let wss = RelayUrl::parse("wss://relay.ngit.dev").expect("url");
|
|
assert_eq!(
|
|
grasp_base_url(&wss).as_deref(),
|
|
Some("https://relay.ngit.dev")
|
|
);
|
|
|
|
let ws = RelayUrl::parse("ws://localhost:8080").expect("url");
|
|
assert_eq!(
|
|
grasp_base_url(&ws).as_deref(),
|
|
Some("http://localhost:8080")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn grasp_clone_url_matches_ngit_format() {
|
|
let relay = RelayUrl::parse("wss://gitnostr.com").expect("url");
|
|
let url = grasp_clone_url(&relay, "npub1test", "my-repo").expect("url");
|
|
assert_eq!(
|
|
url.to_string(),
|
|
"https://gitnostr.com/npub1test/my-repo.git"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn grasp06_prs_url_matches_ngit_format() {
|
|
assert_eq!(
|
|
grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"),
|
|
"https://relay.ngit.dev/prs/npub1author/my-repo.git"
|
|
);
|
|
// `ws://` grasp servers, local dev, keep their plain-HTTP base.
|
|
assert_eq!(
|
|
grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"),
|
|
"http://localhost:8080/prs/npub1author/my-repo.git"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pr_clone_urls_orders_author_first_and_deduplicates() {
|
|
let prs = vec![
|
|
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
|
|
Url::parse("https://a.example/prs/npub1me/repo.git").expect("url"),
|
|
];
|
|
let base = vec![
|
|
Url::parse("https://a.example/npub1owner/repo.git").expect("url"),
|
|
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
|
|
Url::parse("https://b.example/npub1owner/repo.git").expect("url"),
|
|
];
|
|
|
|
let urls = pr_clone_urls(prs, base);
|
|
assert_eq!(
|
|
urls.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
|
vec![
|
|
"https://a.example/prs/npub1me/repo.git",
|
|
"https://a.example/npub1owner/repo.git",
|
|
"https://b.example/npub1owner/repo.git",
|
|
]
|
|
);
|
|
}
|
|
|
|
fn grasp_list_event(servers: &[&str], created_at: u64) -> Event {
|
|
let keys = Keys::generate();
|
|
let tags: Vec<Tag> = servers
|
|
.iter()
|
|
.map(|url| Tag::parse(vec!["g", *url]).expect("valid tag"))
|
|
.collect();
|
|
EventBuilder::new(Kind::GitUserGraspList, "")
|
|
.tags(tags)
|
|
.custom_created_at(Timestamp::from(created_at))
|
|
.finalize(&keys)
|
|
.expect("signed event")
|
|
}
|
|
|
|
#[test]
|
|
fn grasp_list_servers_reads_g_tags_in_order() {
|
|
let event = grasp_list_event(
|
|
&["wss://first.example", "wss://second.example", "not a url"],
|
|
1000,
|
|
);
|
|
|
|
let servers = grasp_list_servers(&event);
|
|
assert_eq!(
|
|
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
|
vec!["wss://first.example", "wss://second.example"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn latest_grasp_list_servers_takes_the_newest_list_and_falls_back_empty() {
|
|
let old = grasp_list_event(&["wss://old.example"], 1000);
|
|
let fresh = grasp_list_event(&["wss://fresh.example", "wss://also.example"], 2000);
|
|
|
|
// The newest list wins, its `g` order preserved.
|
|
let servers = latest_grasp_list_servers(vec![old.clone(), fresh.clone()]);
|
|
assert_eq!(
|
|
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
|
vec!["wss://fresh.example", "wss://also.example"]
|
|
);
|
|
|
|
// The order of the input events does not matter.
|
|
let servers = latest_grasp_list_servers(vec![fresh, old]);
|
|
assert_eq!(
|
|
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
|
vec!["wss://fresh.example", "wss://also.example"]
|
|
);
|
|
|
|
// No list at all, empty, so the caller falls back to the defaults.
|
|
assert!(latest_grasp_list_servers(Vec::new()).is_empty());
|
|
}
|
|
}
|