1use nostr_sdk::prelude::PublicKey;
11
12use super::super::{ChannelId, CommunityId, Epoch};
13use super::control::{CommunityIdentity, Genesis, ImageRef};
14use super::invite::CommunityInvite;
15
16#[derive(Debug, Clone)]
20pub struct ChannelV2 {
21 pub id: ChannelId,
22 pub name: String,
23 pub private: bool,
24 pub key: Option<[u8; 32]>,
27 pub epoch: Epoch,
28 pub voice: Option<bool>,
32 pub meta_custom: Option<serde_json::Map<String, serde_json::Value>>,
33 pub meta_extra: serde_json::Map<String, serde_json::Value>,
34}
35
36impl ChannelV2 {
37 pub fn metadata(&self) -> super::control::ChannelMetadata {
40 super::control::ChannelMetadata {
41 name: self.name.clone(),
42 private: self.private,
43 voice: self.voice,
44 deleted: None,
45 custom: self.meta_custom.clone(),
46 extra: self.meta_extra.clone(),
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
55pub struct CommunityV2 {
56 pub identity: CommunityIdentity,
57 pub community_root: [u8; 32],
59 pub root_epoch: Epoch,
60 pub name: String,
61 pub description: Option<String>,
62 pub icon: Option<ImageRef>,
66 pub banner: Option<ImageRef>,
67 pub meta_custom: Option<serde_json::Map<String, serde_json::Value>>,
70 pub meta_extra: serde_json::Map<String, serde_json::Value>,
71 pub relays: Vec<String>,
72 pub channels: Vec<ChannelV2>,
73 pub dissolved: bool,
74 pub created_at_ms: u64,
76}
77
78impl CommunityV2 {
79 pub fn from_genesis(g: &Genesis, name: &str, description: Option<String>, relays: Vec<String>, created_at_ms: u64) -> CommunityV2 {
82 CommunityV2 {
83 identity: g.identity.clone(),
84 community_root: g.community_root,
85 root_epoch: Epoch(0),
86 name: name.to_string(),
87 description,
88 icon: None,
89 banner: None,
90 meta_custom: None,
91 meta_extra: Default::default(),
92 relays,
93 channels: vec![ChannelV2 {
94 id: g.general_channel_id,
95 name: "general".to_string(),
96 private: false,
97 key: None,
98 epoch: Epoch(0),
99 voice: None,
100 meta_custom: None,
101 meta_extra: Default::default(),
102 }],
103 dissolved: false,
104 created_at_ms,
105 }
106 }
107
108 pub fn from_bundle(bundle: &CommunityInvite, created_at_ms: u64) -> Result<CommunityV2, String> {
115 bundle.validate().map_err(|e| e.to_string())?;
120 let community_id = CommunityId(parse_hex32(&bundle.community_id, "community_id")?);
121 let owner_xonly = parse_hex32(&bundle.owner, "owner")?;
122 let owner_salt = parse_hex32(&bundle.owner_salt, "owner_salt")?;
123 let identity = CommunityIdentity { community_id, owner_xonly, owner_salt };
124 let community_root = parse_hex32(&bundle.community_root, "community_root")?;
125
126 let mut channels = Vec::with_capacity(bundle.channels.len());
127 for g in &bundle.channels {
128 let id = ChannelId(parse_hex32(&g.id, "channel id")?);
129 let key = parse_hex32(&g.key, "channel key")?;
130 let private = key != community_root;
131 channels.push(ChannelV2 {
132 id,
133 name: g.name.clone(),
134 private,
135 key: private.then_some(key),
136 epoch: Epoch(g.epoch),
137 voice: None,
138 meta_custom: None,
139 meta_extra: Default::default(),
140 });
141 }
142
143 Ok(CommunityV2 {
144 identity,
145 community_root,
146 root_epoch: Epoch(bundle.root_epoch),
147 name: bundle.name.clone(),
148 description: None,
149 icon: bundle.icon.clone(),
152 banner: None,
153 meta_custom: None,
154 meta_extra: Default::default(),
155 relays: bundle.relays.clone(),
156 channels,
157 dissolved: false,
158 created_at_ms,
159 })
160 }
161
162 pub fn id(&self) -> &CommunityId {
164 &self.identity.community_id
165 }
166
167 pub fn metadata(&self) -> super::control::CommunityMetadata {
171 super::control::CommunityMetadata {
172 name: self.name.clone(),
173 description: self.description.clone(),
174 relays: self.relays.clone(),
175 icon: self.icon.clone(),
176 banner: self.banner.clone(),
177 custom: self.meta_custom.clone(),
178 extra: self.meta_extra.clone(),
179 }
180 }
181
182 pub fn owner(&self) -> Result<PublicKey, String> {
184 self.identity.owner()
185 }
186
187 pub fn channel(&self, id: &ChannelId) -> Option<&ChannelV2> {
188 self.channels.iter().find(|c| c.id.0 == id.0)
189 }
190
191 pub fn vendable_channels<'a>(
202 &'a self,
203 roster: &crate::community::roles::CommunityRoles,
204 owner_hex: Option<&str>,
205 audience: Option<&str>,
206 with: &[String],
207 without: &[String],
208 ) -> Vec<&'a ChannelV2> {
209 self.channels
210 .iter()
211 .filter(|c| {
212 if !c.private {
213 return true;
214 }
215 if c.key.is_none() {
216 return false;
217 }
218 match audience {
219 None => false,
220 Some(m) => roster.is_entitled(
221 owner_hex,
222 m,
223 &crate::simd::hex::bytes_to_hex_32(&c.id.0),
224 with,
225 without,
226 ),
227 }
228 })
229 .collect()
230 }
231
232 pub fn primary_channel(&self) -> Option<&ChannelV2> {
237 let readable = |c: &&ChannelV2| !(c.private && c.key.is_none());
238 self.channels
239 .iter()
240 .filter(readable)
241 .find(|c| c.name.eq_ignore_ascii_case("general"))
242 .or_else(|| self.channels.iter().find(readable))
243 .or_else(|| self.channels.first())
244 }
245
246 pub fn channel_secret(&self, ch: &ChannelV2) -> ([u8; 32], Epoch) {
250 match ch.key {
251 Some(k) if ch.private => (k, ch.epoch),
252 _ => (self.community_root, self.root_epoch),
253 }
254 }
255
256 pub fn channel_read_coords(&self, ch: &ChannelV2) -> Vec<([u8; 32], Epoch)> {
260 if ch.private && ch.key.is_none() {
264 return Vec::new();
265 }
266 vec![self.channel_secret(ch)]
267 }
268
269 pub fn to_summary_json(&self) -> serde_json::Value {
273 serde_json::json!({
274 "id": crate::simd::hex::bytes_to_hex_32(&self.identity.community_id.0),
275 "version": 2,
276 "name": self.name,
277 "description": self.description,
278 "relays": self.relays,
279 "owner": crate::simd::hex::bytes_to_hex_32(&self.identity.owner_xonly),
280 "dissolved": self.dissolved,
281 "channels": self.channels.iter().map(|c| serde_json::json!({
282 "id": crate::simd::hex::bytes_to_hex_32(&c.id.0),
283 "name": c.name,
284 "private": c.private,
285 })).collect::<Vec<_>>(),
286 })
287 }
288}
289
290fn parse_hex32(hex: &str, field: &str) -> Result<[u8; 32], String> {
291 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
292 return Err(format!("{field} is not 32-byte hex"));
293 }
294 Ok(crate::simd::hex::hex_to_bytes_32(hex))
295}
296
297#[cfg(test)]
298mod tests {
299 use super::super::invite::ChannelGrant;
300 use super::*;
301 use nostr_sdk::prelude::Keys;
302
303 #[test]
304 fn genesis_yields_a_public_general_channel() {
305 let owner = Keys::generate();
306 let meta = super::super::control::CommunityMetadata { name: "Test".into(), ..Default::default() };
307 let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
308 let c = CommunityV2::from_genesis(&g, "Test", None, vec!["wss://r".into()], 42);
309
310 assert!(c.identity.verify());
311 assert_eq!(c.owner().unwrap(), owner.public_key());
312 assert_eq!(c.channels.len(), 1);
313 let ch = &c.channels[0];
314 assert!(!ch.private);
315 assert_eq!(ch.key, None, "a public channel stores no key");
316 assert_eq!(c.channel_secret(ch), (c.community_root, Epoch(0)));
318 }
319
320 #[test]
321 fn primary_channel_prefers_a_readable_general() {
322 let owner = Keys::generate();
323 let meta = super::super::control::CommunityMetadata { name: "T".into(), ..Default::default() };
324 let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
325 let mut c = CommunityV2::from_genesis(&g, "T", None, vec!["wss://r".into()], 0);
326 assert_eq!(c.primary_channel().unwrap().name, "general");
328
329 c.channels[0].name = "lobby".into();
332 c.channels.insert(0, ChannelV2 { id: ChannelId([9u8; 32]), name: "sekrit".into(), private: true, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() });
333 assert_eq!(c.primary_channel().unwrap().name, "lobby");
334
335 c.channels.push(ChannelV2 { id: ChannelId([8u8; 32]), name: "General".into(), private: false, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() });
337 assert_eq!(c.primary_channel().unwrap().name, "General");
338 }
339
340 #[test]
341 fn metadata_document_rebuilds_the_full_entity() {
342 let owner = Keys::generate();
343 let meta = super::super::control::CommunityMetadata { name: "Test".into(), ..Default::default() };
344 let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
345 let mut c = CommunityV2::from_genesis(&g, "Test", Some("desc".into()), vec!["wss://r".into()], 42);
346 let mut extra = serde_json::Map::new();
347 extra.insert("ext".into(), serde_json::Value::String("png".into()));
348 c.icon = Some(ImageRef {
349 url: "https://blossom.example/i".into(),
350 key: "k".into(),
351 nonce: "n".into(),
352 hash: "h".into(),
353 extra,
354 });
355
356 let mut custom = serde_json::Map::new();
359 custom.insert("theme".into(), serde_json::Value::String("solarpunk".into()));
360 c.meta_custom = Some(custom.clone());
361 c.meta_extra.insert("future_field".into(), serde_json::Value::Bool(true));
362 let doc = c.metadata();
363 assert_eq!(doc.name, "Test");
364 assert_eq!(doc.description.as_deref(), Some("desc"));
365 assert_eq!(doc.icon, c.icon);
366 assert_eq!(doc.banner, None);
367 assert_eq!(doc.relays, c.relays);
368 assert_eq!(doc.custom, Some(custom), "client-extensible custom rides the edit base");
369 assert_eq!(doc.extra.get("future_field"), Some(&serde_json::Value::Bool(true)), "unknown fields ride too");
370 }
371
372 #[test]
373 fn channel_metadata_document_preserves_undriven_fields() {
374 let owner = Keys::generate();
375 let meta = super::super::control::CommunityMetadata { name: "T".into(), ..Default::default() };
376 let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
377 let mut c = CommunityV2::from_genesis(&g, "T", None, vec!["wss://r".into()], 0);
378 c.channels[0].voice = Some(true);
380 let mut custom = serde_json::Map::new();
381 custom.insert("bitrate".into(), serde_json::Value::from(64000));
382 c.channels[0].meta_custom = Some(custom.clone());
383 c.channels[0].meta_extra.insert("vnd_field".into(), serde_json::Value::from("x"));
384
385 let mut doc = c.channels[0].metadata();
387 doc.name = "lounge".into();
388 assert_eq!(doc.voice, Some(true), "a rename must not wipe the voice flag");
389 assert_eq!(doc.custom, Some(custom));
390 assert_eq!(doc.extra.get("vnd_field"), Some(&serde_json::Value::from("x")));
391 assert_eq!(doc.deleted, None);
392 }
393
394 #[test]
395 fn from_bundle_verifies_owner_and_classifies_channels() {
396 let owner = Keys::generate();
397 let identity = CommunityIdentity::mint(&owner.public_key());
398 let root = [0x11u8; 32];
399 let hex = crate::simd::hex::bytes_to_hex_32;
400
401 let priv_key = [0x22u8; 32];
402 let bundle = CommunityInvite {
403 community_id: hex(&identity.community_id.0),
404 owner: hex(&identity.owner_xonly),
405 owner_salt: hex(&identity.owner_salt),
406 community_root: hex(&root),
407 root_epoch: 0,
408 channels: vec![
409 ChannelGrant { id: hex(&[0xa1; 32]), key: hex(&root), epoch: 0, name: "general".into() },
411 ChannelGrant { id: hex(&[0xa2; 32]), key: hex(&priv_key), epoch: 1, name: "mods".into() },
413 ],
414 relays: vec!["wss://r".into()],
415 name: "Test".into(),
416 icon: None,
417 expires_at: None,
418 creator_npub: None,
419 label: None,
420 extra: Default::default(),
421 };
422
423 let c = CommunityV2::from_bundle(&bundle, 99).unwrap();
424 assert_eq!(c.owner().unwrap(), owner.public_key());
425 assert!(!c.channels[0].private);
426 assert!(c.channels[1].private);
427 assert_eq!(c.channels[1].key, Some(priv_key));
428 assert_eq!(c.channel_secret(&c.channels[0]), (root, Epoch(0)));
430 assert_eq!(c.channel_secret(&c.channels[1]), (priv_key, Epoch(1)));
431 }
432
433 #[test]
434 fn from_bundle_rejects_an_out_of_range_epoch() {
435 let owner = Keys::generate();
439 let identity = CommunityIdentity::mint(&owner.public_key());
440 let hex = crate::simd::hex::bytes_to_hex_32;
441 let root = [0x11u8; 32];
442 let bundle = CommunityInvite {
443 community_id: hex(&identity.community_id.0),
444 owner: hex(&identity.owner_xonly),
445 owner_salt: hex(&identity.owner_salt),
446 community_root: hex(&root),
447 root_epoch: u64::MAX,
448 channels: vec![ChannelGrant { id: hex(&[0xa1; 32]), key: hex(&root), epoch: 0, name: "general".into() }],
449 relays: vec!["wss://r".into()],
450 name: "Overflow".into(),
451 icon: None,
452 expires_at: None,
453 creator_npub: None,
454 label: None,
455 extra: Default::default(),
456 };
457 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an out-of-range epoch is refused");
458 }
459
460 #[test]
461 fn from_bundle_rejects_a_forged_owner_commitment() {
462 let owner = Keys::generate();
463 let attacker = Keys::generate();
464 let identity = CommunityIdentity::mint(&owner.public_key());
465 let hex = crate::simd::hex::bytes_to_hex_32;
466 let bundle = CommunityInvite {
468 community_id: hex(&identity.community_id.0),
469 owner: hex(&attacker.public_key().to_bytes()),
470 owner_salt: hex(&identity.owner_salt),
471 community_root: hex(&[0x11; 32]),
472 root_epoch: 0,
473 channels: vec![],
474 relays: vec![],
475 name: "X".into(),
476 icon: None,
477 expires_at: None,
478 creator_npub: None,
479 label: None,
480 extra: Default::default(),
481 };
482 assert!(CommunityV2::from_bundle(&bundle, 0).is_err());
483 }
484}