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 control_pk: Option<PublicKey>,
68 pub control_root: Option<[u8; 32]>,
74 pub name: String,
75 pub description: Option<String>,
76 pub icon: Option<ImageRef>,
80 pub banner: Option<ImageRef>,
81 pub meta_custom: Option<serde_json::Map<String, serde_json::Value>>,
84 pub meta_extra: serde_json::Map<String, serde_json::Value>,
85 pub relays: Vec<String>,
86 pub channels: Vec<ChannelV2>,
87 pub dissolved: bool,
88 pub created_at_ms: u64,
90}
91
92impl CommunityV2 {
93 pub fn from_genesis(g: &Genesis, name: &str, description: Option<String>, relays: Vec<String>, created_at_ms: u64) -> CommunityV2 {
96 CommunityV2 {
97 identity: g.identity.clone(),
98 community_root: g.community_root,
99 root_epoch: Epoch(0),
100 control_pk: Some(g.control_pk()),
101 control_root: Some(g.control_root),
102 name: name.to_string(),
103 description,
104 icon: None,
105 banner: None,
106 meta_custom: None,
107 meta_extra: Default::default(),
108 relays,
109 channels: vec![ChannelV2 {
110 id: g.general_channel_id,
111 name: "general".to_string(),
112 private: false,
113 key: None,
114 epoch: Epoch(0),
115 voice: None,
116 meta_custom: None,
117 meta_extra: Default::default(),
118 }],
119 dissolved: false,
120 created_at_ms,
121 }
122 }
123
124 pub fn from_bundle(bundle: &CommunityInvite, created_at_ms: u64) -> Result<CommunityV2, String> {
131 bundle.validate().map_err(|e| e.to_string())?;
136 let community_id = CommunityId(parse_hex32(&bundle.community_id, "community_id")?);
137 let owner_xonly = parse_hex32(&bundle.owner, "owner")?;
138 let owner_salt = parse_hex32(&bundle.owner_salt, "owner_salt")?;
139 let identity = CommunityIdentity { community_id, owner_xonly, owner_salt };
140 let community_root = parse_hex32(&bundle.community_root, "community_root")?;
141
142 let mut channels = Vec::with_capacity(bundle.channels.len());
143 for g in &bundle.channels {
144 let id = ChannelId(parse_hex32(&g.id, "channel id")?);
145 let key = parse_hex32(&g.key, "channel key")?;
146 let private = key != community_root;
147 channels.push(ChannelV2 {
148 id,
149 name: g.name.clone(),
150 private,
151 key: private.then_some(key),
152 epoch: Epoch(g.epoch),
153 voice: None,
154 meta_custom: None,
155 meta_extra: Default::default(),
156 });
157 }
158
159 let control_pk = bundle
165 .control_pk
166 .as_deref()
167 .and_then(|h| PublicKey::from_hex(h).ok());
168
169 Ok(CommunityV2 {
170 identity,
171 community_root,
172 root_epoch: Epoch(bundle.root_epoch),
173 control_pk,
174 control_root: None,
178 name: bundle.name.clone(),
179 description: None,
180 icon: bundle.icon.clone(),
183 banner: None,
184 meta_custom: None,
185 meta_extra: Default::default(),
186 relays: bundle.relays.clone(),
187 channels,
188 dissolved: false,
189 created_at_ms,
190 })
191 }
192
193 pub fn id(&self) -> &CommunityId {
195 &self.identity.community_id
196 }
197
198 pub fn metadata(&self) -> super::control::CommunityMetadata {
202 super::control::CommunityMetadata {
203 name: self.name.clone(),
204 description: self.description.clone(),
205 relays: self.relays.clone(),
206 icon: self.icon.clone(),
207 banner: self.banner.clone(),
208 custom: self.meta_custom.clone(),
209 extra: self.meta_extra.clone(),
210 }
211 }
212
213 pub fn owner(&self) -> Result<PublicKey, String> {
215 self.identity.owner()
216 }
217
218 pub fn channel(&self, id: &ChannelId) -> Option<&ChannelV2> {
219 self.channels.iter().find(|c| c.id.0 == id.0)
220 }
221
222 pub fn vendable_channels<'a>(
233 &'a self,
234 roster: &crate::community::roles::CommunityRoles,
235 owner_hex: Option<&str>,
236 audience: Option<&str>,
237 with: &[String],
238 without: &[String],
239 ) -> Vec<&'a ChannelV2> {
240 self.channels
241 .iter()
242 .filter(|c| {
243 if !c.private {
244 return true;
245 }
246 if c.key.is_none() {
247 return false;
248 }
249 match audience {
250 None => false,
251 Some(m) => roster.is_entitled(
252 owner_hex,
253 m,
254 &crate::simd::hex::bytes_to_hex_32(&c.id.0),
255 with,
256 without,
257 ),
258 }
259 })
260 .collect()
261 }
262
263 pub fn primary_channel(&self) -> Option<&ChannelV2> {
268 let readable = |c: &&ChannelV2| !(c.private && c.key.is_none());
269 self.channels
270 .iter()
271 .filter(readable)
272 .find(|c| c.name.eq_ignore_ascii_case("general"))
273 .or_else(|| self.channels.iter().find(readable))
274 .or_else(|| self.channels.first())
275 }
276
277 pub fn channel_secret(&self, ch: &ChannelV2) -> ([u8; 32], Epoch) {
281 match ch.key {
282 Some(k) if ch.private => (k, ch.epoch),
283 _ => (self.community_root, self.root_epoch),
284 }
285 }
286
287 pub fn channel_read_coords(&self, ch: &ChannelV2) -> Vec<([u8; 32], Epoch)> {
291 if ch.private && ch.key.is_none() {
295 return Vec::new();
296 }
297 vec![self.channel_secret(ch)]
298 }
299
300 pub fn to_summary_json(&self) -> serde_json::Value {
304 serde_json::json!({
305 "id": crate::simd::hex::bytes_to_hex_32(&self.identity.community_id.0),
306 "version": 2,
307 "name": self.name,
308 "description": self.description,
309 "relays": self.relays,
310 "owner": crate::simd::hex::bytes_to_hex_32(&self.identity.owner_xonly),
311 "dissolved": self.dissolved,
312 "channels": self.channels.iter().map(|c| serde_json::json!({
313 "id": crate::simd::hex::bytes_to_hex_32(&c.id.0),
314 "name": c.name,
315 "private": c.private,
316 })).collect::<Vec<_>>(),
317 })
318 }
319}
320
321fn parse_hex32(hex: &str, field: &str) -> Result<[u8; 32], String> {
322 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
323 return Err(format!("{field} is not 32-byte hex"));
324 }
325 Ok(crate::simd::hex::hex_to_bytes_32(hex))
326}
327
328#[cfg(test)]
329mod tests {
330 use super::super::invite::ChannelGrant;
331 use super::*;
332 use nostr_sdk::prelude::Keys;
333
334 #[test]
335 fn genesis_yields_a_public_general_channel() {
336 let owner = Keys::generate();
337 let meta = super::super::control::CommunityMetadata { name: "Test".into(), ..Default::default() };
338 let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
339 let c = CommunityV2::from_genesis(&g, "Test", None, vec!["wss://r".into()], 42);
340
341 assert!(c.identity.verify());
342 assert_eq!(c.owner().unwrap(), owner.public_key());
343 assert_eq!(c.channels.len(), 1);
344 let ch = &c.channels[0];
345 assert!(!ch.private);
346 assert_eq!(ch.key, None, "a public channel stores no key");
347 assert_eq!(c.channel_secret(ch), (c.community_root, Epoch(0)));
349 }
350
351 #[test]
352 fn primary_channel_prefers_a_readable_general() {
353 let owner = Keys::generate();
354 let meta = super::super::control::CommunityMetadata { name: "T".into(), ..Default::default() };
355 let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
356 let mut c = CommunityV2::from_genesis(&g, "T", None, vec!["wss://r".into()], 0);
357 assert_eq!(c.primary_channel().unwrap().name, "general");
359
360 c.channels[0].name = "lobby".into();
363 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() });
364 assert_eq!(c.primary_channel().unwrap().name, "lobby");
365
366 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() });
368 assert_eq!(c.primary_channel().unwrap().name, "General");
369 }
370
371 #[test]
372 fn metadata_document_rebuilds_the_full_entity() {
373 let owner = Keys::generate();
374 let meta = super::super::control::CommunityMetadata { name: "Test".into(), ..Default::default() };
375 let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
376 let mut c = CommunityV2::from_genesis(&g, "Test", Some("desc".into()), vec!["wss://r".into()], 42);
377 let mut extra = serde_json::Map::new();
378 extra.insert("ext".into(), serde_json::Value::String("png".into()));
379 c.icon = Some(ImageRef {
380 url: "https://blossom.example/i".into(),
381 key: "k".into(),
382 nonce: "n".into(),
383 hash: "h".into(),
384 extra,
385 });
386
387 let mut custom = serde_json::Map::new();
390 custom.insert("theme".into(), serde_json::Value::String("solarpunk".into()));
391 c.meta_custom = Some(custom.clone());
392 c.meta_extra.insert("future_field".into(), serde_json::Value::Bool(true));
393 let doc = c.metadata();
394 assert_eq!(doc.name, "Test");
395 assert_eq!(doc.description.as_deref(), Some("desc"));
396 assert_eq!(doc.icon, c.icon);
397 assert_eq!(doc.banner, None);
398 assert_eq!(doc.relays, c.relays);
399 assert_eq!(doc.custom, Some(custom), "client-extensible custom rides the edit base");
400 assert_eq!(doc.extra.get("future_field"), Some(&serde_json::Value::Bool(true)), "unknown fields ride too");
401 }
402
403 #[test]
404 fn channel_metadata_document_preserves_undriven_fields() {
405 let owner = Keys::generate();
406 let meta = super::super::control::CommunityMetadata { name: "T".into(), ..Default::default() };
407 let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
408 let mut c = CommunityV2::from_genesis(&g, "T", None, vec!["wss://r".into()], 0);
409 c.channels[0].voice = Some(true);
411 let mut custom = serde_json::Map::new();
412 custom.insert("bitrate".into(), serde_json::Value::from(64000));
413 c.channels[0].meta_custom = Some(custom.clone());
414 c.channels[0].meta_extra.insert("vnd_field".into(), serde_json::Value::from("x"));
415
416 let mut doc = c.channels[0].metadata();
418 doc.name = "lounge".into();
419 assert_eq!(doc.voice, Some(true), "a rename must not wipe the voice flag");
420 assert_eq!(doc.custom, Some(custom));
421 assert_eq!(doc.extra.get("vnd_field"), Some(&serde_json::Value::from("x")));
422 assert_eq!(doc.deleted, None);
423 }
424
425 #[test]
426 fn from_bundle_verifies_owner_and_classifies_channels() {
427 let owner = Keys::generate();
428 let identity = CommunityIdentity::mint(&owner.public_key());
429 let root = [0x11u8; 32];
430 let hex = crate::simd::hex::bytes_to_hex_32;
431
432 let priv_key = [0x22u8; 32];
433 let bundle = CommunityInvite {
434 community_id: hex(&identity.community_id.0),
435 owner: hex(&identity.owner_xonly),
436 owner_salt: hex(&identity.owner_salt),
437 community_root: hex(&root),
438 root_epoch: 0,
439 control_pk: None,
440 channels: vec![
441 ChannelGrant { id: hex(&[0xa1; 32]), key: hex(&root), epoch: 0, name: "general".into() },
443 ChannelGrant { id: hex(&[0xa2; 32]), key: hex(&priv_key), epoch: 1, name: "mods".into() },
445 ],
446 relays: vec!["wss://r".into()],
447 name: "Test".into(),
448 icon: None,
449 expires_at: None,
450 creator_npub: None,
451 label: None,
452 extra: Default::default(),
453 };
454
455 let c = CommunityV2::from_bundle(&bundle, 99).unwrap();
456 assert_eq!(c.owner().unwrap(), owner.public_key());
457 assert!(!c.channels[0].private);
458 assert!(c.channels[1].private);
459 assert_eq!(c.channels[1].key, Some(priv_key));
460 assert_eq!(c.channel_secret(&c.channels[0]), (root, Epoch(0)));
462 assert_eq!(c.channel_secret(&c.channels[1]), (priv_key, Epoch(1)));
463 }
464
465 #[test]
466 fn from_bundle_rejects_an_out_of_range_epoch() {
467 let owner = Keys::generate();
471 let identity = CommunityIdentity::mint(&owner.public_key());
472 let hex = crate::simd::hex::bytes_to_hex_32;
473 let root = [0x11u8; 32];
474 let bundle = CommunityInvite {
475 community_id: hex(&identity.community_id.0),
476 owner: hex(&identity.owner_xonly),
477 owner_salt: hex(&identity.owner_salt),
478 community_root: hex(&root),
479 root_epoch: u64::MAX,
480 control_pk: None,
481 channels: vec![ChannelGrant { id: hex(&[0xa1; 32]), key: hex(&root), epoch: 0, name: "general".into() }],
482 relays: vec!["wss://r".into()],
483 name: "Overflow".into(),
484 icon: None,
485 expires_at: None,
486 creator_npub: None,
487 label: None,
488 extra: Default::default(),
489 };
490 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an out-of-range epoch is refused");
491 }
492
493 #[test]
494 fn from_bundle_rejects_a_forged_owner_commitment() {
495 let owner = Keys::generate();
496 let attacker = Keys::generate();
497 let identity = CommunityIdentity::mint(&owner.public_key());
498 let hex = crate::simd::hex::bytes_to_hex_32;
499 let bundle = CommunityInvite {
501 community_id: hex(&identity.community_id.0),
502 owner: hex(&attacker.public_key().to_bytes()),
503 owner_salt: hex(&identity.owner_salt),
504 community_root: hex(&[0x11; 32]),
505 root_epoch: 0,
506 control_pk: None,
507 channels: vec![],
508 relays: vec![],
509 name: "X".into(),
510 icon: None,
511 expires_at: None,
512 creator_npub: None,
513 label: None,
514 extra: Default::default(),
515 };
516 assert!(CommunityV2::from_bundle(&bundle, 0).is_err());
517 }
518}