1use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
15use nostr_sdk::ToBech32;
16use rusqlite::{params, OptionalExtension};
17
18use crate::community::{Channel, ChannelId, ChannelKey, Community, CommunityId, Epoch, ServerRootKey};
19
20fn now_secs() -> i64 {
21 std::time::SystemTime::now()
22 .duration_since(std::time::UNIX_EPOCH)
23 .map(|d| d.as_secs() as i64)
24 .unwrap_or(0)
25}
26
27fn to_32(bytes: &[u8]) -> Result<[u8; 32], String> {
28 bytes
29 .try_into()
30 .map_err(|_| format!("expected 32-byte key, got {} bytes", bytes.len()))
31}
32
33fn enc_key(k: &[u8; 32]) -> Result<Vec<u8>, String> { crate::crypto::maybe_encrypt_blob(k) }
35fn dec_key(stored: &[u8]) -> Result<[u8; 32], String> { to_32(&crate::crypto::maybe_decrypt_blob(stored)) }
36fn enc_txt(s: &str) -> Result<String, String> { crate::crypto::maybe_encrypt_text(s) }
37fn dec_txt(s: &str) -> String { crate::crypto::maybe_decrypt_text(s) }
38fn enc_txt_opt(s: &Option<String>) -> Result<Option<String>, String> {
40 s.as_deref().map(enc_txt).transpose()
41}
42
43pub(crate) fn hex_id_to_32(hex: &str) -> Result<[u8; 32], String> {
47 crate::simd::hex::hex_to_bytes_32_checked(hex)
48 .ok_or_else(|| format!("corrupt or wrong-length 64-char hex id ({} chars)", hex.len()))
49}
50
51pub fn save_community(community: &Community) -> Result<(), String> {
54 let conn = super::get_write_connection_guard_static()?;
55 let relays_json = serde_json::to_string(&community.relays).map_err(|e| e.to_string())?;
56 let community_id = community.id.to_hex();
57
58 let icon_json = community
60 .icon
61 .as_ref()
62 .map(|i| serde_json::to_string(i))
63 .transpose()
64 .map_err(|e| e.to_string())?;
65 let banner_json = community
66 .banner
67 .as_ref()
68 .map(|b| serde_json::to_string(b))
69 .transpose()
70 .map_err(|e| e.to_string())?;
71 let tx = conn.unchecked_transaction().map_err(|e| format!("save community tx: {e}"))?;
74 let enc_root = enc_key(community.server_root_key.as_bytes())?;
79 let enc_name = enc_txt(&community.name)?;
80 let enc_relays = enc_txt(&relays_json)?;
81 let enc_desc = enc_txt_opt(&community.description)?;
82 let enc_icon = enc_txt_opt(&icon_json)?;
83 let enc_banner = enc_txt_opt(&banner_json)?;
84 let enc_owner = enc_txt_opt(&community.owner_attestation)?;
85 tx.execute(
86 "INSERT INTO communities
87 (community_id, server_root_key, name, relays, created_at,
88 description, icon, banner, owner_attestation, server_root_epoch)
89 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
90 ON CONFLICT(community_id) DO UPDATE SET
91 server_root_key=excluded.server_root_key, name=excluded.name, relays=excluded.relays,
92 description=excluded.description, icon=excluded.icon, banner=excluded.banner,
93 owner_attestation=excluded.owner_attestation, server_root_epoch=excluded.server_root_epoch",
94 params![
95 community_id,
96 &enc_root[..],
97 enc_name,
98 enc_relays,
99 now_secs(),
100 enc_desc,
101 enc_icon,
102 enc_banner,
103 enc_owner,
104 community.server_root_epoch.0 as i64,
105 ],
106 )
107 .map_err(|e| format!("save community: {e}"))?;
108
109 store_epoch_key_tx(&tx, &community_id, crate::community::SERVER_ROOT_SCOPE_HEX,
111 community.server_root_epoch.0, community.server_root_key.as_bytes())?;
112
113 for channel in &community.channels {
114 let enc_chan_key = enc_key(channel.key.as_bytes())?;
115 let enc_chan_name = enc_txt(&channel.name)?;
116 tx.execute(
119 "INSERT INTO community_channels
120 (channel_id, community_id, channel_key, epoch, name, created_at, rekeyed_at_server_epoch)
121 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
122 ON CONFLICT(channel_id) DO UPDATE SET
123 community_id=excluded.community_id, channel_key=excluded.channel_key,
124 epoch=excluded.epoch, name=excluded.name",
125 params![
126 channel.id.to_hex(),
127 community_id,
128 &enc_chan_key[..],
129 channel.epoch.0 as i64,
133 enc_chan_name,
134 now_secs(),
135 community.server_root_epoch.0 as i64,
137 ],
138 )
139 .map_err(|e| format!("save channel: {e}"))?;
140 store_epoch_key_tx(&tx, &community_id, &channel.id.to_hex(), channel.epoch.0, channel.key.as_bytes())?;
144 }
145 tx.commit().map_err(|e| format!("save community commit: {e}"))?;
146 Ok(())
147}
148
149pub fn store_epoch_key(community_id: &str, scope_id: &str, epoch: u64, key: &[u8; 32]) -> Result<(), String> {
156 let conn = super::get_write_connection_guard_static()?;
157 store_epoch_key_tx(&conn, community_id, scope_id, epoch, key)
158}
159
160fn store_epoch_key_tx<C: std::ops::Deref<Target = rusqlite::Connection>>(
164 conn: &C,
165 community_id: &str,
166 scope_id: &str,
167 epoch: u64,
168 key: &[u8; 32],
169) -> Result<(), String> {
170 let enc = enc_key(key)?;
171 conn.execute(
172 "INSERT OR REPLACE INTO community_epoch_keys
173 (community_id, scope_id, epoch, key, created_at)
174 VALUES (?1, ?2, ?3, ?4, ?5)",
175 params![community_id, scope_id, epoch as i64, &enc[..], now_secs()],
177 )
178 .map_err(|e| format!("store epoch key: {e}"))?;
179 Ok(())
180}
181
182pub fn advance_channel_epoch(
189 community_id: &str,
190 channel_id: &str,
191 new_epoch: u64,
192 new_key: &[u8; 32],
193) -> Result<bool, String> {
194 let conn = super::get_write_connection_guard_static()?;
195 let tx = conn.unchecked_transaction().map_err(|e| format!("advance channel epoch tx: {e}"))?;
196 store_epoch_key_tx(&tx, community_id, channel_id, new_epoch, new_key)?;
198 let cur: Option<i64> = tx
200 .query_row(
201 "SELECT epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
202 params![community_id, channel_id],
203 |r| r.get(0),
204 )
205 .optional()
206 .map_err(|e| format!("read channel head: {e}"))?;
207 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
208 if advanced {
209 let enc = enc_key(new_key)?;
210 tx.execute(
211 "UPDATE community_channels SET epoch = ?1, channel_key = ?2
212 WHERE community_id = ?3 AND channel_id = ?4",
213 params![new_epoch as i64, &enc[..], community_id, channel_id],
214 )
215 .map_err(|e| format!("advance channel head: {e}"))?;
216 }
217 tx.commit().map_err(|e| format!("advance channel epoch commit: {e}"))?;
218 Ok(advanced)
219}
220
221pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
228 let conn = super::get_write_connection_guard_static()?;
229 let tx = conn.unchecked_transaction().map_err(|e| format!("advance server root tx: {e}"))?;
230 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch, new_root)?;
233 let cur: Option<i64> = tx
234 .query_row(
235 "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
236 params![community_id],
237 |r| r.get(0),
238 )
239 .optional()
240 .map_err(|e| format!("read server-root epoch: {e}"))?;
241 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
242 if advanced {
243 let enc = enc_key(new_root)?;
244 tx.execute(
245 "UPDATE communities SET server_root_epoch = ?1, server_root_key = ?2 WHERE community_id = ?3",
246 params![new_epoch as i64, &enc[..], community_id],
247 )
248 .map_err(|e| format!("advance server-root head: {e}"))?;
249 }
250 tx.commit().map_err(|e| format!("advance server root commit: {e}"))?;
251 Ok(advanced)
252}
253
254pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
261 let conn = super::get_write_connection_guard_static()?;
262 let tx = conn.unchecked_transaction().map_err(|e| format!("converge server root tx: {e}"))?;
263 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, epoch, new_root)?;
264 let enc = enc_key(new_root)?;
265 let switched = tx
266 .execute(
267 "UPDATE communities SET server_root_key = ?1 WHERE community_id = ?2 AND server_root_epoch = ?3",
268 params![&enc[..], community_id, epoch as i64],
269 )
270 .map_err(|e| format!("converge server-root head: {e}"))?
271 > 0;
272 tx.commit().map_err(|e| format!("converge server root commit: {e}"))?;
273 Ok(switched)
274}
275
276pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, new_key: &[u8; 32]) -> Result<bool, String> {
281 let conn = super::get_write_connection_guard_static()?;
282 let tx = conn.unchecked_transaction().map_err(|e| format!("converge channel tx: {e}"))?;
283 store_epoch_key_tx(&tx, community_id, channel_id, epoch, new_key)?;
284 let enc = enc_key(new_key)?;
285 let switched = tx
286 .execute(
287 "UPDATE community_channels SET channel_key = ?1 WHERE community_id = ?2 AND channel_id = ?3 AND epoch = ?4",
288 params![&enc[..], community_id, channel_id, epoch as i64],
289 )
290 .map_err(|e| format!("converge channel head: {e}"))?
291 > 0;
292 tx.commit().map_err(|e| format!("converge channel commit: {e}"))?;
293 Ok(switched)
294}
295
296pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result<Vec<(Epoch, [u8; 32])>, String> {
300 let conn = super::get_db_connection_guard_static()?;
301 let mut stmt = conn
302 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
303 .map_err(|e| e.to_string())?;
304 let rows = stmt
305 .query_map(params![community_id, scope_id], |r| {
306 Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?))
307 })
308 .map_err(|e| e.to_string())?;
309 let mut out: Vec<(Epoch, [u8; 32])> = Vec::new();
310 for row in rows {
311 let (epoch, key_blob) = row.map_err(|e| e.to_string())?;
312 out.push((Epoch(epoch as u64), dec_key(&key_blob)?));
313 }
314 out.sort_by_key(|(e, _)| e.0);
315 Ok(out)
316}
317
318pub fn held_epoch_key(community_id: &str, scope_id: &str, epoch: u64) -> Result<Option<[u8; 32]>, String> {
321 let conn = super::get_db_connection_guard_static()?;
322 let blob: Option<Vec<u8>> = conn
323 .query_row(
324 "SELECT key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2 AND epoch = ?3",
325 params![community_id, scope_id, epoch as i64],
326 |r| r.get(0),
327 )
328 .optional()
329 .map_err(|e| format!("held epoch key: {e}"))?;
330 blob.map(|b| dec_key(&b)).transpose()
331}
332
333pub fn community_created_at_ms(id: &CommunityId) -> Option<u64> {
337 let conn = super::get_db_connection_guard_static().ok()?;
338 conn.query_row(
339 "SELECT created_at FROM communities WHERE community_id = ?1",
340 params![id.to_hex()],
341 |r| r.get::<_, i64>(0),
342 )
343 .optional()
344 .ok()
345 .flatten()
346 .map(|secs| (secs.max(0) as u64) * 1000)
347}
348
349pub fn load_community(id: &CommunityId) -> Result<Option<Community>, String> {
351 let conn = super::get_db_connection_guard_static()?;
352 let id_hex = id.to_hex();
353
354 let row = conn
355 .query_row(
356 "SELECT server_root_key, name, relays,
357 description, icon, banner, banlist, owner_attestation, server_root_epoch, dissolved
358 FROM communities WHERE community_id = ?1",
359 params![id_hex],
360 |r| {
361 Ok((
362 r.get::<_, Vec<u8>>(0)?,
363 r.get::<_, String>(1)?,
364 r.get::<_, String>(2)?,
365 r.get::<_, Option<String>>(3)?,
366 r.get::<_, Option<String>>(4)?,
367 r.get::<_, Option<String>>(5)?,
368 r.get::<_, String>(6)?,
369 r.get::<_, Option<String>>(7)?,
370 r.get::<_, i64>(8)?,
371 r.get::<_, i64>(9)?,
372 ))
373 },
374 )
375 .optional()
376 .map_err(|e| format!("load community: {e}"))?;
377
378 let (root_blob, name, relays_json, description, icon_json, banner_json, banlist_json, owner_attestation, server_root_epoch, dissolved_int) =
379 match row {
380 Some(t) => t,
381 None => return Ok(None),
382 };
383 let dissolved = dissolved_int != 0;
384
385 let name = dec_txt(&name);
387 let relays_json = dec_txt(&relays_json);
388 let description = description.map(|s| dec_txt(&s));
389 let icon_json = icon_json.map(|s| dec_txt(&s));
390 let banner_json = banner_json.map(|s| dec_txt(&s));
391 let banlist_json = dec_txt(&banlist_json);
392 let owner_attestation = owner_attestation.map(|s| dec_txt(&s));
393
394 let banned: Vec<PublicKey> = serde_json::from_str::<Vec<String>>(&banlist_json)
398 .unwrap_or_default()
399 .iter()
400 .filter_map(|h| PublicKey::from_hex(h).ok())
401 .collect();
402
403 let icon = icon_json
404 .map(|j| serde_json::from_str(&j))
405 .transpose()
406 .map_err(|e| format!("icon json: {e}"))?;
407 let banner = banner_json
408 .map(|j| serde_json::from_str(&j))
409 .transpose()
410 .map_err(|e| format!("banner json: {e}"))?;
411
412 let server_root_key = ServerRootKey(dec_key(&root_blob)?);
413 let relays: Vec<String> = serde_json::from_str(&relays_json).map_err(|e| e.to_string())?;
414
415 let mut protected: Vec<PublicKey> = Vec::new();
422 if let Some(owner) = owner_attestation
423 .as_ref()
424 .and_then(|att| crate::community::owner::verify_owner_attestation(att, &id_hex))
425 {
426 protected.push(owner);
427 }
428 let banned: Vec<PublicKey> = banned.into_iter().filter(|pk| !protected.contains(pk)).collect();
429
430 let raw_channels: Vec<(String, Vec<u8>, i64, String)> = {
433 let mut stmt = conn
434 .prepare(
435 "SELECT channel_id, channel_key, epoch, name
436 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
437 )
438 .map_err(|e| e.to_string())?;
439 let rows = stmt
440 .query_map(params![id_hex], |r| {
441 Ok((
442 r.get::<_, String>(0)?,
443 r.get::<_, Vec<u8>>(1)?,
444 r.get::<_, i64>(2)?,
445 r.get::<_, String>(3)?,
446 ))
447 })
448 .map_err(|e| e.to_string())?;
449 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
450 };
451
452 let roster = get_community_roles(&id_hex).unwrap_or_default();
455
456 let mut channels = Vec::new();
457 for (cid_hex, key_blob, epoch, cname) in raw_channels {
458 let epoch_keys: Vec<(Epoch, crate::community::ChannelKey)> = {
462 let mut ek_stmt = conn
463 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
464 .map_err(|e| e.to_string())?;
465 let rows = ek_stmt
466 .query_map(params![id_hex, cid_hex], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
467 .map_err(|e| e.to_string())?;
468 let mut out = Vec::new();
469 for row in rows {
470 let (e, blob) = row.map_err(|e| e.to_string())?;
471 if let Ok(k) = dec_key(&blob) {
472 out.push((Epoch(e as u64), crate::community::ChannelKey(k)));
473 }
474 }
475 out
476 };
477 channels.push(Channel {
478 id: ChannelId(hex_id_to_32(&cid_hex)?),
479 key: ChannelKey(dec_key(&key_blob)?),
480 epoch: Epoch(epoch as u64),
483 name: dec_txt(&cname),
484 banned: banned.clone(),
485 protected: protected.clone(),
486 roster: roster.clone(),
487 epoch_keys,
488 dissolved,
489 });
490 }
491
492 Ok(Some(Community {
493 id: *id,
494 server_root_key,
495 server_root_epoch: Epoch(server_root_epoch as u64),
497 name,
498 description,
499 icon,
500 banner,
501 relays,
502 channels,
503 owner_attestation,
504 dissolved,
505 }))
506}
507
508pub fn store_message_key(
511 message_id: &str,
512 outer_event_id: &str,
513 ephemeral: &Keys,
514 relays: &[String],
515) -> Result<(), String> {
516 let conn = super::get_write_connection_guard_static()?;
517 let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?;
518 let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?;
519 let enc_secret = enc_key(&sk_bytes)?;
520 let enc_relays = enc_txt(&relays_json)?;
521 conn.execute(
522 "INSERT OR REPLACE INTO community_message_keys
523 (outer_event_id, message_id, ephemeral_secret, relays, created_at)
524 VALUES (?1, ?2, ?3, ?4, ?5)",
525 params![
526 outer_event_id,
527 message_id,
528 &enc_secret[..],
529 enc_relays,
530 now_secs(),
531 ],
532 )
533 .map_err(|e| format!("store message key: {e}"))?;
534 Ok(())
535}
536
537pub fn get_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
543 let conn = super::get_db_connection_guard_static()?;
544 let row = conn
545 .query_row(
546 "SELECT ephemeral_secret, outer_event_id, relays
547 FROM community_message_keys WHERE message_id = ?1",
548 params![message_id],
549 |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)),
550 )
551 .optional()
552 .map_err(|e| format!("get message key: {e}"))?;
553 let (secret_blob, outer_event_id, relays_json) = match row {
554 Some(t) => t,
555 None => return Ok(None),
556 };
557 let secret = SecretKey::from_slice(&dec_key(&secret_blob)?).map_err(|e| format!("ephemeral secret: {e}"))?;
558 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_json)).map_err(|e| e.to_string())?;
559 Ok(Some((Keys::new(secret), outer_event_id, relays)))
560}
561
562pub fn delete_message_key(message_id: &str) -> Result<(), String> {
564 let conn = super::get_write_connection_guard_static()?;
565 conn.execute(
566 "DELETE FROM community_message_keys WHERE message_id = ?1",
567 params![message_id],
568 )
569 .map_err(|e| format!("remove message key: {e}"))?;
570 Ok(())
571}
572
573pub fn take_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
576 let r = get_message_key(message_id)?;
577 if r.is_some() {
578 delete_message_key(message_id)?;
579 }
580 Ok(r)
581}
582
583pub fn community_id_for_channel(channel_id: &str) -> Result<Option<String>, String> {
586 let conn = super::get_db_connection_guard_static()?;
587 conn.query_row(
588 "SELECT community_id FROM community_channels WHERE channel_id = ?1",
589 params![channel_id],
590 |r| r.get::<_, String>(0),
591 )
592 .optional()
593 .map_err(|e| format!("community_id_for_channel: {e}"))
594}
595
596pub fn community_exists(id: &CommunityId) -> Result<bool, String> {
599 let conn = super::get_db_connection_guard_static()?;
600 let found: Option<i64> = conn
601 .query_row(
602 "SELECT 1 FROM communities WHERE community_id = ?1",
603 params![id.to_hex()],
604 |r| r.get(0),
605 )
606 .optional()
607 .map_err(|e| format!("community_exists: {e}"))?;
608 Ok(found.is_some())
609}
610
611#[derive(Debug, Clone, serde::Serialize)]
613pub struct PendingCommunityInvite {
614 pub community_id: String,
615 pub bundle_json: String,
616 pub inviter_npub: String,
617 pub received_at: i64,
618}
619
620pub fn save_pending_invite(
625 community_id: &str,
626 bundle_json: &str,
627 inviter_npub: &str,
628) -> Result<bool, String> {
629 const MAX_PENDING_INVITES: usize = 100;
633
634 let conn = super::get_write_connection_guard_static()?;
635 let enc_bundle = enc_txt(bundle_json)?;
636 let enc_inviter = enc_txt(inviter_npub)?;
637 let changed = conn
642 .execute(
643 "INSERT OR IGNORE INTO pending_community_invites
644 (community_id, bundle_json, inviter_npub, received_at)
645 VALUES (?1, ?2, ?3, ?4)",
646 params![community_id, enc_bundle, enc_inviter, now_secs()],
647 )
648 .map_err(|e| format!("save pending invite: {e}"))?;
649 if changed > 0 {
652 let _ = conn.execute(
653 "DELETE FROM pending_community_invites
654 WHERE community_id IN (
655 SELECT community_id FROM pending_community_invites
656 ORDER BY received_at DESC, community_id DESC
657 LIMIT -1 OFFSET ?1
658 )",
659 params![MAX_PENDING_INVITES],
660 );
661 }
662 Ok(changed > 0)
663}
664
665pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
671 let conn = super::get_write_connection_guard_static()?;
672 let n = conn
673 .execute(
674 "DELETE FROM pending_community_invites
675 WHERE community_id IN (SELECT community_id FROM communities)",
676 [],
677 )
678 .map_err(|e| format!("purge held pending invites: {e}"))?;
679 Ok(n)
680}
681
682pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
684 let conn = super::get_db_connection_guard_static()?;
685 let mut stmt = conn
686 .prepare(
687 "SELECT community_id, bundle_json, inviter_npub, received_at
688 FROM pending_community_invites ORDER BY received_at DESC",
689 )
690 .map_err(|e| e.to_string())?;
691 let rows = stmt
692 .query_map([], |r| {
693 Ok(PendingCommunityInvite {
694 community_id: r.get(0)?,
695 bundle_json: dec_txt(&r.get::<_, String>(1)?),
696 inviter_npub: dec_txt(&r.get::<_, String>(2)?),
697 received_at: r.get(3)?,
698 })
699 })
700 .map_err(|e| e.to_string())?;
701 let mut out = Vec::new();
702 for row in rows {
703 out.push(row.map_err(|e| e.to_string())?);
704 }
705 Ok(out)
706}
707
708pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
712 let conn = super::get_db_connection_guard_static()?;
713 let raw: Option<String> = conn
714 .query_row(
715 "SELECT bundle_json FROM pending_community_invites WHERE community_id = ?1",
716 params![community_id],
717 |r| r.get::<_, String>(0),
718 )
719 .optional()
720 .map_err(|e| format!("get pending invite: {e}"))?;
721 Ok(raw.map(|s| dec_txt(&s)))
722}
723
724pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
726 let conn = super::get_write_connection_guard_static()?;
727 conn.execute(
728 "DELETE FROM pending_community_invites WHERE community_id = ?1",
729 params![community_id],
730 )
731 .map_err(|e| format!("delete pending invite: {e}"))?;
732 Ok(())
733}
734
735pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
737 let conn = super::get_db_connection_guard_static()?;
738 let found: Option<i64> = conn
739 .query_row(
740 "SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
741 params![community_id],
742 |r| r.get(0),
743 )
744 .optional()
745 .map_err(|e| format!("pending_invite_exists: {e}"))?;
746 Ok(found.is_some())
747}
748
749#[derive(Debug, Clone, serde::Serialize)]
751pub struct PublicInviteRecord {
752 pub token: String,
754 pub community_id: String,
755 pub url: String,
756 pub expires_at: Option<i64>,
757 pub created_at: i64,
758 pub label: Option<String>,
760 #[serde(default)]
762 pub join_count: u64,
763}
764
765pub fn save_public_invite(
767 token: &str,
768 community_id: &str,
769 url: &str,
770 expires_at: Option<i64>,
771 label: Option<&str>,
772) -> Result<(), String> {
773 let conn = super::get_write_connection_guard_static()?;
774 let enc_token = enc_txt(token)?;
777 let enc_url = enc_txt(url)?;
778 let enc_label = label.map(enc_txt).transpose()?;
780 conn.execute(
781 "INSERT OR REPLACE INTO community_public_invites
782 (token, community_id, url, expires_at, created_at, label)
783 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
784 params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
785 )
786 .map_err(|e| format!("save public invite: {e}"))?;
787 Ok(())
788}
789
790pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
792 let conn = super::get_db_connection_guard_static()?;
793 let mut stmt = conn
794 .prepare(
795 "SELECT token, community_id, url, expires_at, created_at, label
796 FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
797 )
798 .map_err(|e| e.to_string())?;
799 let rows = stmt
800 .query_map(params![community_id], |r| {
801 Ok(PublicInviteRecord {
802 token: dec_txt(&r.get::<_, String>(0)?),
803 community_id: r.get(1)?,
804 url: dec_txt(&r.get::<_, String>(2)?),
805 expires_at: r.get(3)?,
806 created_at: r.get(4)?,
807 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
808 join_count: 0,
809 })
810 })
811 .map_err(|e| e.to_string())?;
812 let mut out = Vec::new();
813 for row in rows {
814 out.push(row.map_err(|e| e.to_string())?);
815 }
816 if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
818 if let Ok(counts) = community_invite_join_counts(community_id, &me) {
819 for rec in &mut out {
820 if let Some(l) = rec.label.as_deref() {
821 rec.join_count = counts.get(l).copied().unwrap_or(0);
822 }
823 }
824 }
825 }
826 Ok(out)
827}
828
829pub fn delete_public_invite(token: &str) -> Result<(), String> {
831 let conn = super::get_write_connection_guard_static()?;
832 let rows: Vec<(i64, String)> = {
835 let mut stmt = conn
836 .prepare("SELECT rowid, token FROM community_public_invites")
837 .map_err(|e| e.to_string())?;
838 let mapped = stmt
839 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
840 .map_err(|e| e.to_string())?;
841 mapped.filter_map(|r| r.ok()).collect()
842 };
843 for (rowid, stored) in rows {
844 if dec_txt(&stored) == token {
845 conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
846 .map_err(|e| format!("delete public invite: {e}"))?;
847 }
848 }
849 Ok(())
850}
851
852pub fn list_all_public_invites() -> Result<Vec<PublicInviteRecord>, String> {
854 let conn = super::get_db_connection_guard_static()?;
855 let mut stmt = conn
856 .prepare(
857 "SELECT token, community_id, url, expires_at, created_at, label
858 FROM community_public_invites ORDER BY created_at DESC",
859 )
860 .map_err(|e| e.to_string())?;
861 let rows = stmt
862 .query_map([], |r| {
863 Ok(PublicInviteRecord {
864 token: dec_txt(&r.get::<_, String>(0)?),
865 community_id: r.get(1)?,
866 url: dec_txt(&r.get::<_, String>(2)?),
867 expires_at: r.get(3)?,
868 created_at: r.get(4)?,
869 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
870 join_count: 0,
871 })
872 })
873 .map_err(|e| e.to_string())?;
874 let mut out = Vec::new();
875 for row in rows {
876 out.push(row.map_err(|e| e.to_string())?);
877 }
878 Ok(out)
879}
880
881pub fn upsert_public_invite(
886 token: &str,
887 community_id: &str,
888 url: &str,
889 expires_at: Option<i64>,
890 created_at: i64,
891 label: Option<&str>,
892) -> Result<bool, String> {
893 let conn = super::get_write_connection_guard_static()?;
894 let already = {
895 let mut stmt = conn
896 .prepare("SELECT token FROM community_public_invites WHERE community_id = ?1")
897 .map_err(|e| e.to_string())?;
898 let stored: Vec<String> = stmt
899 .query_map(params![community_id], |r| r.get::<_, String>(0))
900 .map_err(|e| e.to_string())?
901 .filter_map(|r| r.ok())
902 .collect();
903 stored.iter().any(|s| dec_txt(s) == token)
904 };
905 if already {
906 return Ok(false);
907 }
908 let enc_token = enc_txt(token)?;
909 let enc_url = enc_txt(url)?;
910 let enc_label = label.map(enc_txt).transpose()?;
911 conn.execute(
912 "INSERT INTO community_public_invites
913 (token, community_id, url, expires_at, created_at, label)
914 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
915 params![enc_token, community_id, enc_url, expires_at, created_at, enc_label],
916 )
917 .map_err(|e| format!("upsert public invite: {e}"))?;
918 Ok(true)
919}
920
921pub fn delete_community(community_id: &str) -> Result<(), String> {
926 delete_community_inner(community_id, false)
927}
928
929pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
935 delete_community_inner(community_id, true)
936}
937
938fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
939 let conn = super::get_write_connection_guard_static()?;
940 let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
943 for sql in [
944 Some("DELETE FROM communities WHERE community_id = ?1"),
945 Some("DELETE FROM community_channels WHERE community_id = ?1"),
946 (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
950 Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
951 Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
952 Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
953 Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
956 ]
957 .into_iter()
958 .flatten()
959 {
960 tx.execute(sql, params![community_id])
961 .map_err(|e| format!("delete community: {e}"))?;
962 }
963 tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
964 Ok(())
969}
970
971pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
978 const COMMUNITY_MEMBER_CAP: usize = 500;
981 use std::collections::HashMap;
982
983 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
984 Some(c) => c,
985 None => return Ok(Vec::new()),
986 };
987 let owner_b32: Option<String> = community
992 .owner_attestation
993 .as_deref()
994 .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
995 .and_then(|pk| pk.to_bech32().ok());
996
997 let mut chat_ints: Vec<i64> = Vec::new();
999 for ch in &community.channels {
1000 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1001 chat_ints.push(cid);
1002 }
1003 }
1004
1005 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1008
1009 let mut active: HashMap<String, u64> = HashMap::new();
1014 let mut left: HashMap<String, u64> = HashMap::new();
1015 if !chat_ints.is_empty() {
1018 let conn = super::get_db_connection_guard_static()?;
1019 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1020
1021 {
1022 let sql = format!(
1023 "SELECT npub, MAX(created_at) FROM events \
1024 WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
1025 GROUP BY npub"
1026 );
1027 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1028 let rows = stmt
1029 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1030 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
1031 })
1032 .map_err(|e| e.to_string())?;
1033 for row in rows {
1034 let (npub, at) = row.map_err(|e| e.to_string())?;
1035 active.insert(npub, at);
1036 }
1037 }
1038
1039 {
1041 let sql = format!(
1042 "SELECT npub, created_at, tags FROM events \
1043 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1044 );
1045 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1046 let rows = stmt
1047 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1048 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
1049 })
1050 .map_err(|e| e.to_string())?;
1051 for row in rows {
1052 let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
1053 let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
1055 .ok()
1056 .and_then(|tags| {
1057 tags.into_iter()
1058 .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
1059 .and_then(|t| t.into_iter().nth(1))
1060 });
1061 match etype.as_deref() {
1062 Some("1") => {
1063 let e = active.entry(npub).or_insert(0);
1064 if at > *e { *e = at; }
1065 }
1066 Some("0") => {
1067 let e = left.entry(npub).or_insert(0);
1068 if at > *e { *e = at; }
1069 }
1070 _ => {}
1071 }
1072 }
1073 }
1074 }
1075
1076 let banned: std::collections::HashSet<String> = community
1079 .channels
1080 .first()
1081 .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
1082 .unwrap_or_default();
1083
1084 let mut out: Vec<(String, u64)> = active
1086 .into_iter()
1087 .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l))
1088 .collect();
1089
1090 {
1097 let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
1098 let mut reassert = |npub: String| {
1099 if !banned.contains(&npub) && present.insert(npub.clone()) {
1100 out.push((npub, now_secs() as u64));
1101 }
1102 };
1103 if let Some(o) = owner_b32 {
1104 reassert(o);
1105 }
1106 if let Ok(roles) = get_community_roles(community_id) {
1107 for g in &roles.grants {
1108 if g.role_ids.is_empty() {
1109 continue; }
1111 if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
1112 reassert(b32);
1113 }
1114 }
1115 }
1116 }
1117 out.sort_by(|a, b| b.1.cmp(&a.1));
1118 out.truncate(COMMUNITY_MEMBER_CAP);
1119 Ok(out)
1120}
1121
1122pub fn community_invite_join_counts(
1127 community_id: &str,
1128 inviter_npub: &str,
1129) -> Result<std::collections::HashMap<String, u64>, String> {
1130 use std::collections::{HashMap, HashSet};
1131 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1132 Some(c) => c,
1133 None => return Ok(HashMap::new()),
1134 };
1135 let mut chat_ints: Vec<i64> = Vec::new();
1136 for ch in &community.channels {
1137 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1138 chat_ints.push(cid);
1139 }
1140 }
1141 if chat_ints.is_empty() {
1142 return Ok(HashMap::new());
1143 }
1144 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1145 let conn = super::get_db_connection_guard_static()?;
1146 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1147 let sql = format!(
1148 "SELECT npub, tags FROM events \
1149 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1150 );
1151 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1152 let rows = stmt
1153 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1154 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1155 })
1156 .map_err(|e| e.to_string())?;
1157 let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
1159 for row in rows {
1160 let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
1161 let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
1162 Ok(t) => t,
1163 Err(_) => continue,
1164 };
1165 let tag_val = |key: &str| -> Option<String> {
1166 tags.iter()
1167 .find(|t| t.first().map(|s| s == key).unwrap_or(false))
1168 .and_then(|t| t.get(1).cloned())
1169 };
1170 if tag_val("event-type").as_deref() != Some("1") {
1172 continue;
1173 }
1174 if tag_val("invited-by").as_deref() != Some(inviter_npub) {
1175 continue;
1176 }
1177 if let Some(label) = tag_val("invited-label") {
1178 per_label.entry(label).or_default().insert(joiner);
1179 }
1180 }
1181 Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
1182}
1183
1184pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
1189 let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?;
1190 let conn = super::get_write_connection_guard_static()?;
1191 conn.execute(
1192 "UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3",
1193 params![json, at, community_id],
1194 )
1195 .map_err(|e| format!("set banlist: {e}"))?;
1196 Ok(())
1197}
1198
1199pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
1202 let conn = super::get_db_connection_guard_static()?;
1203 let at: Option<i64> = conn
1204 .query_row(
1205 "SELECT banlist_at FROM communities WHERE community_id = ?1",
1206 params![community_id],
1207 |r| r.get(0),
1208 )
1209 .optional()
1210 .map_err(|e| format!("get banlist_at: {e}"))?;
1211 Ok(at.unwrap_or(0))
1212}
1213
1214pub fn set_community_roles(
1219 community_id: &str,
1220 roles: &crate::community::roles::CommunityRoles,
1221 at: i64,
1222) -> Result<(), String> {
1223 let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
1224 let conn = super::get_write_connection_guard_static()?;
1225 conn.execute(
1226 "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
1227 params![json, at, community_id],
1228 )
1229 .map_err(|e| format!("set roles: {e}"))?;
1230 Ok(())
1231}
1232
1233pub fn get_community_roles(
1235 community_id: &str,
1236) -> Result<crate::community::roles::CommunityRoles, String> {
1237 let conn = super::get_db_connection_guard_static()?;
1238 let json: Option<String> = conn
1239 .query_row(
1240 "SELECT roles FROM communities WHERE community_id = ?1",
1241 params![community_id],
1242 |r| r.get(0),
1243 )
1244 .optional()
1245 .map_err(|e| format!("get roles: {e}"))?;
1246 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1247}
1248
1249pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
1251 let conn = super::get_db_connection_guard_static()?;
1252 let at: Option<i64> = conn
1253 .query_row(
1254 "SELECT roles_at FROM communities WHERE community_id = ?1",
1255 params![community_id],
1256 |r| r.get(0),
1257 )
1258 .optional()
1259 .map_err(|e| format!("get roles_at: {e}"))?;
1260 Ok(at.unwrap_or(0))
1261}
1262
1263pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
1268 set_edition_head_inner(community_id, entity_id, version, self_hash, None, None)
1269}
1270
1271pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1274 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), None)
1275}
1276
1277pub fn set_edition_head_at_epoch(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32], epoch: u64) -> Result<(), String> {
1282 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), Some(epoch))
1283}
1284
1285fn set_edition_head_inner(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: Option<&[u8; 32]>, epoch: Option<u64>) -> Result<(), String> {
1286 let conn = super::get_write_connection_guard_static()?;
1287 conn.execute(
1293 "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
1294 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
1295 ON CONFLICT(community_id, entity_id) DO UPDATE SET
1296 version = excluded.version,
1297 self_hash = excluded.self_hash,
1298 inner_id = excluded.inner_id,
1299 epoch = excluded.epoch
1300 WHERE excluded.epoch > community_edition_heads.epoch
1301 OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
1302 params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.map(|i| i.as_slice()), epoch.map(|e| e as i64)],
1303 )
1304 .map_err(|e| format!("set edition head: {e}"))?;
1305 Ok(())
1306}
1307
1308pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1318 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, None)
1319}
1320
1321pub fn converge_edition_head_at_epoch(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32], epoch: u64) -> Result<(), String> {
1325 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, Some(epoch))
1326}
1327
1328fn converge_edition_head_inner(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32], epoch: Option<u64>) -> Result<(), String> {
1329 let conn = super::get_write_connection_guard_static()?;
1330 conn.execute(
1333 "UPDATE community_edition_heads
1334 SET self_hash = ?4, inner_id = ?5
1335 WHERE community_id = ?1 AND entity_id = ?2
1336 AND version = ?3
1337 AND epoch = COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
1338 AND (inner_id IS NULL OR ?5 < inner_id)",
1339 params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice(), epoch.map(|e| e as i64)],
1340 )
1341 .map_err(|e| format!("converge edition head: {e}"))?;
1342 Ok(())
1343}
1344
1345pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
1350 let conn = super::get_db_connection_guard_static()?;
1351 let row: Option<Option<Vec<u8>>> = conn
1352 .query_row(
1353 "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1354 params![community_id, entity_id],
1355 |r| r.get(0),
1356 )
1357 .optional()
1358 .map_err(|e| format!("get edition head inner_id: {e}"))?;
1359 match row.flatten() {
1360 Some(blob) if blob.len() == 32 => {
1361 let mut h = [0u8; 32];
1362 h.copy_from_slice(&blob);
1363 Ok(Some(h))
1364 }
1365 _ => Ok(None),
1366 }
1367}
1368
1369pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
1372 let conn = super::get_db_connection_guard_static()?;
1373 let row: Option<(i64, Vec<u8>)> = conn
1374 .query_row(
1375 "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1376 params![community_id, entity_id],
1377 |r| Ok((r.get(0)?, r.get(1)?)),
1378 )
1379 .optional()
1380 .map_err(|e| format!("get edition head: {e}"))?;
1381 match row {
1382 Some((v, hash)) if hash.len() == 32 => {
1383 let mut h = [0u8; 32];
1384 h.copy_from_slice(&hash);
1385 Ok(Some((v as u64, h)))
1386 }
1387 _ => Ok(None),
1388 }
1389}
1390
1391pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
1396 let conn = super::get_db_connection_guard_static()?;
1397 let mut stmt = conn
1398 .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
1399 .map_err(|e| e.to_string())?;
1400 let rows = stmt
1401 .query_map(params![community_id], |r| r.get::<_, String>(0))
1402 .map_err(|e| e.to_string())?;
1403 let mut out = std::collections::HashSet::new();
1404 for row in rows {
1405 out.insert(row.map_err(|e| e.to_string())?);
1406 }
1407 Ok(out)
1408}
1409
1410
1411pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
1417 let conn = super::get_db_connection_guard_static()?;
1418 let mut stmt = conn
1419 .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1420 .map_err(|e| e.to_string())?;
1421 let rows = stmt
1422 .query_map(params![community_id], |r| {
1423 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
1424 })
1425 .map_err(|e| e.to_string())?;
1426 let mut out = std::collections::HashMap::new();
1427 for row in rows {
1428 let (entity, version, hash) = row.map_err(|e| e.to_string())?;
1429 if hash.len() == 32 {
1430 let mut h = [0u8; 32];
1431 h.copy_from_slice(&hash);
1432 out.insert(entity, (version as u64, h));
1433 }
1434 }
1435 Ok(out)
1436}
1437
1438pub fn get_all_edition_heads_full(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32], Option<[u8; 32]>)>, String> {
1446 let conn = super::get_db_connection_guard_static()?;
1447 let mut stmt = conn
1448 .prepare("SELECT entity_id, epoch, version, self_hash, inner_id FROM community_edition_heads WHERE community_id = ?1")
1449 .map_err(|e| e.to_string())?;
1450 let rows = stmt
1451 .query_map(params![community_id], |r| {
1452 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, Option<Vec<u8>>>(4)?))
1453 })
1454 .map_err(|e| e.to_string())?;
1455 let mut out = std::collections::HashMap::new();
1456 for row in rows {
1457 let (entity, epoch, version, hash, inner) = row.map_err(|e| e.to_string())?;
1458 if hash.len() == 32 {
1459 let mut h = [0u8; 32];
1460 h.copy_from_slice(&hash);
1461 let inner_id = inner.and_then(|b| {
1462 (b.len() == 32).then(|| {
1463 let mut i = [0u8; 32];
1464 i.copy_from_slice(&b);
1465 i
1466 })
1467 });
1468 out.insert(entity, (epoch as u64, version as u64, h, inner_id));
1469 }
1470 }
1471 Ok(out)
1472}
1473
1474pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32])>, String> {
1475 let conn = super::get_db_connection_guard_static()?;
1476 let mut stmt = conn
1477 .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1478 .map_err(|e| e.to_string())?;
1479 let rows = stmt
1480 .query_map(params![community_id], |r| {
1481 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
1482 })
1483 .map_err(|e| e.to_string())?;
1484 let mut out = std::collections::HashMap::new();
1485 for row in rows {
1486 let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
1487 if hash.len() == 32 {
1488 let mut h = [0u8; 32];
1489 h.copy_from_slice(&hash);
1490 out.insert(entity, (epoch as u64, version as u64, h));
1491 }
1492 }
1493 Ok(out)
1494}
1495
1496pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
1498 let conn = super::get_db_connection_guard_static()?;
1499 let json: Option<String> = conn
1500 .query_row(
1501 "SELECT banlist FROM communities WHERE community_id = ?1",
1502 params![community_id],
1503 |r| r.get(0),
1504 )
1505 .optional()
1506 .map_err(|e| format!("get banlist: {e}"))?;
1507 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1508}
1509
1510pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
1514 let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
1515 let conn = super::get_write_connection_guard_static()?;
1516 conn.execute(
1517 "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
1518 params![json, community_id],
1519 )
1520 .map_err(|e| format!("set invite registry: {e}"))?;
1521 Ok(())
1522}
1523
1524pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
1527 let conn = super::get_db_connection_guard_static()?;
1528 let json: Option<String> = conn
1529 .query_row(
1530 "SELECT invite_registry FROM communities WHERE community_id = ?1",
1531 params![community_id],
1532 |r| r.get(0),
1533 )
1534 .optional()
1535 .map_err(|e| format!("get invite registry: {e}"))?;
1536 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1537}
1538
1539pub struct InviteLinkSetRow {
1542 pub creator_hex: String,
1543 pub locators: Vec<String>,
1544}
1545
1546pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
1550 let mut conn = super::get_write_connection_guard_static()?;
1551 let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
1552 tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
1553 .map_err(|e| format!("clear invite-link-sets: {e}"))?;
1554 for s in sets {
1555 if s.locators.is_empty() {
1556 continue; }
1558 let enc_creator = enc_txt(&s.creator_hex)?;
1559 let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
1560 tx.execute(
1563 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1564 params![community_id, enc_creator, enc_locators],
1565 )
1566 .map_err(|e| format!("insert invite-link-set: {e}"))?;
1567 }
1568 tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
1569 Ok(())
1570}
1571
1572pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
1575 let conn = super::get_write_connection_guard_static()?;
1576 let existing_rowid: Option<i64> = {
1578 let mut stmt = conn
1579 .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
1580 .map_err(|e| e.to_string())?;
1581 let rows = stmt
1582 .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1583 .map_err(|e| e.to_string())?;
1584 let mut found = None;
1585 for row in rows {
1586 let (rowid, stored) = row.map_err(|e| e.to_string())?;
1587 if dec_txt(&stored) == creator_hex {
1588 found = Some(rowid);
1589 break;
1590 }
1591 }
1592 found
1593 };
1594 if locators.is_empty() {
1595 if let Some(rowid) = existing_rowid {
1596 conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
1597 .map_err(|e| format!("delete invite-link-set: {e}"))?;
1598 }
1599 return Ok(());
1600 }
1601 let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
1602 match existing_rowid {
1603 Some(rowid) => {
1604 conn.execute(
1605 "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
1606 params![enc_locators, rowid],
1607 )
1608 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1609 }
1610 None => {
1611 let enc_creator = enc_txt(creator_hex)?;
1612 conn.execute(
1613 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1614 params![community_id, enc_creator, enc_locators],
1615 )
1616 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1617 }
1618 }
1619 Ok(())
1620}
1621
1622pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
1625 let conn = super::get_db_connection_guard_static()?;
1626 let mut stmt = conn
1627 .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
1628 .map_err(|e| format!("prepare invite-link-sets: {e}"))?;
1629 let rows = stmt
1630 .query_map(params![community_id], |r| {
1631 let creator_hex: String = r.get(0)?;
1632 let json: String = r.get(1)?;
1633 Ok((creator_hex, json))
1634 })
1635 .map_err(|e| format!("query invite-link-sets: {e}"))?;
1636 let mut out = Vec::new();
1637 for row in rows {
1638 let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
1639 let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
1640 out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
1641 }
1642 Ok(out)
1643}
1644
1645pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
1649 let conn = super::get_write_connection_guard_static()?;
1650 conn.execute(
1651 "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
1652 params![pending as i64, community_id],
1653 )
1654 .map_err(|e| format!("set read_cut_pending: {e}"))?;
1655 Ok(())
1656}
1657
1658pub fn set_community_dissolved(community_id: &str) -> Result<bool, String> {
1666 let conn = super::get_write_connection_guard_static()?;
1667 let changed = conn
1668 .execute(
1669 "UPDATE communities SET dissolved = 1 WHERE community_id = ?1 AND dissolved = 0",
1670 params![community_id],
1671 )
1672 .map_err(|e| format!("set dissolved: {e}"))?;
1673 Ok(changed > 0)
1674}
1675
1676pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
1679 let conn = super::get_db_connection_guard_static()?;
1680 let v: Option<i64> = conn
1681 .query_row(
1682 "SELECT dissolved FROM communities WHERE community_id = ?1",
1683 params![community_id],
1684 |r| r.get(0),
1685 )
1686 .optional()
1687 .map_err(|e| format!("get dissolved: {e}"))?;
1688 Ok(v.unwrap_or(0) != 0)
1689}
1690
1691pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
1694 let conn = super::get_db_connection_guard_static()?;
1695 let v: Option<i64> = conn
1696 .query_row(
1697 "SELECT read_cut_pending FROM communities WHERE community_id = ?1",
1698 params![community_id],
1699 |r| r.get(0),
1700 )
1701 .optional()
1702 .map_err(|e| format!("get read_cut_pending: {e}"))?;
1703 Ok(v.unwrap_or(0) != 0)
1704}
1705
1706pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
1711 let conn = super::get_write_connection_guard_static()?;
1712 conn.execute(
1713 "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
1714 params![target as i64, community_id],
1715 )
1716 .map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
1717 Ok(())
1718}
1719
1720pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
1723 let conn = super::get_db_connection_guard_static()?;
1724 let v: Option<i64> = conn
1725 .query_row(
1726 "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
1727 params![community_id],
1728 |r| r.get(0),
1729 )
1730 .optional()
1731 .map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
1732 Ok(v.unwrap_or(0) as u64)
1733}
1734
1735pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
1738 let conn = super::get_db_connection_guard_static()?;
1739 let v: Option<i64> = conn
1740 .query_row(
1741 "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
1742 params![community_id, channel_id],
1743 |r| r.get(0),
1744 )
1745 .optional()
1746 .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
1747 Ok(v.unwrap_or(0) as u64)
1748}
1749
1750pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
1754 let conn = super::get_write_connection_guard_static()?;
1755 conn.execute(
1756 "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
1757 params![server_epoch as i64, community_id, channel_id],
1758 )
1759 .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
1760 Ok(())
1761}
1762
1763pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
1765 let conn = super::get_db_connection_guard_static()?;
1766 let mut stmt = conn
1767 .prepare("SELECT community_id FROM communities ORDER BY created_at")
1768 .map_err(|e| e.to_string())?;
1769 let rows = stmt
1770 .query_map([], |r| r.get::<_, String>(0))
1771 .map_err(|e| e.to_string())?;
1772 let mut ids = Vec::new();
1773 for row in rows {
1774 ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
1775 }
1776 Ok(ids)
1777}
1778
1779pub fn community_protocol(id: &CommunityId) -> Result<Option<crate::community::ConcordProtocol>, String> {
1790 let conn = super::get_db_connection_guard_static()?;
1791 let n: Option<i64> = conn
1792 .query_row("SELECT protocol FROM communities WHERE community_id = ?1", params![id.to_hex()], |r| r.get(0))
1793 .optional()
1794 .map_err(|e| e.to_string())?;
1795 Ok(n.map(crate::community::ConcordProtocol::from_i64))
1796}
1797
1798#[derive(serde::Serialize, serde::Deserialize, Default)]
1801struct CommunityMetaStash {
1802 #[serde(default, skip_serializing_if = "Option::is_none")]
1803 custom: Option<serde_json::Map<String, serde_json::Value>>,
1804 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
1805 extra: serde_json::Map<String, serde_json::Value>,
1806}
1807
1808#[derive(serde::Serialize, serde::Deserialize, Default)]
1810struct ChannelMetaStash {
1811 #[serde(default, skip_serializing_if = "Option::is_none")]
1812 voice: Option<bool>,
1813 #[serde(default, skip_serializing_if = "Option::is_none")]
1814 custom: Option<serde_json::Map<String, serde_json::Value>>,
1815 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
1816 extra: serde_json::Map<String, serde_json::Value>,
1817}
1818
1819pub fn save_community_v2(c: &crate::community::v2::community::CommunityV2) -> Result<(), String> {
1822 let conn = super::get_write_connection_guard_static()?;
1823 let id_hex = crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0);
1824 let relays_json = serde_json::to_string(&c.relays).map_err(|e| e.to_string())?;
1825 let created = (c.created_at_ms / 1000) as i64;
1826
1827 let enc_root = enc_key(&c.community_root)?;
1828 let enc_name = enc_txt(&c.name)?;
1829 let enc_relays = enc_txt(&relays_json)?;
1830 let enc_desc = enc_txt_opt(&c.description)?;
1831 let enc_owner_pk = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_xonly))?;
1832 let enc_owner_salt = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_salt))?;
1833 let icon_json = c.icon.as_ref().map(|i| serde_json::to_string(i).map_err(|e| e.to_string())).transpose()?;
1837 let banner_json = c.banner.as_ref().map(|b| serde_json::to_string(b).map_err(|e| e.to_string())).transpose()?;
1838 let enc_icon = enc_txt_opt(&icon_json)?;
1839 let enc_banner = enc_txt_opt(&banner_json)?;
1840 let stash_json = (c.meta_custom.is_some() || !c.meta_extra.is_empty())
1841 .then(|| serde_json::to_string(&CommunityMetaStash { custom: c.meta_custom.clone(), extra: c.meta_extra.clone() }).map_err(|e| e.to_string()))
1842 .transpose()?;
1843 let enc_stash = enc_txt_opt(&stash_json)?;
1844
1845 let tx = conn.unchecked_transaction().map_err(|e| format!("save v2 community tx: {e}"))?;
1846 tx.execute(
1847 "INSERT INTO communities
1848 (community_id, server_root_key, name, relays, created_at, description,
1849 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt, icon, banner, meta_extra)
1850 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2, ?9, ?10, ?11, ?12, ?13)
1851 ON CONFLICT(community_id) DO UPDATE SET
1852 server_root_key=?2, name=?3, relays=?4, description=?6,
1853 server_root_epoch=?7, dissolved=?8, protocol=2, owner_pubkey=?9, owner_salt=?10,
1854 icon=?11, banner=?12, meta_extra=?13",
1855 params![
1856 id_hex, enc_root, enc_name, enc_relays, created, enc_desc,
1857 c.root_epoch.0 as i64, c.dissolved as i64, enc_owner_pk, enc_owner_salt,
1858 enc_icon, enc_banner, enc_stash,
1859 ],
1860 )
1861 .map_err(|e| format!("save v2 community: {e}"))?;
1862
1863 for ch in &c.channels {
1864 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
1865 let owner_of: Option<String> = tx
1871 .query_row("SELECT community_id FROM community_channels WHERE channel_id=?1", params![ch_hex], |r| r.get(0))
1872 .optional()
1873 .map_err(|e| format!("channel ownership check: {e}"))?;
1874 if owner_of.is_some_and(|existing| existing != id_hex) {
1875 continue;
1881 }
1882 let stored_key = ch.key.unwrap_or(c.community_root);
1886 let enc_ch_key = enc_key(&stored_key)?;
1887 let enc_ch_name = enc_txt(&ch.name)?;
1888 let ch_stash_json = (ch.voice.is_some() || ch.meta_custom.is_some() || !ch.meta_extra.is_empty())
1889 .then(|| {
1890 serde_json::to_string(&ChannelMetaStash { voice: ch.voice, custom: ch.meta_custom.clone(), extra: ch.meta_extra.clone() })
1891 .map_err(|e| e.to_string())
1892 })
1893 .transpose()?;
1894 let enc_ch_stash = enc_txt_opt(&ch_stash_json)?;
1895 tx.execute(
1896 "INSERT INTO community_channels
1897 (channel_id, community_id, channel_key, epoch, name, created_at, private, meta_extra)
1898 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1899 ON CONFLICT(channel_id) DO UPDATE SET
1900 channel_key=?3, epoch=?4, name=?5, private=?7, meta_extra=?8",
1901 params![ch_hex, id_hex, enc_ch_key, ch.epoch.0 as i64, enc_ch_name, created, ch.private as i64, enc_ch_stash],
1902 )
1903 .map_err(|e| format!("save v2 channel: {e}"))?;
1904 }
1905
1906 let keep: Vec<String> = c.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
1911 if keep.is_empty() {
1912 tx.execute("DELETE FROM community_channels WHERE community_id=?1", params![id_hex])
1913 .map_err(|e| format!("prune v2 channels: {e}"))?;
1914 } else {
1915 let placeholders = std::iter::repeat("?").take(keep.len()).collect::<Vec<_>>().join(",");
1916 let sql = format!("DELETE FROM community_channels WHERE community_id=? AND channel_id NOT IN ({placeholders})");
1917 let mut binds: Vec<String> = Vec::with_capacity(keep.len() + 1);
1918 binds.push(id_hex.clone());
1919 binds.extend(keep);
1920 tx.execute(&sql, rusqlite::params_from_iter(binds.iter()))
1921 .map_err(|e| format!("prune v2 channels: {e}"))?;
1922 }
1923
1924 tx.commit().map_err(|e| format!("commit v2 community: {e}"))?;
1925 Ok(())
1926}
1927
1928pub fn load_community_v2(id: &CommunityId) -> Result<Option<crate::community::v2::community::CommunityV2>, String> {
1930 use crate::community::v2::community::{ChannelV2, CommunityV2};
1931 use crate::community::v2::control::CommunityIdentity;
1932 let conn = super::get_db_connection_guard_static()?;
1933 let id_hex = id.to_hex();
1934
1935 let row = conn
1936 .query_row(
1937 "SELECT server_root_key, name, relays, created_at, description,
1938 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt,
1939 icon, banner, meta_extra
1940 FROM communities WHERE community_id = ?1",
1941 params![id_hex],
1942 |r| {
1943 Ok((
1944 r.get::<_, Vec<u8>>(0)?,
1945 r.get::<_, String>(1)?,
1946 r.get::<_, String>(2)?,
1947 r.get::<_, i64>(3)?,
1948 r.get::<_, Option<String>>(4)?,
1949 r.get::<_, i64>(5)?,
1950 r.get::<_, i64>(6)?,
1951 r.get::<_, i64>(7)?,
1952 r.get::<_, Option<String>>(8)?,
1953 r.get::<_, Option<String>>(9)?,
1954 r.get::<_, Option<String>>(10)?,
1955 r.get::<_, Option<String>>(11)?,
1956 r.get::<_, Option<String>>(12)?,
1957 ))
1958 },
1959 )
1960 .optional()
1961 .map_err(|e| e.to_string())?;
1962 let Some((root_blob, name_e, relays_e, created, desc_e, root_epoch, dissolved, protocol, owner_pk_e, owner_salt_e, icon_e, banner_e, stash_e)) = row
1963 else {
1964 return Ok(None);
1965 };
1966 if crate::community::ConcordProtocol::from_i64(protocol) != crate::community::ConcordProtocol::V2 {
1967 return Ok(None);
1968 }
1969 let (Some(owner_pk_e), Some(owner_salt_e)) = (owner_pk_e, owner_salt_e) else {
1970 return Err("v2 community row is missing its owner commitment".to_string());
1971 };
1972
1973 let community_root = dec_key(&root_blob)?;
1974 let owner_xonly = parse_hex32(&dec_txt(&owner_pk_e))?;
1975 let owner_salt = parse_hex32(&dec_txt(&owner_salt_e))?;
1976 let identity = CommunityIdentity { community_id: *id, owner_xonly, owner_salt };
1977 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_e)).unwrap_or_default();
1978
1979 let mut channels = Vec::new();
1980 {
1981 let mut stmt = conn
1982 .prepare(
1983 "SELECT channel_id, channel_key, epoch, name, private, meta_extra
1984 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
1985 )
1986 .map_err(|e| e.to_string())?;
1987 let rows = stmt
1988 .query_map(params![id_hex], |r| {
1989 Ok((
1990 r.get::<_, String>(0)?,
1991 r.get::<_, Vec<u8>>(1)?,
1992 r.get::<_, i64>(2)?,
1993 r.get::<_, String>(3)?,
1994 r.get::<_, i64>(4)?,
1995 r.get::<_, Option<String>>(5)?,
1996 ))
1997 })
1998 .map_err(|e| e.to_string())?;
1999 for row in rows {
2000 let (ch_hex, key_blob, epoch, name_e, private, ch_stash_e) = row.map_err(|e| e.to_string())?;
2001 let private = private != 0;
2002 let key = dec_key(&key_blob)?;
2003 let ch_stash: ChannelMetaStash = ch_stash_e
2006 .map(|s| dec_txt(&s))
2007 .and_then(|j| serde_json::from_str(&j).ok())
2008 .unwrap_or_default();
2009 channels.push(ChannelV2 {
2010 id: ChannelId(hex_id_to_32(&ch_hex)?),
2011 name: dec_txt(&name_e),
2012 private,
2013 key: (private && key != community_root).then_some(key),
2020 epoch: Epoch(epoch as u64),
2021 voice: ch_stash.voice,
2022 meta_custom: ch_stash.custom,
2023 meta_extra: ch_stash.extra,
2024 });
2025 }
2026 }
2027
2028 let icon = icon_e
2031 .map(|s| dec_txt(&s))
2032 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2033 let banner = banner_e
2034 .map(|s| dec_txt(&s))
2035 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2036 let stash: CommunityMetaStash = stash_e
2037 .map(|s| dec_txt(&s))
2038 .and_then(|j| serde_json::from_str(&j).ok())
2039 .unwrap_or_default();
2040
2041 Ok(Some(CommunityV2 {
2042 identity,
2043 community_root,
2044 root_epoch: Epoch(root_epoch as u64),
2045 name: dec_txt(&name_e),
2046 description: desc_e.map(|d| dec_txt(&d)),
2047 icon,
2048 banner,
2049 meta_custom: stash.custom,
2050 meta_extra: stash.extra,
2051 relays,
2052 channels,
2053 dissolved: dissolved != 0,
2054 created_at_ms: (created as u64).saturating_mul(1000),
2055 }))
2056}
2057
2058fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
2059 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
2060 return Err("stored value is not 32-byte hex".to_string());
2061 }
2062 Ok(crate::simd::hex::hex_to_bytes_32(hex))
2063}
2064
2065pub fn get_guestbook(community_id: &str) -> Result<(Vec<crate::community::v2::guestbook::GuestbookEvent>, u64), String> {
2069 let conn = super::get_db_connection_guard_static()?;
2070 let row = conn
2071 .query_row(
2072 "SELECT events, cursor_secs FROM community_guestbook WHERE community_id = ?1",
2073 params![community_id],
2074 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
2075 )
2076 .optional()
2077 .map_err(|e| format!("load guestbook: {e}"))?;
2078 let Some((events_e, cursor)) = row else {
2079 return Ok((Vec::new(), 0));
2080 };
2081 let events = serde_json::from_str(&dec_txt(&events_e)).unwrap_or_default();
2082 Ok((events, cursor.max(0) as u64))
2083}
2084
2085pub fn set_guestbook(
2088 community_id: &str,
2089 events: &[crate::community::v2::guestbook::GuestbookEvent],
2090 cursor_secs: u64,
2091) -> Result<(), String> {
2092 let conn = super::get_write_connection_guard_static()?;
2093 let json = serde_json::to_string(events).map_err(|e| e.to_string())?;
2094 let enc = enc_txt(&json)?;
2095 conn.execute(
2096 "INSERT INTO community_guestbook (community_id, events, cursor_secs)
2097 VALUES (?1, ?2, ?3)
2098 ON CONFLICT(community_id) DO UPDATE SET events=?2, cursor_secs=?3",
2099 params![community_id, enc, cursor_secs as i64],
2100 )
2101 .map_err(|e| format!("save guestbook: {e}"))?;
2102 Ok(())
2103}
2104
2105#[cfg(test)]
2106mod tests {
2107 use super::*;
2108
2109 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
2110
2111 fn make_test_npub(n: u32) -> String {
2114 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
2115 let mut payload = vec![b'q'; 58];
2116 let mut x = n as u64;
2117 let mut i = 58;
2118 while x > 0 && i > 0 {
2119 i -= 1;
2120 payload[i] = BECH32[(x as usize) % 32];
2121 x /= 32;
2122 }
2123 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
2124 }
2125
2126 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2127 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2128 crate::db::close_database();
2129 crate::db::clear_id_caches();
2132 let tmp = tempfile::tempdir().unwrap();
2133 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2134 let account = make_test_npub(n);
2135 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2136 crate::db::set_app_data_dir(tmp.path().to_path_buf());
2137 crate::db::set_current_account(account.clone()).unwrap();
2138 crate::db::init_database(&account).unwrap();
2139 (tmp, guard)
2140 }
2141
2142 #[test]
2143 fn edition_head_round_trips_and_upserts() {
2144 let (_tmp, _guard) = init_test_db();
2145 let cid = "f".repeat(64);
2146 let entity = "a".repeat(64);
2147
2148 assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
2150
2151 let h1 = [0x11u8; 32];
2153 set_edition_head(&cid, &entity, 1, &h1).unwrap();
2154 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
2155
2156 let h2 = [0x22u8; 32];
2158 set_edition_head(&cid, &entity, 2, &h2).unwrap();
2159 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
2160
2161 set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
2164 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
2165 set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
2166 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
2167
2168 let other = "b".repeat(64);
2170 assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
2171 }
2172
2173 #[test]
2174 fn guestbook_round_trips_events_and_cursor() {
2175 let (_tmp, _guard) = init_test_db();
2176 let member = nostr_sdk::prelude::Keys::generate();
2177 let ev = crate::community::v2::guestbook::GuestbookEvent {
2178 rumor_id: [7u8; 32],
2179 entry: crate::community::v2::guestbook::GuestbookEntry::Join {
2180 member: member.public_key(),
2181 invited_by: Some(("creator".into(), "label".into())),
2182 at_ms: 1_000,
2183 },
2184 };
2185 let cid = "d".repeat(64);
2186 assert_eq!(get_guestbook(&cid).unwrap(), (Vec::new(), 0), "absent reads as empty at cursor 0");
2187 set_guestbook(&cid, std::slice::from_ref(&ev), 42).unwrap();
2188 let (events, cursor) = get_guestbook(&cid).unwrap();
2189 assert_eq!(events, vec![ev], "events round-trip through the encrypted blob");
2190 assert_eq!(cursor, 42);
2191 }
2192
2193 #[test]
2194 fn v2_images_round_trip_and_read_as_v1_community_images() {
2195 let (_tmp, _guard) = init_test_db();
2196 let owner = nostr_sdk::prelude::Keys::generate();
2197 let g = crate::community::v2::control::genesis(
2198 &owner,
2199 crate::community::v2::control::CommunityMetadata { name: "Icons".into(), ..Default::default() },
2200 1_000,
2201 )
2202 .unwrap();
2203 let mut c = crate::community::v2::community::CommunityV2::from_genesis(&g, "Icons", None, vec!["wss://r".into()], 1_000);
2204 let mut extra = serde_json::Map::new();
2205 extra.insert("ext".into(), serde_json::Value::String("webp".into()));
2206 c.icon = Some(crate::community::v2::control::ImageRef {
2207 url: "https://blossom.example/abc".into(),
2208 key: "0".repeat(64),
2209 nonce: "1".repeat(32),
2210 hash: "a".repeat(64),
2211 extra,
2212 });
2213 c.meta_custom = Some({
2214 let mut m = serde_json::Map::new();
2215 m.insert("k".into(), serde_json::Value::from("v"));
2216 m
2217 });
2218 c.channels[0].voice = Some(true);
2219 c.channels[0].meta_extra.insert("vnd".into(), serde_json::Value::from(7));
2220 save_community_v2(&c).unwrap();
2221
2222 let re = load_community_v2(c.id()).unwrap().unwrap();
2224 assert_eq!(re.icon, c.icon);
2225 assert_eq!(re.banner, None);
2226 assert_eq!(re.meta_custom, c.meta_custom);
2228 assert_eq!(re.channels[0].voice, Some(true));
2229 assert_eq!(re.channels[0].meta_extra.get("vnd"), Some(&serde_json::Value::from(7)));
2230
2231 let v1 = load_community(c.id()).unwrap().unwrap();
2235 let img = v1.icon.expect("v1 reader sees the v2 icon");
2236 assert_eq!(img.url, "https://blossom.example/abc");
2237 assert_eq!(img.ext, "webp");
2238 assert_eq!(img.hash, "a".repeat(64));
2239 }
2240
2241 #[test]
2242 fn server_root_epoch_round_trips() {
2243 let (_tmp, _guard) = init_test_db();
2245 let mut c = Community::create("HQ", "general", vec![]);
2246 save_community(&c).unwrap();
2247 assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
2248
2249 c.server_root_epoch = Epoch(5);
2250 c.server_root_key = ServerRootKey([0x42u8; 32]);
2251 save_community(&c).unwrap();
2252 let loaded = load_community(&c.id).unwrap().unwrap();
2253 assert_eq!(loaded.server_root_epoch, Epoch(5));
2254 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2255 }
2256
2257 #[test]
2258 fn epoch_key_archive_retains_every_epoch() {
2259 let (_tmp, _guard) = init_test_db();
2262 let cid = "f".repeat(64);
2263 let scope = "a".repeat(64);
2264
2265 store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
2266 store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
2267 store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
2268
2269 let held = held_epoch_keys(&cid, &scope).unwrap();
2270 assert_eq!(held.len(), 3, "all three epoch keys retained");
2271 assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
2272 assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
2273 assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
2274
2275 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
2277 assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
2278
2279 store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
2281 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
2282 assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
2283
2284 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2287 assert_eq!(
2288 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
2289 None,
2290 "epoch 1 under a different scope is not the channel's key"
2291 );
2292 }
2293
2294 #[test]
2295 fn save_community_populates_the_epoch_archive() {
2296 let (_tmp, _guard) = init_test_db();
2299 let c = Community::create("HQ", "general", vec![]);
2300 save_community(&c).unwrap();
2301 let cid = c.id.to_hex();
2302
2303 assert_eq!(
2305 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
2306 Some(c.server_root_key.as_bytes())
2307 );
2308 let chan = &c.channels[0];
2310 assert_eq!(
2311 held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
2312 Some(chan.key.as_bytes())
2313 );
2314 }
2315
2316 #[test]
2317 fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
2318 let (_tmp, _guard) = init_test_db();
2319 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2322 crate::state::set_encryption_enabled(true);
2323
2324 let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
2325 c.server_root_key = ServerRootKey([0x42u8; 32]);
2326 c.description = Some("top secret".into());
2327 save_community(&c).unwrap();
2328 let cid = c.id.to_hex();
2329 set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
2330
2331 {
2334 let conn = crate::db::get_db_connection_guard_static().unwrap();
2335 let (root_len, name, banlist): (i64, String, String) = conn
2336 .query_row(
2337 "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
2338 params![cid],
2339 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
2340 )
2341 .unwrap();
2342 assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
2343 assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
2344 assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
2345 assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext");
2346 let key_len: i64 = conn
2347 .query_row(
2348 "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
2349 params![cid],
2350 |r| r.get(0),
2351 )
2352 .unwrap();
2353 assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
2354 }
2355
2356 let loaded = load_community(&c.id).unwrap().unwrap();
2358 assert_eq!(loaded.name, "Secret HQ");
2359 assert_eq!(loaded.description.as_deref(), Some("top secret"));
2360 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2361 assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
2362 assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
2363
2364 crate::state::set_encryption_enabled(false);
2365 crate::state::ENCRYPTION_KEY.clear(&[]);
2366 }
2367
2368 #[test]
2369 fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
2370 let (_tmp, _guard) = init_test_db();
2373 crate::state::set_encryption_enabled(false);
2374 let mut c = Community::create("Legacy HQ", "general", vec![]);
2375 c.server_root_key = ServerRootKey([0x42u8; 32]);
2376 save_community(&c).unwrap();
2377
2378 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2379 crate::state::set_encryption_enabled(true);
2380 let loaded = load_community(&c.id).unwrap().unwrap();
2381 assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
2382 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
2383
2384 crate::state::set_encryption_enabled(false);
2385 crate::state::ENCRYPTION_KEY.clear(&[]);
2386 }
2387
2388 #[test]
2389 fn save_and_load_round_trip() {
2390 let (_tmp, _guard) = init_test_db();
2391 let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
2392 save_community(&original).unwrap();
2393
2394 let loaded = load_community(&original.id).unwrap().expect("present");
2395 assert_eq!(loaded.id, original.id);
2396 assert_eq!(loaded.name, "Vector HQ");
2397 assert_eq!(loaded.relays, original.relays);
2398 assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
2400 assert_eq!(loaded.channels.len(), 1);
2402 assert_eq!(loaded.channels[0].id, original.channels[0].id);
2403 assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
2404 assert_eq!(loaded.channels[0].epoch, Epoch(0));
2405 assert_eq!(loaded.channels[0].name, "general");
2406 }
2407
2408 #[test]
2409 fn owner_is_protected_from_the_banlist_a_member_is_not() {
2410 use nostr_sdk::JsonUtil;
2411 let (_tmp, _guard) = init_test_db();
2412 let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
2413 let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
2415 community.owner_attestation = Some(
2416 crate::community::owner::build_owner_attestation_unsigned(
2417 owner_id.public_key(),
2418 &community.id.to_hex(),
2419 )
2420 .sign_with_keys(&owner_id)
2421 .unwrap()
2422 .as_json(),
2423 );
2424 save_community(&community).unwrap();
2425
2426 let member = Keys::generate();
2428 set_community_banlist(
2429 &community.id.to_hex(),
2430 &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
2431 1,
2432 )
2433 .unwrap();
2434
2435 let loaded = load_community(&community.id).unwrap().unwrap();
2436 let ch = &loaded.channels[0];
2437 assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
2439 assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
2440 assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
2442 }
2443
2444 #[test]
2445 fn loaded_keys_actually_decrypt() {
2446 let (_tmp, _guard) = init_test_db();
2449 let original = Community::create("HQ", "general", vec![]);
2450 save_community(&original).unwrap();
2451 let loaded = load_community(&original.id).unwrap().unwrap();
2452
2453 let author = nostr_sdk::prelude::Keys::generate();
2454 let chan = &original.channels[0];
2455 let sealed = crate::community::envelope::seal_message(
2456 &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
2457 )
2458 .unwrap();
2459 let opened = crate::community::envelope::open_message(
2460 &sealed,
2461 &loaded.channels[0].key,
2462 &loaded.channels[0].id,
2463 loaded.channels[0].epoch,
2464 )
2465 .unwrap();
2466 assert_eq!(opened.content, "persisted!");
2467 }
2468
2469 #[test]
2470 fn member_view_round_trips() {
2471 let (_tmp, _guard) = init_test_db();
2474 let member = Community {
2475 id: CommunityId([7u8; 32]),
2476 server_root_key: ServerRootKey([8u8; 32]),
2477 server_root_epoch: Epoch(0),
2478 name: "Joined".into(),
2479 description: None,
2480 icon: None,
2481 banner: None,
2482 relays: vec!["wss://r".into()],
2483 channels: vec![Channel {
2484 id: ChannelId([9u8; 32]),
2485 key: ChannelKey([10u8; 32]),
2486 epoch: Epoch(0),
2487 name: "general".into(),
2488 banned: Vec::new(),
2489 protected: Vec::new(), roster: Default::default(),
2490 epoch_keys: Vec::new(),
2491 dissolved: false,
2492 }],
2493 owner_attestation: None,
2494 dissolved: false,
2495 };
2496 save_community(&member).unwrap();
2497 let loaded = load_community(&member.id).unwrap().expect("present");
2498 assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
2499 assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
2500 }
2501
2502 #[test]
2503 fn large_epoch_round_trips_losslessly() {
2504 let (_tmp, _guard) = init_test_db();
2506 let mut c = Community::create("HQ", "g", vec![]);
2507 c.channels[0].epoch = Epoch(u64::MAX - 7);
2508 save_community(&c).unwrap();
2509 let loaded = load_community(&c.id).unwrap().unwrap();
2510 assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
2511 }
2512
2513 #[test]
2514 fn malformed_channel_id_row_errors_not_corrupts() {
2515 let (_tmp, _guard) = init_test_db();
2518 let c = Community::create("HQ", "g", vec![]);
2519 save_community(&c).unwrap();
2520 {
2521 let conn = crate::db::get_write_connection_guard_static().unwrap();
2522 conn.execute(
2523 "INSERT OR REPLACE INTO community_channels
2524 (channel_id, community_id, channel_key, epoch, name, created_at)
2525 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
2526 rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
2527 )
2528 .unwrap();
2529 }
2530 assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
2531 }
2532
2533 #[test]
2534 fn message_key_store_take_round_trip() {
2535 let (_tmp, _guard) = init_test_db();
2536 let eph = Keys::generate();
2537 let relays = vec!["wss://r.one".to_string()];
2538 store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
2540
2541 let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
2542 assert_eq!(
2543 loaded.secret_key().as_secret_bytes(),
2544 eph.secret_key().as_secret_bytes()
2545 );
2546 assert_eq!(outer, "outer_evid");
2547 assert_eq!(r, relays);
2548 assert!(take_message_key("inner_msg_id").unwrap().is_none());
2550 }
2551
2552 #[test]
2553 fn missing_community_is_none() {
2554 let (_tmp, _guard) = init_test_db();
2555 let absent = CommunityId([0x33u8; 32]);
2556 assert!(load_community(&absent).unwrap().is_none());
2557 }
2558
2559 #[test]
2560 fn list_ids_reflects_saved() {
2561 let (_tmp, _guard) = init_test_db();
2562 let a = Community::create("A", "g", vec![]);
2563 let b = Community::create("B", "g", vec![]);
2564 save_community(&a).unwrap();
2565 save_community(&b).unwrap();
2566 let ids = list_community_ids().unwrap();
2567 assert_eq!(ids.len(), 2);
2568 assert!(ids.contains(&a.id) && ids.contains(&b.id));
2569 }
2570
2571 #[test]
2572 fn delete_community_clears_all_local_state() {
2573 let (_tmp, _guard) = init_test_db();
2574 let c = Community::create("HQ", "general", vec!["r1".into()]);
2575 save_community(&c).unwrap();
2576 let cid = c.id.to_hex();
2577 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2578 save_pending_invite(&"cd".repeat(32), "{}", "npub1x").unwrap();
2579 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2580
2581 assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2583
2584 delete_community(&cid).unwrap();
2585 assert!(!community_exists(&c.id).unwrap());
2586 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2587 assert!(list_public_invites(&cid).unwrap().is_empty());
2588 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
2589 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
2590 }
2591
2592 #[test]
2593 fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
2594 let (_tmp, _guard) = init_test_db();
2597 let c = Community::create("HQ", "general", vec!["r1".into()]);
2598 save_community(&c).unwrap();
2599 let cid = c.id.to_hex();
2600 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2601 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2602
2603 let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
2604 let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
2605 assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
2606
2607 delete_community_retain_keys(&cid).unwrap();
2608
2609 assert!(!community_exists(&c.id).unwrap());
2611 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2612 assert!(list_public_invites(&cid).unwrap().is_empty());
2613 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
2614 assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
2616 "base epoch keys retained for self-scrub");
2617 assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
2618 "channel epoch keys retained for self-scrub");
2619 }
2620
2621 #[test]
2622 fn channel_resolves_to_owning_community() {
2623 let (_tmp, _guard) = init_test_db();
2624 let c = Community::create("HQ", "general", vec![]);
2625 save_community(&c).unwrap();
2626 let chan = c.channels[0].id.to_hex();
2627 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
2628 assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
2629 }
2630
2631 #[test]
2632 fn community_exists_reflects_saved() {
2633 let (_tmp, _guard) = init_test_db();
2634 let c = Community::create("A", "g", vec![]);
2635 assert!(!community_exists(&c.id).unwrap());
2636 save_community(&c).unwrap();
2637 assert!(community_exists(&c.id).unwrap());
2638 }
2639
2640 #[test]
2641 fn pending_invite_first_wins_and_round_trips() {
2642 let (_tmp, _guard) = init_test_db();
2643 let cid = "ab".repeat(32);
2644 assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter").unwrap());
2647 assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other").unwrap());
2648 assert!(pending_invite_exists(&cid).unwrap());
2649
2650 let listed = list_pending_invites().unwrap();
2651 assert_eq!(listed.len(), 1);
2652 assert_eq!(listed[0].community_id, cid);
2653 assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
2654 assert_eq!(listed[0].inviter_npub, "npub1inviter");
2655
2656 assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
2658 assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
2659 delete_pending_invite(&cid).unwrap();
2660 assert!(!pending_invite_exists(&cid).unwrap());
2661 assert!(get_pending_invite(&cid).unwrap().is_none());
2662 }
2663
2664 #[test]
2665 fn purge_drops_invites_for_held_communities_only() {
2666 let (_tmp, _guard) = init_test_db();
2667 let held = Community::create("Held", "general", vec![]);
2670 save_community(&held).unwrap();
2671 let held_hex = held.id.to_hex();
2672 save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter").unwrap();
2673 let stranger = "ab".repeat(32);
2675 save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter").unwrap();
2676
2677 let n = purge_pending_invites_for_held_communities().unwrap();
2678 assert_eq!(n, 1, "only the held community's invite is purged");
2679 assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
2680 assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
2681 }
2682
2683 #[test]
2684 fn decline_drops_pending_invite() {
2685 let (_tmp, _guard) = init_test_db();
2686 let cid = "cd".repeat(32);
2687 save_pending_invite(&cid, "{}", "npub1x").unwrap();
2688 delete_pending_invite(&cid).unwrap();
2689 assert!(!pending_invite_exists(&cid).unwrap());
2690 }
2691
2692 #[test]
2693 fn pending_invites_are_capped_keeping_the_newest() {
2694 let (_tmp, _guard) = init_test_db();
2695 for i in 0..150u32 {
2700 let cid = format!("{:064x}", i);
2701 save_pending_invite(&cid, "{}", "npub1x").unwrap();
2702 }
2703 let all = list_pending_invites().unwrap();
2704 assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
2705 for i in 150..400u32 {
2707 let cid = format!("{:064x}", i);
2708 save_pending_invite(&cid, "{}", "npub1x").unwrap();
2709 }
2710 assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
2711 }
2712}