use std::{str::FromStr, time::Duration};
use ed25519_dalek::SigningKey;
use iroh::{Endpoint, PublicKey, address_lookup::memory::MemoryLookup};
use iroh_blobs::{Hash, ticket::BlobTicket};
use iroh_gossip::{
TopicId,
api::{Event, GossipSender, GossipTopic},
};
use n0_future::StreamExt;
use semver::Version;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
editorial::Channel,
error::{Result, SyncwebError},
node::gossip_service::GossipService,
node::signed_message::SignedMessage,
};
const CATALOG_TOPIC_SEED: &[u8] = b"syncweb/public-package-catalog/v1";
#[must_use]
pub fn catalog_topic() -> TopicId {
TopicId::from_bytes(*blake3::hash(CATALOG_TOPIC_SEED).as_bytes())
}
#[must_use]
pub fn channel_topic_id(name: impl AsRef<str>) -> TopicId {
let seed = format!("syncweb/editorial/{}/v1", name.as_ref());
TopicId::from_bytes(*blake3::hash(seed.as_bytes()).as_bytes())
}
#[must_use]
pub fn channel_topic(channel: &Channel) -> TopicId {
channel_topic_id(&channel.name)
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PackageAnnouncement {
pub collection_id: Uuid,
pub name: String,
pub version: String,
pub sequence: u64,
pub manifest: Hash,
pub manifest_ticket: String,
pub publisher: String,
}
impl PackageAnnouncement {
pub fn new(
collection_id: Uuid,
name: impl Into<String>,
version: impl Into<String>,
sequence: u64,
manifest: Hash,
manifest_ticket: impl Into<String>,
publisher: PublicKey,
) -> Result<Self> {
let announcement = Self {
collection_id,
name: name.into(),
version: version.into(),
sequence,
manifest,
manifest_ticket: manifest_ticket.into(),
publisher: publisher.to_string(),
};
announcement.validate()?;
Ok(announcement)
}
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(SyncwebError::InvalidConfig(
"package announcement name cannot be empty".to_owned(),
));
}
if self.sequence == 0 {
return Err(SyncwebError::InvalidConfig(
"package announcement sequence must be greater than zero".to_owned(),
));
}
Version::parse(&self.version)
.map_err(|error| SyncwebError::InvalidConfig(format!("invalid package version: {error}")))?;
let ticket = BlobTicket::from_str(&self.manifest_ticket)
.map_err(|error| SyncwebError::InvalidTicket(error.to_string()))?;
if ticket.hash() != self.manifest {
return Err(SyncwebError::InvalidTicket(
"package manifest ticket does not match its manifest hash".to_owned(),
));
}
self.publisher
.parse::<PublicKey>()
.map_err(|error| SyncwebError::InvalidIdentity(error.to_string()))?;
Ok(())
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
self.validate()?;
serde_json::to_vec(self)
.map_err(|error| SyncwebError::operation("failed to serialize package announcement", error))
}
pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
let announcement: Self = serde_json::from_slice(bytes.as_ref())
.map_err(|error| SyncwebError::operation("failed to deserialize package announcement", error))?;
announcement.validate()?;
Ok(announcement)
}
pub fn ticket(&self) -> Result<BlobTicket> {
BlobTicket::from_str(&self.manifest_ticket).map_err(|error| SyncwebError::InvalidTicket(error.to_string()))
}
fn matches(&self, query: Option<&str>) -> bool {
query.is_none_or(|needle| {
self.name.contains(needle)
|| self.collection_id.to_string().contains(needle)
|| self.version.contains(needle)
})
}
}
#[derive(Clone)]
pub struct PackageCatalog {
gossip: GossipService,
signing_key: SigningKey,
}
impl PackageCatalog {
#[must_use]
pub fn new(gossip: &GossipService, endpoint: &Endpoint) -> Self {
Self {
gossip: gossip.clone(),
signing_key: SigningKey::from_bytes(&endpoint.secret_key().to_bytes()),
}
}
pub async fn subscribe(&self, bootstrap: Vec<PublicKey>) -> Result<GossipTopic> {
self.gossip.subscribe(catalog_topic(), bootstrap).await
}
pub async fn subscribe_and_join(&self, bootstrap: Vec<PublicKey>) -> Result<GossipTopic> {
self.gossip.subscribe_and_join(catalog_topic(), bootstrap).await
}
pub async fn announce(&self, sender: &GossipSender, announcement: &PackageAnnouncement) -> Result<()> {
let content = announcement.to_bytes()?;
let wire = SignedMessage::sign(&self.signing_key, &content);
self.gossip.publish(sender, wire).await
}
pub async fn subscribe_channel(&self, channel: &Channel, bootstrap: Vec<PublicKey>) -> Result<GossipTopic> {
self.gossip.subscribe(channel_topic(channel), bootstrap).await
}
pub async fn subscribe_channel_and_join(
&self,
channel: &Channel,
bootstrap: Vec<PublicKey>,
) -> Result<GossipTopic> {
self.gossip.subscribe_and_join(channel_topic(channel), bootstrap).await
}
pub async fn search(
&self,
topic: &mut GossipTopic,
query: Option<&str>,
timeout: Duration,
) -> Result<Vec<PackageAnnouncement>> {
let mut announcements = Vec::new();
let receive = async {
while let Some(event) = topic.next().await {
if let Event::Received(message) =
event.map_err(|error| SyncwebError::operation("package catalog event failed", error))?
{
let (verifying_key, content) = SignedMessage::verify(&message.content).map_err(|error| {
SyncwebError::operation("package catalog signature verification failed", error)
})?;
let announcement = PackageAnnouncement::from_bytes(content)?;
let claimed_publisher: PublicKey = announcement
.publisher
.parse()
.map_err(|error| SyncwebError::InvalidIdentity(format!("invalid publisher key: {error}")))?;
let actual_publisher = PublicKey::from_bytes(&verifying_key.to_bytes()).map_err(|error| {
SyncwebError::InvalidIdentity(format!("derived NodeId from verifying key is invalid: {error}"))
})?;
if claimed_publisher != actual_publisher {
return Err(SyncwebError::operation(
"package catalog publisher mismatch",
format!("claimed {claimed_publisher}, signed by {actual_publisher}"),
));
}
if announcement.matches(query)
&& !announcements.iter().any(|item: &PackageAnnouncement| {
item.collection_id == announcement.collection_id
&& item.version == announcement.version
&& item.manifest == announcement.manifest
})
{
announcements.push(announcement);
}
}
}
Ok::<(), SyncwebError>(())
};
if let Ok(result) = tokio::time::timeout(timeout, receive).await {
result?;
}
Ok(announcements)
}
pub fn register_ticket_endpoint(lookup: &MemoryLookup, announcement: &PackageAnnouncement) -> Result<()> {
let ticket = announcement.ticket()?;
lookup.add_endpoint_info(ticket.addr().clone());
Ok(())
}
}