1use std::{
2 collections::HashSet,
3 fmt::{Display, Formatter},
4 str::FromStr,
5};
6
7use iroh::PublicKey;
8use iroh_docs::NamespaceId;
9use iroh_gossip::TopicId;
10use serde::{Deserialize, Serialize};
11
12use crate::{Result, SyncwebError};
13
14#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub struct NetworkId([u8; 32]);
17
18impl NetworkId {
19 #[must_use]
20 pub fn from_name(name: &str) -> Self {
21 let mut hasher = blake3::Hasher::new();
22 hasher.update(b"network:");
23 hasher.update(name.as_bytes());
24 Self(*hasher.finalize().as_bytes())
25 }
26
27 #[must_use]
28 pub const fn as_bytes(&self) -> &[u8; 32] {
29 &self.0
30 }
31}
32
33impl Display for NetworkId {
34 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
35 formatter.write_str(&hex::encode(self.0))
36 }
37}
38
39impl FromStr for NetworkId {
40 type Err = SyncwebError;
41
42 fn from_str(value: &str) -> Result<Self> {
43 let bytes =
44 hex::decode(value).map_err(|error| SyncwebError::InvalidConfig(format!("invalid network ID: {error}")))?;
45 let array: [u8; 32] = bytes
46 .try_into()
47 .map_err(|_length_error| SyncwebError::InvalidConfig("network ID must contain 32 bytes".to_owned()))?;
48 Ok(Self(array))
49 }
50}
51
52#[derive(Clone, Debug, Default, Eq, PartialEq)]
54#[non_exhaustive]
55pub struct NetworkOptions {
56 pub label: String,
57 pub invite_only: bool,
58}
59
60impl NetworkOptions {
61 #[must_use]
62 pub fn with_label(mut self, label: impl Into<String>) -> Self {
63 self.label = label.into();
64 self
65 }
66
67 #[must_use]
68 pub const fn invite_only(mut self, invite_only: bool) -> Self {
69 self.invite_only = invite_only;
70 self
71 }
72}
73
74#[derive(Clone, Debug)]
76#[non_exhaustive]
77pub struct Network {
78 pub id: NetworkId,
79 pub name: String,
80 pub label: String,
81 pub topic: TopicId,
82 pub owner: PublicKey,
83 pub members: HashSet<PublicKey>,
84 pub folders: HashSet<NamespaceId>,
85 pub(crate) shared_secret: Option<[u8; 32]>,
86 pub doc_ticket: Option<String>,
87}
88
89impl Network {
90 #[must_use]
91 pub fn new(name_arg: impl Into<String>, owner: PublicKey, options: NetworkOptions) -> Self {
92 let name = name_arg.into().trim().to_owned();
93 let id = NetworkId::from_name(&name);
94 let topic = network_topic(id);
95 let mut members = HashSet::new();
96 members.insert(owner);
97 Self {
98 id,
99 name,
100 label: options.label,
101 topic,
102 owner,
103 members,
104 folders: HashSet::new(),
105 shared_secret: options.invite_only.then(rand::random),
106 doc_ticket: None,
107 }
108 }
109
110 #[must_use]
111 pub fn is_member(&self, node_id: &PublicKey) -> bool {
112 self.members.contains(node_id)
113 }
114
115 #[must_use]
116 pub const fn topic(&self) -> TopicId {
117 self.topic
118 }
119
120 #[must_use]
121 pub const fn is_invite_only(&self) -> bool {
122 self.shared_secret.is_some()
123 }
124}
125
126#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct NetworkTicket {
129 pub network_id: NetworkId,
130 pub name: String,
131 pub label: String,
132 pub owner: PublicKey,
133 pub invited_node: Option<PublicKey>,
134 pub members: HashSet<PublicKey>,
135 pub folders: HashSet<NamespaceId>,
136 pub(crate) shared_secret: Option<[u8; 32]>,
137 pub doc_ticket: Option<String>,
138}
139
140impl NetworkTicket {
141 #[must_use]
142 pub const fn is_invite_only(&self) -> bool {
143 self.shared_secret.is_some()
144 }
145}
146
147impl Display for NetworkTicket {
148 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
149 let wire = TicketWire::from(self);
150 let bytes = serde_json::to_vec(&wire).map_err(|_serialization_error| std::fmt::Error)?;
151 write!(formatter, "syncweb://network/{}", hex::encode(bytes))
152 }
153}
154
155impl FromStr for NetworkTicket {
156 type Err = SyncwebError;
157
158 fn from_str(value: &str) -> Result<Self> {
159 let encoded = value.strip_prefix("syncweb://network/").ok_or_else(|| {
160 SyncwebError::InvalidTicket("network ticket must start with syncweb://network/".to_owned())
161 })?;
162 let bytes = hex::decode(encoded)
163 .map_err(|error| SyncwebError::InvalidTicket(format!("invalid network ticket encoding: {error}")))?;
164 let wire: TicketWire = serde_json::from_slice(&bytes)
165 .map_err(|error| SyncwebError::InvalidTicket(format!("invalid network ticket: {error}")))?;
166 wire.try_into()
167 }
168}
169
170#[derive(Deserialize, Serialize)]
171struct TicketWire {
172 network_id: String,
173 name: String,
174 label: String,
175 owner: String,
176 invited_node: Option<String>,
177 members: Vec<String>,
178 folders: Vec<String>,
179 shared_secret: Option<String>,
180 #[serde(default)]
181 doc_ticket: Option<String>,
182}
183
184impl From<&NetworkTicket> for TicketWire {
185 fn from(ticket: &NetworkTicket) -> Self {
186 let mut members = ticket.members.iter().map(ToString::to_string).collect::<Vec<_>>();
187 members.sort_unstable();
188 let mut folders = ticket.folders.iter().map(ToString::to_string).collect::<Vec<_>>();
189 folders.sort_unstable();
190 Self {
191 network_id: ticket.network_id.to_string(),
192 name: ticket.name.clone(),
193 label: ticket.label.clone(),
194 owner: ticket.owner.to_string(),
195 invited_node: ticket.invited_node.map(|node| node.to_string()),
196 members,
197 folders,
198 shared_secret: ticket.shared_secret.map(hex::encode),
199 doc_ticket: ticket.doc_ticket.clone(),
200 }
201 }
202}
203
204impl TryFrom<TicketWire> for NetworkTicket {
205 type Error = SyncwebError;
206
207 fn try_from(wire: TicketWire) -> Result<Self> {
208 if wire.name.trim().is_empty() {
209 return Err(SyncwebError::InvalidTicket("network name cannot be empty".to_owned()));
210 }
211 let network_id: NetworkId = wire.network_id.parse()?;
212 if network_id != NetworkId::from_name(wire.name.trim()) {
213 return Err(SyncwebError::InvalidTicket(
214 "network ticket ID does not match its network name".to_owned(),
215 ));
216 }
217 let members = wire
218 .members
219 .into_iter()
220 .map(|member| parse_public_key(&member))
221 .collect::<Result<HashSet<_>>>()?;
222 let folders = wire
223 .folders
224 .into_iter()
225 .map(|folder| {
226 folder
227 .parse()
228 .map_err(|error| SyncwebError::InvalidTicket(format!("invalid folder namespace: {error}")))
229 })
230 .collect::<Result<HashSet<_>>>()?;
231 let shared_secret = wire
232 .shared_secret
233 .map(|secret| {
234 let bytes = hex::decode(secret)
235 .map_err(|error| SyncwebError::InvalidTicket(format!("invalid network secret: {error}")))?;
236 bytes.try_into().map_err(|_length_error| {
237 SyncwebError::InvalidTicket("network secret must contain 32 bytes".to_owned())
238 })
239 })
240 .transpose()?;
241 Ok(Self {
242 network_id,
243 name: wire.name,
244 label: wire.label,
245 owner: parse_public_key(&wire.owner)?,
246 invited_node: wire.invited_node.map(|node| parse_public_key(&node)).transpose()?,
247 members,
248 folders,
249 shared_secret,
250 doc_ticket: wire.doc_ticket,
251 })
252 }
253}
254
255pub(crate) fn network_topic(id: NetworkId) -> TopicId {
256 let digest = blake3::hash(format!("syncweb/net/{id}").as_bytes());
257 TopicId::from_bytes(*digest.as_bytes())
258}
259
260pub(crate) fn parse_public_key(value: &str) -> Result<PublicKey> {
261 value
262 .parse()
263 .map_err(|error| SyncwebError::InvalidTicket(format!("invalid node ID: {error}")))
264}