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
638 .execute(
639 "INSERT OR IGNORE INTO pending_community_invites
640 (community_id, bundle_json, inviter_npub, received_at)
641 VALUES (?1, ?2, ?3, ?4)",
642 params![community_id, enc_bundle, enc_inviter, now_secs()],
643 )
644 .map_err(|e| format!("save pending invite: {e}"))?;
645 if changed > 0 {
648 let _ = conn.execute(
649 "DELETE FROM pending_community_invites
650 WHERE community_id IN (
651 SELECT community_id FROM pending_community_invites
652 ORDER BY received_at DESC, community_id DESC
653 LIMIT -1 OFFSET ?1
654 )",
655 params![MAX_PENDING_INVITES],
656 );
657 }
658 Ok(changed > 0)
659}
660
661pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
667 let conn = super::get_write_connection_guard_static()?;
668 let n = conn
669 .execute(
670 "DELETE FROM pending_community_invites
671 WHERE community_id IN (SELECT community_id FROM communities)",
672 [],
673 )
674 .map_err(|e| format!("purge held pending invites: {e}"))?;
675 Ok(n)
676}
677
678pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
680 let conn = super::get_db_connection_guard_static()?;
681 let mut stmt = conn
682 .prepare(
683 "SELECT community_id, bundle_json, inviter_npub, received_at
684 FROM pending_community_invites ORDER BY received_at DESC",
685 )
686 .map_err(|e| e.to_string())?;
687 let rows = stmt
688 .query_map([], |r| {
689 Ok(PendingCommunityInvite {
690 community_id: r.get(0)?,
691 bundle_json: dec_txt(&r.get::<_, String>(1)?),
692 inviter_npub: dec_txt(&r.get::<_, String>(2)?),
693 received_at: r.get(3)?,
694 })
695 })
696 .map_err(|e| e.to_string())?;
697 let mut out = Vec::new();
698 for row in rows {
699 out.push(row.map_err(|e| e.to_string())?);
700 }
701 Ok(out)
702}
703
704pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
708 let conn = super::get_db_connection_guard_static()?;
709 let raw: Option<String> = conn
710 .query_row(
711 "SELECT bundle_json FROM pending_community_invites WHERE community_id = ?1",
712 params![community_id],
713 |r| r.get::<_, String>(0),
714 )
715 .optional()
716 .map_err(|e| format!("get pending invite: {e}"))?;
717 Ok(raw.map(|s| dec_txt(&s)))
718}
719
720pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
722 let conn = super::get_write_connection_guard_static()?;
723 conn.execute(
724 "DELETE FROM pending_community_invites WHERE community_id = ?1",
725 params![community_id],
726 )
727 .map_err(|e| format!("delete pending invite: {e}"))?;
728 Ok(())
729}
730
731pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
733 let conn = super::get_db_connection_guard_static()?;
734 let found: Option<i64> = conn
735 .query_row(
736 "SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
737 params![community_id],
738 |r| r.get(0),
739 )
740 .optional()
741 .map_err(|e| format!("pending_invite_exists: {e}"))?;
742 Ok(found.is_some())
743}
744
745#[derive(Debug, Clone, serde::Serialize)]
747pub struct PublicInviteRecord {
748 pub token: String,
750 pub community_id: String,
751 pub url: String,
752 pub expires_at: Option<i64>,
753 pub created_at: i64,
754 pub label: Option<String>,
756 #[serde(default)]
758 pub join_count: u64,
759}
760
761pub fn save_public_invite(
763 token: &str,
764 community_id: &str,
765 url: &str,
766 expires_at: Option<i64>,
767 label: Option<&str>,
768) -> Result<(), String> {
769 let conn = super::get_write_connection_guard_static()?;
770 let enc_token = enc_txt(token)?;
773 let enc_url = enc_txt(url)?;
774 let enc_label = label.map(enc_txt).transpose()?;
776 conn.execute(
777 "INSERT OR REPLACE INTO community_public_invites
778 (token, community_id, url, expires_at, created_at, label)
779 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
780 params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
781 )
782 .map_err(|e| format!("save public invite: {e}"))?;
783 Ok(())
784}
785
786pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
788 let conn = super::get_db_connection_guard_static()?;
789 let mut stmt = conn
790 .prepare(
791 "SELECT token, community_id, url, expires_at, created_at, label
792 FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
793 )
794 .map_err(|e| e.to_string())?;
795 let rows = stmt
796 .query_map(params![community_id], |r| {
797 Ok(PublicInviteRecord {
798 token: dec_txt(&r.get::<_, String>(0)?),
799 community_id: r.get(1)?,
800 url: dec_txt(&r.get::<_, String>(2)?),
801 expires_at: r.get(3)?,
802 created_at: r.get(4)?,
803 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
804 join_count: 0,
805 })
806 })
807 .map_err(|e| e.to_string())?;
808 let mut out = Vec::new();
809 for row in rows {
810 out.push(row.map_err(|e| e.to_string())?);
811 }
812 if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
814 if let Ok(counts) = community_invite_join_counts(community_id, &me) {
815 for rec in &mut out {
816 if let Some(l) = rec.label.as_deref() {
817 rec.join_count = counts.get(l).copied().unwrap_or(0);
818 }
819 }
820 }
821 }
822 Ok(out)
823}
824
825pub fn delete_public_invite(token: &str) -> Result<(), String> {
827 let conn = super::get_write_connection_guard_static()?;
828 let rows: Vec<(i64, String)> = {
831 let mut stmt = conn
832 .prepare("SELECT rowid, token FROM community_public_invites")
833 .map_err(|e| e.to_string())?;
834 let mapped = stmt
835 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
836 .map_err(|e| e.to_string())?;
837 mapped.filter_map(|r| r.ok()).collect()
838 };
839 for (rowid, stored) in rows {
840 if dec_txt(&stored) == token {
841 conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
842 .map_err(|e| format!("delete public invite: {e}"))?;
843 }
844 }
845 Ok(())
846}
847
848pub fn delete_community(community_id: &str) -> Result<(), String> {
853 delete_community_inner(community_id, false)
854}
855
856pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
862 delete_community_inner(community_id, true)
863}
864
865fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
866 let conn = super::get_write_connection_guard_static()?;
867 let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
870 for sql in [
871 Some("DELETE FROM communities WHERE community_id = ?1"),
872 Some("DELETE FROM community_channels WHERE community_id = ?1"),
873 (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
877 Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
878 Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
879 Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
880 Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
883 ]
884 .into_iter()
885 .flatten()
886 {
887 tx.execute(sql, params![community_id])
888 .map_err(|e| format!("delete community: {e}"))?;
889 }
890 tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
891 Ok(())
896}
897
898pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
905 const COMMUNITY_MEMBER_CAP: usize = 500;
908 use std::collections::HashMap;
909
910 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
911 Some(c) => c,
912 None => return Ok(Vec::new()),
913 };
914 let owner_b32: Option<String> = community
919 .owner_attestation
920 .as_deref()
921 .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
922 .and_then(|pk| pk.to_bech32().ok());
923
924 let mut chat_ints: Vec<i64> = Vec::new();
926 for ch in &community.channels {
927 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
928 chat_ints.push(cid);
929 }
930 }
931
932 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
935
936 let mut active: HashMap<String, u64> = HashMap::new();
941 let mut left: HashMap<String, u64> = HashMap::new();
942 if !chat_ints.is_empty() {
945 let conn = super::get_db_connection_guard_static()?;
946 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
947
948 {
949 let sql = format!(
950 "SELECT npub, MAX(created_at) FROM events \
951 WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
952 GROUP BY npub"
953 );
954 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
955 let rows = stmt
956 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
957 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
958 })
959 .map_err(|e| e.to_string())?;
960 for row in rows {
961 let (npub, at) = row.map_err(|e| e.to_string())?;
962 active.insert(npub, at);
963 }
964 }
965
966 {
968 let sql = format!(
969 "SELECT npub, created_at, tags FROM events \
970 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
971 );
972 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
973 let rows = stmt
974 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
975 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
976 })
977 .map_err(|e| e.to_string())?;
978 for row in rows {
979 let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
980 let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
982 .ok()
983 .and_then(|tags| {
984 tags.into_iter()
985 .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
986 .and_then(|t| t.into_iter().nth(1))
987 });
988 match etype.as_deref() {
989 Some("1") => {
990 let e = active.entry(npub).or_insert(0);
991 if at > *e { *e = at; }
992 }
993 Some("0") => {
994 let e = left.entry(npub).or_insert(0);
995 if at > *e { *e = at; }
996 }
997 _ => {}
998 }
999 }
1000 }
1001 }
1002
1003 let banned: std::collections::HashSet<String> = community
1006 .channels
1007 .first()
1008 .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
1009 .unwrap_or_default();
1010
1011 let mut out: Vec<(String, u64)> = active
1013 .into_iter()
1014 .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l))
1015 .collect();
1016
1017 {
1024 let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
1025 let mut reassert = |npub: String| {
1026 if !banned.contains(&npub) && present.insert(npub.clone()) {
1027 out.push((npub, now_secs() as u64));
1028 }
1029 };
1030 if let Some(o) = owner_b32 {
1031 reassert(o);
1032 }
1033 if let Ok(roles) = get_community_roles(community_id) {
1034 for g in &roles.grants {
1035 if g.role_ids.is_empty() {
1036 continue; }
1038 if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
1039 reassert(b32);
1040 }
1041 }
1042 }
1043 }
1044 out.sort_by(|a, b| b.1.cmp(&a.1));
1045 out.truncate(COMMUNITY_MEMBER_CAP);
1046 Ok(out)
1047}
1048
1049pub fn community_invite_join_counts(
1054 community_id: &str,
1055 inviter_npub: &str,
1056) -> Result<std::collections::HashMap<String, u64>, String> {
1057 use std::collections::{HashMap, HashSet};
1058 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1059 Some(c) => c,
1060 None => return Ok(HashMap::new()),
1061 };
1062 let mut chat_ints: Vec<i64> = Vec::new();
1063 for ch in &community.channels {
1064 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1065 chat_ints.push(cid);
1066 }
1067 }
1068 if chat_ints.is_empty() {
1069 return Ok(HashMap::new());
1070 }
1071 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1072 let conn = super::get_db_connection_guard_static()?;
1073 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1074 let sql = format!(
1075 "SELECT npub, tags FROM events \
1076 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1077 );
1078 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1079 let rows = stmt
1080 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1081 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1082 })
1083 .map_err(|e| e.to_string())?;
1084 let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
1086 for row in rows {
1087 let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
1088 let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
1089 Ok(t) => t,
1090 Err(_) => continue,
1091 };
1092 let tag_val = |key: &str| -> Option<String> {
1093 tags.iter()
1094 .find(|t| t.first().map(|s| s == key).unwrap_or(false))
1095 .and_then(|t| t.get(1).cloned())
1096 };
1097 if tag_val("event-type").as_deref() != Some("1") {
1099 continue;
1100 }
1101 if tag_val("invited-by").as_deref() != Some(inviter_npub) {
1102 continue;
1103 }
1104 if let Some(label) = tag_val("invited-label") {
1105 per_label.entry(label).or_default().insert(joiner);
1106 }
1107 }
1108 Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
1109}
1110
1111pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
1116 let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?;
1117 let conn = super::get_write_connection_guard_static()?;
1118 conn.execute(
1119 "UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3",
1120 params![json, at, community_id],
1121 )
1122 .map_err(|e| format!("set banlist: {e}"))?;
1123 Ok(())
1124}
1125
1126pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
1129 let conn = super::get_db_connection_guard_static()?;
1130 let at: Option<i64> = conn
1131 .query_row(
1132 "SELECT banlist_at FROM communities WHERE community_id = ?1",
1133 params![community_id],
1134 |r| r.get(0),
1135 )
1136 .optional()
1137 .map_err(|e| format!("get banlist_at: {e}"))?;
1138 Ok(at.unwrap_or(0))
1139}
1140
1141pub fn set_community_roles(
1146 community_id: &str,
1147 roles: &crate::community::roles::CommunityRoles,
1148 at: i64,
1149) -> Result<(), String> {
1150 let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
1151 let conn = super::get_write_connection_guard_static()?;
1152 conn.execute(
1153 "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
1154 params![json, at, community_id],
1155 )
1156 .map_err(|e| format!("set roles: {e}"))?;
1157 Ok(())
1158}
1159
1160pub fn get_community_roles(
1162 community_id: &str,
1163) -> Result<crate::community::roles::CommunityRoles, String> {
1164 let conn = super::get_db_connection_guard_static()?;
1165 let json: Option<String> = conn
1166 .query_row(
1167 "SELECT roles FROM communities WHERE community_id = ?1",
1168 params![community_id],
1169 |r| r.get(0),
1170 )
1171 .optional()
1172 .map_err(|e| format!("get roles: {e}"))?;
1173 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1174}
1175
1176pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
1178 let conn = super::get_db_connection_guard_static()?;
1179 let at: Option<i64> = conn
1180 .query_row(
1181 "SELECT roles_at FROM communities WHERE community_id = ?1",
1182 params![community_id],
1183 |r| r.get(0),
1184 )
1185 .optional()
1186 .map_err(|e| format!("get roles_at: {e}"))?;
1187 Ok(at.unwrap_or(0))
1188}
1189
1190pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
1195 set_edition_head_inner(community_id, entity_id, version, self_hash, None)
1196}
1197
1198pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1201 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id))
1202}
1203
1204fn set_edition_head_inner(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: Option<&[u8; 32]>) -> Result<(), String> {
1205 let conn = super::get_write_connection_guard_static()?;
1206 conn.execute(
1211 "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
1212 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE((SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
1213 ON CONFLICT(community_id, entity_id) DO UPDATE SET
1214 version = excluded.version,
1215 self_hash = excluded.self_hash,
1216 inner_id = excluded.inner_id,
1217 epoch = excluded.epoch
1218 WHERE excluded.epoch > community_edition_heads.epoch
1219 OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
1220 params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.map(|i| i.as_slice())],
1221 )
1222 .map_err(|e| format!("set edition head: {e}"))?;
1223 Ok(())
1224}
1225
1226pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1236 let conn = super::get_write_connection_guard_static()?;
1237 conn.execute(
1240 "UPDATE community_edition_heads
1241 SET self_hash = ?4, inner_id = ?5
1242 WHERE community_id = ?1 AND entity_id = ?2
1243 AND version = ?3
1244 AND epoch = COALESCE((SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
1245 AND (inner_id IS NULL OR ?5 < inner_id)",
1246 params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice()],
1247 )
1248 .map_err(|e| format!("converge edition head: {e}"))?;
1249 Ok(())
1250}
1251
1252pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
1257 let conn = super::get_db_connection_guard_static()?;
1258 let row: Option<Option<Vec<u8>>> = conn
1259 .query_row(
1260 "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1261 params![community_id, entity_id],
1262 |r| r.get(0),
1263 )
1264 .optional()
1265 .map_err(|e| format!("get edition head inner_id: {e}"))?;
1266 match row.flatten() {
1267 Some(blob) if blob.len() == 32 => {
1268 let mut h = [0u8; 32];
1269 h.copy_from_slice(&blob);
1270 Ok(Some(h))
1271 }
1272 _ => Ok(None),
1273 }
1274}
1275
1276pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
1279 let conn = super::get_db_connection_guard_static()?;
1280 let row: Option<(i64, Vec<u8>)> = conn
1281 .query_row(
1282 "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1283 params![community_id, entity_id],
1284 |r| Ok((r.get(0)?, r.get(1)?)),
1285 )
1286 .optional()
1287 .map_err(|e| format!("get edition head: {e}"))?;
1288 match row {
1289 Some((v, hash)) if hash.len() == 32 => {
1290 let mut h = [0u8; 32];
1291 h.copy_from_slice(&hash);
1292 Ok(Some((v as u64, h)))
1293 }
1294 _ => Ok(None),
1295 }
1296}
1297
1298pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
1303 let conn = super::get_db_connection_guard_static()?;
1304 let mut stmt = conn
1305 .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
1306 .map_err(|e| e.to_string())?;
1307 let rows = stmt
1308 .query_map(params![community_id], |r| r.get::<_, String>(0))
1309 .map_err(|e| e.to_string())?;
1310 let mut out = std::collections::HashSet::new();
1311 for row in rows {
1312 out.insert(row.map_err(|e| e.to_string())?);
1313 }
1314 Ok(out)
1315}
1316
1317
1318pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
1324 let conn = super::get_db_connection_guard_static()?;
1325 let mut stmt = conn
1326 .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1327 .map_err(|e| e.to_string())?;
1328 let rows = stmt
1329 .query_map(params![community_id], |r| {
1330 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
1331 })
1332 .map_err(|e| e.to_string())?;
1333 let mut out = std::collections::HashMap::new();
1334 for row in rows {
1335 let (entity, version, hash) = row.map_err(|e| e.to_string())?;
1336 if hash.len() == 32 {
1337 let mut h = [0u8; 32];
1338 h.copy_from_slice(&hash);
1339 out.insert(entity, (version as u64, h));
1340 }
1341 }
1342 Ok(out)
1343}
1344
1345pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32])>, String> {
1350 let conn = super::get_db_connection_guard_static()?;
1351 let mut stmt = conn
1352 .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1353 .map_err(|e| e.to_string())?;
1354 let rows = stmt
1355 .query_map(params![community_id], |r| {
1356 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
1357 })
1358 .map_err(|e| e.to_string())?;
1359 let mut out = std::collections::HashMap::new();
1360 for row in rows {
1361 let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
1362 if hash.len() == 32 {
1363 let mut h = [0u8; 32];
1364 h.copy_from_slice(&hash);
1365 out.insert(entity, (epoch as u64, version as u64, h));
1366 }
1367 }
1368 Ok(out)
1369}
1370
1371pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
1373 let conn = super::get_db_connection_guard_static()?;
1374 let json: Option<String> = conn
1375 .query_row(
1376 "SELECT banlist FROM communities WHERE community_id = ?1",
1377 params![community_id],
1378 |r| r.get(0),
1379 )
1380 .optional()
1381 .map_err(|e| format!("get banlist: {e}"))?;
1382 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1383}
1384
1385pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
1389 let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
1390 let conn = super::get_write_connection_guard_static()?;
1391 conn.execute(
1392 "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
1393 params![json, community_id],
1394 )
1395 .map_err(|e| format!("set invite registry: {e}"))?;
1396 Ok(())
1397}
1398
1399pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
1402 let conn = super::get_db_connection_guard_static()?;
1403 let json: Option<String> = conn
1404 .query_row(
1405 "SELECT invite_registry FROM communities WHERE community_id = ?1",
1406 params![community_id],
1407 |r| r.get(0),
1408 )
1409 .optional()
1410 .map_err(|e| format!("get invite registry: {e}"))?;
1411 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1412}
1413
1414pub struct InviteLinkSetRow {
1417 pub creator_hex: String,
1418 pub locators: Vec<String>,
1419}
1420
1421pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
1425 let mut conn = super::get_write_connection_guard_static()?;
1426 let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
1427 tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
1428 .map_err(|e| format!("clear invite-link-sets: {e}"))?;
1429 for s in sets {
1430 if s.locators.is_empty() {
1431 continue; }
1433 let enc_creator = enc_txt(&s.creator_hex)?;
1434 let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
1435 tx.execute(
1438 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1439 params![community_id, enc_creator, enc_locators],
1440 )
1441 .map_err(|e| format!("insert invite-link-set: {e}"))?;
1442 }
1443 tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
1444 Ok(())
1445}
1446
1447pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
1450 let conn = super::get_write_connection_guard_static()?;
1451 let existing_rowid: Option<i64> = {
1453 let mut stmt = conn
1454 .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
1455 .map_err(|e| e.to_string())?;
1456 let rows = stmt
1457 .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1458 .map_err(|e| e.to_string())?;
1459 let mut found = None;
1460 for row in rows {
1461 let (rowid, stored) = row.map_err(|e| e.to_string())?;
1462 if dec_txt(&stored) == creator_hex {
1463 found = Some(rowid);
1464 break;
1465 }
1466 }
1467 found
1468 };
1469 if locators.is_empty() {
1470 if let Some(rowid) = existing_rowid {
1471 conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
1472 .map_err(|e| format!("delete invite-link-set: {e}"))?;
1473 }
1474 return Ok(());
1475 }
1476 let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
1477 match existing_rowid {
1478 Some(rowid) => {
1479 conn.execute(
1480 "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
1481 params![enc_locators, rowid],
1482 )
1483 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1484 }
1485 None => {
1486 let enc_creator = enc_txt(creator_hex)?;
1487 conn.execute(
1488 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1489 params![community_id, enc_creator, enc_locators],
1490 )
1491 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1492 }
1493 }
1494 Ok(())
1495}
1496
1497pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
1500 let conn = super::get_db_connection_guard_static()?;
1501 let mut stmt = conn
1502 .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
1503 .map_err(|e| format!("prepare invite-link-sets: {e}"))?;
1504 let rows = stmt
1505 .query_map(params![community_id], |r| {
1506 let creator_hex: String = r.get(0)?;
1507 let json: String = r.get(1)?;
1508 Ok((creator_hex, json))
1509 })
1510 .map_err(|e| format!("query invite-link-sets: {e}"))?;
1511 let mut out = Vec::new();
1512 for row in rows {
1513 let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
1514 let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
1515 out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
1516 }
1517 Ok(out)
1518}
1519
1520pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
1524 let conn = super::get_write_connection_guard_static()?;
1525 conn.execute(
1526 "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
1527 params![pending as i64, community_id],
1528 )
1529 .map_err(|e| format!("set read_cut_pending: {e}"))?;
1530 Ok(())
1531}
1532
1533pub fn set_community_dissolved(community_id: &str) -> Result<(), String> {
1537 let conn = super::get_write_connection_guard_static()?;
1538 conn.execute(
1539 "UPDATE communities SET dissolved = 1 WHERE community_id = ?1",
1540 params![community_id],
1541 )
1542 .map_err(|e| format!("set dissolved: {e}"))?;
1543 Ok(())
1544}
1545
1546pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
1549 let conn = super::get_db_connection_guard_static()?;
1550 let v: Option<i64> = conn
1551 .query_row(
1552 "SELECT dissolved FROM communities WHERE community_id = ?1",
1553 params![community_id],
1554 |r| r.get(0),
1555 )
1556 .optional()
1557 .map_err(|e| format!("get dissolved: {e}"))?;
1558 Ok(v.unwrap_or(0) != 0)
1559}
1560
1561pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
1564 let conn = super::get_db_connection_guard_static()?;
1565 let v: Option<i64> = conn
1566 .query_row(
1567 "SELECT read_cut_pending FROM communities WHERE community_id = ?1",
1568 params![community_id],
1569 |r| r.get(0),
1570 )
1571 .optional()
1572 .map_err(|e| format!("get read_cut_pending: {e}"))?;
1573 Ok(v.unwrap_or(0) != 0)
1574}
1575
1576pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
1581 let conn = super::get_write_connection_guard_static()?;
1582 conn.execute(
1583 "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
1584 params![target as i64, community_id],
1585 )
1586 .map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
1587 Ok(())
1588}
1589
1590pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
1593 let conn = super::get_db_connection_guard_static()?;
1594 let v: Option<i64> = conn
1595 .query_row(
1596 "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
1597 params![community_id],
1598 |r| r.get(0),
1599 )
1600 .optional()
1601 .map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
1602 Ok(v.unwrap_or(0) as u64)
1603}
1604
1605pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
1608 let conn = super::get_db_connection_guard_static()?;
1609 let v: Option<i64> = conn
1610 .query_row(
1611 "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
1612 params![community_id, channel_id],
1613 |r| r.get(0),
1614 )
1615 .optional()
1616 .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
1617 Ok(v.unwrap_or(0) as u64)
1618}
1619
1620pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
1624 let conn = super::get_write_connection_guard_static()?;
1625 conn.execute(
1626 "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
1627 params![server_epoch as i64, community_id, channel_id],
1628 )
1629 .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
1630 Ok(())
1631}
1632
1633pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
1635 let conn = super::get_db_connection_guard_static()?;
1636 let mut stmt = conn
1637 .prepare("SELECT community_id FROM communities ORDER BY created_at")
1638 .map_err(|e| e.to_string())?;
1639 let rows = stmt
1640 .query_map([], |r| r.get::<_, String>(0))
1641 .map_err(|e| e.to_string())?;
1642 let mut ids = Vec::new();
1643 for row in rows {
1644 ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
1645 }
1646 Ok(ids)
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651 use super::*;
1652
1653 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1654
1655 fn make_test_npub(n: u32) -> String {
1658 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1659 let mut payload = vec![b'q'; 58];
1660 let mut x = n as u64;
1661 let mut i = 58;
1662 while x > 0 && i > 0 {
1663 i -= 1;
1664 payload[i] = BECH32[(x as usize) % 32];
1665 x /= 32;
1666 }
1667 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
1668 }
1669
1670 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1671 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1672 crate::db::close_database();
1673 let tmp = tempfile::tempdir().unwrap();
1674 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1675 let account = make_test_npub(n);
1676 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
1677 crate::db::set_app_data_dir(tmp.path().to_path_buf());
1678 crate::db::set_current_account(account.clone()).unwrap();
1679 crate::db::init_database(&account).unwrap();
1680 (tmp, guard)
1681 }
1682
1683 #[test]
1684 fn edition_head_round_trips_and_upserts() {
1685 let (_tmp, _guard) = init_test_db();
1686 let cid = "f".repeat(64);
1687 let entity = "a".repeat(64);
1688
1689 assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
1691
1692 let h1 = [0x11u8; 32];
1694 set_edition_head(&cid, &entity, 1, &h1).unwrap();
1695 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
1696
1697 let h2 = [0x22u8; 32];
1699 set_edition_head(&cid, &entity, 2, &h2).unwrap();
1700 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
1701
1702 set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
1705 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
1706 set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
1707 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
1708
1709 let other = "b".repeat(64);
1711 assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
1712 }
1713
1714 #[test]
1715 fn server_root_epoch_round_trips() {
1716 let (_tmp, _guard) = init_test_db();
1718 let mut c = Community::create("HQ", "general", vec![]);
1719 save_community(&c).unwrap();
1720 assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
1721
1722 c.server_root_epoch = Epoch(5);
1723 c.server_root_key = ServerRootKey([0x42u8; 32]);
1724 save_community(&c).unwrap();
1725 let loaded = load_community(&c.id).unwrap().unwrap();
1726 assert_eq!(loaded.server_root_epoch, Epoch(5));
1727 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
1728 }
1729
1730 #[test]
1731 fn epoch_key_archive_retains_every_epoch() {
1732 let (_tmp, _guard) = init_test_db();
1735 let cid = "f".repeat(64);
1736 let scope = "a".repeat(64);
1737
1738 store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
1739 store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
1740 store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
1741
1742 let held = held_epoch_keys(&cid, &scope).unwrap();
1743 assert_eq!(held.len(), 3, "all three epoch keys retained");
1744 assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
1745 assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
1746 assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
1747
1748 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
1750 assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
1751
1752 store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
1754 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
1755 assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
1756
1757 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
1760 assert_eq!(
1761 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
1762 None,
1763 "epoch 1 under a different scope is not the channel's key"
1764 );
1765 }
1766
1767 #[test]
1768 fn save_community_populates_the_epoch_archive() {
1769 let (_tmp, _guard) = init_test_db();
1772 let c = Community::create("HQ", "general", vec![]);
1773 save_community(&c).unwrap();
1774 let cid = c.id.to_hex();
1775
1776 assert_eq!(
1778 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
1779 Some(c.server_root_key.as_bytes())
1780 );
1781 let chan = &c.channels[0];
1783 assert_eq!(
1784 held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
1785 Some(chan.key.as_bytes())
1786 );
1787 }
1788
1789 #[test]
1790 fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
1791 let (_tmp, _guard) = init_test_db();
1792 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
1795 crate::state::set_encryption_enabled(true);
1796
1797 let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
1798 c.server_root_key = ServerRootKey([0x42u8; 32]);
1799 c.description = Some("top secret".into());
1800 save_community(&c).unwrap();
1801 let cid = c.id.to_hex();
1802 set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
1803
1804 {
1807 let conn = crate::db::get_db_connection_guard_static().unwrap();
1808 let (root_len, name, banlist): (i64, String, String) = conn
1809 .query_row(
1810 "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
1811 params![cid],
1812 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1813 )
1814 .unwrap();
1815 assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
1816 assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
1817 assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
1818 assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext");
1819 let key_len: i64 = conn
1820 .query_row(
1821 "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
1822 params![cid],
1823 |r| r.get(0),
1824 )
1825 .unwrap();
1826 assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
1827 }
1828
1829 let loaded = load_community(&c.id).unwrap().unwrap();
1831 assert_eq!(loaded.name, "Secret HQ");
1832 assert_eq!(loaded.description.as_deref(), Some("top secret"));
1833 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
1834 assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
1835 assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
1836
1837 crate::state::set_encryption_enabled(false);
1838 crate::state::ENCRYPTION_KEY.clear(&[]);
1839 }
1840
1841 #[test]
1842 fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
1843 let (_tmp, _guard) = init_test_db();
1846 crate::state::set_encryption_enabled(false);
1847 let mut c = Community::create("Legacy HQ", "general", vec![]);
1848 c.server_root_key = ServerRootKey([0x42u8; 32]);
1849 save_community(&c).unwrap();
1850
1851 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
1852 crate::state::set_encryption_enabled(true);
1853 let loaded = load_community(&c.id).unwrap().unwrap();
1854 assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
1855 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
1856
1857 crate::state::set_encryption_enabled(false);
1858 crate::state::ENCRYPTION_KEY.clear(&[]);
1859 }
1860
1861 #[test]
1862 fn save_and_load_round_trip() {
1863 let (_tmp, _guard) = init_test_db();
1864 let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
1865 save_community(&original).unwrap();
1866
1867 let loaded = load_community(&original.id).unwrap().expect("present");
1868 assert_eq!(loaded.id, original.id);
1869 assert_eq!(loaded.name, "Vector HQ");
1870 assert_eq!(loaded.relays, original.relays);
1871 assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
1873 assert_eq!(loaded.channels.len(), 1);
1875 assert_eq!(loaded.channels[0].id, original.channels[0].id);
1876 assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
1877 assert_eq!(loaded.channels[0].epoch, Epoch(0));
1878 assert_eq!(loaded.channels[0].name, "general");
1879 }
1880
1881 #[test]
1882 fn owner_is_protected_from_the_banlist_a_member_is_not() {
1883 use nostr_sdk::JsonUtil;
1884 let (_tmp, _guard) = init_test_db();
1885 let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
1886 let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
1888 community.owner_attestation = Some(
1889 crate::community::owner::build_owner_attestation_unsigned(
1890 owner_id.public_key(),
1891 &community.id.to_hex(),
1892 )
1893 .sign_with_keys(&owner_id)
1894 .unwrap()
1895 .as_json(),
1896 );
1897 save_community(&community).unwrap();
1898
1899 let member = Keys::generate();
1901 set_community_banlist(
1902 &community.id.to_hex(),
1903 &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
1904 1,
1905 )
1906 .unwrap();
1907
1908 let loaded = load_community(&community.id).unwrap().unwrap();
1909 let ch = &loaded.channels[0];
1910 assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
1912 assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
1913 assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
1915 }
1916
1917 #[test]
1918 fn loaded_keys_actually_decrypt() {
1919 let (_tmp, _guard) = init_test_db();
1922 let original = Community::create("HQ", "general", vec![]);
1923 save_community(&original).unwrap();
1924 let loaded = load_community(&original.id).unwrap().unwrap();
1925
1926 let author = nostr_sdk::prelude::Keys::generate();
1927 let chan = &original.channels[0];
1928 let sealed = crate::community::envelope::seal_message(
1929 &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
1930 )
1931 .unwrap();
1932 let opened = crate::community::envelope::open_message(
1933 &sealed,
1934 &loaded.channels[0].key,
1935 &loaded.channels[0].id,
1936 loaded.channels[0].epoch,
1937 )
1938 .unwrap();
1939 assert_eq!(opened.content, "persisted!");
1940 }
1941
1942 #[test]
1943 fn member_view_round_trips() {
1944 let (_tmp, _guard) = init_test_db();
1947 let member = Community {
1948 id: CommunityId([7u8; 32]),
1949 server_root_key: ServerRootKey([8u8; 32]),
1950 server_root_epoch: Epoch(0),
1951 name: "Joined".into(),
1952 description: None,
1953 icon: None,
1954 banner: None,
1955 relays: vec!["wss://r".into()],
1956 channels: vec![Channel {
1957 id: ChannelId([9u8; 32]),
1958 key: ChannelKey([10u8; 32]),
1959 epoch: Epoch(0),
1960 name: "general".into(),
1961 banned: Vec::new(),
1962 protected: Vec::new(), roster: Default::default(),
1963 epoch_keys: Vec::new(),
1964 dissolved: false,
1965 }],
1966 owner_attestation: None,
1967 dissolved: false,
1968 };
1969 save_community(&member).unwrap();
1970 let loaded = load_community(&member.id).unwrap().expect("present");
1971 assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
1972 assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
1973 }
1974
1975 #[test]
1976 fn large_epoch_round_trips_losslessly() {
1977 let (_tmp, _guard) = init_test_db();
1979 let mut c = Community::create("HQ", "g", vec![]);
1980 c.channels[0].epoch = Epoch(u64::MAX - 7);
1981 save_community(&c).unwrap();
1982 let loaded = load_community(&c.id).unwrap().unwrap();
1983 assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
1984 }
1985
1986 #[test]
1987 fn malformed_channel_id_row_errors_not_corrupts() {
1988 let (_tmp, _guard) = init_test_db();
1991 let c = Community::create("HQ", "g", vec![]);
1992 save_community(&c).unwrap();
1993 {
1994 let conn = crate::db::get_write_connection_guard_static().unwrap();
1995 conn.execute(
1996 "INSERT OR REPLACE INTO community_channels
1997 (channel_id, community_id, channel_key, epoch, name, created_at)
1998 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
1999 rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
2000 )
2001 .unwrap();
2002 }
2003 assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
2004 }
2005
2006 #[test]
2007 fn message_key_store_take_round_trip() {
2008 let (_tmp, _guard) = init_test_db();
2009 let eph = Keys::generate();
2010 let relays = vec!["wss://r.one".to_string()];
2011 store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
2013
2014 let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
2015 assert_eq!(
2016 loaded.secret_key().as_secret_bytes(),
2017 eph.secret_key().as_secret_bytes()
2018 );
2019 assert_eq!(outer, "outer_evid");
2020 assert_eq!(r, relays);
2021 assert!(take_message_key("inner_msg_id").unwrap().is_none());
2023 }
2024
2025 #[test]
2026 fn missing_community_is_none() {
2027 let (_tmp, _guard) = init_test_db();
2028 let absent = CommunityId([0x33u8; 32]);
2029 assert!(load_community(&absent).unwrap().is_none());
2030 }
2031
2032 #[test]
2033 fn list_ids_reflects_saved() {
2034 let (_tmp, _guard) = init_test_db();
2035 let a = Community::create("A", "g", vec![]);
2036 let b = Community::create("B", "g", vec![]);
2037 save_community(&a).unwrap();
2038 save_community(&b).unwrap();
2039 let ids = list_community_ids().unwrap();
2040 assert_eq!(ids.len(), 2);
2041 assert!(ids.contains(&a.id) && ids.contains(&b.id));
2042 }
2043
2044 #[test]
2045 fn delete_community_clears_all_local_state() {
2046 let (_tmp, _guard) = init_test_db();
2047 let c = Community::create("HQ", "general", vec!["r1".into()]);
2048 save_community(&c).unwrap();
2049 let cid = c.id.to_hex();
2050 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2051 save_pending_invite(&"cd".repeat(32), "{}", "npub1x").unwrap();
2052 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2053
2054 assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2056
2057 delete_community(&cid).unwrap();
2058 assert!(!community_exists(&c.id).unwrap());
2059 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2060 assert!(list_public_invites(&cid).unwrap().is_empty());
2061 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
2062 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
2063 }
2064
2065 #[test]
2066 fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
2067 let (_tmp, _guard) = init_test_db();
2070 let c = Community::create("HQ", "general", vec!["r1".into()]);
2071 save_community(&c).unwrap();
2072 let cid = c.id.to_hex();
2073 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2074 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2075
2076 let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
2077 let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
2078 assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
2079
2080 delete_community_retain_keys(&cid).unwrap();
2081
2082 assert!(!community_exists(&c.id).unwrap());
2084 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2085 assert!(list_public_invites(&cid).unwrap().is_empty());
2086 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
2087 assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
2089 "base epoch keys retained for self-scrub");
2090 assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
2091 "channel epoch keys retained for self-scrub");
2092 }
2093
2094 #[test]
2095 fn channel_resolves_to_owning_community() {
2096 let (_tmp, _guard) = init_test_db();
2097 let c = Community::create("HQ", "general", vec![]);
2098 save_community(&c).unwrap();
2099 let chan = c.channels[0].id.to_hex();
2100 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
2101 assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
2102 }
2103
2104 #[test]
2105 fn community_exists_reflects_saved() {
2106 let (_tmp, _guard) = init_test_db();
2107 let c = Community::create("A", "g", vec![]);
2108 assert!(!community_exists(&c.id).unwrap());
2109 save_community(&c).unwrap();
2110 assert!(community_exists(&c.id).unwrap());
2111 }
2112
2113 #[test]
2114 fn pending_invite_first_wins_and_round_trips() {
2115 let (_tmp, _guard) = init_test_db();
2116 let cid = "ab".repeat(32);
2117 assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter").unwrap());
2120 assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other").unwrap());
2121 assert!(pending_invite_exists(&cid).unwrap());
2122
2123 let listed = list_pending_invites().unwrap();
2124 assert_eq!(listed.len(), 1);
2125 assert_eq!(listed[0].community_id, cid);
2126 assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
2127 assert_eq!(listed[0].inviter_npub, "npub1inviter");
2128
2129 assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
2131 assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
2132 delete_pending_invite(&cid).unwrap();
2133 assert!(!pending_invite_exists(&cid).unwrap());
2134 assert!(get_pending_invite(&cid).unwrap().is_none());
2135 }
2136
2137 #[test]
2138 fn purge_drops_invites_for_held_communities_only() {
2139 let (_tmp, _guard) = init_test_db();
2140 let held = Community::create("Held", "general", vec![]);
2143 save_community(&held).unwrap();
2144 let held_hex = held.id.to_hex();
2145 save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter").unwrap();
2146 let stranger = "ab".repeat(32);
2148 save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter").unwrap();
2149
2150 let n = purge_pending_invites_for_held_communities().unwrap();
2151 assert_eq!(n, 1, "only the held community's invite is purged");
2152 assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
2153 assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
2154 }
2155
2156 #[test]
2157 fn decline_drops_pending_invite() {
2158 let (_tmp, _guard) = init_test_db();
2159 let cid = "cd".repeat(32);
2160 save_pending_invite(&cid, "{}", "npub1x").unwrap();
2161 delete_pending_invite(&cid).unwrap();
2162 assert!(!pending_invite_exists(&cid).unwrap());
2163 }
2164
2165 #[test]
2166 fn pending_invites_are_capped_keeping_the_newest() {
2167 let (_tmp, _guard) = init_test_db();
2168 for i in 0..150u32 {
2173 let cid = format!("{:064x}", i);
2174 save_pending_invite(&cid, "{}", "npub1x").unwrap();
2175 }
2176 let all = list_pending_invites().unwrap();
2177 assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
2178 for i in 150..400u32 {
2180 let cid = format!("{:064x}", i);
2181 save_pending_invite(&cid, "{}", "npub1x").unwrap();
2182 }
2183 assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
2184 }
2185}