1use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
15use nostr_sdk::prelude::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
149#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct PendingChannelKey {
154 pub id: i64,
156 pub channel_id: String,
157 pub epoch: u64,
158 pub key: [u8; 32],
159 pub sender: String,
161 pub received_at: i64,
162}
163
164const MAX_PARKED_PER_CHANNEL: usize = 4;
170const MAX_PARKED_PER_COMMUNITY: usize = 64;
171
172pub fn park_channel_key(
184 community_id: &str,
185 channel_id: &str,
186 epoch: u64,
187 key: &[u8; 32],
188 sender: &str,
189) -> Result<(), String> {
190 let conn = super::get_write_connection_guard_static()?;
191 let tx = conn.unchecked_transaction().map_err(|e| format!("park channel key tx: {e}"))?;
192 let enc = enc_key(key)?;
193 let enc_sender = enc_txt(sender)?;
194 tx.execute(
195 "INSERT INTO pending_channel_keys
196 (community_id, channel_id, epoch, channel_key, sender, received_at)
197 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
198 params![community_id, channel_id, epoch as i64, &enc[..], enc_sender, now_secs()],
199 )
200 .map_err(|e| format!("park channel key: {e}"))?;
201 tx.execute(
205 "DELETE FROM pending_channel_keys WHERE id IN (
206 SELECT id FROM pending_channel_keys
207 WHERE community_id = ?1 AND channel_id = ?2
208 ORDER BY id DESC LIMIT -1 OFFSET ?3)",
209 params![community_id, channel_id, MAX_PARKED_PER_CHANNEL as i64],
210 )
211 .map_err(|e| format!("trim parked channel keys: {e}"))?;
212 tx.execute(
213 "DELETE FROM pending_channel_keys WHERE id IN (
214 SELECT id FROM pending_channel_keys
215 WHERE community_id = ?1
216 ORDER BY id DESC LIMIT -1 OFFSET ?2)",
217 params![community_id, MAX_PARKED_PER_COMMUNITY as i64],
218 )
219 .map_err(|e| format!("trim parked community keys: {e}"))?;
220 tx.commit().map_err(|e| format!("park channel key commit: {e}"))?;
221 Ok(())
222}
223
224pub fn get_pending_channel_keys(community_id: &str) -> Result<Vec<PendingChannelKey>, String> {
226 let conn = super::get_db_connection_guard_static()?;
227 let mut stmt = conn
230 .prepare("SELECT id, channel_id, epoch, channel_key, sender, received_at FROM pending_channel_keys WHERE community_id = ?1 ORDER BY id DESC")
231 .map_err(|e| e.to_string())?;
232 let rows = stmt
233 .query_map(params![community_id], |row| {
234 Ok((
235 row.get::<_, i64>(0)?,
236 row.get::<_, String>(1)?,
237 row.get::<_, i64>(2)?,
238 row.get::<_, Vec<u8>>(3)?,
239 row.get::<_, String>(4)?,
240 row.get::<_, i64>(5)?,
241 ))
242 })
243 .map_err(|e| e.to_string())?;
244 let mut out = Vec::new();
245 for row in rows {
246 let (id, channel_id, epoch, blob, sender, received_at) = row.map_err(|e| e.to_string())?;
247 let Ok(key) = dec_key(&blob) else { continue };
250 out.push(PendingChannelKey { id, channel_id, epoch: epoch as u64, key, sender: dec_txt(&sender), received_at });
251 }
252 Ok(out)
253}
254
255pub fn seat_channel_key(
265 community_id: &str,
266 channel_id: &str,
267 epoch: u64,
268 key: &[u8; 32],
269) -> Result<(), String> {
270 let conn = super::get_write_connection_guard_static()?;
271 let tx = conn.unchecked_transaction().map_err(|e| format!("seat channel key tx: {e}"))?;
272 store_epoch_key_tx(&tx, community_id, channel_id, epoch, key)?;
273 let enc = enc_key(key)?;
274 tx.execute(
275 "UPDATE community_channels SET epoch = ?1, channel_key = ?2
276 WHERE community_id = ?3 AND channel_id = ?4",
277 params![epoch as i64, &enc[..], community_id, channel_id],
278 )
279 .map_err(|e| format!("seat channel key: {e}"))?;
280 tx.commit().map_err(|e| format!("seat channel key commit: {e}"))?;
281 Ok(())
282}
283
284pub fn drop_pending_channel_key(id: i64) -> Result<(), String> {
286 let conn = super::get_write_connection_guard_static()?;
287 conn.execute("DELETE FROM pending_channel_keys WHERE id = ?1", params![id])
288 .map_err(|e| format!("drop pending channel key: {e}"))?;
289 Ok(())
290}
291
292pub fn drop_pending_channel_keys_for(community_id: &str, channel_id: &str) -> Result<(), String> {
294 let conn = super::get_write_connection_guard_static()?;
295 conn.execute(
296 "DELETE FROM pending_channel_keys WHERE community_id = ?1 AND channel_id = ?2",
297 params![community_id, channel_id],
298 )
299 .map_err(|e| format!("drop pending channel keys: {e}"))?;
300 Ok(())
301}
302
303pub fn store_epoch_key(community_id: &str, scope_id: &str, epoch: u64, key: &[u8; 32]) -> Result<(), String> {
310 let conn = super::get_write_connection_guard_static()?;
311 store_epoch_key_tx(&conn, community_id, scope_id, epoch, key)
312}
313
314fn store_epoch_key_tx<C: std::ops::Deref<Target = rusqlite::Connection>>(
318 conn: &C,
319 community_id: &str,
320 scope_id: &str,
321 epoch: u64,
322 key: &[u8; 32],
323) -> Result<(), String> {
324 let enc = enc_key(key)?;
325 conn.execute(
326 "INSERT OR REPLACE INTO community_epoch_keys
327 (community_id, scope_id, epoch, key, created_at)
328 VALUES (?1, ?2, ?3, ?4, ?5)",
329 params![community_id, scope_id, epoch as i64, &enc[..], now_secs()],
331 )
332 .map_err(|e| format!("store epoch key: {e}"))?;
333 Ok(())
334}
335
336pub fn advance_channel_epoch(
343 community_id: &str,
344 channel_id: &str,
345 new_epoch: u64,
346 new_key: &[u8; 32],
347) -> Result<bool, String> {
348 let conn = super::get_write_connection_guard_static()?;
349 let tx = conn.unchecked_transaction().map_err(|e| format!("advance channel epoch tx: {e}"))?;
350 store_epoch_key_tx(&tx, community_id, channel_id, new_epoch, new_key)?;
352 let cur: Option<i64> = tx
354 .query_row(
355 "SELECT epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
356 params![community_id, channel_id],
357 |r| r.get(0),
358 )
359 .optional()
360 .map_err(|e| format!("read channel head: {e}"))?;
361 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
362 if advanced {
363 let enc = enc_key(new_key)?;
364 tx.execute(
365 "UPDATE community_channels SET epoch = ?1, channel_key = ?2
366 WHERE community_id = ?3 AND channel_id = ?4",
367 params![new_epoch as i64, &enc[..], community_id, channel_id],
368 )
369 .map_err(|e| format!("advance channel head: {e}"))?;
370 }
371 tx.commit().map_err(|e| format!("advance channel epoch commit: {e}"))?;
372 Ok(advanced)
373}
374
375pub fn get_server_root_epoch(community_id: &str) -> Result<Option<u64>, String> {
384 let conn = super::get_db_connection_guard_static()?;
385 conn.query_row(
386 "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
387 params![community_id],
388 |r| r.get::<_, i64>(0),
389 )
390 .optional()
391 .map(|v| v.map(|e| e as u64))
392 .map_err(|e| format!("get server root epoch: {e}"))
393}
394
395pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
396 let conn = super::get_write_connection_guard_static()?;
397 let tx = conn.unchecked_transaction().map_err(|e| format!("advance server root tx: {e}"))?;
398 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch, new_root)?;
401 let cur: Option<i64> = tx
402 .query_row(
403 "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
404 params![community_id],
405 |r| r.get(0),
406 )
407 .optional()
408 .map_err(|e| format!("read server-root epoch: {e}"))?;
409 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
410 if advanced {
411 let enc = enc_key(new_root)?;
412 tx.execute(
413 "UPDATE communities SET server_root_epoch = ?1, server_root_key = ?2 WHERE community_id = ?3",
414 params![new_epoch as i64, &enc[..], community_id],
415 )
416 .map_err(|e| format!("advance server-root head: {e}"))?;
417 }
418 tx.commit().map_err(|e| format!("advance server root commit: {e}"))?;
419 Ok(advanced)
420}
421
422pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
429 let conn = super::get_write_connection_guard_static()?;
430 let tx = conn.unchecked_transaction().map_err(|e| format!("converge server root tx: {e}"))?;
431 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, epoch, new_root)?;
432 let enc = enc_key(new_root)?;
433 let switched = tx
434 .execute(
435 "UPDATE communities SET server_root_key = ?1 WHERE community_id = ?2 AND server_root_epoch = ?3",
436 params![&enc[..], community_id, epoch as i64],
437 )
438 .map_err(|e| format!("converge server-root head: {e}"))?
439 > 0;
440 tx.commit().map_err(|e| format!("converge server root commit: {e}"))?;
441 Ok(switched)
442}
443
444pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, new_key: &[u8; 32]) -> Result<bool, String> {
449 let conn = super::get_write_connection_guard_static()?;
450 let tx = conn.unchecked_transaction().map_err(|e| format!("converge channel tx: {e}"))?;
451 store_epoch_key_tx(&tx, community_id, channel_id, epoch, new_key)?;
452 let enc = enc_key(new_key)?;
453 let switched = tx
454 .execute(
455 "UPDATE community_channels SET channel_key = ?1 WHERE community_id = ?2 AND channel_id = ?3 AND epoch = ?4",
456 params![&enc[..], community_id, channel_id, epoch as i64],
457 )
458 .map_err(|e| format!("converge channel head: {e}"))?
459 > 0;
460 tx.commit().map_err(|e| format!("converge channel commit: {e}"))?;
461 Ok(switched)
462}
463
464pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result<Vec<(Epoch, [u8; 32])>, String> {
468 let conn = super::get_db_connection_guard_static()?;
469 let mut stmt = conn
470 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
471 .map_err(|e| e.to_string())?;
472 let rows = stmt
473 .query_map(params![community_id, scope_id], |r| {
474 Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?))
475 })
476 .map_err(|e| e.to_string())?;
477 let mut out: Vec<(Epoch, [u8; 32])> = Vec::new();
478 for row in rows {
479 let (epoch, key_blob) = row.map_err(|e| e.to_string())?;
480 out.push((Epoch(epoch as u64), dec_key(&key_blob)?));
481 }
482 out.sort_by_key(|(e, _)| e.0);
483 Ok(out)
484}
485
486pub fn held_epoch_key(community_id: &str, scope_id: &str, epoch: u64) -> Result<Option<[u8; 32]>, String> {
489 let conn = super::get_db_connection_guard_static()?;
490 let blob: Option<Vec<u8>> = conn
491 .query_row(
492 "SELECT key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2 AND epoch = ?3",
493 params![community_id, scope_id, epoch as i64],
494 |r| r.get(0),
495 )
496 .optional()
497 .map_err(|e| format!("held epoch key: {e}"))?;
498 blob.map(|b| dec_key(&b)).transpose()
499}
500
501pub fn community_created_at_ms(id: &CommunityId) -> Option<u64> {
505 let conn = super::get_db_connection_guard_static().ok()?;
506 conn.query_row(
507 "SELECT created_at FROM communities WHERE community_id = ?1",
508 params![id.to_hex()],
509 |r| r.get::<_, i64>(0),
510 )
511 .optional()
512 .ok()
513 .flatten()
514 .map(|secs| (secs.max(0) as u64) * 1000)
515}
516
517pub fn load_community(id: &CommunityId) -> Result<Option<Community>, String> {
519 let conn = super::get_db_connection_guard_static()?;
520 let id_hex = id.to_hex();
521
522 let row = conn
523 .query_row(
524 "SELECT server_root_key, name, relays,
525 description, icon, banner, banlist, owner_attestation, server_root_epoch, dissolved
526 FROM communities WHERE community_id = ?1",
527 params![id_hex],
528 |r| {
529 Ok((
530 r.get::<_, Vec<u8>>(0)?,
531 r.get::<_, String>(1)?,
532 r.get::<_, String>(2)?,
533 r.get::<_, Option<String>>(3)?,
534 r.get::<_, Option<String>>(4)?,
535 r.get::<_, Option<String>>(5)?,
536 r.get::<_, String>(6)?,
537 r.get::<_, Option<String>>(7)?,
538 r.get::<_, i64>(8)?,
539 r.get::<_, i64>(9)?,
540 ))
541 },
542 )
543 .optional()
544 .map_err(|e| format!("load community: {e}"))?;
545
546 let (root_blob, name, relays_json, description, icon_json, banner_json, banlist_json, owner_attestation, server_root_epoch, dissolved_int) =
547 match row {
548 Some(t) => t,
549 None => return Ok(None),
550 };
551 let dissolved = dissolved_int != 0;
552
553 let name = dec_txt(&name);
555 let relays_json = dec_txt(&relays_json);
556 let description = description.map(|s| dec_txt(&s));
557 let icon_json = icon_json.map(|s| dec_txt(&s));
558 let banner_json = banner_json.map(|s| dec_txt(&s));
559 let banlist_json = dec_txt(&banlist_json);
560 let owner_attestation = owner_attestation.map(|s| dec_txt(&s));
561
562 let banned: Vec<PublicKey> = serde_json::from_str::<Vec<String>>(&banlist_json)
566 .unwrap_or_default()
567 .iter()
568 .filter_map(|h| PublicKey::from_hex(h).ok())
569 .collect();
570
571 let icon = icon_json
572 .map(|j| serde_json::from_str(&j))
573 .transpose()
574 .map_err(|e| format!("icon json: {e}"))?;
575 let banner = banner_json
576 .map(|j| serde_json::from_str(&j))
577 .transpose()
578 .map_err(|e| format!("banner json: {e}"))?;
579
580 let server_root_key = ServerRootKey(dec_key(&root_blob)?);
581 let relays: Vec<String> = serde_json::from_str(&relays_json).map_err(|e| e.to_string())?;
582
583 let mut protected: Vec<PublicKey> = Vec::new();
590 if let Some(owner) = owner_attestation
591 .as_ref()
592 .and_then(|att| crate::community::owner::verify_owner_attestation(att, &id_hex))
593 {
594 protected.push(owner);
595 }
596 let banned: Vec<PublicKey> = banned.into_iter().filter(|pk| !protected.contains(pk)).collect();
597
598 let raw_channels: Vec<(String, Vec<u8>, i64, String)> = {
601 let mut stmt = conn
602 .prepare(
603 "SELECT channel_id, channel_key, epoch, name
604 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
605 )
606 .map_err(|e| e.to_string())?;
607 let rows = stmt
608 .query_map(params![id_hex], |r| {
609 Ok((
610 r.get::<_, String>(0)?,
611 r.get::<_, Vec<u8>>(1)?,
612 r.get::<_, i64>(2)?,
613 r.get::<_, String>(3)?,
614 ))
615 })
616 .map_err(|e| e.to_string())?;
617 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
618 };
619
620 let roster = get_community_roles(&id_hex).unwrap_or_default();
623
624 let mut channels = Vec::new();
625 for (cid_hex, key_blob, epoch, cname) in raw_channels {
626 let epoch_keys: Vec<(Epoch, crate::community::ChannelKey)> = {
630 let mut ek_stmt = conn
631 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
632 .map_err(|e| e.to_string())?;
633 let rows = ek_stmt
634 .query_map(params![id_hex, cid_hex], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
635 .map_err(|e| e.to_string())?;
636 let mut out = Vec::new();
637 for row in rows {
638 let (e, blob) = row.map_err(|e| e.to_string())?;
639 if let Ok(k) = dec_key(&blob) {
640 out.push((Epoch(e as u64), crate::community::ChannelKey(k)));
641 }
642 }
643 out
644 };
645 channels.push(Channel {
646 id: ChannelId(hex_id_to_32(&cid_hex)?),
647 key: ChannelKey(dec_key(&key_blob)?),
648 epoch: Epoch(epoch as u64),
651 name: dec_txt(&cname),
652 banned: banned.clone(),
653 protected: protected.clone(),
654 roster: roster.clone(),
655 epoch_keys,
656 dissolved,
657 });
658 }
659
660 Ok(Some(Community {
661 id: *id,
662 server_root_key,
663 server_root_epoch: Epoch(server_root_epoch as u64),
665 name,
666 description,
667 icon,
668 banner,
669 relays,
670 channels,
671 owner_attestation,
672 dissolved,
673 }))
674}
675
676pub fn store_message_key(
679 message_id: &str,
680 outer_event_id: &str,
681 ephemeral: &Keys,
682 relays: &[String],
683) -> Result<(), String> {
684 let conn = super::get_write_connection_guard_static()?;
685 let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?;
686 let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?;
687 let enc_secret = enc_key(&sk_bytes)?;
688 let enc_relays = enc_txt(&relays_json)?;
689 conn.execute(
690 "INSERT OR REPLACE INTO community_message_keys
691 (outer_event_id, message_id, ephemeral_secret, relays, created_at)
692 VALUES (?1, ?2, ?3, ?4, ?5)",
693 params![
694 outer_event_id,
695 message_id,
696 &enc_secret[..],
697 enc_relays,
698 now_secs(),
699 ],
700 )
701 .map_err(|e| format!("store message key: {e}"))?;
702 Ok(())
703}
704
705pub fn get_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
711 let conn = super::get_db_connection_guard_static()?;
712 let row = conn
713 .query_row(
714 "SELECT ephemeral_secret, outer_event_id, relays
715 FROM community_message_keys WHERE message_id = ?1",
716 params![message_id],
717 |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)),
718 )
719 .optional()
720 .map_err(|e| format!("get message key: {e}"))?;
721 let (secret_blob, outer_event_id, relays_json) = match row {
722 Some(t) => t,
723 None => return Ok(None),
724 };
725 let secret = SecretKey::from_slice(&dec_key(&secret_blob)?).map_err(|e| format!("ephemeral secret: {e}"))?;
726 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_json)).map_err(|e| e.to_string())?;
727 Ok(Some((Keys::new(secret), outer_event_id, relays)))
728}
729
730pub fn delete_message_key(message_id: &str) -> Result<(), String> {
732 let conn = super::get_write_connection_guard_static()?;
733 conn.execute(
734 "DELETE FROM community_message_keys WHERE message_id = ?1",
735 params![message_id],
736 )
737 .map_err(|e| format!("remove message key: {e}"))?;
738 Ok(())
739}
740
741pub fn take_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
744 let r = get_message_key(message_id)?;
745 if r.is_some() {
746 delete_message_key(message_id)?;
747 }
748 Ok(r)
749}
750
751static CHANNEL_COMMUNITY_CACHE: std::sync::LazyLock<
763 std::sync::RwLock<std::collections::HashMap<String, String>>,
764> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
765
766pub fn clear_channel_community_cache() {
768 CHANNEL_COMMUNITY_CACHE.write().unwrap().clear();
769}
770
771fn forget_community_channels(community_id: &str) {
774 CHANNEL_COMMUNITY_CACHE.write().unwrap().retain(|_, cid| cid != community_id);
775}
776
777pub fn community_id_for_channel(channel_id: &str) -> Result<Option<String>, String> {
778 if let Some(cid) = CHANNEL_COMMUNITY_CACHE.read().unwrap().get(channel_id) {
779 return Ok(Some(cid.clone()));
780 }
781 let conn = super::get_db_connection_guard_static()?;
782 let cid: Option<String> = conn
783 .query_row(
784 "SELECT community_id FROM community_channels WHERE channel_id = ?1",
785 params![channel_id],
786 |r| r.get::<_, String>(0),
787 )
788 .optional()
789 .map_err(|e| format!("community_id_for_channel: {e}"))?;
790 if let Some(ref c) = cid {
791 CHANNEL_COMMUNITY_CACHE.write().unwrap().insert(channel_id.to_string(), c.clone());
792 }
793 Ok(cid)
794}
795
796pub fn community_exists(id: &CommunityId) -> Result<bool, String> {
799 let conn = super::get_db_connection_guard_static()?;
800 let found: Option<i64> = conn
801 .query_row(
802 "SELECT 1 FROM communities WHERE community_id = ?1",
803 params![id.to_hex()],
804 |r| r.get(0),
805 )
806 .optional()
807 .map_err(|e| format!("community_exists: {e}"))?;
808 Ok(found.is_some())
809}
810
811#[derive(Debug, Clone, serde::Serialize)]
813pub struct PendingCommunityInvite {
814 pub community_id: String,
815 pub bundle_json: String,
816 pub inviter_npub: String,
817 pub received_at: i64,
818 pub expires_at: i64,
820}
821
822pub fn save_pending_invite(
827 community_id: &str,
828 bundle_json: &str,
829 inviter_npub: &str,
830 expires_at: i64,
831) -> Result<bool, String> {
832 const MAX_PENDING_INVITES: usize = 100;
836
837 let conn = super::get_write_connection_guard_static()?;
838 let enc_bundle = enc_txt(bundle_json)?;
839 let enc_inviter = enc_txt(inviter_npub)?;
840 let changed = conn
845 .execute(
846 "INSERT OR IGNORE INTO pending_community_invites
847 (community_id, bundle_json, inviter_npub, received_at, expires_at)
848 VALUES (?1, ?2, ?3, ?4, ?5)",
849 params![community_id, enc_bundle, enc_inviter, now_secs(), expires_at],
850 )
851 .map_err(|e| format!("save pending invite: {e}"))?;
852 if changed > 0 {
855 let _ = conn.execute(
856 "DELETE FROM pending_community_invites
857 WHERE community_id IN (
858 SELECT community_id FROM pending_community_invites
859 ORDER BY received_at DESC, community_id DESC
860 LIMIT -1 OFFSET ?1
861 )",
862 params![MAX_PENDING_INVITES],
863 );
864 }
865 Ok(changed > 0)
866}
867
868pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
874 let conn = super::get_write_connection_guard_static()?;
875 let n = conn
876 .execute(
877 "DELETE FROM pending_community_invites
878 WHERE community_id IN (SELECT community_id FROM communities)",
879 [],
880 )
881 .map_err(|e| format!("purge held pending invites: {e}"))?;
882 Ok(n)
883}
884
885pub fn purge_expired_pending_invites() -> Result<usize, String> {
893 let conn = super::get_write_connection_guard_static()?;
894 let now = now_secs();
895 let n = conn
896 .execute(
897 "DELETE FROM pending_community_invites
898 WHERE (expires_at != 0 AND expires_at <= ?1)
899 OR received_at <= ?2",
900 params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64],
901 )
902 .map_err(|e| format!("purge expired pending invites: {e}"))?;
903 Ok(n)
904}
905
906pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
908 let conn = super::get_db_connection_guard_static()?;
909 let mut stmt = conn
910 .prepare(
911 "SELECT community_id, bundle_json, inviter_npub, received_at, expires_at
912 FROM pending_community_invites
913 WHERE (expires_at = 0 OR expires_at > ?1)
914 AND received_at > ?2
915 ORDER BY received_at DESC",
916 )
917 .map_err(|e| e.to_string())?;
918 let now = now_secs();
919 let rows = stmt
920 .query_map(params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64], |r| {
921 Ok(PendingCommunityInvite {
922 community_id: r.get(0)?,
923 bundle_json: dec_txt(&r.get::<_, String>(1)?),
924 inviter_npub: dec_txt(&r.get::<_, String>(2)?),
925 received_at: r.get(3)?,
926 expires_at: r.get(4)?,
927 })
928 })
929 .map_err(|e| e.to_string())?;
930 let mut out = Vec::new();
931 for row in rows {
932 out.push(row.map_err(|e| e.to_string())?);
933 }
934 Ok(out)
935}
936
937pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
941 let conn = super::get_db_connection_guard_static()?;
942 let raw: Option<String> = conn
943 .query_row(
944 "SELECT bundle_json FROM pending_community_invites
945 WHERE community_id = ?1 AND (expires_at = 0 OR expires_at > ?2)",
946 params![community_id, now_secs()],
947 |r| r.get::<_, String>(0),
948 )
949 .optional()
950 .map_err(|e| format!("get pending invite: {e}"))?;
951 Ok(raw.map(|s| dec_txt(&s)))
952}
953
954pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
956 let conn = super::get_write_connection_guard_static()?;
957 conn.execute(
958 "DELETE FROM pending_community_invites WHERE community_id = ?1",
959 params![community_id],
960 )
961 .map_err(|e| format!("delete pending invite: {e}"))?;
962 Ok(())
963}
964
965pub fn pending_invite_received_at(community_id: &str) -> Result<Option<i64>, String> {
970 let conn = super::get_db_connection_guard_static()?;
971 conn.query_row(
972 "SELECT received_at FROM pending_community_invites WHERE community_id = ?1",
973 params![community_id],
974 |r| r.get(0),
975 )
976 .optional()
977 .map_err(|e| format!("pending_invite_received_at: {e}"))
978}
979
980pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
981 let conn = super::get_db_connection_guard_static()?;
982 let found: Option<i64> = conn
983 .query_row(
984 "SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
985 params![community_id],
986 |r| r.get(0),
987 )
988 .optional()
989 .map_err(|e| format!("pending_invite_exists: {e}"))?;
990 Ok(found.is_some())
991}
992
993#[derive(Debug, Clone, serde::Serialize)]
995pub struct PublicInviteRecord {
996 pub token: String,
998 pub community_id: String,
999 pub url: String,
1000 pub expires_at: Option<i64>,
1001 pub created_at: i64,
1002 pub label: Option<String>,
1004 #[serde(default)]
1006 pub join_count: u64,
1007}
1008
1009pub fn save_public_invite(
1011 token: &str,
1012 community_id: &str,
1013 url: &str,
1014 expires_at: Option<i64>,
1015 label: Option<&str>,
1016) -> Result<(), String> {
1017 let conn = super::get_write_connection_guard_static()?;
1018 let enc_token = enc_txt(token)?;
1021 let enc_url = enc_txt(url)?;
1022 let enc_label = label.map(enc_txt).transpose()?;
1024 conn.execute(
1025 "INSERT OR REPLACE INTO community_public_invites
1026 (token, community_id, url, expires_at, created_at, label)
1027 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1028 params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
1029 )
1030 .map_err(|e| format!("save public invite: {e}"))?;
1031 Ok(())
1032}
1033
1034pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
1036 let conn = super::get_db_connection_guard_static()?;
1037 let mut stmt = conn
1038 .prepare(
1039 "SELECT token, community_id, url, expires_at, created_at, label
1040 FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
1041 )
1042 .map_err(|e| e.to_string())?;
1043 let rows = stmt
1044 .query_map(params![community_id], |r| {
1045 Ok(PublicInviteRecord {
1046 token: dec_txt(&r.get::<_, String>(0)?),
1047 community_id: r.get(1)?,
1048 url: dec_txt(&r.get::<_, String>(2)?),
1049 expires_at: r.get(3)?,
1050 created_at: r.get(4)?,
1051 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
1052 join_count: 0,
1053 })
1054 })
1055 .map_err(|e| e.to_string())?;
1056 let mut out = Vec::new();
1057 for row in rows {
1058 out.push(row.map_err(|e| e.to_string())?);
1059 }
1060 if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
1062 if let Ok(counts) = community_invite_join_counts(community_id, &me) {
1063 for rec in &mut out {
1064 if let Some(l) = rec.label.as_deref() {
1065 rec.join_count = counts.get(l).copied().unwrap_or(0);
1066 }
1067 }
1068 }
1069 }
1070 Ok(out)
1071}
1072
1073pub fn delete_public_invite(token: &str) -> Result<(), String> {
1075 let conn = super::get_write_connection_guard_static()?;
1076 let rows: Vec<(i64, String)> = {
1079 let mut stmt = conn
1080 .prepare("SELECT rowid, token FROM community_public_invites")
1081 .map_err(|e| e.to_string())?;
1082 let mapped = stmt
1083 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1084 .map_err(|e| e.to_string())?;
1085 mapped.filter_map(|r| r.ok()).collect()
1086 };
1087 for (rowid, stored) in rows {
1088 if dec_txt(&stored) == token {
1089 conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
1090 .map_err(|e| format!("delete public invite: {e}"))?;
1091 }
1092 }
1093 Ok(())
1094}
1095
1096pub fn list_all_public_invites() -> Result<Vec<PublicInviteRecord>, String> {
1098 let conn = super::get_db_connection_guard_static()?;
1099 let mut stmt = conn
1100 .prepare(
1101 "SELECT token, community_id, url, expires_at, created_at, label
1102 FROM community_public_invites ORDER BY created_at DESC",
1103 )
1104 .map_err(|e| e.to_string())?;
1105 let rows = stmt
1106 .query_map([], |r| {
1107 Ok(PublicInviteRecord {
1108 token: dec_txt(&r.get::<_, String>(0)?),
1109 community_id: r.get(1)?,
1110 url: dec_txt(&r.get::<_, String>(2)?),
1111 expires_at: r.get(3)?,
1112 created_at: r.get(4)?,
1113 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
1114 join_count: 0,
1115 })
1116 })
1117 .map_err(|e| e.to_string())?;
1118 let mut out = Vec::new();
1119 for row in rows {
1120 out.push(row.map_err(|e| e.to_string())?);
1121 }
1122 Ok(out)
1123}
1124
1125pub fn upsert_public_invite(
1130 token: &str,
1131 community_id: &str,
1132 url: &str,
1133 expires_at: Option<i64>,
1134 created_at: i64,
1135 label: Option<&str>,
1136) -> Result<bool, String> {
1137 let conn = super::get_write_connection_guard_static()?;
1138 let already = {
1139 let mut stmt = conn
1140 .prepare("SELECT token FROM community_public_invites WHERE community_id = ?1")
1141 .map_err(|e| e.to_string())?;
1142 let stored: Vec<String> = stmt
1143 .query_map(params![community_id], |r| r.get::<_, String>(0))
1144 .map_err(|e| e.to_string())?
1145 .filter_map(|r| r.ok())
1146 .collect();
1147 stored.iter().any(|s| dec_txt(s) == token)
1148 };
1149 if already {
1150 return Ok(false);
1151 }
1152 let enc_token = enc_txt(token)?;
1153 let enc_url = enc_txt(url)?;
1154 let enc_label = label.map(enc_txt).transpose()?;
1155 conn.execute(
1156 "INSERT INTO community_public_invites
1157 (token, community_id, url, expires_at, created_at, label)
1158 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1159 params![enc_token, community_id, enc_url, expires_at, created_at, enc_label],
1160 )
1161 .map_err(|e| format!("upsert public invite: {e}"))?;
1162 Ok(true)
1163}
1164
1165pub fn delete_community(community_id: &str) -> Result<(), String> {
1170 delete_community_inner(community_id, false)
1171}
1172
1173pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
1179 delete_community_inner(community_id, true)
1180}
1181
1182fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
1183 let conn = super::get_write_connection_guard_static()?;
1184 let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
1187 for sql in [
1188 Some("DELETE FROM communities WHERE community_id = ?1"),
1189 Some("DELETE FROM community_channels WHERE community_id = ?1"),
1190 (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
1194 Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
1195 Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
1196 Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
1197 Some("DELETE FROM pending_channel_keys WHERE community_id = ?1"),
1203 Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
1206 ]
1207 .into_iter()
1208 .flatten()
1209 {
1210 tx.execute(sql, params![community_id])
1211 .map_err(|e| format!("delete community: {e}"))?;
1212 }
1213 tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
1214 BANLIST_CACHE.write().unwrap().remove(community_id);
1215 forget_community_channels(community_id);
1216 Ok(())
1221}
1222
1223pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
1230 community_member_activity_capped(community_id, true)
1231}
1232
1233pub fn community_member_activity_capped(community_id: &str, capped: bool) -> Result<Vec<(String, u64)>, String> {
1239 const COMMUNITY_MEMBER_CAP: usize = 500;
1242 use std::collections::HashMap;
1243
1244 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1245 Some(c) => c,
1246 None => return Ok(Vec::new()),
1247 };
1248 let owner_b32: Option<String> = community
1253 .owner_attestation
1254 .as_deref()
1255 .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
1256 .and_then(|pk| pk.to_bech32().ok());
1257
1258 let mut chat_ints: Vec<i64> = Vec::new();
1260 for ch in &community.channels {
1261 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1262 chat_ints.push(cid);
1263 }
1264 }
1265
1266 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1269
1270 let mut active: HashMap<String, u64> = HashMap::new();
1275 let mut left: HashMap<String, u64> = HashMap::new();
1276 if !chat_ints.is_empty() {
1279 let conn = super::get_db_connection_guard_static()?;
1280 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1281
1282 {
1283 let sql = format!(
1284 "SELECT npub, MAX(created_at) FROM events \
1285 WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
1286 GROUP BY npub"
1287 );
1288 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1289 let rows = stmt
1290 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1291 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
1292 })
1293 .map_err(|e| e.to_string())?;
1294 for row in rows {
1295 let (npub, at) = row.map_err(|e| e.to_string())?;
1296 active.insert(npub, at);
1297 }
1298 }
1299
1300 {
1302 let sql = format!(
1303 "SELECT npub, created_at, tags FROM events \
1304 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1305 );
1306 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1307 let rows = stmt
1308 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1309 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
1310 })
1311 .map_err(|e| e.to_string())?;
1312 for row in rows {
1313 let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
1314 let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
1316 .ok()
1317 .and_then(|tags| {
1318 tags.into_iter()
1319 .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
1320 .and_then(|t| t.into_iter().nth(1))
1321 });
1322 match etype.as_deref() {
1323 Some("1") => {
1324 let e = active.entry(npub).or_insert(0);
1325 if at > *e { *e = at; }
1326 }
1327 Some("0") => {
1328 let e = left.entry(npub).or_insert(0);
1329 if at > *e { *e = at; }
1330 }
1331 _ => {}
1332 }
1333 }
1334 }
1335 }
1336
1337 let banned: std::collections::HashSet<String> = community
1340 .channels
1341 .first()
1342 .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
1343 .unwrap_or_default();
1344
1345 let mut out: Vec<(String, u64)> = active
1347 .into_iter()
1348 .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l))
1349 .collect();
1350
1351 {
1358 let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
1359 let mut reassert = |npub: String| {
1360 if !banned.contains(&npub) && present.insert(npub.clone()) {
1361 out.push((npub, now_secs() as u64));
1362 }
1363 };
1364 if let Some(o) = owner_b32 {
1365 reassert(o);
1366 }
1367 if let Ok(roles) = get_community_roles(community_id) {
1368 for g in &roles.grants {
1369 if g.role_ids.is_empty() {
1370 continue; }
1372 if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
1373 reassert(b32);
1374 }
1375 }
1376 }
1377 }
1378 out.sort_by(|a, b| b.1.cmp(&a.1));
1379 if capped {
1380 out.truncate(COMMUNITY_MEMBER_CAP);
1381 }
1382 Ok(out)
1383}
1384
1385pub fn community_invite_join_counts(
1390 community_id: &str,
1391 inviter_npub: &str,
1392) -> Result<std::collections::HashMap<String, u64>, String> {
1393 use std::collections::{HashMap, HashSet};
1394 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1395 Some(c) => c,
1396 None => return Ok(HashMap::new()),
1397 };
1398 let mut chat_ints: Vec<i64> = Vec::new();
1399 for ch in &community.channels {
1400 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1401 chat_ints.push(cid);
1402 }
1403 }
1404 if chat_ints.is_empty() {
1405 return Ok(HashMap::new());
1406 }
1407 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1408 let conn = super::get_db_connection_guard_static()?;
1409 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1410 let sql = format!(
1411 "SELECT npub, tags FROM events \
1412 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1413 );
1414 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1415 let rows = stmt
1416 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1417 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1418 })
1419 .map_err(|e| e.to_string())?;
1420 let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
1422 for row in rows {
1423 let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
1424 let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
1425 Ok(t) => t,
1426 Err(_) => continue,
1427 };
1428 let tag_val = |key: &str| -> Option<String> {
1429 tags.iter()
1430 .find(|t| t.first().map(|s| s == key).unwrap_or(false))
1431 .and_then(|t| t.get(1).cloned())
1432 };
1433 if tag_val("event-type").as_deref() != Some("1") {
1435 continue;
1436 }
1437 if tag_val("invited-by").as_deref() != Some(inviter_npub) {
1438 continue;
1439 }
1440 if let Some(label) = tag_val("invited-label") {
1441 per_label.entry(label).or_default().insert(joiner);
1442 }
1443 }
1444 Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
1445}
1446
1447static BANLIST_CACHE: std::sync::LazyLock<
1459 std::sync::RwLock<std::collections::HashMap<String, std::sync::Arc<std::collections::HashSet<[u8; 32]>>>>,
1460> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
1461
1462fn banlist_set_from_hexes(hexes: &[String]) -> std::collections::HashSet<[u8; 32]> {
1463 hexes.iter().filter_map(|h| crate::simd::hex::hex_to_bytes_32_checked(h)).collect()
1464}
1465
1466pub fn clear_banlist_cache() {
1468 BANLIST_CACHE.write().unwrap().clear();
1469}
1470
1471pub fn banned_set(community_id: &str) -> std::sync::Arc<std::collections::HashSet<[u8; 32]>> {
1474 if let Some(set) = BANLIST_CACHE.read().unwrap().get(community_id) {
1475 return std::sync::Arc::clone(set);
1476 }
1477 let set = std::sync::Arc::new(banlist_set_from_hexes(
1478 &get_community_banlist(community_id).unwrap_or_default(),
1479 ));
1480 BANLIST_CACHE
1481 .write()
1482 .unwrap()
1483 .insert(community_id.to_string(), std::sync::Arc::clone(&set));
1484 set
1485}
1486
1487pub fn is_author_banned(community_id: &str, author: &PublicKey) -> bool {
1490 let set = banned_set(community_id);
1491 !set.is_empty() && set.contains(&author.to_bytes())
1492}
1493
1494pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
1495 let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?;
1496 let conn = super::get_write_connection_guard_static()?;
1497 conn.execute(
1498 "UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3",
1499 params![json, at, community_id],
1500 )
1501 .map_err(|e| format!("set banlist: {e}"))?;
1502 BANLIST_CACHE
1505 .write()
1506 .unwrap()
1507 .insert(community_id.to_string(), std::sync::Arc::new(banlist_set_from_hexes(banned_hex)));
1508 Ok(())
1509}
1510
1511pub fn get_community_ban_marks(community_id: &str) -> Result<std::collections::BTreeMap<String, u64>, String> {
1516 let conn = super::get_db_connection_guard_static()?;
1517 let json: Option<String> = conn
1518 .query_row(
1519 "SELECT banlist_marks FROM communities WHERE community_id = ?1",
1520 params![community_id],
1521 |r| r.get(0),
1522 )
1523 .optional()
1524 .map_err(|e| format!("get ban marks: {e}"))?;
1525 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1526}
1527
1528pub fn merge_community_ban_marks(community_id: &str, marks: &std::collections::BTreeMap<String, u64>) -> Result<bool, String> {
1533 if marks.is_empty() {
1534 return Ok(false);
1535 }
1536 let mut stored = get_community_ban_marks(community_id)?;
1537 let mut changed = false;
1538 for (npub, at) in marks {
1539 let slot = stored.entry(npub.clone()).or_insert(0);
1540 if *at > *slot {
1541 *slot = *at;
1542 changed = true;
1543 }
1544 }
1545 if !changed {
1546 return Ok(false);
1547 }
1548 let json = enc_txt(&serde_json::to_string(&stored).map_err(|e| e.to_string())?)?;
1549 let conn = super::get_write_connection_guard_static()?;
1550 conn.execute(
1551 "UPDATE communities SET banlist_marks = ?1 WHERE community_id = ?2",
1552 params![json, community_id],
1553 )
1554 .map_err(|e| format!("set ban marks: {e}"))?;
1555 Ok(true)
1556}
1557
1558pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
1561 let conn = super::get_db_connection_guard_static()?;
1562 let at: Option<i64> = conn
1563 .query_row(
1564 "SELECT banlist_at FROM communities WHERE community_id = ?1",
1565 params![community_id],
1566 |r| r.get(0),
1567 )
1568 .optional()
1569 .map_err(|e| format!("get banlist_at: {e}"))?;
1570 Ok(at.unwrap_or(0))
1571}
1572
1573pub fn set_community_roles(
1578 community_id: &str,
1579 roles: &crate::community::roles::CommunityRoles,
1580 at: i64,
1581) -> Result<(), String> {
1582 let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
1583 let conn = super::get_write_connection_guard_static()?;
1584 conn.execute(
1585 "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
1586 params![json, at, community_id],
1587 )
1588 .map_err(|e| format!("set roles: {e}"))?;
1589 Ok(())
1590}
1591
1592pub fn get_community_roles(
1594 community_id: &str,
1595) -> Result<crate::community::roles::CommunityRoles, String> {
1596 let conn = super::get_db_connection_guard_static()?;
1597 let json: Option<String> = conn
1598 .query_row(
1599 "SELECT roles FROM communities WHERE community_id = ?1",
1600 params![community_id],
1601 |r| r.get(0),
1602 )
1603 .optional()
1604 .map_err(|e| format!("get roles: {e}"))?;
1605 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1606}
1607
1608pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
1610 let conn = super::get_db_connection_guard_static()?;
1611 let at: Option<i64> = conn
1612 .query_row(
1613 "SELECT roles_at FROM communities WHERE community_id = ?1",
1614 params![community_id],
1615 |r| r.get(0),
1616 )
1617 .optional()
1618 .map_err(|e| format!("get roles_at: {e}"))?;
1619 Ok(at.unwrap_or(0))
1620}
1621
1622pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
1627 set_edition_head_inner(community_id, entity_id, version, self_hash, None, None)
1628}
1629
1630pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1633 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), None)
1634}
1635
1636pub 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> {
1641 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), Some(epoch))
1642}
1643
1644fn 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> {
1645 let conn = super::get_write_connection_guard_static()?;
1646 conn.execute(
1652 "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
1653 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
1654 ON CONFLICT(community_id, entity_id) DO UPDATE SET
1655 version = excluded.version,
1656 self_hash = excluded.self_hash,
1657 inner_id = excluded.inner_id,
1658 epoch = excluded.epoch
1659 WHERE excluded.epoch > community_edition_heads.epoch
1660 OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
1661 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)],
1662 )
1663 .map_err(|e| format!("set edition head: {e}"))?;
1664 Ok(())
1665}
1666
1667pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1677 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, None)
1678}
1679
1680pub 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> {
1684 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, Some(epoch))
1685}
1686
1687fn 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> {
1688 let conn = super::get_write_connection_guard_static()?;
1689 conn.execute(
1692 "UPDATE community_edition_heads
1693 SET self_hash = ?4, inner_id = ?5
1694 WHERE community_id = ?1 AND entity_id = ?2
1695 AND version = ?3
1696 AND epoch = COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
1697 AND (inner_id IS NULL OR ?5 < inner_id)",
1698 params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice(), epoch.map(|e| e as i64)],
1699 )
1700 .map_err(|e| format!("converge edition head: {e}"))?;
1701 Ok(())
1702}
1703
1704pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
1709 let conn = super::get_db_connection_guard_static()?;
1710 let row: Option<Option<Vec<u8>>> = conn
1711 .query_row(
1712 "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1713 params![community_id, entity_id],
1714 |r| r.get(0),
1715 )
1716 .optional()
1717 .map_err(|e| format!("get edition head inner_id: {e}"))?;
1718 match row.flatten() {
1719 Some(blob) if blob.len() == 32 => {
1720 let mut h = [0u8; 32];
1721 h.copy_from_slice(&blob);
1722 Ok(Some(h))
1723 }
1724 _ => Ok(None),
1725 }
1726}
1727
1728pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
1731 let conn = super::get_db_connection_guard_static()?;
1732 let row: Option<(i64, Vec<u8>)> = conn
1733 .query_row(
1734 "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1735 params![community_id, entity_id],
1736 |r| Ok((r.get(0)?, r.get(1)?)),
1737 )
1738 .optional()
1739 .map_err(|e| format!("get edition head: {e}"))?;
1740 match row {
1741 Some((v, hash)) if hash.len() == 32 => {
1742 let mut h = [0u8; 32];
1743 h.copy_from_slice(&hash);
1744 Ok(Some((v as u64, h)))
1745 }
1746 _ => Ok(None),
1747 }
1748}
1749
1750pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
1755 let conn = super::get_db_connection_guard_static()?;
1756 let mut stmt = conn
1757 .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
1758 .map_err(|e| e.to_string())?;
1759 let rows = stmt
1760 .query_map(params![community_id], |r| r.get::<_, String>(0))
1761 .map_err(|e| e.to_string())?;
1762 let mut out = std::collections::HashSet::new();
1763 for row in rows {
1764 out.insert(row.map_err(|e| e.to_string())?);
1765 }
1766 Ok(out)
1767}
1768
1769
1770pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
1776 let conn = super::get_db_connection_guard_static()?;
1777 let mut stmt = conn
1778 .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1779 .map_err(|e| e.to_string())?;
1780 let rows = stmt
1781 .query_map(params![community_id], |r| {
1782 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
1783 })
1784 .map_err(|e| e.to_string())?;
1785 let mut out = std::collections::HashMap::new();
1786 for row in rows {
1787 let (entity, version, hash) = row.map_err(|e| e.to_string())?;
1788 if hash.len() == 32 {
1789 let mut h = [0u8; 32];
1790 h.copy_from_slice(&hash);
1791 out.insert(entity, (version as u64, h));
1792 }
1793 }
1794 Ok(out)
1795}
1796
1797pub fn get_all_edition_heads_full(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32], Option<[u8; 32]>)>, String> {
1805 let conn = super::get_db_connection_guard_static()?;
1806 let mut stmt = conn
1807 .prepare("SELECT entity_id, epoch, version, self_hash, inner_id FROM community_edition_heads WHERE community_id = ?1")
1808 .map_err(|e| e.to_string())?;
1809 let rows = stmt
1810 .query_map(params![community_id], |r| {
1811 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, Option<Vec<u8>>>(4)?))
1812 })
1813 .map_err(|e| e.to_string())?;
1814 let mut out = std::collections::HashMap::new();
1815 for row in rows {
1816 let (entity, epoch, version, hash, inner) = row.map_err(|e| e.to_string())?;
1817 if hash.len() == 32 {
1818 let mut h = [0u8; 32];
1819 h.copy_from_slice(&hash);
1820 let inner_id = inner.and_then(|b| {
1821 (b.len() == 32).then(|| {
1822 let mut i = [0u8; 32];
1823 i.copy_from_slice(&b);
1824 i
1825 })
1826 });
1827 out.insert(entity, (epoch as u64, version as u64, h, inner_id));
1828 }
1829 }
1830 Ok(out)
1831}
1832
1833pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32])>, String> {
1834 let conn = super::get_db_connection_guard_static()?;
1835 let mut stmt = conn
1836 .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1837 .map_err(|e| e.to_string())?;
1838 let rows = stmt
1839 .query_map(params![community_id], |r| {
1840 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
1841 })
1842 .map_err(|e| e.to_string())?;
1843 let mut out = std::collections::HashMap::new();
1844 for row in rows {
1845 let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
1846 if hash.len() == 32 {
1847 let mut h = [0u8; 32];
1848 h.copy_from_slice(&hash);
1849 out.insert(entity, (epoch as u64, version as u64, h));
1850 }
1851 }
1852 Ok(out)
1853}
1854
1855pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
1857 let conn = super::get_db_connection_guard_static()?;
1858 let json: Option<String> = conn
1859 .query_row(
1860 "SELECT banlist FROM communities WHERE community_id = ?1",
1861 params![community_id],
1862 |r| r.get(0),
1863 )
1864 .optional()
1865 .map_err(|e| format!("get banlist: {e}"))?;
1866 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1867}
1868
1869pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
1873 let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
1874 let conn = super::get_write_connection_guard_static()?;
1875 conn.execute(
1876 "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
1877 params![json, community_id],
1878 )
1879 .map_err(|e| format!("set invite registry: {e}"))?;
1880 Ok(())
1881}
1882
1883pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
1886 let conn = super::get_db_connection_guard_static()?;
1887 let json: Option<String> = conn
1888 .query_row(
1889 "SELECT invite_registry FROM communities WHERE community_id = ?1",
1890 params![community_id],
1891 |r| r.get(0),
1892 )
1893 .optional()
1894 .map_err(|e| format!("get invite registry: {e}"))?;
1895 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1896}
1897
1898pub struct InviteLinkSetRow {
1901 pub creator_hex: String,
1902 pub locators: Vec<String>,
1903}
1904
1905pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
1909 let mut conn = super::get_write_connection_guard_static()?;
1910 let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
1911 tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
1912 .map_err(|e| format!("clear invite-link-sets: {e}"))?;
1913 for s in sets {
1914 if s.locators.is_empty() {
1915 continue; }
1917 let enc_creator = enc_txt(&s.creator_hex)?;
1918 let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
1919 tx.execute(
1922 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1923 params![community_id, enc_creator, enc_locators],
1924 )
1925 .map_err(|e| format!("insert invite-link-set: {e}"))?;
1926 }
1927 tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
1928 Ok(())
1929}
1930
1931pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
1934 let conn = super::get_write_connection_guard_static()?;
1935 let existing_rowid: Option<i64> = {
1937 let mut stmt = conn
1938 .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
1939 .map_err(|e| e.to_string())?;
1940 let rows = stmt
1941 .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1942 .map_err(|e| e.to_string())?;
1943 let mut found = None;
1944 for row in rows {
1945 let (rowid, stored) = row.map_err(|e| e.to_string())?;
1946 if dec_txt(&stored) == creator_hex {
1947 found = Some(rowid);
1948 break;
1949 }
1950 }
1951 found
1952 };
1953 if locators.is_empty() {
1954 if let Some(rowid) = existing_rowid {
1955 conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
1956 .map_err(|e| format!("delete invite-link-set: {e}"))?;
1957 }
1958 return Ok(());
1959 }
1960 let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
1961 match existing_rowid {
1962 Some(rowid) => {
1963 conn.execute(
1964 "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
1965 params![enc_locators, rowid],
1966 )
1967 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1968 }
1969 None => {
1970 let enc_creator = enc_txt(creator_hex)?;
1971 conn.execute(
1972 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1973 params![community_id, enc_creator, enc_locators],
1974 )
1975 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1976 }
1977 }
1978 Ok(())
1979}
1980
1981pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
1984 let conn = super::get_db_connection_guard_static()?;
1985 let mut stmt = conn
1986 .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
1987 .map_err(|e| format!("prepare invite-link-sets: {e}"))?;
1988 let rows = stmt
1989 .query_map(params![community_id], |r| {
1990 let creator_hex: String = r.get(0)?;
1991 let json: String = r.get(1)?;
1992 Ok((creator_hex, json))
1993 })
1994 .map_err(|e| format!("query invite-link-sets: {e}"))?;
1995 let mut out = Vec::new();
1996 for row in rows {
1997 let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
1998 let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
1999 out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
2000 }
2001 Ok(out)
2002}
2003
2004pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
2008 let conn = super::get_write_connection_guard_static()?;
2009 conn.execute(
2010 "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
2011 params![pending as i64, community_id],
2012 )
2013 .map_err(|e| format!("set read_cut_pending: {e}"))?;
2014 Ok(())
2015}
2016
2017pub fn set_community_dissolved(community_id: &str) -> Result<bool, String> {
2025 let conn = super::get_write_connection_guard_static()?;
2026 let changed = conn
2027 .execute(
2028 "UPDATE communities SET dissolved = 1 WHERE community_id = ?1 AND dissolved = 0",
2029 params![community_id],
2030 )
2031 .map_err(|e| format!("set dissolved: {e}"))?;
2032 Ok(changed > 0)
2033}
2034
2035pub fn set_migration_pointer(community_id: &str, payload_json: &str) -> Result<(), String> {
2043 let conn = super::get_write_connection_guard_static()?;
2044 let wrapped = enc_txt(payload_json)?;
2045 conn.execute(
2046 "UPDATE communities SET migration_pointer = ?2, migration_checked = 1 WHERE community_id = ?1",
2047 params![community_id, wrapped],
2048 )
2049 .map_err(|e| format!("set migration pointer: {e}"))?;
2050 Ok(())
2051}
2052
2053pub fn get_migration_pointer(community_id: &str) -> Result<Option<String>, String> {
2055 let conn = super::get_db_connection_guard_static()?;
2056 let v: Option<Option<String>> = conn
2057 .query_row(
2058 "SELECT migration_pointer FROM communities WHERE community_id = ?1",
2059 params![community_id],
2060 |r| r.get(0),
2061 )
2062 .optional()
2063 .map_err(|e| format!("get migration pointer: {e}"))?;
2064 Ok(v.flatten().map(|s| dec_txt(&s)))
2065}
2066
2067pub fn set_migrated_to(community_id: &str, v2_community_id: &str) -> Result<(), String> {
2070 let conn = super::get_write_connection_guard_static()?;
2071 conn.execute(
2072 "UPDATE communities SET migrated_to = ?2 WHERE community_id = ?1 AND migrated_to IS NULL",
2073 params![community_id, v2_community_id],
2074 )
2075 .map_err(|e| format!("set migrated_to: {e}"))?;
2076 Ok(())
2077}
2078
2079pub fn get_migrated_to(community_id: &str) -> Result<Option<String>, String> {
2081 let conn = super::get_db_connection_guard_static()?;
2082 let v: Option<Option<String>> = conn
2083 .query_row(
2084 "SELECT migrated_to FROM communities WHERE community_id = ?1",
2085 params![community_id],
2086 |r| r.get(0),
2087 )
2088 .optional()
2089 .map_err(|e| format!("get migrated_to: {e}"))?;
2090 Ok(v.flatten())
2091}
2092
2093pub fn set_migration_checked(community_id: &str) -> Result<(), String> {
2097 let conn = super::get_write_connection_guard_static()?;
2098 conn.execute(
2099 "UPDATE communities SET migration_checked = 1 WHERE community_id = ?1",
2100 params![community_id],
2101 )
2102 .map_err(|e| format!("set migration checked: {e}"))?;
2103 Ok(())
2104}
2105
2106pub fn set_migration_ledger(v1_community_id: &str, v2_community_id: &str, phase: i64, twin_json: &str) -> Result<(), String> {
2113 let conn = super::get_write_connection_guard_static()?;
2114 let wrapped = enc_txt(twin_json)?;
2115 let now = now_secs();
2118 conn.execute(
2119 "INSERT INTO community_migrations (community_id, v2_community_id, phase, twin, updated_at)
2120 VALUES (?1, ?2, ?3, ?4, ?5)
2121 ON CONFLICT(community_id) DO UPDATE SET v2_community_id=?2, phase=?3, twin=?4, updated_at=?5",
2122 params![v1_community_id, v2_community_id, phase, wrapped, now],
2123 )
2124 .map_err(|e| format!("set migration ledger: {e}"))?;
2125 Ok(())
2126}
2127
2128pub fn get_migration_ledger(v1_community_id: &str) -> Result<Option<(String, i64, String)>, String> {
2130 let conn = super::get_db_connection_guard_static()?;
2131 let row = conn
2132 .query_row(
2133 "SELECT v2_community_id, phase, twin FROM community_migrations WHERE community_id = ?1",
2134 params![v1_community_id],
2135 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)),
2136 )
2137 .optional()
2138 .map_err(|e| format!("get migration ledger: {e}"))?;
2139 Ok(row.map(|(v2, phase, twin)| (v2, phase, dec_txt(&twin))))
2140}
2141
2142pub fn reparent_channels_and_fence(v1_community_id: &str, v2_community_id: &str) -> Result<(), String> {
2152 let conn = super::get_write_connection_guard_static()?;
2153 let tx = conn.unchecked_transaction().map_err(|e| format!("flip txn: {e}"))?;
2154 tx.execute(
2155 "UPDATE community_channels SET community_id = ?2 WHERE community_id = ?1",
2156 params![v1_community_id, v2_community_id],
2157 )
2158 .map_err(|e| format!("reparent channels: {e}"))?;
2159 tx.execute(
2163 "UPDATE communities SET migrated_to = ?2, dissolved = 1 WHERE community_id = ?1 AND migrated_to IS NULL",
2164 params![v1_community_id, v2_community_id],
2165 )
2166 .map_err(|e| format!("set fence: {e}"))?;
2167 tx.commit().map_err(|e| format!("flip commit: {e}"))?;
2168 forget_community_channels(v1_community_id);
2172 Ok(())
2173}
2174
2175pub fn migration_sweep_candidates() -> Result<Vec<String>, String> {
2178 let conn = super::get_db_connection_guard_static()?;
2179 let mut stmt = conn
2180 .prepare(
2181 "SELECT community_id FROM communities
2182 WHERE dissolved = 1 AND migrated_to IS NULL AND migration_checked = 0",
2183 )
2184 .map_err(|e| e.to_string())?;
2185 let rows = stmt
2186 .query_map([], |r| r.get::<_, String>(0))
2187 .map_err(|e| e.to_string())?;
2188 Ok(rows.flatten().collect())
2189}
2190
2191pub fn migration_flip_candidates() -> Result<Vec<String>, String> {
2195 let conn = super::get_db_connection_guard_static()?;
2196 let mut stmt = conn
2197 .prepare(
2198 "SELECT community_id FROM communities
2199 WHERE migration_pointer IS NOT NULL AND migrated_to IS NULL",
2200 )
2201 .map_err(|e| e.to_string())?;
2202 let rows = stmt
2203 .query_map([], |r| r.get::<_, String>(0))
2204 .map_err(|e| e.to_string())?;
2205 Ok(rows.flatten().collect())
2206}
2207
2208pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
2211 let conn = super::get_db_connection_guard_static()?;
2212 let v: Option<i64> = conn
2213 .query_row(
2214 "SELECT dissolved FROM communities WHERE community_id = ?1",
2215 params![community_id],
2216 |r| r.get(0),
2217 )
2218 .optional()
2219 .map_err(|e| format!("get dissolved: {e}"))?;
2220 Ok(v.unwrap_or(0) != 0)
2221}
2222
2223pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
2226 let conn = super::get_db_connection_guard_static()?;
2227 let v: Option<i64> = conn
2228 .query_row(
2229 "SELECT read_cut_pending FROM communities WHERE community_id = ?1",
2230 params![community_id],
2231 |r| r.get(0),
2232 )
2233 .optional()
2234 .map_err(|e| format!("get read_cut_pending: {e}"))?;
2235 Ok(v.unwrap_or(0) != 0)
2236}
2237
2238pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
2243 let conn = super::get_write_connection_guard_static()?;
2244 conn.execute(
2245 "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
2246 params![target as i64, community_id],
2247 )
2248 .map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
2249 Ok(())
2250}
2251
2252pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
2255 let conn = super::get_db_connection_guard_static()?;
2256 let v: Option<i64> = conn
2257 .query_row(
2258 "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
2259 params![community_id],
2260 |r| r.get(0),
2261 )
2262 .optional()
2263 .map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
2264 Ok(v.unwrap_or(0) as u64)
2265}
2266
2267pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
2270 let conn = super::get_db_connection_guard_static()?;
2271 let v: Option<i64> = conn
2272 .query_row(
2273 "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
2274 params![community_id, channel_id],
2275 |r| r.get(0),
2276 )
2277 .optional()
2278 .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
2279 Ok(v.unwrap_or(0) as u64)
2280}
2281
2282pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
2286 let conn = super::get_write_connection_guard_static()?;
2287 conn.execute(
2288 "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
2289 params![server_epoch as i64, community_id, channel_id],
2290 )
2291 .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
2292 Ok(())
2293}
2294
2295pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
2297 let conn = super::get_db_connection_guard_static()?;
2298 let mut stmt = conn
2299 .prepare("SELECT community_id FROM communities ORDER BY created_at")
2300 .map_err(|e| e.to_string())?;
2301 let rows = stmt
2302 .query_map([], |r| r.get::<_, String>(0))
2303 .map_err(|e| e.to_string())?;
2304 let mut ids = Vec::new();
2305 for row in rows {
2306 ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
2307 }
2308 Ok(ids)
2309}
2310
2311pub fn community_protocol(id: &CommunityId) -> Result<Option<crate::community::ConcordProtocol>, String> {
2322 let conn = super::get_db_connection_guard_static()?;
2323 let n: Option<i64> = conn
2324 .query_row("SELECT protocol FROM communities WHERE community_id = ?1", params![id.to_hex()], |r| r.get(0))
2325 .optional()
2326 .map_err(|e| e.to_string())?;
2327 Ok(n.map(crate::community::ConcordProtocol::from_i64))
2328}
2329
2330#[derive(serde::Serialize, serde::Deserialize, Default)]
2333struct CommunityMetaStash {
2334 #[serde(default, skip_serializing_if = "Option::is_none")]
2335 custom: Option<serde_json::Map<String, serde_json::Value>>,
2336 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
2337 extra: serde_json::Map<String, serde_json::Value>,
2338}
2339
2340#[derive(serde::Serialize, serde::Deserialize, Default)]
2342struct ChannelMetaStash {
2343 #[serde(default, skip_serializing_if = "Option::is_none")]
2344 voice: Option<bool>,
2345 #[serde(default, skip_serializing_if = "Option::is_none")]
2346 custom: Option<serde_json::Map<String, serde_json::Value>>,
2347 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
2348 extra: serde_json::Map<String, serde_json::Value>,
2349}
2350
2351pub fn save_community_v2(c: &crate::community::v2::community::CommunityV2) -> Result<(), String> {
2354 let conn = super::get_write_connection_guard_static()?;
2355 let id_hex = crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0);
2356 let relays_json = serde_json::to_string(&c.relays).map_err(|e| e.to_string())?;
2357 let created = (c.created_at_ms / 1000) as i64;
2358
2359 let enc_root = enc_key(&c.community_root)?;
2360 let enc_name = enc_txt(&c.name)?;
2361 let enc_relays = enc_txt(&relays_json)?;
2362 let enc_desc = enc_txt_opt(&c.description)?;
2363 let enc_owner_pk = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_xonly))?;
2364 let enc_owner_salt = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_salt))?;
2365 let icon_json = c.icon.as_ref().map(|i| serde_json::to_string(i).map_err(|e| e.to_string())).transpose()?;
2369 let banner_json = c.banner.as_ref().map(|b| serde_json::to_string(b).map_err(|e| e.to_string())).transpose()?;
2370 let enc_icon = enc_txt_opt(&icon_json)?;
2371 let enc_banner = enc_txt_opt(&banner_json)?;
2372 let stash_json = (c.meta_custom.is_some() || !c.meta_extra.is_empty())
2373 .then(|| serde_json::to_string(&CommunityMetaStash { custom: c.meta_custom.clone(), extra: c.meta_extra.clone() }).map_err(|e| e.to_string()))
2374 .transpose()?;
2375 let enc_stash = enc_txt_opt(&stash_json)?;
2376
2377 let tx = conn.unchecked_transaction().map_err(|e| format!("save v2 community tx: {e}"))?;
2378 tx.execute(
2379 "INSERT INTO communities
2380 (community_id, server_root_key, name, relays, created_at, description,
2381 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt, icon, banner, meta_extra)
2382 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2, ?9, ?10, ?11, ?12, ?13)
2383 ON CONFLICT(community_id) DO UPDATE SET
2384 server_root_key=?2, name=?3, relays=?4, description=?6,
2385 server_root_epoch=?7, dissolved=?8, protocol=2, owner_pubkey=?9, owner_salt=?10,
2386 icon=?11, banner=?12, meta_extra=?13",
2387 params![
2388 id_hex, enc_root, enc_name, enc_relays, created, enc_desc,
2389 c.root_epoch.0 as i64, c.dissolved as i64, enc_owner_pk, enc_owner_salt,
2390 enc_icon, enc_banner, enc_stash,
2391 ],
2392 )
2393 .map_err(|e| format!("save v2 community: {e}"))?;
2394
2395 for ch in &c.channels {
2396 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2397 let owner_of: Option<String> = tx
2403 .query_row("SELECT community_id FROM community_channels WHERE channel_id=?1", params![ch_hex], |r| r.get(0))
2404 .optional()
2405 .map_err(|e| format!("channel ownership check: {e}"))?;
2406 if owner_of.is_some_and(|existing| existing != id_hex) {
2407 continue;
2413 }
2414 let stored_key = ch.key.unwrap_or(c.community_root);
2418 let enc_ch_key = enc_key(&stored_key)?;
2419 let enc_ch_name = enc_txt(&ch.name)?;
2420 let ch_stash_json = (ch.voice.is_some() || ch.meta_custom.is_some() || !ch.meta_extra.is_empty())
2421 .then(|| {
2422 serde_json::to_string(&ChannelMetaStash { voice: ch.voice, custom: ch.meta_custom.clone(), extra: ch.meta_extra.clone() })
2423 .map_err(|e| e.to_string())
2424 })
2425 .transpose()?;
2426 let enc_ch_stash = enc_txt_opt(&ch_stash_json)?;
2427 tx.execute(
2428 "INSERT INTO community_channels
2429 (channel_id, community_id, channel_key, epoch, name, created_at, private, meta_extra)
2430 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
2431 ON CONFLICT(channel_id) DO UPDATE SET
2432 channel_key=?3, epoch=?4, name=?5, private=?7, meta_extra=?8",
2433 params![ch_hex, id_hex, enc_ch_key, ch.epoch.0 as i64, enc_ch_name, created, ch.private as i64, enc_ch_stash],
2434 )
2435 .map_err(|e| format!("save v2 channel: {e}"))?;
2436 }
2437
2438 let keep: Vec<String> = c.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
2443 if keep.is_empty() {
2444 tx.execute("DELETE FROM community_channels WHERE community_id=?1", params![id_hex])
2445 .map_err(|e| format!("prune v2 channels: {e}"))?;
2446 } else {
2447 let placeholders = std::iter::repeat("?").take(keep.len()).collect::<Vec<_>>().join(",");
2448 let sql = format!("DELETE FROM community_channels WHERE community_id=? AND channel_id NOT IN ({placeholders})");
2449 let mut binds: Vec<String> = Vec::with_capacity(keep.len() + 1);
2450 binds.push(id_hex.clone());
2451 binds.extend(keep);
2452 tx.execute(&sql, rusqlite::params_from_iter(binds.iter()))
2453 .map_err(|e| format!("prune v2 channels: {e}"))?;
2454 }
2455
2456 tx.commit().map_err(|e| format!("commit v2 community: {e}"))?;
2457 forget_community_channels(&id_hex);
2460 Ok(())
2461}
2462
2463pub fn load_community_v2(id: &CommunityId) -> Result<Option<crate::community::v2::community::CommunityV2>, String> {
2465 use crate::community::v2::community::{ChannelV2, CommunityV2};
2466 use crate::community::v2::control::CommunityIdentity;
2467 let conn = super::get_db_connection_guard_static()?;
2468 let id_hex = id.to_hex();
2469
2470 let row = conn
2471 .query_row(
2472 "SELECT server_root_key, name, relays, created_at, description,
2473 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt,
2474 icon, banner, meta_extra
2475 FROM communities WHERE community_id = ?1",
2476 params![id_hex],
2477 |r| {
2478 Ok((
2479 r.get::<_, Vec<u8>>(0)?,
2480 r.get::<_, String>(1)?,
2481 r.get::<_, String>(2)?,
2482 r.get::<_, i64>(3)?,
2483 r.get::<_, Option<String>>(4)?,
2484 r.get::<_, i64>(5)?,
2485 r.get::<_, i64>(6)?,
2486 r.get::<_, i64>(7)?,
2487 r.get::<_, Option<String>>(8)?,
2488 r.get::<_, Option<String>>(9)?,
2489 r.get::<_, Option<String>>(10)?,
2490 r.get::<_, Option<String>>(11)?,
2491 r.get::<_, Option<String>>(12)?,
2492 ))
2493 },
2494 )
2495 .optional()
2496 .map_err(|e| e.to_string())?;
2497 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
2498 else {
2499 return Ok(None);
2500 };
2501 if crate::community::ConcordProtocol::from_i64(protocol) != crate::community::ConcordProtocol::V2 {
2502 return Ok(None);
2503 }
2504 let (Some(owner_pk_e), Some(owner_salt_e)) = (owner_pk_e, owner_salt_e) else {
2505 return Err("v2 community row is missing its owner commitment".to_string());
2506 };
2507
2508 let community_root = dec_key(&root_blob)?;
2509 let owner_xonly = parse_hex32(&dec_txt(&owner_pk_e))?;
2510 let owner_salt = parse_hex32(&dec_txt(&owner_salt_e))?;
2511 let identity = CommunityIdentity { community_id: *id, owner_xonly, owner_salt };
2512 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_e)).unwrap_or_default();
2513
2514 let mut channels = Vec::new();
2515 {
2516 let mut stmt = conn
2517 .prepare(
2518 "SELECT channel_id, channel_key, epoch, name, private, meta_extra
2519 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
2520 )
2521 .map_err(|e| e.to_string())?;
2522 let rows = stmt
2523 .query_map(params![id_hex], |r| {
2524 Ok((
2525 r.get::<_, String>(0)?,
2526 r.get::<_, Vec<u8>>(1)?,
2527 r.get::<_, i64>(2)?,
2528 r.get::<_, String>(3)?,
2529 r.get::<_, i64>(4)?,
2530 r.get::<_, Option<String>>(5)?,
2531 ))
2532 })
2533 .map_err(|e| e.to_string())?;
2534 for row in rows {
2535 let (ch_hex, key_blob, epoch, name_e, private, ch_stash_e) = row.map_err(|e| e.to_string())?;
2536 let private = private != 0;
2537 let key = dec_key(&key_blob)?;
2538 let ch_stash: ChannelMetaStash = ch_stash_e
2541 .map(|s| dec_txt(&s))
2542 .and_then(|j| serde_json::from_str(&j).ok())
2543 .unwrap_or_default();
2544 channels.push(ChannelV2 {
2545 id: ChannelId(hex_id_to_32(&ch_hex)?),
2546 name: dec_txt(&name_e),
2547 private,
2548 key: (private && key != community_root).then_some(key),
2555 epoch: Epoch(epoch as u64),
2556 voice: ch_stash.voice,
2557 meta_custom: ch_stash.custom,
2558 meta_extra: ch_stash.extra,
2559 });
2560 }
2561 }
2562
2563 let icon = icon_e
2566 .map(|s| dec_txt(&s))
2567 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2568 let banner = banner_e
2569 .map(|s| dec_txt(&s))
2570 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2571 let stash: CommunityMetaStash = stash_e
2572 .map(|s| dec_txt(&s))
2573 .and_then(|j| serde_json::from_str(&j).ok())
2574 .unwrap_or_default();
2575
2576 Ok(Some(CommunityV2 {
2577 identity,
2578 community_root,
2579 root_epoch: Epoch(root_epoch as u64),
2580 name: dec_txt(&name_e),
2581 description: desc_e.map(|d| dec_txt(&d)),
2582 icon,
2583 banner,
2584 meta_custom: stash.custom,
2585 meta_extra: stash.extra,
2586 relays,
2587 channels,
2588 dissolved: dissolved != 0,
2589 created_at_ms: (created as u64).saturating_mul(1000),
2590 }))
2591}
2592
2593fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
2594 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
2595 return Err("stored value is not 32-byte hex".to_string());
2596 }
2597 Ok(crate::simd::hex::hex_to_bytes_32(hex))
2598}
2599
2600pub fn get_guestbook(community_id: &str) -> Result<(Vec<crate::community::v2::guestbook::GuestbookEvent>, u64), String> {
2604 let conn = super::get_db_connection_guard_static()?;
2605 let row = conn
2606 .query_row(
2607 "SELECT events, cursor_secs FROM community_guestbook WHERE community_id = ?1",
2608 params![community_id],
2609 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
2610 )
2611 .optional()
2612 .map_err(|e| format!("load guestbook: {e}"))?;
2613 let Some((events_e, cursor)) = row else {
2614 return Ok((Vec::new(), 0));
2615 };
2616 let events = serde_json::from_str(&dec_txt(&events_e)).unwrap_or_default();
2617 Ok((events, cursor.max(0) as u64))
2618}
2619
2620pub fn set_guestbook(
2623 community_id: &str,
2624 events: &[crate::community::v2::guestbook::GuestbookEvent],
2625 cursor_secs: u64,
2626) -> Result<(), String> {
2627 let conn = super::get_write_connection_guard_static()?;
2628 let json = serde_json::to_string(events).map_err(|e| e.to_string())?;
2629 let enc = enc_txt(&json)?;
2630 conn.execute(
2631 "INSERT INTO community_guestbook (community_id, events, cursor_secs)
2632 VALUES (?1, ?2, ?3)
2633 ON CONFLICT(community_id) DO UPDATE SET events=?2, cursor_secs=?3",
2634 params![community_id, enc, cursor_secs as i64],
2635 )
2636 .map_err(|e| format!("save guestbook: {e}"))?;
2637 Ok(())
2638}
2639
2640#[cfg(test)]
2641mod tests {
2642 use nostr_sdk::prelude::FinalizeEvent;
2643 use super::*;
2644
2645 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
2646
2647 fn make_test_npub(n: u32) -> String {
2650 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
2651 let mut payload = vec![b'q'; 58];
2652 let mut x = n as u64;
2653 let mut i = 58;
2654 while x > 0 && i > 0 {
2655 i -= 1;
2656 payload[i] = BECH32[(x as usize) % 32];
2657 x /= 32;
2658 }
2659 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
2660 }
2661
2662 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2663 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2664 crate::db::close_database();
2665 crate::db::clear_id_caches();
2668 let tmp = tempfile::tempdir().unwrap();
2669 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2670 let account = make_test_npub(n);
2671 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2672 crate::db::set_app_data_dir(tmp.path().to_path_buf());
2673 crate::db::set_current_account(account.clone()).unwrap();
2674 crate::db::init_database(&account).unwrap();
2675 (tmp, guard)
2676 }
2677
2678 #[test]
2679 fn edition_head_round_trips_and_upserts() {
2680 let (_tmp, _guard) = init_test_db();
2681 let cid = "f".repeat(64);
2682 let entity = "a".repeat(64);
2683
2684 assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
2686
2687 let h1 = [0x11u8; 32];
2689 set_edition_head(&cid, &entity, 1, &h1).unwrap();
2690 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
2691
2692 let h2 = [0x22u8; 32];
2694 set_edition_head(&cid, &entity, 2, &h2).unwrap();
2695 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
2696
2697 set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
2700 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
2701 set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
2702 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
2703
2704 let other = "b".repeat(64);
2706 assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
2707 }
2708
2709 #[test]
2710 fn guestbook_round_trips_events_and_cursor() {
2711 let (_tmp, _guard) = init_test_db();
2712 let member = nostr_sdk::prelude::Keys::generate();
2713 let ev = crate::community::v2::guestbook::GuestbookEvent {
2714 rumor_id: [7u8; 32],
2715 entry: crate::community::v2::guestbook::GuestbookEntry::Join {
2716 member: member.public_key(),
2717 invited_by: Some(("creator".into(), "label".into())),
2718 at_ms: 1_000,
2719 },
2720 };
2721 let cid = "d".repeat(64);
2722 assert_eq!(get_guestbook(&cid).unwrap(), (Vec::new(), 0), "absent reads as empty at cursor 0");
2723 set_guestbook(&cid, std::slice::from_ref(&ev), 42).unwrap();
2724 let (events, cursor) = get_guestbook(&cid).unwrap();
2725 assert_eq!(events, vec![ev], "events round-trip through the encrypted blob");
2726 assert_eq!(cursor, 42);
2727 }
2728
2729 #[test]
2730 fn v2_images_round_trip_and_read_as_v1_community_images() {
2731 let (_tmp, _guard) = init_test_db();
2732 let owner = nostr_sdk::prelude::Keys::generate();
2733 let g = crate::community::v2::control::genesis(
2734 &owner,
2735 crate::community::v2::control::CommunityMetadata { name: "Icons".into(), ..Default::default() },
2736 1_000,
2737 )
2738 .unwrap();
2739 let mut c = crate::community::v2::community::CommunityV2::from_genesis(&g, "Icons", None, vec!["wss://r".into()], 1_000);
2740 let mut extra = serde_json::Map::new();
2741 extra.insert("ext".into(), serde_json::Value::String("webp".into()));
2742 c.icon = Some(crate::community::v2::control::ImageRef {
2743 url: "https://blossom.example/abc".into(),
2744 key: "0".repeat(64),
2745 nonce: "1".repeat(32),
2746 hash: "a".repeat(64),
2747 extra,
2748 });
2749 c.meta_custom = Some({
2750 let mut m = serde_json::Map::new();
2751 m.insert("k".into(), serde_json::Value::from("v"));
2752 m
2753 });
2754 c.channels[0].voice = Some(true);
2755 c.channels[0].meta_extra.insert("vnd".into(), serde_json::Value::from(7));
2756 save_community_v2(&c).unwrap();
2757
2758 let re = load_community_v2(c.id()).unwrap().unwrap();
2760 assert_eq!(re.icon, c.icon);
2761 assert_eq!(re.banner, None);
2762 assert_eq!(re.meta_custom, c.meta_custom);
2764 assert_eq!(re.channels[0].voice, Some(true));
2765 assert_eq!(re.channels[0].meta_extra.get("vnd"), Some(&serde_json::Value::from(7)));
2766
2767 let v1 = load_community(c.id()).unwrap().unwrap();
2771 let img = v1.icon.expect("v1 reader sees the v2 icon");
2772 assert_eq!(img.url, "https://blossom.example/abc");
2773 assert_eq!(img.ext, "webp");
2774 assert_eq!(img.hash, "a".repeat(64));
2775 }
2776
2777 #[test]
2778 fn server_root_epoch_round_trips() {
2779 let (_tmp, _guard) = init_test_db();
2781 let mut c = Community::create("HQ", "general", vec![]);
2782 save_community(&c).unwrap();
2783 assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
2784
2785 c.server_root_epoch = Epoch(5);
2786 c.server_root_key = ServerRootKey([0x42u8; 32]);
2787 save_community(&c).unwrap();
2788 let loaded = load_community(&c.id).unwrap().unwrap();
2789 assert_eq!(loaded.server_root_epoch, Epoch(5));
2790 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2791 }
2792
2793 #[test]
2794 fn epoch_key_archive_retains_every_epoch() {
2795 let (_tmp, _guard) = init_test_db();
2798 let cid = "f".repeat(64);
2799 let scope = "a".repeat(64);
2800
2801 store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
2802 store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
2803 store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
2804
2805 let held = held_epoch_keys(&cid, &scope).unwrap();
2806 assert_eq!(held.len(), 3, "all three epoch keys retained");
2807 assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
2808 assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
2809 assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
2810
2811 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
2813 assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
2814
2815 store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
2817 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
2818 assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
2819
2820 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2823 assert_eq!(
2824 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
2825 None,
2826 "epoch 1 under a different scope is not the channel's key"
2827 );
2828 }
2829
2830 #[test]
2831 fn save_community_populates_the_epoch_archive() {
2832 let (_tmp, _guard) = init_test_db();
2835 let c = Community::create("HQ", "general", vec![]);
2836 save_community(&c).unwrap();
2837 let cid = c.id.to_hex();
2838
2839 assert_eq!(
2841 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
2842 Some(c.server_root_key.as_bytes())
2843 );
2844 let chan = &c.channels[0];
2846 assert_eq!(
2847 held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
2848 Some(chan.key.as_bytes())
2849 );
2850 }
2851
2852 #[test]
2853 fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
2854 let (_tmp, _guard) = init_test_db();
2855 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2858 crate::state::set_encryption_enabled(true);
2859
2860 let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
2861 c.server_root_key = ServerRootKey([0x42u8; 32]);
2862 c.description = Some("top secret".into());
2863 save_community(&c).unwrap();
2864 let cid = c.id.to_hex();
2865 set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
2866
2867 {
2870 let conn = crate::db::get_db_connection_guard_static().unwrap();
2871 let (root_len, name, banlist): (i64, String, String) = conn
2872 .query_row(
2873 "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
2874 params![cid],
2875 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
2876 )
2877 .unwrap();
2878 assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
2879 assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
2880 assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
2881 assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext");
2882 let key_len: i64 = conn
2883 .query_row(
2884 "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
2885 params![cid],
2886 |r| r.get(0),
2887 )
2888 .unwrap();
2889 assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
2890 }
2891
2892 let loaded = load_community(&c.id).unwrap().unwrap();
2894 assert_eq!(loaded.name, "Secret HQ");
2895 assert_eq!(loaded.description.as_deref(), Some("top secret"));
2896 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2897 assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
2898 assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
2899
2900 crate::state::set_encryption_enabled(false);
2901 crate::state::ENCRYPTION_KEY.clear(&[]);
2902 }
2903
2904 #[test]
2905 fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
2906 let (_tmp, _guard) = init_test_db();
2909 crate::state::set_encryption_enabled(false);
2910 let mut c = Community::create("Legacy HQ", "general", vec![]);
2911 c.server_root_key = ServerRootKey([0x42u8; 32]);
2912 save_community(&c).unwrap();
2913
2914 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2915 crate::state::set_encryption_enabled(true);
2916 let loaded = load_community(&c.id).unwrap().unwrap();
2917 assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
2918 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
2919
2920 crate::state::set_encryption_enabled(false);
2921 crate::state::ENCRYPTION_KEY.clear(&[]);
2922 }
2923
2924 #[test]
2925 fn save_and_load_round_trip() {
2926 let (_tmp, _guard) = init_test_db();
2927 let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
2928 save_community(&original).unwrap();
2929
2930 let loaded = load_community(&original.id).unwrap().expect("present");
2931 assert_eq!(loaded.id, original.id);
2932 assert_eq!(loaded.name, "Vector HQ");
2933 assert_eq!(loaded.relays, original.relays);
2934 assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
2936 assert_eq!(loaded.channels.len(), 1);
2938 assert_eq!(loaded.channels[0].id, original.channels[0].id);
2939 assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
2940 assert_eq!(loaded.channels[0].epoch, Epoch(0));
2941 assert_eq!(loaded.channels[0].name, "general");
2942 }
2943
2944 #[test]
2945 fn owner_is_protected_from_the_banlist_a_member_is_not() {
2946 let (_tmp, _guard) = init_test_db();
2947 let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
2948 let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
2950 community.owner_attestation = Some(
2951 crate::community::owner::build_owner_attestation_unsigned(
2952 owner_id.public_key(),
2953 &community.id.to_hex(),
2954 )
2955 .finalize(&owner_id)
2956 .unwrap()
2957 .as_json(),
2958 );
2959 save_community(&community).unwrap();
2960
2961 let member = Keys::generate();
2963 set_community_banlist(
2964 &community.id.to_hex(),
2965 &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
2966 1,
2967 )
2968 .unwrap();
2969
2970 let loaded = load_community(&community.id).unwrap().unwrap();
2971 let ch = &loaded.channels[0];
2972 assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
2974 assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
2975 assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
2977 }
2978
2979 #[test]
2980 fn loaded_keys_actually_decrypt() {
2981 let (_tmp, _guard) = init_test_db();
2984 let original = Community::create("HQ", "general", vec![]);
2985 save_community(&original).unwrap();
2986 let loaded = load_community(&original.id).unwrap().unwrap();
2987
2988 let author = nostr_sdk::prelude::Keys::generate();
2989 let chan = &original.channels[0];
2990 let sealed = crate::community::envelope::seal_message(
2991 &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
2992 )
2993 .unwrap();
2994 let opened = crate::community::envelope::open_message(
2995 &sealed,
2996 &loaded.channels[0].key,
2997 &loaded.channels[0].id,
2998 loaded.channels[0].epoch,
2999 )
3000 .unwrap();
3001 assert_eq!(opened.content, "persisted!");
3002 }
3003
3004 #[test]
3005 fn member_view_round_trips() {
3006 let (_tmp, _guard) = init_test_db();
3009 let member = Community {
3010 id: CommunityId([7u8; 32]),
3011 server_root_key: ServerRootKey([8u8; 32]),
3012 server_root_epoch: Epoch(0),
3013 name: "Joined".into(),
3014 description: None,
3015 icon: None,
3016 banner: None,
3017 relays: vec!["wss://r".into()],
3018 channels: vec![Channel {
3019 id: ChannelId([9u8; 32]),
3020 key: ChannelKey([10u8; 32]),
3021 epoch: Epoch(0),
3022 name: "general".into(),
3023 banned: Vec::new(),
3024 protected: Vec::new(), roster: Default::default(),
3025 epoch_keys: Vec::new(),
3026 dissolved: false,
3027 }],
3028 owner_attestation: None,
3029 dissolved: false,
3030 };
3031 save_community(&member).unwrap();
3032 let loaded = load_community(&member.id).unwrap().expect("present");
3033 assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
3034 assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
3035 }
3036
3037 #[test]
3038 fn large_epoch_round_trips_losslessly() {
3039 let (_tmp, _guard) = init_test_db();
3041 let mut c = Community::create("HQ", "g", vec![]);
3042 c.channels[0].epoch = Epoch(u64::MAX - 7);
3043 save_community(&c).unwrap();
3044 let loaded = load_community(&c.id).unwrap().unwrap();
3045 assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
3046 }
3047
3048 #[test]
3049 fn malformed_channel_id_row_errors_not_corrupts() {
3050 let (_tmp, _guard) = init_test_db();
3053 let c = Community::create("HQ", "g", vec![]);
3054 save_community(&c).unwrap();
3055 {
3056 let conn = crate::db::get_write_connection_guard_static().unwrap();
3057 conn.execute(
3058 "INSERT OR REPLACE INTO community_channels
3059 (channel_id, community_id, channel_key, epoch, name, created_at)
3060 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
3061 rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
3062 )
3063 .unwrap();
3064 }
3065 assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
3066 }
3067
3068 #[test]
3069 fn message_key_store_take_round_trip() {
3070 let (_tmp, _guard) = init_test_db();
3071 let eph = Keys::generate();
3072 let relays = vec!["wss://r.one".to_string()];
3073 store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
3075
3076 let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
3077 assert_eq!(
3078 loaded.secret_key().as_secret_bytes(),
3079 eph.secret_key().as_secret_bytes()
3080 );
3081 assert_eq!(outer, "outer_evid");
3082 assert_eq!(r, relays);
3083 assert!(take_message_key("inner_msg_id").unwrap().is_none());
3085 }
3086
3087 #[test]
3088 fn missing_community_is_none() {
3089 let (_tmp, _guard) = init_test_db();
3090 let absent = CommunityId([0x33u8; 32]);
3091 assert!(load_community(&absent).unwrap().is_none());
3092 }
3093
3094 #[test]
3095 fn list_ids_reflects_saved() {
3096 let (_tmp, _guard) = init_test_db();
3097 let a = Community::create("A", "g", vec![]);
3098 let b = Community::create("B", "g", vec![]);
3099 save_community(&a).unwrap();
3100 save_community(&b).unwrap();
3101 let ids = list_community_ids().unwrap();
3102 assert_eq!(ids.len(), 2);
3103 assert!(ids.contains(&a.id) && ids.contains(&b.id));
3104 }
3105
3106 #[test]
3107 fn delete_community_clears_all_local_state() {
3108 let (_tmp, _guard) = init_test_db();
3109 let c = Community::create("HQ", "general", vec!["r1".into()]);
3110 save_community(&c).unwrap();
3111 let cid = c.id.to_hex();
3112 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
3113 save_pending_invite(&"cd".repeat(32), "{}", "npub1x", 0).unwrap();
3114 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
3115
3116 assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
3118
3119 delete_community(&cid).unwrap();
3120 assert!(!community_exists(&c.id).unwrap());
3121 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
3122 assert!(list_public_invites(&cid).unwrap().is_empty());
3123 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
3124 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
3125 }
3126
3127 #[test]
3128 fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
3129 let (_tmp, _guard) = init_test_db();
3132 let c = Community::create("HQ", "general", vec!["r1".into()]);
3133 save_community(&c).unwrap();
3134 let cid = c.id.to_hex();
3135 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
3136 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
3137
3138 let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
3139 let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
3140 assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
3141
3142 delete_community_retain_keys(&cid).unwrap();
3143
3144 assert!(!community_exists(&c.id).unwrap());
3146 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
3147 assert!(list_public_invites(&cid).unwrap().is_empty());
3148 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
3149 assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
3151 "base epoch keys retained for self-scrub");
3152 assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
3153 "channel epoch keys retained for self-scrub");
3154 }
3155
3156 #[test]
3157 fn channel_resolves_to_owning_community() {
3158 let (_tmp, _guard) = init_test_db();
3159 let c = Community::create("HQ", "general", vec![]);
3160 save_community(&c).unwrap();
3161 let chan = c.channels[0].id.to_hex();
3162 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
3163 assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
3164 }
3165
3166 #[test]
3167 fn community_exists_reflects_saved() {
3168 let (_tmp, _guard) = init_test_db();
3169 let c = Community::create("A", "g", vec![]);
3170 assert!(!community_exists(&c.id).unwrap());
3171 save_community(&c).unwrap();
3172 assert!(community_exists(&c.id).unwrap());
3173 }
3174
3175 #[test]
3176 fn reparent_moves_channels_stamps_fence_and_invalidates_cache() {
3177 let (_tmp, _guard) = init_test_db();
3178 let v1 = Community::create("HQ", "general", vec![]);
3179 save_community(&v1).unwrap();
3180 let v1_cid = v1.id.to_hex();
3181 let v2_cid = "ab".repeat(32);
3182 let chan = v1.channels[0].id.to_hex();
3183
3184 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v1_cid.as_str()));
3186 reparent_channels_and_fence(&v1_cid, &v2_cid).unwrap();
3187
3188 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v2_cid.as_str()),
3190 "stale v1 cache entry must not survive the re-parent");
3191 assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()));
3192 assert!(get_community_dissolved(&v1_cid).unwrap(), "flip seals v1 (fence layer 0)");
3193
3194 reparent_channels_and_fence(&v1_cid, &"cd".repeat(32)).unwrap();
3196 assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()),
3197 "migrated_to is one-way — a second flip cannot repoint it");
3198 }
3199
3200 #[test]
3201 fn migration_sweep_candidates_are_sealed_unflipped_unchecked() {
3202 let (_tmp, _guard) = init_test_db();
3203 let a = Community::create("A", "g", vec![]);
3204 let b = Community::create("B", "g", vec![]);
3205 let c = Community::create("C", "g", vec![]);
3206 for x in [&a, &b, &c] { save_community(x).unwrap(); }
3207 set_community_dissolved(&a.id.to_hex()).unwrap();
3209 set_community_dissolved(&b.id.to_hex()).unwrap();
3210 set_migrated_to(&b.id.to_hex(), &"ab".repeat(32)).unwrap();
3211 let cands = migration_sweep_candidates().unwrap();
3212 assert!(cands.contains(&a.id.to_hex()));
3213 assert!(!cands.contains(&b.id.to_hex()), "flipped is not a candidate");
3214 assert!(!cands.contains(&c.id.to_hex()), "live is not a candidate");
3215 set_migration_checked(&a.id.to_hex()).unwrap();
3217 assert!(!migration_sweep_candidates().unwrap().contains(&a.id.to_hex()));
3218 }
3219
3220 #[test]
3221 fn pending_invite_first_wins_and_round_trips() {
3222 let (_tmp, _guard) = init_test_db();
3223 let cid = "ab".repeat(32);
3224 assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter", 0).unwrap());
3227 assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other", 0).unwrap());
3228 assert!(pending_invite_exists(&cid).unwrap());
3229
3230 let listed = list_pending_invites().unwrap();
3231 assert_eq!(listed.len(), 1);
3232 assert_eq!(listed[0].community_id, cid);
3233 assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
3234 assert_eq!(listed[0].inviter_npub, "npub1inviter");
3235
3236 assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
3238 assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
3239 delete_pending_invite(&cid).unwrap();
3240 assert!(!pending_invite_exists(&cid).unwrap());
3241 assert!(get_pending_invite(&cid).unwrap().is_none());
3242 }
3243
3244 #[test]
3245 fn purge_drops_invites_for_held_communities_only() {
3246 let (_tmp, _guard) = init_test_db();
3247 let held = Community::create("Held", "general", vec![]);
3250 save_community(&held).unwrap();
3251 let held_hex = held.id.to_hex();
3252 save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter", 0).unwrap();
3253 let stranger = "ab".repeat(32);
3255 save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter", 0).unwrap();
3256
3257 let n = purge_pending_invites_for_held_communities().unwrap();
3258 assert_eq!(n, 1, "only the held community's invite is purged");
3259 assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
3260 assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
3261 }
3262
3263 #[test]
3264 fn decline_drops_pending_invite() {
3265 let (_tmp, _guard) = init_test_db();
3266 let cid = "cd".repeat(32);
3267 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3268 delete_pending_invite(&cid).unwrap();
3269 assert!(!pending_invite_exists(&cid).unwrap());
3270 }
3271
3272 #[test]
3273 fn pending_invites_are_capped_keeping_the_newest() {
3274 let (_tmp, _guard) = init_test_db();
3275 for i in 0..150u32 {
3280 let cid = format!("{:064x}", i);
3281 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3282 }
3283 let all = list_pending_invites().unwrap();
3284 assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
3285 for i in 150..400u32 {
3287 let cid = format!("{:064x}", i);
3288 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3289 }
3290 assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
3291 }
3292
3293 #[test]
3298 fn expired_parked_invites_are_hidden_from_list_and_accept() {
3299 let (_tmp, _guard) = init_test_db();
3300 let now = now_secs();
3301 let live = "aa".repeat(32);
3302 let expired = "bb".repeat(32);
3303 let permanent = "cc".repeat(32);
3304
3305 save_pending_invite(&live, "{\"live\":1}", "npub1x", now + 3600).unwrap();
3306 save_pending_invite(&expired, "{\"dead\":1}", "npub1x", now - 1).unwrap();
3307 save_pending_invite(&permanent, "{\"forever\":1}", "npub1x", 0).unwrap();
3310
3311 let listed: Vec<String> = list_pending_invites().unwrap().into_iter().map(|i| i.community_id).collect();
3312 assert!(listed.contains(&live), "an unexpired invite still lists");
3313 assert!(listed.contains(&permanent), "a no-deadline invite still lists");
3314 assert!(!listed.contains(&expired), "an expired invite is hidden from the list");
3315
3316 assert!(get_pending_invite(&live).unwrap().is_some());
3317 assert!(get_pending_invite(&permanent).unwrap().is_some());
3318 assert!(
3319 get_pending_invite(&expired).unwrap().is_none(),
3320 "an expired invite must not be redeemable"
3321 );
3322
3323 assert!(pending_invite_exists(&expired).unwrap(), "hidden, not yet deleted");
3325 assert_eq!(purge_expired_pending_invites().unwrap(), 1);
3326 assert!(!pending_invite_exists(&expired).unwrap());
3327 assert!(pending_invite_exists(&live).unwrap(), "the sweep spares live invites");
3328 assert!(pending_invite_exists(&permanent).unwrap(), "and no-deadline ones");
3329 }
3330
3331 #[test]
3334 fn expiry_follows_the_senders_deadline_not_receipt_time() {
3335 let (_tmp, _guard) = init_test_db();
3336 let cid = "de".repeat(32);
3337 save_pending_invite(&cid, "{}", "npub1x", now_secs() - 10).unwrap();
3340 assert!(list_pending_invites().unwrap().is_empty(), "receipt time does not extend the deadline");
3341 assert!(get_pending_invite(&cid).unwrap().is_none());
3342 }
3343}