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 let placeholder: Vec<u8> = tx
277 .query_row(
278 "SELECT server_root_key FROM communities WHERE community_id = ?1",
279 params![community_id],
280 |r| r.get(0),
281 )
282 .map_err(|e| format!("seat channel key root: {e}"))?;
283 let current: Vec<u8> = tx
284 .query_row(
285 "SELECT channel_key FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
286 params![community_id, channel_id],
287 |r| r.get(0),
288 )
289 .map_err(|e| format!("seat channel key read: {e}"))?;
290 if dec_key(¤t)? != dec_key(&placeholder)? {
292 return Err("channel already holds a key — seating would downgrade it".to_string());
293 }
294 store_epoch_key_tx(&tx, community_id, channel_id, epoch, key)?;
295 let enc = enc_key(key)?;
296 tx.execute(
297 "UPDATE community_channels SET epoch = ?1, channel_key = ?2
298 WHERE community_id = ?3 AND channel_id = ?4",
299 params![epoch as i64, &enc[..], community_id, channel_id],
300 )
301 .map_err(|e| format!("seat channel key: {e}"))?;
302 tx.commit().map_err(|e| format!("seat channel key commit: {e}"))?;
303 Ok(())
304}
305
306pub fn drop_pending_channel_key(id: i64) -> Result<(), String> {
308 let conn = super::get_write_connection_guard_static()?;
309 conn.execute("DELETE FROM pending_channel_keys WHERE id = ?1", params![id])
310 .map_err(|e| format!("drop pending channel key: {e}"))?;
311 Ok(())
312}
313
314pub fn drop_pending_channel_keys_for(community_id: &str, channel_id: &str) -> Result<(), String> {
316 let conn = super::get_write_connection_guard_static()?;
317 conn.execute(
318 "DELETE FROM pending_channel_keys WHERE community_id = ?1 AND channel_id = ?2",
319 params![community_id, channel_id],
320 )
321 .map_err(|e| format!("drop pending channel keys: {e}"))?;
322 Ok(())
323}
324
325pub fn store_epoch_key(community_id: &str, scope_id: &str, epoch: u64, key: &[u8; 32]) -> Result<(), String> {
332 let conn = super::get_write_connection_guard_static()?;
333 store_epoch_key_tx(&conn, community_id, scope_id, epoch, key)
334}
335
336fn store_epoch_key_tx<C: std::ops::Deref<Target = rusqlite::Connection>>(
340 conn: &C,
341 community_id: &str,
342 scope_id: &str,
343 epoch: u64,
344 key: &[u8; 32],
345) -> Result<(), String> {
346 let enc = enc_key(key)?;
347 conn.execute(
348 "INSERT OR REPLACE INTO community_epoch_keys
349 (community_id, scope_id, epoch, key, created_at)
350 VALUES (?1, ?2, ?3, ?4, ?5)",
351 params![community_id, scope_id, epoch as i64, &enc[..], now_secs()],
353 )
354 .map_err(|e| format!("store epoch key: {e}"))?;
355 Ok(())
356}
357
358pub fn advance_channel_epoch(
365 community_id: &str,
366 channel_id: &str,
367 new_epoch: u64,
368 new_key: &[u8; 32],
369) -> Result<bool, String> {
370 let conn = super::get_write_connection_guard_static()?;
371 let tx = conn.unchecked_transaction().map_err(|e| format!("advance channel epoch tx: {e}"))?;
372 store_epoch_key_tx(&tx, community_id, channel_id, new_epoch, new_key)?;
374 let cur: Option<i64> = tx
376 .query_row(
377 "SELECT epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
378 params![community_id, channel_id],
379 |r| r.get(0),
380 )
381 .optional()
382 .map_err(|e| format!("read channel head: {e}"))?;
383 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
384 if advanced {
385 let enc = enc_key(new_key)?;
386 tx.execute(
387 "UPDATE community_channels SET epoch = ?1, channel_key = ?2
388 WHERE community_id = ?3 AND channel_id = ?4",
389 params![new_epoch as i64, &enc[..], community_id, channel_id],
390 )
391 .map_err(|e| format!("advance channel head: {e}"))?;
392 }
393 tx.commit().map_err(|e| format!("advance channel epoch commit: {e}"))?;
394 Ok(advanced)
395}
396
397pub fn get_server_root_epoch(community_id: &str) -> Result<Option<u64>, String> {
406 let conn = super::get_db_connection_guard_static()?;
407 conn.query_row(
408 "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
409 params![community_id],
410 |r| r.get::<_, i64>(0),
411 )
412 .optional()
413 .map(|v| v.map(|e| e as u64))
414 .map_err(|e| format!("get server root epoch: {e}"))
415}
416
417pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
418 let conn = super::get_write_connection_guard_static()?;
419 let tx = conn.unchecked_transaction().map_err(|e| format!("advance server root tx: {e}"))?;
420 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch, new_root)?;
423 let cur: Option<i64> = tx
424 .query_row(
425 "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
426 params![community_id],
427 |r| r.get(0),
428 )
429 .optional()
430 .map_err(|e| format!("read server-root epoch: {e}"))?;
431 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
432 if advanced {
433 let enc = enc_key(new_root)?;
434 tx.execute(
435 "UPDATE communities SET server_root_epoch = ?1, server_root_key = ?2 WHERE community_id = ?3",
436 params![new_epoch as i64, &enc[..], community_id],
437 )
438 .map_err(|e| format!("advance server-root head: {e}"))?;
439 }
440 tx.commit().map_err(|e| format!("advance server root commit: {e}"))?;
441 Ok(advanced)
442}
443
444pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
451 let conn = super::get_write_connection_guard_static()?;
452 let tx = conn.unchecked_transaction().map_err(|e| format!("converge server root tx: {e}"))?;
453 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, epoch, new_root)?;
454 let enc = enc_key(new_root)?;
455 let switched = tx
456 .execute(
457 "UPDATE communities SET server_root_key = ?1 WHERE community_id = ?2 AND server_root_epoch = ?3",
458 params![&enc[..], community_id, epoch as i64],
459 )
460 .map_err(|e| format!("converge server-root head: {e}"))?
461 > 0;
462 tx.commit().map_err(|e| format!("converge server root commit: {e}"))?;
463 Ok(switched)
464}
465
466pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, new_key: &[u8; 32]) -> Result<bool, String> {
471 let conn = super::get_write_connection_guard_static()?;
472 let tx = conn.unchecked_transaction().map_err(|e| format!("converge channel tx: {e}"))?;
473 store_epoch_key_tx(&tx, community_id, channel_id, epoch, new_key)?;
474 let enc = enc_key(new_key)?;
475 let switched = tx
476 .execute(
477 "UPDATE community_channels SET channel_key = ?1 WHERE community_id = ?2 AND channel_id = ?3 AND epoch = ?4",
478 params![&enc[..], community_id, channel_id, epoch as i64],
479 )
480 .map_err(|e| format!("converge channel head: {e}"))?
481 > 0;
482 tx.commit().map_err(|e| format!("converge channel commit: {e}"))?;
483 Ok(switched)
484}
485
486pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result<Vec<(Epoch, [u8; 32])>, String> {
490 let conn = super::get_db_connection_guard_static()?;
491 let mut stmt = conn
492 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
493 .map_err(|e| e.to_string())?;
494 let rows = stmt
495 .query_map(params![community_id, scope_id], |r| {
496 Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?))
497 })
498 .map_err(|e| e.to_string())?;
499 let mut out: Vec<(Epoch, [u8; 32])> = Vec::new();
500 for row in rows {
501 let (epoch, key_blob) = row.map_err(|e| e.to_string())?;
502 out.push((Epoch(epoch as u64), dec_key(&key_blob)?));
503 }
504 out.sort_by_key(|(e, _)| e.0);
505 Ok(out)
506}
507
508pub fn held_epoch_key(community_id: &str, scope_id: &str, epoch: u64) -> Result<Option<[u8; 32]>, String> {
511 let conn = super::get_db_connection_guard_static()?;
512 let blob: Option<Vec<u8>> = conn
513 .query_row(
514 "SELECT key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2 AND epoch = ?3",
515 params![community_id, scope_id, epoch as i64],
516 |r| r.get(0),
517 )
518 .optional()
519 .map_err(|e| format!("held epoch key: {e}"))?;
520 blob.map(|b| dec_key(&b)).transpose()
521}
522
523pub fn community_created_at_ms(id: &CommunityId) -> Option<u64> {
527 let conn = super::get_db_connection_guard_static().ok()?;
528 conn.query_row(
529 "SELECT created_at FROM communities WHERE community_id = ?1",
530 params![id.to_hex()],
531 |r| r.get::<_, i64>(0),
532 )
533 .optional()
534 .ok()
535 .flatten()
536 .map(|secs| (secs.max(0) as u64) * 1000)
537}
538
539pub fn load_community(id: &CommunityId) -> Result<Option<Community>, String> {
541 let conn = super::get_db_connection_guard_static()?;
542 let id_hex = id.to_hex();
543
544 let row = conn
545 .query_row(
546 "SELECT server_root_key, name, relays,
547 description, icon, banner, banlist, owner_attestation, server_root_epoch, dissolved
548 FROM communities WHERE community_id = ?1",
549 params![id_hex],
550 |r| {
551 Ok((
552 r.get::<_, Vec<u8>>(0)?,
553 r.get::<_, String>(1)?,
554 r.get::<_, String>(2)?,
555 r.get::<_, Option<String>>(3)?,
556 r.get::<_, Option<String>>(4)?,
557 r.get::<_, Option<String>>(5)?,
558 r.get::<_, String>(6)?,
559 r.get::<_, Option<String>>(7)?,
560 r.get::<_, i64>(8)?,
561 r.get::<_, i64>(9)?,
562 ))
563 },
564 )
565 .optional()
566 .map_err(|e| format!("load community: {e}"))?;
567
568 let (root_blob, name, relays_json, description, icon_json, banner_json, banlist_json, owner_attestation, server_root_epoch, dissolved_int) =
569 match row {
570 Some(t) => t,
571 None => return Ok(None),
572 };
573 let dissolved = dissolved_int != 0;
574
575 let name = dec_txt(&name);
577 let relays_json = dec_txt(&relays_json);
578 let description = description.map(|s| dec_txt(&s));
579 let icon_json = icon_json.map(|s| dec_txt(&s));
580 let banner_json = banner_json.map(|s| dec_txt(&s));
581 let banlist_json = dec_txt(&banlist_json);
582 let owner_attestation = owner_attestation.map(|s| dec_txt(&s));
583
584 let banned: Vec<PublicKey> = serde_json::from_str::<Vec<String>>(&banlist_json)
588 .unwrap_or_default()
589 .iter()
590 .filter_map(|h| PublicKey::from_hex(h).ok())
591 .collect();
592
593 let icon = icon_json
594 .map(|j| serde_json::from_str(&j))
595 .transpose()
596 .map_err(|e| format!("icon json: {e}"))?;
597 let banner = banner_json
598 .map(|j| serde_json::from_str(&j))
599 .transpose()
600 .map_err(|e| format!("banner json: {e}"))?;
601
602 let server_root_key = ServerRootKey(dec_key(&root_blob)?);
603 let relays: Vec<String> = serde_json::from_str(&relays_json).map_err(|e| e.to_string())?;
604
605 let mut protected: Vec<PublicKey> = Vec::new();
612 if let Some(owner) = owner_attestation
613 .as_ref()
614 .and_then(|att| crate::community::owner::verify_owner_attestation(att, &id_hex))
615 {
616 protected.push(owner);
617 }
618 let banned: Vec<PublicKey> = banned.into_iter().filter(|pk| !protected.contains(pk)).collect();
619
620 let raw_channels: Vec<(String, Vec<u8>, i64, String)> = {
623 let mut stmt = conn
624 .prepare(
625 "SELECT channel_id, channel_key, epoch, name
626 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
627 )
628 .map_err(|e| e.to_string())?;
629 let rows = stmt
630 .query_map(params![id_hex], |r| {
631 Ok((
632 r.get::<_, String>(0)?,
633 r.get::<_, Vec<u8>>(1)?,
634 r.get::<_, i64>(2)?,
635 r.get::<_, String>(3)?,
636 ))
637 })
638 .map_err(|e| e.to_string())?;
639 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
640 };
641
642 let roster = get_community_roles(&id_hex).unwrap_or_default();
645
646 let mut channels = Vec::new();
647 for (cid_hex, key_blob, epoch, cname) in raw_channels {
648 let epoch_keys: Vec<(Epoch, crate::community::ChannelKey)> = {
652 let mut ek_stmt = conn
653 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
654 .map_err(|e| e.to_string())?;
655 let rows = ek_stmt
656 .query_map(params![id_hex, cid_hex], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
657 .map_err(|e| e.to_string())?;
658 let mut out = Vec::new();
659 for row in rows {
660 let (e, blob) = row.map_err(|e| e.to_string())?;
661 if let Ok(k) = dec_key(&blob) {
662 out.push((Epoch(e as u64), crate::community::ChannelKey(k)));
663 }
664 }
665 out
666 };
667 channels.push(Channel {
668 id: ChannelId(hex_id_to_32(&cid_hex)?),
669 key: ChannelKey(dec_key(&key_blob)?),
670 epoch: Epoch(epoch as u64),
673 name: dec_txt(&cname),
674 banned: banned.clone(),
675 protected: protected.clone(),
676 roster: roster.clone(),
677 epoch_keys,
678 dissolved,
679 });
680 }
681
682 Ok(Some(Community {
683 id: *id,
684 server_root_key,
685 server_root_epoch: Epoch(server_root_epoch as u64),
687 name,
688 description,
689 icon,
690 banner,
691 relays,
692 channels,
693 owner_attestation,
694 dissolved,
695 }))
696}
697
698pub fn store_message_key(
701 message_id: &str,
702 outer_event_id: &str,
703 ephemeral: &Keys,
704 relays: &[String],
705) -> Result<(), String> {
706 let conn = super::get_write_connection_guard_static()?;
707 let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?;
708 let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?;
709 let enc_secret = enc_key(&sk_bytes)?;
710 let enc_relays = enc_txt(&relays_json)?;
711 conn.execute(
712 "INSERT OR REPLACE INTO community_message_keys
713 (outer_event_id, message_id, ephemeral_secret, relays, created_at)
714 VALUES (?1, ?2, ?3, ?4, ?5)",
715 params![
716 outer_event_id,
717 message_id,
718 &enc_secret[..],
719 enc_relays,
720 now_secs(),
721 ],
722 )
723 .map_err(|e| format!("store message key: {e}"))?;
724 Ok(())
725}
726
727pub fn get_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
733 let conn = super::get_db_connection_guard_static()?;
734 let row = conn
735 .query_row(
736 "SELECT ephemeral_secret, outer_event_id, relays
737 FROM community_message_keys WHERE message_id = ?1",
738 params![message_id],
739 |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)),
740 )
741 .optional()
742 .map_err(|e| format!("get message key: {e}"))?;
743 let (secret_blob, outer_event_id, relays_json) = match row {
744 Some(t) => t,
745 None => return Ok(None),
746 };
747 let secret = SecretKey::from_slice(&dec_key(&secret_blob)?).map_err(|e| format!("ephemeral secret: {e}"))?;
748 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_json)).map_err(|e| e.to_string())?;
749 Ok(Some((Keys::new(secret), outer_event_id, relays)))
750}
751
752pub fn delete_message_key(message_id: &str) -> Result<(), String> {
754 let conn = super::get_write_connection_guard_static()?;
755 conn.execute(
756 "DELETE FROM community_message_keys WHERE message_id = ?1",
757 params![message_id],
758 )
759 .map_err(|e| format!("remove message key: {e}"))?;
760 Ok(())
761}
762
763pub fn take_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
766 let r = get_message_key(message_id)?;
767 if r.is_some() {
768 delete_message_key(message_id)?;
769 }
770 Ok(r)
771}
772
773static CHANNEL_COMMUNITY_CACHE: std::sync::LazyLock<
785 std::sync::RwLock<std::collections::HashMap<String, String>>,
786> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
787
788pub fn clear_channel_community_cache() {
790 CHANNEL_COMMUNITY_CACHE.write().unwrap().clear();
791}
792
793fn forget_community_channels(community_id: &str) {
796 CHANNEL_COMMUNITY_CACHE.write().unwrap().retain(|_, cid| cid != community_id);
797}
798
799pub fn community_id_for_channel(channel_id: &str) -> Result<Option<String>, String> {
800 if let Some(cid) = CHANNEL_COMMUNITY_CACHE.read().unwrap().get(channel_id) {
801 return Ok(Some(cid.clone()));
802 }
803 let conn = super::get_db_connection_guard_static()?;
804 let cid: Option<String> = conn
805 .query_row(
806 "SELECT community_id FROM community_channels WHERE channel_id = ?1",
807 params![channel_id],
808 |r| r.get::<_, String>(0),
809 )
810 .optional()
811 .map_err(|e| format!("community_id_for_channel: {e}"))?;
812 if let Some(ref c) = cid {
813 CHANNEL_COMMUNITY_CACHE.write().unwrap().insert(channel_id.to_string(), c.clone());
814 }
815 Ok(cid)
816}
817
818pub fn community_exists(id: &CommunityId) -> Result<bool, String> {
821 let conn = super::get_db_connection_guard_static()?;
822 let found: Option<i64> = conn
823 .query_row(
824 "SELECT 1 FROM communities WHERE community_id = ?1",
825 params![id.to_hex()],
826 |r| r.get(0),
827 )
828 .optional()
829 .map_err(|e| format!("community_exists: {e}"))?;
830 Ok(found.is_some())
831}
832
833#[derive(Debug, Clone, serde::Serialize)]
835pub struct PendingCommunityInvite {
836 pub community_id: String,
837 pub bundle_json: String,
838 pub inviter_npub: String,
839 pub received_at: i64,
840 pub expires_at: i64,
842}
843
844pub fn save_pending_invite(
849 community_id: &str,
850 bundle_json: &str,
851 inviter_npub: &str,
852 expires_at: i64,
853) -> Result<bool, String> {
854 const MAX_PENDING_INVITES: usize = 100;
858
859 let conn = super::get_write_connection_guard_static()?;
860 let enc_bundle = enc_txt(bundle_json)?;
861 let enc_inviter = enc_txt(inviter_npub)?;
862 let changed = conn
867 .execute(
868 "INSERT OR IGNORE INTO pending_community_invites
869 (community_id, bundle_json, inviter_npub, received_at, expires_at)
870 VALUES (?1, ?2, ?3, ?4, ?5)",
871 params![community_id, enc_bundle, enc_inviter, now_secs(), expires_at],
872 )
873 .map_err(|e| format!("save pending invite: {e}"))?;
874 if changed > 0 {
877 let _ = conn.execute(
878 "DELETE FROM pending_community_invites
879 WHERE community_id IN (
880 SELECT community_id FROM pending_community_invites
881 ORDER BY received_at DESC, community_id DESC
882 LIMIT -1 OFFSET ?1
883 )",
884 params![MAX_PENDING_INVITES],
885 );
886 }
887 Ok(changed > 0)
888}
889
890pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
896 let conn = super::get_write_connection_guard_static()?;
897 let n = conn
898 .execute(
899 "DELETE FROM pending_community_invites
900 WHERE community_id IN (SELECT community_id FROM communities)",
901 [],
902 )
903 .map_err(|e| format!("purge held pending invites: {e}"))?;
904 Ok(n)
905}
906
907pub fn purge_expired_pending_invites() -> Result<usize, String> {
915 let conn = super::get_write_connection_guard_static()?;
916 let now = now_secs();
917 let n = conn
918 .execute(
919 "DELETE FROM pending_community_invites
920 WHERE (expires_at != 0 AND expires_at <= ?1)
921 OR received_at <= ?2",
922 params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64],
923 )
924 .map_err(|e| format!("purge expired pending invites: {e}"))?;
925 Ok(n)
926}
927
928pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
930 let conn = super::get_db_connection_guard_static()?;
931 let mut stmt = conn
932 .prepare(
933 "SELECT community_id, bundle_json, inviter_npub, received_at, expires_at
934 FROM pending_community_invites
935 WHERE (expires_at = 0 OR expires_at > ?1)
936 AND received_at > ?2
937 ORDER BY received_at DESC",
938 )
939 .map_err(|e| e.to_string())?;
940 let now = now_secs();
941 let rows = stmt
942 .query_map(params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64], |r| {
943 Ok(PendingCommunityInvite {
944 community_id: r.get(0)?,
945 bundle_json: dec_txt(&r.get::<_, String>(1)?),
946 inviter_npub: dec_txt(&r.get::<_, String>(2)?),
947 received_at: r.get(3)?,
948 expires_at: r.get(4)?,
949 })
950 })
951 .map_err(|e| e.to_string())?;
952 let mut out = Vec::new();
953 for row in rows {
954 out.push(row.map_err(|e| e.to_string())?);
955 }
956 Ok(out)
957}
958
959pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
963 let conn = super::get_db_connection_guard_static()?;
964 let raw: Option<String> = conn
965 .query_row(
966 "SELECT bundle_json FROM pending_community_invites
967 WHERE community_id = ?1 AND (expires_at = 0 OR expires_at > ?2)",
968 params![community_id, now_secs()],
969 |r| r.get::<_, String>(0),
970 )
971 .optional()
972 .map_err(|e| format!("get pending invite: {e}"))?;
973 Ok(raw.map(|s| dec_txt(&s)))
974}
975
976pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
978 let conn = super::get_write_connection_guard_static()?;
979 conn.execute(
980 "DELETE FROM pending_community_invites WHERE community_id = ?1",
981 params![community_id],
982 )
983 .map_err(|e| format!("delete pending invite: {e}"))?;
984 Ok(())
985}
986
987pub fn pending_invite_received_at(community_id: &str) -> Result<Option<i64>, String> {
992 let conn = super::get_db_connection_guard_static()?;
993 conn.query_row(
994 "SELECT received_at FROM pending_community_invites WHERE community_id = ?1",
995 params![community_id],
996 |r| r.get(0),
997 )
998 .optional()
999 .map_err(|e| format!("pending_invite_received_at: {e}"))
1000}
1001
1002pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
1003 let conn = super::get_db_connection_guard_static()?;
1004 let found: Option<i64> = conn
1005 .query_row(
1006 "SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
1007 params![community_id],
1008 |r| r.get(0),
1009 )
1010 .optional()
1011 .map_err(|e| format!("pending_invite_exists: {e}"))?;
1012 Ok(found.is_some())
1013}
1014
1015#[derive(Debug, Clone, serde::Serialize)]
1017pub struct PublicInviteRecord {
1018 pub token: String,
1020 pub community_id: String,
1021 pub url: String,
1022 pub expires_at: Option<i64>,
1023 pub created_at: i64,
1024 pub label: Option<String>,
1026 #[serde(default)]
1028 pub join_count: u64,
1029}
1030
1031pub fn save_public_invite(
1033 token: &str,
1034 community_id: &str,
1035 url: &str,
1036 expires_at: Option<i64>,
1037 label: Option<&str>,
1038) -> Result<(), String> {
1039 let conn = super::get_write_connection_guard_static()?;
1040 let enc_token = enc_txt(token)?;
1043 let enc_url = enc_txt(url)?;
1044 let enc_label = label.map(enc_txt).transpose()?;
1046 conn.execute(
1047 "INSERT OR REPLACE INTO community_public_invites
1048 (token, community_id, url, expires_at, created_at, label)
1049 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1050 params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
1051 )
1052 .map_err(|e| format!("save public invite: {e}"))?;
1053 Ok(())
1054}
1055
1056pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
1058 let conn = super::get_db_connection_guard_static()?;
1059 let mut stmt = conn
1060 .prepare(
1061 "SELECT token, community_id, url, expires_at, created_at, label
1062 FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
1063 )
1064 .map_err(|e| e.to_string())?;
1065 let rows = stmt
1066 .query_map(params![community_id], |r| {
1067 Ok(PublicInviteRecord {
1068 token: dec_txt(&r.get::<_, String>(0)?),
1069 community_id: r.get(1)?,
1070 url: dec_txt(&r.get::<_, String>(2)?),
1071 expires_at: r.get(3)?,
1072 created_at: r.get(4)?,
1073 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
1074 join_count: 0,
1075 })
1076 })
1077 .map_err(|e| e.to_string())?;
1078 let mut out = Vec::new();
1079 for row in rows {
1080 out.push(row.map_err(|e| e.to_string())?);
1081 }
1082 if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
1084 if let Ok(counts) = community_invite_join_counts(community_id, &me) {
1085 for rec in &mut out {
1086 if let Some(l) = rec.label.as_deref() {
1087 rec.join_count = counts.get(l).copied().unwrap_or(0);
1088 }
1089 }
1090 }
1091 }
1092 Ok(out)
1093}
1094
1095pub fn delete_public_invite(token: &str) -> Result<(), String> {
1097 let conn = super::get_write_connection_guard_static()?;
1098 let rows: Vec<(i64, String)> = {
1101 let mut stmt = conn
1102 .prepare("SELECT rowid, token FROM community_public_invites")
1103 .map_err(|e| e.to_string())?;
1104 let mapped = stmt
1105 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1106 .map_err(|e| e.to_string())?;
1107 mapped.filter_map(|r| r.ok()).collect()
1108 };
1109 for (rowid, stored) in rows {
1110 if dec_txt(&stored) == token {
1111 conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
1112 .map_err(|e| format!("delete public invite: {e}"))?;
1113 }
1114 }
1115 Ok(())
1116}
1117
1118pub fn list_all_public_invites() -> Result<Vec<PublicInviteRecord>, String> {
1120 let conn = super::get_db_connection_guard_static()?;
1121 let mut stmt = conn
1122 .prepare(
1123 "SELECT token, community_id, url, expires_at, created_at, label
1124 FROM community_public_invites ORDER BY created_at DESC",
1125 )
1126 .map_err(|e| e.to_string())?;
1127 let rows = stmt
1128 .query_map([], |r| {
1129 Ok(PublicInviteRecord {
1130 token: dec_txt(&r.get::<_, String>(0)?),
1131 community_id: r.get(1)?,
1132 url: dec_txt(&r.get::<_, String>(2)?),
1133 expires_at: r.get(3)?,
1134 created_at: r.get(4)?,
1135 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
1136 join_count: 0,
1137 })
1138 })
1139 .map_err(|e| e.to_string())?;
1140 let mut out = Vec::new();
1141 for row in rows {
1142 out.push(row.map_err(|e| e.to_string())?);
1143 }
1144 Ok(out)
1145}
1146
1147pub fn upsert_public_invite(
1152 token: &str,
1153 community_id: &str,
1154 url: &str,
1155 expires_at: Option<i64>,
1156 created_at: i64,
1157 label: Option<&str>,
1158) -> Result<bool, String> {
1159 let conn = super::get_write_connection_guard_static()?;
1160 let already = {
1161 let mut stmt = conn
1162 .prepare("SELECT token FROM community_public_invites WHERE community_id = ?1")
1163 .map_err(|e| e.to_string())?;
1164 let stored: Vec<String> = stmt
1165 .query_map(params![community_id], |r| r.get::<_, String>(0))
1166 .map_err(|e| e.to_string())?
1167 .filter_map(|r| r.ok())
1168 .collect();
1169 stored.iter().any(|s| dec_txt(s) == token)
1170 };
1171 if already {
1172 return Ok(false);
1173 }
1174 let enc_token = enc_txt(token)?;
1175 let enc_url = enc_txt(url)?;
1176 let enc_label = label.map(enc_txt).transpose()?;
1177 conn.execute(
1178 "INSERT INTO community_public_invites
1179 (token, community_id, url, expires_at, created_at, label)
1180 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1181 params![enc_token, community_id, enc_url, expires_at, created_at, enc_label],
1182 )
1183 .map_err(|e| format!("upsert public invite: {e}"))?;
1184 Ok(true)
1185}
1186
1187pub fn delete_community(community_id: &str) -> Result<(), String> {
1192 delete_community_inner(community_id, false)
1193}
1194
1195pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
1201 delete_community_inner(community_id, true)
1202}
1203
1204fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
1205 let conn = super::get_write_connection_guard_static()?;
1206 let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
1209 for sql in [
1210 Some("DELETE FROM communities WHERE community_id = ?1"),
1211 Some("DELETE FROM community_channels WHERE community_id = ?1"),
1212 (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
1216 Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
1217 Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
1218 Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
1219 Some("DELETE FROM pending_channel_keys WHERE community_id = ?1"),
1225 Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
1228 ]
1229 .into_iter()
1230 .flatten()
1231 {
1232 tx.execute(sql, params![community_id])
1233 .map_err(|e| format!("delete community: {e}"))?;
1234 }
1235 tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
1236 BANLIST_CACHE.write().unwrap().remove(community_id);
1237 forget_community_channels(community_id);
1238 Ok(())
1243}
1244
1245pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
1252 community_member_activity_capped(community_id, true)
1253}
1254
1255pub fn community_member_activity_capped(community_id: &str, capped: bool) -> Result<Vec<(String, u64)>, String> {
1261 const COMMUNITY_MEMBER_CAP: usize = 500;
1264 use std::collections::HashMap;
1265
1266 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1267 Some(c) => c,
1268 None => return Ok(Vec::new()),
1269 };
1270 let owner_b32: Option<String> = community
1275 .owner_attestation
1276 .as_deref()
1277 .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
1278 .and_then(|pk| pk.to_bech32().ok());
1279
1280 let mut chat_ints: Vec<i64> = Vec::new();
1282 for ch in &community.channels {
1283 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1284 chat_ints.push(cid);
1285 }
1286 }
1287
1288 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1291
1292 let mut active: HashMap<String, u64> = HashMap::new();
1297 let mut left: HashMap<String, u64> = HashMap::new();
1298 if !chat_ints.is_empty() {
1301 let conn = super::get_db_connection_guard_static()?;
1302 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1303
1304 {
1305 let sql = format!(
1306 "SELECT npub, MAX(created_at) FROM events \
1307 WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
1308 GROUP BY npub"
1309 );
1310 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1311 let rows = stmt
1312 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1313 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
1314 })
1315 .map_err(|e| e.to_string())?;
1316 for row in rows {
1317 let (npub, at) = row.map_err(|e| e.to_string())?;
1318 active.insert(npub, at);
1319 }
1320 }
1321
1322 {
1324 let sql = format!(
1325 "SELECT npub, created_at, tags FROM events \
1326 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1327 );
1328 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1329 let rows = stmt
1330 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1331 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
1332 })
1333 .map_err(|e| e.to_string())?;
1334 for row in rows {
1335 let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
1336 let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
1338 .ok()
1339 .and_then(|tags| {
1340 tags.into_iter()
1341 .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
1342 .and_then(|t| t.into_iter().nth(1))
1343 });
1344 match etype.as_deref() {
1345 Some("1") => {
1346 let e = active.entry(npub).or_insert(0);
1347 if at > *e { *e = at; }
1348 }
1349 Some("0") => {
1350 let e = left.entry(npub).or_insert(0);
1351 if at > *e { *e = at; }
1352 }
1353 _ => {}
1354 }
1355 }
1356 }
1357 }
1358
1359 let banned: std::collections::HashSet<String> = community
1362 .channels
1363 .first()
1364 .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
1365 .unwrap_or_default();
1366
1367 let mut out: Vec<(String, u64)> = active
1369 .into_iter()
1370 .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l))
1371 .collect();
1372
1373 {
1380 let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
1381 let mut reassert = |npub: String| {
1382 if !banned.contains(&npub) && present.insert(npub.clone()) {
1383 out.push((npub, now_secs() as u64));
1384 }
1385 };
1386 if let Some(o) = owner_b32 {
1387 reassert(o);
1388 }
1389 if let Ok(roles) = get_community_roles(community_id) {
1390 for g in &roles.grants {
1391 if g.role_ids.is_empty() {
1392 continue; }
1394 if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
1395 reassert(b32);
1396 }
1397 }
1398 }
1399 }
1400 out.sort_by(|a, b| b.1.cmp(&a.1));
1401 if capped {
1402 out.truncate(COMMUNITY_MEMBER_CAP);
1403 }
1404 Ok(out)
1405}
1406
1407pub fn community_invite_join_counts(
1412 community_id: &str,
1413 inviter_npub: &str,
1414) -> Result<std::collections::HashMap<String, u64>, String> {
1415 use std::collections::{HashMap, HashSet};
1416 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1417 Some(c) => c,
1418 None => return Ok(HashMap::new()),
1419 };
1420 let mut chat_ints: Vec<i64> = Vec::new();
1421 for ch in &community.channels {
1422 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1423 chat_ints.push(cid);
1424 }
1425 }
1426 if chat_ints.is_empty() {
1427 return Ok(HashMap::new());
1428 }
1429 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1430 let conn = super::get_db_connection_guard_static()?;
1431 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1432 let sql = format!(
1433 "SELECT npub, tags FROM events \
1434 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1435 );
1436 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1437 let rows = stmt
1438 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1439 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1440 })
1441 .map_err(|e| e.to_string())?;
1442 let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
1444 for row in rows {
1445 let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
1446 let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
1447 Ok(t) => t,
1448 Err(_) => continue,
1449 };
1450 let tag_val = |key: &str| -> Option<String> {
1451 tags.iter()
1452 .find(|t| t.first().map(|s| s == key).unwrap_or(false))
1453 .and_then(|t| t.get(1).cloned())
1454 };
1455 if tag_val("event-type").as_deref() != Some("1") {
1457 continue;
1458 }
1459 if tag_val("invited-by").as_deref() != Some(inviter_npub) {
1460 continue;
1461 }
1462 if let Some(label) = tag_val("invited-label") {
1463 per_label.entry(label).or_default().insert(joiner);
1464 }
1465 }
1466 Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
1467}
1468
1469static BANLIST_CACHE: std::sync::LazyLock<
1481 std::sync::RwLock<std::collections::HashMap<String, std::sync::Arc<std::collections::HashSet<[u8; 32]>>>>,
1482> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
1483
1484fn banlist_set_from_hexes(hexes: &[String]) -> std::collections::HashSet<[u8; 32]> {
1485 hexes.iter().filter_map(|h| crate::simd::hex::hex_to_bytes_32_checked(h)).collect()
1486}
1487
1488pub fn clear_banlist_cache() {
1490 BANLIST_CACHE.write().unwrap().clear();
1491}
1492
1493pub fn banned_set(community_id: &str) -> std::sync::Arc<std::collections::HashSet<[u8; 32]>> {
1496 if let Some(set) = BANLIST_CACHE.read().unwrap().get(community_id) {
1497 return std::sync::Arc::clone(set);
1498 }
1499 let set = std::sync::Arc::new(banlist_set_from_hexes(
1500 &get_community_banlist(community_id).unwrap_or_default(),
1501 ));
1502 BANLIST_CACHE
1503 .write()
1504 .unwrap()
1505 .insert(community_id.to_string(), std::sync::Arc::clone(&set));
1506 set
1507}
1508
1509pub fn is_author_banned(community_id: &str, author: &PublicKey) -> bool {
1512 let set = banned_set(community_id);
1513 !set.is_empty() && set.contains(&author.to_bytes())
1514}
1515
1516pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
1517 let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?;
1518 let conn = super::get_write_connection_guard_static()?;
1519 conn.execute(
1520 "UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3",
1521 params![json, at, community_id],
1522 )
1523 .map_err(|e| format!("set banlist: {e}"))?;
1524 BANLIST_CACHE
1527 .write()
1528 .unwrap()
1529 .insert(community_id.to_string(), std::sync::Arc::new(banlist_set_from_hexes(banned_hex)));
1530 Ok(())
1531}
1532
1533pub fn get_community_ban_marks(community_id: &str) -> Result<std::collections::BTreeMap<String, u64>, String> {
1538 let conn = super::get_db_connection_guard_static()?;
1539 let json: Option<String> = conn
1540 .query_row(
1541 "SELECT banlist_marks FROM communities WHERE community_id = ?1",
1542 params![community_id],
1543 |r| r.get(0),
1544 )
1545 .optional()
1546 .map_err(|e| format!("get ban marks: {e}"))?;
1547 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1548}
1549
1550pub fn merge_community_ban_marks(community_id: &str, marks: &std::collections::BTreeMap<String, u64>) -> Result<bool, String> {
1555 if marks.is_empty() {
1556 return Ok(false);
1557 }
1558 let mut stored = get_community_ban_marks(community_id)?;
1559 let mut changed = false;
1560 for (npub, at) in marks {
1561 let slot = stored.entry(npub.clone()).or_insert(0);
1562 if *at > *slot {
1563 *slot = *at;
1564 changed = true;
1565 }
1566 }
1567 if !changed {
1568 return Ok(false);
1569 }
1570 let json = enc_txt(&serde_json::to_string(&stored).map_err(|e| e.to_string())?)?;
1571 let conn = super::get_write_connection_guard_static()?;
1572 conn.execute(
1573 "UPDATE communities SET banlist_marks = ?1 WHERE community_id = ?2",
1574 params![json, community_id],
1575 )
1576 .map_err(|e| format!("set ban marks: {e}"))?;
1577 Ok(true)
1578}
1579
1580pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
1583 let conn = super::get_db_connection_guard_static()?;
1584 let at: Option<i64> = conn
1585 .query_row(
1586 "SELECT banlist_at FROM communities WHERE community_id = ?1",
1587 params![community_id],
1588 |r| r.get(0),
1589 )
1590 .optional()
1591 .map_err(|e| format!("get banlist_at: {e}"))?;
1592 Ok(at.unwrap_or(0))
1593}
1594
1595pub fn set_community_roles(
1600 community_id: &str,
1601 roles: &crate::community::roles::CommunityRoles,
1602 at: i64,
1603) -> Result<(), String> {
1604 let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
1605 let conn = super::get_write_connection_guard_static()?;
1606 conn.execute(
1607 "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
1608 params![json, at, community_id],
1609 )
1610 .map_err(|e| format!("set roles: {e}"))?;
1611 Ok(())
1612}
1613
1614pub fn get_community_roles(
1616 community_id: &str,
1617) -> Result<crate::community::roles::CommunityRoles, String> {
1618 let conn = super::get_db_connection_guard_static()?;
1619 let json: Option<String> = conn
1620 .query_row(
1621 "SELECT roles FROM communities WHERE community_id = ?1",
1622 params![community_id],
1623 |r| r.get(0),
1624 )
1625 .optional()
1626 .map_err(|e| format!("get roles: {e}"))?;
1627 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1628}
1629
1630pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
1632 let conn = super::get_db_connection_guard_static()?;
1633 let at: Option<i64> = conn
1634 .query_row(
1635 "SELECT roles_at FROM communities WHERE community_id = ?1",
1636 params![community_id],
1637 |r| r.get(0),
1638 )
1639 .optional()
1640 .map_err(|e| format!("get roles_at: {e}"))?;
1641 Ok(at.unwrap_or(0))
1642}
1643
1644pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
1649 set_edition_head_inner(community_id, entity_id, version, self_hash, None, None)
1650}
1651
1652pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1655 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), None)
1656}
1657
1658pub 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> {
1663 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), Some(epoch))
1664}
1665
1666fn 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> {
1667 let conn = super::get_write_connection_guard_static()?;
1668 conn.execute(
1674 "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
1675 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
1676 ON CONFLICT(community_id, entity_id) DO UPDATE SET
1677 version = excluded.version,
1678 self_hash = excluded.self_hash,
1679 inner_id = excluded.inner_id,
1680 epoch = excluded.epoch
1681 WHERE excluded.epoch > community_edition_heads.epoch
1682 OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
1683 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)],
1684 )
1685 .map_err(|e| format!("set edition head: {e}"))?;
1686 Ok(())
1687}
1688
1689pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1699 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, None)
1700}
1701
1702pub 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> {
1706 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, Some(epoch))
1707}
1708
1709fn 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> {
1710 let conn = super::get_write_connection_guard_static()?;
1711 conn.execute(
1714 "UPDATE community_edition_heads
1715 SET self_hash = ?4, inner_id = ?5
1716 WHERE community_id = ?1 AND entity_id = ?2
1717 AND version = ?3
1718 AND epoch = COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
1719 AND (inner_id IS NULL OR ?5 < inner_id)",
1720 params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice(), epoch.map(|e| e as i64)],
1721 )
1722 .map_err(|e| format!("converge edition head: {e}"))?;
1723 Ok(())
1724}
1725
1726pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
1731 let conn = super::get_db_connection_guard_static()?;
1732 let row: Option<Option<Vec<u8>>> = conn
1733 .query_row(
1734 "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1735 params![community_id, entity_id],
1736 |r| r.get(0),
1737 )
1738 .optional()
1739 .map_err(|e| format!("get edition head inner_id: {e}"))?;
1740 match row.flatten() {
1741 Some(blob) if blob.len() == 32 => {
1742 let mut h = [0u8; 32];
1743 h.copy_from_slice(&blob);
1744 Ok(Some(h))
1745 }
1746 _ => Ok(None),
1747 }
1748}
1749
1750pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
1753 let conn = super::get_db_connection_guard_static()?;
1754 let row: Option<(i64, Vec<u8>)> = conn
1755 .query_row(
1756 "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1757 params![community_id, entity_id],
1758 |r| Ok((r.get(0)?, r.get(1)?)),
1759 )
1760 .optional()
1761 .map_err(|e| format!("get edition head: {e}"))?;
1762 match row {
1763 Some((v, hash)) if hash.len() == 32 => {
1764 let mut h = [0u8; 32];
1765 h.copy_from_slice(&hash);
1766 Ok(Some((v as u64, h)))
1767 }
1768 _ => Ok(None),
1769 }
1770}
1771
1772pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
1777 let conn = super::get_db_connection_guard_static()?;
1778 let mut stmt = conn
1779 .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
1780 .map_err(|e| e.to_string())?;
1781 let rows = stmt
1782 .query_map(params![community_id], |r| r.get::<_, String>(0))
1783 .map_err(|e| e.to_string())?;
1784 let mut out = std::collections::HashSet::new();
1785 for row in rows {
1786 out.insert(row.map_err(|e| e.to_string())?);
1787 }
1788 Ok(out)
1789}
1790
1791
1792pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
1798 let conn = super::get_db_connection_guard_static()?;
1799 let mut stmt = conn
1800 .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1801 .map_err(|e| e.to_string())?;
1802 let rows = stmt
1803 .query_map(params![community_id], |r| {
1804 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
1805 })
1806 .map_err(|e| e.to_string())?;
1807 let mut out = std::collections::HashMap::new();
1808 for row in rows {
1809 let (entity, version, hash) = row.map_err(|e| e.to_string())?;
1810 if hash.len() == 32 {
1811 let mut h = [0u8; 32];
1812 h.copy_from_slice(&hash);
1813 out.insert(entity, (version as u64, h));
1814 }
1815 }
1816 Ok(out)
1817}
1818
1819pub fn get_all_edition_heads_full(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32], Option<[u8; 32]>)>, String> {
1827 let conn = super::get_db_connection_guard_static()?;
1828 let mut stmt = conn
1829 .prepare("SELECT entity_id, epoch, version, self_hash, inner_id FROM community_edition_heads WHERE community_id = ?1")
1830 .map_err(|e| e.to_string())?;
1831 let rows = stmt
1832 .query_map(params![community_id], |r| {
1833 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, Option<Vec<u8>>>(4)?))
1834 })
1835 .map_err(|e| e.to_string())?;
1836 let mut out = std::collections::HashMap::new();
1837 for row in rows {
1838 let (entity, epoch, version, hash, inner) = row.map_err(|e| e.to_string())?;
1839 if hash.len() == 32 {
1840 let mut h = [0u8; 32];
1841 h.copy_from_slice(&hash);
1842 let inner_id = inner.and_then(|b| {
1843 (b.len() == 32).then(|| {
1844 let mut i = [0u8; 32];
1845 i.copy_from_slice(&b);
1846 i
1847 })
1848 });
1849 out.insert(entity, (epoch as u64, version as u64, h, inner_id));
1850 }
1851 }
1852 Ok(out)
1853}
1854
1855pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32])>, String> {
1856 let conn = super::get_db_connection_guard_static()?;
1857 let mut stmt = conn
1858 .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1859 .map_err(|e| e.to_string())?;
1860 let rows = stmt
1861 .query_map(params![community_id], |r| {
1862 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
1863 })
1864 .map_err(|e| e.to_string())?;
1865 let mut out = std::collections::HashMap::new();
1866 for row in rows {
1867 let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
1868 if hash.len() == 32 {
1869 let mut h = [0u8; 32];
1870 h.copy_from_slice(&hash);
1871 out.insert(entity, (epoch as u64, version as u64, h));
1872 }
1873 }
1874 Ok(out)
1875}
1876
1877pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
1879 let conn = super::get_db_connection_guard_static()?;
1880 let json: Option<String> = conn
1881 .query_row(
1882 "SELECT banlist FROM communities WHERE community_id = ?1",
1883 params![community_id],
1884 |r| r.get(0),
1885 )
1886 .optional()
1887 .map_err(|e| format!("get banlist: {e}"))?;
1888 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1889}
1890
1891pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
1895 let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
1896 let conn = super::get_write_connection_guard_static()?;
1897 conn.execute(
1898 "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
1899 params![json, community_id],
1900 )
1901 .map_err(|e| format!("set invite registry: {e}"))?;
1902 Ok(())
1903}
1904
1905pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
1908 let conn = super::get_db_connection_guard_static()?;
1909 let json: Option<String> = conn
1910 .query_row(
1911 "SELECT invite_registry FROM communities WHERE community_id = ?1",
1912 params![community_id],
1913 |r| r.get(0),
1914 )
1915 .optional()
1916 .map_err(|e| format!("get invite registry: {e}"))?;
1917 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1918}
1919
1920pub struct InviteLinkSetRow {
1923 pub creator_hex: String,
1924 pub locators: Vec<String>,
1925}
1926
1927pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
1931 let mut conn = super::get_write_connection_guard_static()?;
1932 let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
1933 tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
1934 .map_err(|e| format!("clear invite-link-sets: {e}"))?;
1935 for s in sets {
1936 if s.locators.is_empty() {
1937 continue; }
1939 let enc_creator = enc_txt(&s.creator_hex)?;
1940 let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
1941 tx.execute(
1944 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1945 params![community_id, enc_creator, enc_locators],
1946 )
1947 .map_err(|e| format!("insert invite-link-set: {e}"))?;
1948 }
1949 tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
1950 Ok(())
1951}
1952
1953pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
1956 let conn = super::get_write_connection_guard_static()?;
1957 let existing_rowid: Option<i64> = {
1959 let mut stmt = conn
1960 .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
1961 .map_err(|e| e.to_string())?;
1962 let rows = stmt
1963 .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1964 .map_err(|e| e.to_string())?;
1965 let mut found = None;
1966 for row in rows {
1967 let (rowid, stored) = row.map_err(|e| e.to_string())?;
1968 if dec_txt(&stored) == creator_hex {
1969 found = Some(rowid);
1970 break;
1971 }
1972 }
1973 found
1974 };
1975 if locators.is_empty() {
1976 if let Some(rowid) = existing_rowid {
1977 conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
1978 .map_err(|e| format!("delete invite-link-set: {e}"))?;
1979 }
1980 return Ok(());
1981 }
1982 let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
1983 match existing_rowid {
1984 Some(rowid) => {
1985 conn.execute(
1986 "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
1987 params![enc_locators, rowid],
1988 )
1989 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1990 }
1991 None => {
1992 let enc_creator = enc_txt(creator_hex)?;
1993 conn.execute(
1994 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1995 params![community_id, enc_creator, enc_locators],
1996 )
1997 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1998 }
1999 }
2000 Ok(())
2001}
2002
2003pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
2006 let conn = super::get_db_connection_guard_static()?;
2007 let mut stmt = conn
2008 .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
2009 .map_err(|e| format!("prepare invite-link-sets: {e}"))?;
2010 let rows = stmt
2011 .query_map(params![community_id], |r| {
2012 let creator_hex: String = r.get(0)?;
2013 let json: String = r.get(1)?;
2014 Ok((creator_hex, json))
2015 })
2016 .map_err(|e| format!("query invite-link-sets: {e}"))?;
2017 let mut out = Vec::new();
2018 for row in rows {
2019 let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
2020 let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
2021 out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
2022 }
2023 Ok(out)
2024}
2025
2026pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
2030 let conn = super::get_write_connection_guard_static()?;
2031 conn.execute(
2032 "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
2033 params![pending as i64, community_id],
2034 )
2035 .map_err(|e| format!("set read_cut_pending: {e}"))?;
2036 Ok(())
2037}
2038
2039pub fn set_community_dissolved(community_id: &str) -> Result<bool, String> {
2047 let conn = super::get_write_connection_guard_static()?;
2048 let changed = conn
2049 .execute(
2050 "UPDATE communities SET dissolved = 1 WHERE community_id = ?1 AND dissolved = 0",
2051 params![community_id],
2052 )
2053 .map_err(|e| format!("set dissolved: {e}"))?;
2054 Ok(changed > 0)
2055}
2056
2057pub fn set_migration_pointer(community_id: &str, payload_json: &str) -> Result<(), String> {
2065 let conn = super::get_write_connection_guard_static()?;
2066 let wrapped = enc_txt(payload_json)?;
2067 conn.execute(
2068 "UPDATE communities SET migration_pointer = ?2, migration_checked = 1 WHERE community_id = ?1",
2069 params![community_id, wrapped],
2070 )
2071 .map_err(|e| format!("set migration pointer: {e}"))?;
2072 Ok(())
2073}
2074
2075pub fn get_migration_pointer(community_id: &str) -> Result<Option<String>, String> {
2077 let conn = super::get_db_connection_guard_static()?;
2078 let v: Option<Option<String>> = conn
2079 .query_row(
2080 "SELECT migration_pointer FROM communities WHERE community_id = ?1",
2081 params![community_id],
2082 |r| r.get(0),
2083 )
2084 .optional()
2085 .map_err(|e| format!("get migration pointer: {e}"))?;
2086 Ok(v.flatten().map(|s| dec_txt(&s)))
2087}
2088
2089pub fn set_migrated_to(community_id: &str, v2_community_id: &str) -> Result<(), String> {
2092 let conn = super::get_write_connection_guard_static()?;
2093 conn.execute(
2094 "UPDATE communities SET migrated_to = ?2 WHERE community_id = ?1 AND migrated_to IS NULL",
2095 params![community_id, v2_community_id],
2096 )
2097 .map_err(|e| format!("set migrated_to: {e}"))?;
2098 Ok(())
2099}
2100
2101pub fn get_migrated_to(community_id: &str) -> Result<Option<String>, String> {
2103 let conn = super::get_db_connection_guard_static()?;
2104 let v: Option<Option<String>> = conn
2105 .query_row(
2106 "SELECT migrated_to FROM communities WHERE community_id = ?1",
2107 params![community_id],
2108 |r| r.get(0),
2109 )
2110 .optional()
2111 .map_err(|e| format!("get migrated_to: {e}"))?;
2112 Ok(v.flatten())
2113}
2114
2115pub fn set_migration_checked(community_id: &str) -> Result<(), String> {
2119 let conn = super::get_write_connection_guard_static()?;
2120 conn.execute(
2121 "UPDATE communities SET migration_checked = 1 WHERE community_id = ?1",
2122 params![community_id],
2123 )
2124 .map_err(|e| format!("set migration checked: {e}"))?;
2125 Ok(())
2126}
2127
2128pub fn set_migration_ledger(v1_community_id: &str, v2_community_id: &str, phase: i64, twin_json: &str) -> Result<(), String> {
2135 let conn = super::get_write_connection_guard_static()?;
2136 let wrapped = enc_txt(twin_json)?;
2137 let now = now_secs();
2140 conn.execute(
2141 "INSERT INTO community_migrations (community_id, v2_community_id, phase, twin, updated_at)
2142 VALUES (?1, ?2, ?3, ?4, ?5)
2143 ON CONFLICT(community_id) DO UPDATE SET v2_community_id=?2, phase=?3, twin=?4, updated_at=?5",
2144 params![v1_community_id, v2_community_id, phase, wrapped, now],
2145 )
2146 .map_err(|e| format!("set migration ledger: {e}"))?;
2147 Ok(())
2148}
2149
2150pub fn get_migration_ledger(v1_community_id: &str) -> Result<Option<(String, i64, String)>, String> {
2152 let conn = super::get_db_connection_guard_static()?;
2153 let row = conn
2154 .query_row(
2155 "SELECT v2_community_id, phase, twin FROM community_migrations WHERE community_id = ?1",
2156 params![v1_community_id],
2157 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)),
2158 )
2159 .optional()
2160 .map_err(|e| format!("get migration ledger: {e}"))?;
2161 Ok(row.map(|(v2, phase, twin)| (v2, phase, dec_txt(&twin))))
2162}
2163
2164pub fn reparent_channels_and_fence(v1_community_id: &str, v2_community_id: &str) -> Result<(), String> {
2174 let conn = super::get_write_connection_guard_static()?;
2175 let tx = conn.unchecked_transaction().map_err(|e| format!("flip txn: {e}"))?;
2176 tx.execute(
2177 "UPDATE community_channels SET community_id = ?2 WHERE community_id = ?1",
2178 params![v1_community_id, v2_community_id],
2179 )
2180 .map_err(|e| format!("reparent channels: {e}"))?;
2181 tx.execute(
2185 "UPDATE communities SET migrated_to = ?2, dissolved = 1 WHERE community_id = ?1 AND migrated_to IS NULL",
2186 params![v1_community_id, v2_community_id],
2187 )
2188 .map_err(|e| format!("set fence: {e}"))?;
2189 tx.commit().map_err(|e| format!("flip commit: {e}"))?;
2190 forget_community_channels(v1_community_id);
2194 Ok(())
2195}
2196
2197pub fn migration_sweep_candidates() -> Result<Vec<String>, String> {
2201 let conn = super::get_db_connection_guard_static()?;
2202 let mut stmt = conn
2210 .prepare(
2211 "SELECT community_id FROM communities
2212 WHERE migrated_to IS NULL AND migration_checked = 0
2213 AND (protocol IS NULL OR protocol = 1)
2214 ORDER BY dissolved DESC
2215 LIMIT 40",
2216 )
2217 .map_err(|e| e.to_string())?;
2218 let rows = stmt
2219 .query_map([], |r| r.get::<_, String>(0))
2220 .map_err(|e| e.to_string())?;
2221 Ok(rows.flatten().collect())
2222}
2223
2224pub fn migration_flip_candidates() -> Result<Vec<String>, String> {
2228 let conn = super::get_db_connection_guard_static()?;
2229 let mut stmt = conn
2230 .prepare(
2231 "SELECT community_id FROM communities
2232 WHERE migration_pointer IS NOT NULL AND migrated_to IS NULL",
2233 )
2234 .map_err(|e| e.to_string())?;
2235 let rows = stmt
2236 .query_map([], |r| r.get::<_, String>(0))
2237 .map_err(|e| e.to_string())?;
2238 Ok(rows.flatten().collect())
2239}
2240
2241pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
2244 let conn = super::get_db_connection_guard_static()?;
2245 let v: Option<i64> = conn
2246 .query_row(
2247 "SELECT dissolved FROM communities WHERE community_id = ?1",
2248 params![community_id],
2249 |r| r.get(0),
2250 )
2251 .optional()
2252 .map_err(|e| format!("get dissolved: {e}"))?;
2253 Ok(v.unwrap_or(0) != 0)
2254}
2255
2256pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
2259 let conn = super::get_db_connection_guard_static()?;
2260 let v: Option<i64> = conn
2261 .query_row(
2262 "SELECT read_cut_pending FROM communities WHERE community_id = ?1",
2263 params![community_id],
2264 |r| r.get(0),
2265 )
2266 .optional()
2267 .map_err(|e| format!("get read_cut_pending: {e}"))?;
2268 Ok(v.unwrap_or(0) != 0)
2269}
2270
2271pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
2276 let conn = super::get_write_connection_guard_static()?;
2277 conn.execute(
2278 "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
2279 params![target as i64, community_id],
2280 )
2281 .map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
2282 Ok(())
2283}
2284
2285pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
2288 let conn = super::get_db_connection_guard_static()?;
2289 let v: Option<i64> = conn
2290 .query_row(
2291 "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
2292 params![community_id],
2293 |r| r.get(0),
2294 )
2295 .optional()
2296 .map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
2297 Ok(v.unwrap_or(0) as u64)
2298}
2299
2300pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
2303 let conn = super::get_db_connection_guard_static()?;
2304 let v: Option<i64> = conn
2305 .query_row(
2306 "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
2307 params![community_id, channel_id],
2308 |r| r.get(0),
2309 )
2310 .optional()
2311 .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
2312 Ok(v.unwrap_or(0) as u64)
2313}
2314
2315pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
2319 let conn = super::get_write_connection_guard_static()?;
2320 conn.execute(
2321 "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
2322 params![server_epoch as i64, community_id, channel_id],
2323 )
2324 .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
2325 Ok(())
2326}
2327
2328pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
2330 let conn = super::get_db_connection_guard_static()?;
2331 let mut stmt = conn
2332 .prepare("SELECT community_id FROM communities ORDER BY created_at")
2333 .map_err(|e| e.to_string())?;
2334 let rows = stmt
2335 .query_map([], |r| r.get::<_, String>(0))
2336 .map_err(|e| e.to_string())?;
2337 let mut ids = Vec::new();
2338 for row in rows {
2339 ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
2340 }
2341 Ok(ids)
2342}
2343
2344pub fn community_protocol(id: &CommunityId) -> Result<Option<crate::community::ConcordProtocol>, String> {
2355 let conn = super::get_db_connection_guard_static()?;
2356 let n: Option<i64> = conn
2357 .query_row("SELECT protocol FROM communities WHERE community_id = ?1", params![id.to_hex()], |r| r.get(0))
2358 .optional()
2359 .map_err(|e| e.to_string())?;
2360 Ok(n.map(crate::community::ConcordProtocol::from_i64))
2361}
2362
2363#[derive(serde::Serialize, serde::Deserialize, Default)]
2366struct CommunityMetaStash {
2367 #[serde(default, skip_serializing_if = "Option::is_none")]
2368 custom: Option<serde_json::Map<String, serde_json::Value>>,
2369 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
2370 extra: serde_json::Map<String, serde_json::Value>,
2371}
2372
2373#[derive(serde::Serialize, serde::Deserialize, Default)]
2375struct ChannelMetaStash {
2376 #[serde(default, skip_serializing_if = "Option::is_none")]
2377 voice: Option<bool>,
2378 #[serde(default, skip_serializing_if = "Option::is_none")]
2379 custom: Option<serde_json::Map<String, serde_json::Value>>,
2380 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
2381 extra: serde_json::Map<String, serde_json::Value>,
2382}
2383
2384pub fn save_community_v2(c: &crate::community::v2::community::CommunityV2) -> Result<(), String> {
2387 let conn = super::get_write_connection_guard_static()?;
2388 let id_hex = crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0);
2389 let relays_json = serde_json::to_string(&c.relays).map_err(|e| e.to_string())?;
2390 let created = (c.created_at_ms / 1000) as i64;
2391
2392 let enc_root = enc_key(&c.community_root)?;
2393 let enc_name = enc_txt(&c.name)?;
2394 let enc_relays = enc_txt(&relays_json)?;
2395 let enc_desc = enc_txt_opt(&c.description)?;
2396 let enc_owner_pk = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_xonly))?;
2397 let enc_owner_salt = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_salt))?;
2398 let icon_json = c.icon.as_ref().map(|i| serde_json::to_string(i).map_err(|e| e.to_string())).transpose()?;
2402 let banner_json = c.banner.as_ref().map(|b| serde_json::to_string(b).map_err(|e| e.to_string())).transpose()?;
2403 let enc_icon = enc_txt_opt(&icon_json)?;
2404 let enc_banner = enc_txt_opt(&banner_json)?;
2405 let stash_json = (c.meta_custom.is_some() || !c.meta_extra.is_empty())
2406 .then(|| serde_json::to_string(&CommunityMetaStash { custom: c.meta_custom.clone(), extra: c.meta_extra.clone() }).map_err(|e| e.to_string()))
2407 .transpose()?;
2408 let enc_stash = enc_txt_opt(&stash_json)?;
2409 let enc_control_pk = enc_txt_opt(&c.control_pk.map(|p| p.to_hex()))?;
2412 let enc_control_root = c.control_root.as_ref().map(enc_key).transpose()?;
2413
2414 let tx = conn.unchecked_transaction().map_err(|e| format!("save v2 community tx: {e}"))?;
2415 tx.execute(
2416 "INSERT INTO communities
2417 (community_id, server_root_key, name, relays, created_at, description,
2418 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt, icon, banner, meta_extra,
2419 control_pk, control_root)
2420 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
2421 ON CONFLICT(community_id) DO UPDATE SET
2422 server_root_key=?2, name=?3, relays=?4, description=?6,
2423 server_root_epoch=?7, dissolved=?8, protocol=2, owner_pubkey=?9, owner_salt=?10,
2424 icon=?11, banner=?12, meta_extra=?13, control_pk=?14, control_root=?15",
2425 params![
2426 id_hex, enc_root, enc_name, enc_relays, created, enc_desc,
2427 c.root_epoch.0 as i64, c.dissolved as i64, enc_owner_pk, enc_owner_salt,
2428 enc_icon, enc_banner, enc_stash, enc_control_pk, enc_control_root,
2429 ],
2430 )
2431 .map_err(|e| format!("save v2 community: {e}"))?;
2432
2433 for ch in &c.channels {
2434 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2435 let owner_of: Option<String> = tx
2441 .query_row("SELECT community_id FROM community_channels WHERE channel_id=?1", params![ch_hex], |r| r.get(0))
2442 .optional()
2443 .map_err(|e| format!("channel ownership check: {e}"))?;
2444 if owner_of.is_some_and(|existing| existing != id_hex) {
2445 continue;
2451 }
2452 let stored_key = ch.key.unwrap_or(c.community_root);
2456 let enc_ch_key = enc_key(&stored_key)?;
2457 let enc_ch_name = enc_txt(&ch.name)?;
2458 let ch_stash_json = (ch.voice.is_some() || ch.meta_custom.is_some() || !ch.meta_extra.is_empty())
2459 .then(|| {
2460 serde_json::to_string(&ChannelMetaStash { voice: ch.voice, custom: ch.meta_custom.clone(), extra: ch.meta_extra.clone() })
2461 .map_err(|e| e.to_string())
2462 })
2463 .transpose()?;
2464 let enc_ch_stash = enc_txt_opt(&ch_stash_json)?;
2465 tx.execute(
2466 "INSERT INTO community_channels
2467 (channel_id, community_id, channel_key, epoch, name, created_at, private, meta_extra)
2468 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
2469 ON CONFLICT(channel_id) DO UPDATE SET
2470 channel_key=?3, epoch=?4, name=?5, private=?7, meta_extra=?8",
2471 params![ch_hex, id_hex, enc_ch_key, ch.epoch.0 as i64, enc_ch_name, created, ch.private as i64, enc_ch_stash],
2472 )
2473 .map_err(|e| format!("save v2 channel: {e}"))?;
2474 }
2475
2476 let keep: Vec<String> = c.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
2481 if keep.is_empty() {
2482 tx.execute("DELETE FROM community_channels WHERE community_id=?1", params![id_hex])
2483 .map_err(|e| format!("prune v2 channels: {e}"))?;
2484 } else {
2485 let placeholders = std::iter::repeat("?").take(keep.len()).collect::<Vec<_>>().join(",");
2486 let sql = format!("DELETE FROM community_channels WHERE community_id=? AND channel_id NOT IN ({placeholders})");
2487 let mut binds: Vec<String> = Vec::with_capacity(keep.len() + 1);
2488 binds.push(id_hex.clone());
2489 binds.extend(keep);
2490 tx.execute(&sql, rusqlite::params_from_iter(binds.iter()))
2491 .map_err(|e| format!("prune v2 channels: {e}"))?;
2492 }
2493
2494 tx.commit().map_err(|e| format!("commit v2 community: {e}"))?;
2495 forget_community_channels(&id_hex);
2498 Ok(())
2499}
2500
2501pub fn load_community_v2(id: &CommunityId) -> Result<Option<crate::community::v2::community::CommunityV2>, String> {
2503 use crate::community::v2::community::{ChannelV2, CommunityV2};
2504 use crate::community::v2::control::CommunityIdentity;
2505 let conn = super::get_db_connection_guard_static()?;
2506 let id_hex = id.to_hex();
2507
2508 let row = conn
2509 .query_row(
2510 "SELECT server_root_key, name, relays, created_at, description,
2511 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt,
2512 icon, banner, meta_extra, control_pk, control_root
2513 FROM communities WHERE community_id = ?1",
2514 params![id_hex],
2515 |r| {
2516 Ok((
2517 r.get::<_, Vec<u8>>(0)?,
2518 r.get::<_, String>(1)?,
2519 r.get::<_, String>(2)?,
2520 r.get::<_, i64>(3)?,
2521 r.get::<_, Option<String>>(4)?,
2522 r.get::<_, i64>(5)?,
2523 r.get::<_, i64>(6)?,
2524 r.get::<_, i64>(7)?,
2525 r.get::<_, Option<String>>(8)?,
2526 r.get::<_, Option<String>>(9)?,
2527 r.get::<_, Option<String>>(10)?,
2528 r.get::<_, Option<String>>(11)?,
2529 r.get::<_, Option<String>>(12)?,
2530 r.get::<_, Option<String>>(13)?,
2531 r.get::<_, Option<Vec<u8>>>(14)?,
2532 ))
2533 },
2534 )
2535 .optional()
2536 .map_err(|e| e.to_string())?;
2537 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, control_pk_e, control_root_b)) = row
2538 else {
2539 return Ok(None);
2540 };
2541 if crate::community::ConcordProtocol::from_i64(protocol) != crate::community::ConcordProtocol::V2 {
2542 return Ok(None);
2543 }
2544 let (Some(owner_pk_e), Some(owner_salt_e)) = (owner_pk_e, owner_salt_e) else {
2545 return Err("v2 community row is missing its owner commitment".to_string());
2546 };
2547
2548 let community_root = dec_key(&root_blob)?;
2549 let owner_xonly = parse_hex32(&dec_txt(&owner_pk_e))?;
2550 let owner_salt = parse_hex32(&dec_txt(&owner_salt_e))?;
2551 let identity = CommunityIdentity { community_id: *id, owner_xonly, owner_salt };
2552 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_e)).unwrap_or_default();
2553
2554 let mut channels = Vec::new();
2555 {
2556 let mut stmt = conn
2557 .prepare(
2558 "SELECT channel_id, channel_key, epoch, name, private, meta_extra
2559 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
2560 )
2561 .map_err(|e| e.to_string())?;
2562 let rows = stmt
2563 .query_map(params![id_hex], |r| {
2564 Ok((
2565 r.get::<_, String>(0)?,
2566 r.get::<_, Vec<u8>>(1)?,
2567 r.get::<_, i64>(2)?,
2568 r.get::<_, String>(3)?,
2569 r.get::<_, i64>(4)?,
2570 r.get::<_, Option<String>>(5)?,
2571 ))
2572 })
2573 .map_err(|e| e.to_string())?;
2574 for row in rows {
2575 let (ch_hex, key_blob, epoch, name_e, private, ch_stash_e) = row.map_err(|e| e.to_string())?;
2576 let private = private != 0;
2577 let key = dec_key(&key_blob)?;
2578 let ch_stash: ChannelMetaStash = ch_stash_e
2581 .map(|s| dec_txt(&s))
2582 .and_then(|j| serde_json::from_str(&j).ok())
2583 .unwrap_or_default();
2584 channels.push(ChannelV2 {
2585 id: ChannelId(hex_id_to_32(&ch_hex)?),
2586 name: dec_txt(&name_e),
2587 private,
2588 key: (private && key != community_root).then_some(key),
2595 epoch: Epoch(epoch as u64),
2596 voice: ch_stash.voice,
2597 meta_custom: ch_stash.custom,
2598 meta_extra: ch_stash.extra,
2599 });
2600 }
2601 }
2602
2603 let icon = icon_e
2606 .map(|s| dec_txt(&s))
2607 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2608 let banner = banner_e
2609 .map(|s| dec_txt(&s))
2610 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2611 let stash: CommunityMetaStash = stash_e
2612 .map(|s| dec_txt(&s))
2613 .and_then(|j| serde_json::from_str(&j).ok())
2614 .unwrap_or_default();
2615
2616 let control_pk = control_pk_e
2621 .as_deref()
2622 .and_then(|e| nostr_sdk::prelude::PublicKey::from_hex(&dec_txt(e)).ok());
2623 let control_root = match (control_pk, control_root_b) {
2624 (Some(pk), Some(blob)) => dec_key(&blob).ok().filter(|cr| {
2625 crate::community::v2::derive::control_signer_group_key(cr, id, Epoch(root_epoch as u64)).pk() == pk
2626 }),
2627 _ => None,
2628 };
2629
2630 Ok(Some(CommunityV2 {
2631 identity,
2632 community_root,
2633 root_epoch: Epoch(root_epoch as u64),
2634 control_pk,
2635 control_root,
2636 name: dec_txt(&name_e),
2637 description: desc_e.map(|d| dec_txt(&d)),
2638 icon,
2639 banner,
2640 meta_custom: stash.custom,
2641 meta_extra: stash.extra,
2642 relays,
2643 channels,
2644 dissolved: dissolved != 0,
2645 created_at_ms: (created as u64).saturating_mul(1000),
2646 }))
2647}
2648
2649fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
2650 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
2651 return Err("stored value is not 32-byte hex".to_string());
2652 }
2653 Ok(crate::simd::hex::hex_to_bytes_32(hex))
2654}
2655
2656pub fn get_guestbook(community_id: &str) -> Result<(Vec<crate::community::v2::guestbook::GuestbookEvent>, u64), String> {
2660 let conn = super::get_db_connection_guard_static()?;
2661 let row = conn
2662 .query_row(
2663 "SELECT events, cursor_secs FROM community_guestbook WHERE community_id = ?1",
2664 params![community_id],
2665 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
2666 )
2667 .optional()
2668 .map_err(|e| format!("load guestbook: {e}"))?;
2669 let Some((events_e, cursor)) = row else {
2670 return Ok((Vec::new(), 0));
2671 };
2672 let events = serde_json::from_str(&dec_txt(&events_e)).unwrap_or_default();
2673 Ok((events, cursor.max(0) as u64))
2674}
2675
2676pub fn set_guestbook(
2679 community_id: &str,
2680 events: &[crate::community::v2::guestbook::GuestbookEvent],
2681 cursor_secs: u64,
2682) -> Result<(), String> {
2683 let conn = super::get_write_connection_guard_static()?;
2684 let json = serde_json::to_string(events).map_err(|e| e.to_string())?;
2685 let enc = enc_txt(&json)?;
2686 conn.execute(
2687 "INSERT INTO community_guestbook (community_id, events, cursor_secs)
2688 VALUES (?1, ?2, ?3)
2689 ON CONFLICT(community_id) DO UPDATE SET events=?2, cursor_secs=?3",
2690 params![community_id, enc, cursor_secs as i64],
2691 )
2692 .map_err(|e| format!("save guestbook: {e}"))?;
2693 Ok(())
2694}
2695
2696#[cfg(test)]
2697mod tests {
2698 use nostr_sdk::prelude::FinalizeEvent;
2699 use super::*;
2700
2701 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
2702
2703 fn make_test_npub(n: u32) -> String {
2706 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
2707 let mut payload = vec![b'q'; 58];
2708 let mut x = n as u64;
2709 let mut i = 58;
2710 while x > 0 && i > 0 {
2711 i -= 1;
2712 payload[i] = BECH32[(x as usize) % 32];
2713 x /= 32;
2714 }
2715 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
2716 }
2717
2718 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2719 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2720 crate::db::close_database();
2721 crate::db::clear_id_caches();
2724 let tmp = tempfile::tempdir().unwrap();
2725 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2726 let account = make_test_npub(n);
2727 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2728 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
2729 crate::db::set_current_account(account.clone()).unwrap();
2730 crate::db::init_database(&account).unwrap();
2731 (tmp, guard)
2732 }
2733
2734 #[test]
2735 fn edition_head_round_trips_and_upserts() {
2736 let (_tmp, _guard) = init_test_db();
2737 let cid = "f".repeat(64);
2738 let entity = "a".repeat(64);
2739
2740 assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
2742
2743 let h1 = [0x11u8; 32];
2745 set_edition_head(&cid, &entity, 1, &h1).unwrap();
2746 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
2747
2748 let h2 = [0x22u8; 32];
2750 set_edition_head(&cid, &entity, 2, &h2).unwrap();
2751 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
2752
2753 set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
2756 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
2757 set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
2758 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
2759
2760 let other = "b".repeat(64);
2762 assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
2763 }
2764
2765 #[test]
2766 fn guestbook_round_trips_events_and_cursor() {
2767 let (_tmp, _guard) = init_test_db();
2768 let member = nostr_sdk::prelude::Keys::generate();
2769 let ev = crate::community::v2::guestbook::GuestbookEvent {
2770 rumor_id: [7u8; 32],
2771 entry: crate::community::v2::guestbook::GuestbookEntry::Join {
2772 member: member.public_key(),
2773 invited_by: Some(("creator".into(), "label".into())),
2774 at_ms: 1_000,
2775 },
2776 };
2777 let cid = "d".repeat(64);
2778 assert_eq!(get_guestbook(&cid).unwrap(), (Vec::new(), 0), "absent reads as empty at cursor 0");
2779 set_guestbook(&cid, std::slice::from_ref(&ev), 42).unwrap();
2780 let (events, cursor) = get_guestbook(&cid).unwrap();
2781 assert_eq!(events, vec![ev], "events round-trip through the encrypted blob");
2782 assert_eq!(cursor, 42);
2783 }
2784
2785 #[test]
2786 fn v2_images_round_trip_and_read_as_v1_community_images() {
2787 let (_tmp, _guard) = init_test_db();
2788 let owner = nostr_sdk::prelude::Keys::generate();
2789 let g = crate::community::v2::control::genesis(
2790 &owner,
2791 crate::community::v2::control::CommunityMetadata { name: "Icons".into(), ..Default::default() },
2792 1_000,
2793 )
2794 .unwrap();
2795 let mut c = crate::community::v2::community::CommunityV2::from_genesis(&g, "Icons", None, vec!["wss://r".into()], 1_000);
2796 let mut extra = serde_json::Map::new();
2797 extra.insert("ext".into(), serde_json::Value::String("webp".into()));
2798 c.icon = Some(crate::community::v2::control::ImageRef {
2799 url: "https://blossom.example/abc".into(),
2800 key: "0".repeat(64),
2801 nonce: "1".repeat(32),
2802 hash: "a".repeat(64),
2803 extra,
2804 });
2805 c.meta_custom = Some({
2806 let mut m = serde_json::Map::new();
2807 m.insert("k".into(), serde_json::Value::from("v"));
2808 m
2809 });
2810 c.channels[0].voice = Some(true);
2811 c.channels[0].meta_extra.insert("vnd".into(), serde_json::Value::from(7));
2812 save_community_v2(&c).unwrap();
2813
2814 let re = load_community_v2(c.id()).unwrap().unwrap();
2816 assert_eq!(re.icon, c.icon);
2817 assert_eq!(re.banner, None);
2818 assert_eq!(re.meta_custom, c.meta_custom);
2820 assert_eq!(re.channels[0].voice, Some(true));
2821 assert_eq!(re.channels[0].meta_extra.get("vnd"), Some(&serde_json::Value::from(7)));
2822
2823 let v1 = load_community(c.id()).unwrap().unwrap();
2827 let img = v1.icon.expect("v1 reader sees the v2 icon");
2828 assert_eq!(img.url, "https://blossom.example/abc");
2829 assert_eq!(img.ext, "webp");
2830 assert_eq!(img.hash, "a".repeat(64));
2831 }
2832
2833 #[test]
2834 fn server_root_epoch_round_trips() {
2835 let (_tmp, _guard) = init_test_db();
2837 let mut c = Community::create("HQ", "general", vec![]);
2838 save_community(&c).unwrap();
2839 assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
2840
2841 c.server_root_epoch = Epoch(5);
2842 c.server_root_key = ServerRootKey([0x42u8; 32]);
2843 save_community(&c).unwrap();
2844 let loaded = load_community(&c.id).unwrap().unwrap();
2845 assert_eq!(loaded.server_root_epoch, Epoch(5));
2846 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2847 }
2848
2849 #[test]
2850 fn epoch_key_archive_retains_every_epoch() {
2851 let (_tmp, _guard) = init_test_db();
2854 let cid = "f".repeat(64);
2855 let scope = "a".repeat(64);
2856
2857 store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
2858 store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
2859 store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
2860
2861 let held = held_epoch_keys(&cid, &scope).unwrap();
2862 assert_eq!(held.len(), 3, "all three epoch keys retained");
2863 assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
2864 assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
2865 assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
2866
2867 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
2869 assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
2870
2871 store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
2873 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
2874 assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
2875
2876 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2879 assert_eq!(
2880 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
2881 None,
2882 "epoch 1 under a different scope is not the channel's key"
2883 );
2884 }
2885
2886 #[test]
2887 fn save_community_populates_the_epoch_archive() {
2888 let (_tmp, _guard) = init_test_db();
2891 let c = Community::create("HQ", "general", vec![]);
2892 save_community(&c).unwrap();
2893 let cid = c.id.to_hex();
2894
2895 assert_eq!(
2897 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
2898 Some(c.server_root_key.as_bytes())
2899 );
2900 let chan = &c.channels[0];
2902 assert_eq!(
2903 held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
2904 Some(chan.key.as_bytes())
2905 );
2906 }
2907
2908 #[test]
2909 fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
2910 let (_tmp, _guard) = init_test_db();
2911 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2914 crate::state::set_encryption_enabled(true);
2915
2916 let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
2917 c.server_root_key = ServerRootKey([0x42u8; 32]);
2918 c.description = Some("top secret".into());
2919 save_community(&c).unwrap();
2920 let cid = c.id.to_hex();
2921 set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
2922
2923 {
2926 let conn = crate::db::get_db_connection_guard_static().unwrap();
2927 let (root_len, name, banlist): (i64, String, String) = conn
2928 .query_row(
2929 "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
2930 params![cid],
2931 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
2932 )
2933 .unwrap();
2934 assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
2935 assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
2936 assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
2937 assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext");
2938 let key_len: i64 = conn
2939 .query_row(
2940 "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
2941 params![cid],
2942 |r| r.get(0),
2943 )
2944 .unwrap();
2945 assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
2946 }
2947
2948 let loaded = load_community(&c.id).unwrap().unwrap();
2950 assert_eq!(loaded.name, "Secret HQ");
2951 assert_eq!(loaded.description.as_deref(), Some("top secret"));
2952 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2953 assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
2954 assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
2955
2956 crate::state::set_encryption_enabled(false);
2957 crate::state::ENCRYPTION_KEY.clear(&[]);
2958 }
2959
2960 #[test]
2961 fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
2962 let (_tmp, _guard) = init_test_db();
2965 crate::state::set_encryption_enabled(false);
2966 let mut c = Community::create("Legacy HQ", "general", vec![]);
2967 c.server_root_key = ServerRootKey([0x42u8; 32]);
2968 save_community(&c).unwrap();
2969
2970 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2971 crate::state::set_encryption_enabled(true);
2972 let loaded = load_community(&c.id).unwrap().unwrap();
2973 assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
2974 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
2975
2976 crate::state::set_encryption_enabled(false);
2977 crate::state::ENCRYPTION_KEY.clear(&[]);
2978 }
2979
2980 #[test]
2981 fn save_and_load_round_trip() {
2982 let (_tmp, _guard) = init_test_db();
2983 let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
2984 save_community(&original).unwrap();
2985
2986 let loaded = load_community(&original.id).unwrap().expect("present");
2987 assert_eq!(loaded.id, original.id);
2988 assert_eq!(loaded.name, "Vector HQ");
2989 assert_eq!(loaded.relays, original.relays);
2990 assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
2992 assert_eq!(loaded.channels.len(), 1);
2994 assert_eq!(loaded.channels[0].id, original.channels[0].id);
2995 assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
2996 assert_eq!(loaded.channels[0].epoch, Epoch(0));
2997 assert_eq!(loaded.channels[0].name, "general");
2998 }
2999
3000 #[test]
3001 fn owner_is_protected_from_the_banlist_a_member_is_not() {
3002 let (_tmp, _guard) = init_test_db();
3003 let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
3004 let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
3006 community.owner_attestation = Some(
3007 crate::community::owner::build_owner_attestation_unsigned(
3008 owner_id.public_key(),
3009 &community.id.to_hex(),
3010 )
3011 .finalize(&owner_id)
3012 .unwrap()
3013 .as_json(),
3014 );
3015 save_community(&community).unwrap();
3016
3017 let member = Keys::generate();
3019 set_community_banlist(
3020 &community.id.to_hex(),
3021 &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
3022 1,
3023 )
3024 .unwrap();
3025
3026 let loaded = load_community(&community.id).unwrap().unwrap();
3027 let ch = &loaded.channels[0];
3028 assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
3030 assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
3031 assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
3033 }
3034
3035 #[test]
3036 fn loaded_keys_actually_decrypt() {
3037 let (_tmp, _guard) = init_test_db();
3040 let original = Community::create("HQ", "general", vec![]);
3041 save_community(&original).unwrap();
3042 let loaded = load_community(&original.id).unwrap().unwrap();
3043
3044 let author = nostr_sdk::prelude::Keys::generate();
3045 let chan = &original.channels[0];
3046 let sealed = crate::community::envelope::seal_message(
3047 &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
3048 )
3049 .unwrap();
3050 let opened = crate::community::envelope::open_message(
3051 &sealed,
3052 &loaded.channels[0].key,
3053 &loaded.channels[0].id,
3054 loaded.channels[0].epoch,
3055 )
3056 .unwrap();
3057 assert_eq!(opened.content, "persisted!");
3058 }
3059
3060 #[test]
3061 fn member_view_round_trips() {
3062 let (_tmp, _guard) = init_test_db();
3065 let member = Community {
3066 id: CommunityId([7u8; 32]),
3067 server_root_key: ServerRootKey([8u8; 32]),
3068 server_root_epoch: Epoch(0),
3069 name: "Joined".into(),
3070 description: None,
3071 icon: None,
3072 banner: None,
3073 relays: vec!["wss://r".into()],
3074 channels: vec![Channel {
3075 id: ChannelId([9u8; 32]),
3076 key: ChannelKey([10u8; 32]),
3077 epoch: Epoch(0),
3078 name: "general".into(),
3079 banned: Vec::new(),
3080 protected: Vec::new(), roster: Default::default(),
3081 epoch_keys: Vec::new(),
3082 dissolved: false,
3083 }],
3084 owner_attestation: None,
3085 dissolved: false,
3086 };
3087 save_community(&member).unwrap();
3088 let loaded = load_community(&member.id).unwrap().expect("present");
3089 assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
3090 assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
3091 }
3092
3093 #[test]
3094 fn large_epoch_round_trips_losslessly() {
3095 let (_tmp, _guard) = init_test_db();
3097 let mut c = Community::create("HQ", "g", vec![]);
3098 c.channels[0].epoch = Epoch(u64::MAX - 7);
3099 save_community(&c).unwrap();
3100 let loaded = load_community(&c.id).unwrap().unwrap();
3101 assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
3102 }
3103
3104 #[test]
3105 fn malformed_channel_id_row_errors_not_corrupts() {
3106 let (_tmp, _guard) = init_test_db();
3109 let c = Community::create("HQ", "g", vec![]);
3110 save_community(&c).unwrap();
3111 {
3112 let conn = crate::db::get_write_connection_guard_static().unwrap();
3113 conn.execute(
3114 "INSERT OR REPLACE INTO community_channels
3115 (channel_id, community_id, channel_key, epoch, name, created_at)
3116 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
3117 rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
3118 )
3119 .unwrap();
3120 }
3121 assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
3122 }
3123
3124 #[test]
3125 fn message_key_store_take_round_trip() {
3126 let (_tmp, _guard) = init_test_db();
3127 let eph = Keys::generate();
3128 let relays = vec!["wss://r.one".to_string()];
3129 store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
3131
3132 let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
3133 assert_eq!(
3134 loaded.secret_key().as_secret_bytes(),
3135 eph.secret_key().as_secret_bytes()
3136 );
3137 assert_eq!(outer, "outer_evid");
3138 assert_eq!(r, relays);
3139 assert!(take_message_key("inner_msg_id").unwrap().is_none());
3141 }
3142
3143 #[test]
3144 fn missing_community_is_none() {
3145 let (_tmp, _guard) = init_test_db();
3146 let absent = CommunityId([0x33u8; 32]);
3147 assert!(load_community(&absent).unwrap().is_none());
3148 }
3149
3150 #[test]
3151 fn list_ids_reflects_saved() {
3152 let (_tmp, _guard) = init_test_db();
3153 let a = Community::create("A", "g", vec![]);
3154 let b = Community::create("B", "g", vec![]);
3155 save_community(&a).unwrap();
3156 save_community(&b).unwrap();
3157 let ids = list_community_ids().unwrap();
3158 assert_eq!(ids.len(), 2);
3159 assert!(ids.contains(&a.id) && ids.contains(&b.id));
3160 }
3161
3162 #[test]
3163 fn delete_community_clears_all_local_state() {
3164 let (_tmp, _guard) = init_test_db();
3165 let c = Community::create("HQ", "general", vec!["r1".into()]);
3166 save_community(&c).unwrap();
3167 let cid = c.id.to_hex();
3168 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
3169 save_pending_invite(&"cd".repeat(32), "{}", "npub1x", 0).unwrap();
3170 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
3171
3172 assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
3174
3175 delete_community(&cid).unwrap();
3176 assert!(!community_exists(&c.id).unwrap());
3177 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
3178 assert!(list_public_invites(&cid).unwrap().is_empty());
3179 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
3180 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
3181 }
3182
3183 #[test]
3184 fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
3185 let (_tmp, _guard) = init_test_db();
3188 let c = Community::create("HQ", "general", vec!["r1".into()]);
3189 save_community(&c).unwrap();
3190 let cid = c.id.to_hex();
3191 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
3192 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
3193
3194 let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
3195 let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
3196 assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
3197
3198 delete_community_retain_keys(&cid).unwrap();
3199
3200 assert!(!community_exists(&c.id).unwrap());
3202 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
3203 assert!(list_public_invites(&cid).unwrap().is_empty());
3204 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
3205 assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
3207 "base epoch keys retained for self-scrub");
3208 assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
3209 "channel epoch keys retained for self-scrub");
3210 }
3211
3212 #[test]
3213 fn channel_resolves_to_owning_community() {
3214 let (_tmp, _guard) = init_test_db();
3215 let c = Community::create("HQ", "general", vec![]);
3216 save_community(&c).unwrap();
3217 let chan = c.channels[0].id.to_hex();
3218 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
3219 assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
3220 }
3221
3222 #[test]
3223 fn community_exists_reflects_saved() {
3224 let (_tmp, _guard) = init_test_db();
3225 let c = Community::create("A", "g", vec![]);
3226 assert!(!community_exists(&c.id).unwrap());
3227 save_community(&c).unwrap();
3228 assert!(community_exists(&c.id).unwrap());
3229 }
3230
3231 #[test]
3232 fn reparent_moves_channels_stamps_fence_and_invalidates_cache() {
3233 let (_tmp, _guard) = init_test_db();
3234 let v1 = Community::create("HQ", "general", vec![]);
3235 save_community(&v1).unwrap();
3236 let v1_cid = v1.id.to_hex();
3237 let v2_cid = "ab".repeat(32);
3238 let chan = v1.channels[0].id.to_hex();
3239
3240 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v1_cid.as_str()));
3242 reparent_channels_and_fence(&v1_cid, &v2_cid).unwrap();
3243
3244 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v2_cid.as_str()),
3246 "stale v1 cache entry must not survive the re-parent");
3247 assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()));
3248 assert!(get_community_dissolved(&v1_cid).unwrap(), "flip seals v1 (fence layer 0)");
3249
3250 reparent_channels_and_fence(&v1_cid, &"cd".repeat(32)).unwrap();
3252 assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()),
3253 "migrated_to is one-way — a second flip cannot repoint it");
3254 }
3255
3256 #[test]
3257 fn migration_sweep_candidates_include_unsealed_v1() {
3258 let (_tmp, _guard) = init_test_db();
3259 let a = Community::create("A", "g", vec![]);
3260 let b = Community::create("B", "g", vec![]);
3261 let c = Community::create("C", "g", vec![]);
3262 for x in [&a, &b, &c] { save_community(x).unwrap(); }
3263 set_community_dissolved(&a.id.to_hex()).unwrap();
3264 set_community_dissolved(&b.id.to_hex()).unwrap();
3265 set_migrated_to(&b.id.to_hex(), &"ab".repeat(32)).unwrap();
3266 let cands = migration_sweep_candidates().unwrap();
3267 assert!(cands.contains(&a.id.to_hex()), "sealed + unchecked is a candidate");
3268 assert!(!cands.contains(&b.id.to_hex()), "flipped is not a candidate");
3269 assert!(cands.contains(&c.id.to_hex()), "UNSEALED v1 is a candidate too");
3273 set_migration_checked(&a.id.to_hex()).unwrap();
3275 set_migration_checked(&c.id.to_hex()).unwrap();
3276 let after = migration_sweep_candidates().unwrap();
3277 assert!(!after.contains(&a.id.to_hex()) && !after.contains(&c.id.to_hex()));
3278 }
3279
3280 #[test]
3281 fn pending_invite_first_wins_and_round_trips() {
3282 let (_tmp, _guard) = init_test_db();
3283 let cid = "ab".repeat(32);
3284 assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter", 0).unwrap());
3287 assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other", 0).unwrap());
3288 assert!(pending_invite_exists(&cid).unwrap());
3289
3290 let listed = list_pending_invites().unwrap();
3291 assert_eq!(listed.len(), 1);
3292 assert_eq!(listed[0].community_id, cid);
3293 assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
3294 assert_eq!(listed[0].inviter_npub, "npub1inviter");
3295
3296 assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
3298 assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
3299 delete_pending_invite(&cid).unwrap();
3300 assert!(!pending_invite_exists(&cid).unwrap());
3301 assert!(get_pending_invite(&cid).unwrap().is_none());
3302 }
3303
3304 #[test]
3305 fn purge_drops_invites_for_held_communities_only() {
3306 let (_tmp, _guard) = init_test_db();
3307 let held = Community::create("Held", "general", vec![]);
3310 save_community(&held).unwrap();
3311 let held_hex = held.id.to_hex();
3312 save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter", 0).unwrap();
3313 let stranger = "ab".repeat(32);
3315 save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter", 0).unwrap();
3316
3317 let n = purge_pending_invites_for_held_communities().unwrap();
3318 assert_eq!(n, 1, "only the held community's invite is purged");
3319 assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
3320 assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
3321 }
3322
3323 #[test]
3324 fn decline_drops_pending_invite() {
3325 let (_tmp, _guard) = init_test_db();
3326 let cid = "cd".repeat(32);
3327 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3328 delete_pending_invite(&cid).unwrap();
3329 assert!(!pending_invite_exists(&cid).unwrap());
3330 }
3331
3332 #[test]
3333 fn pending_invites_are_capped_keeping_the_newest() {
3334 let (_tmp, _guard) = init_test_db();
3335 for i in 0..150u32 {
3340 let cid = format!("{:064x}", i);
3341 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3342 }
3343 let all = list_pending_invites().unwrap();
3344 assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
3345 for i in 150..400u32 {
3347 let cid = format!("{:064x}", i);
3348 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3349 }
3350 assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
3351 }
3352
3353 #[test]
3358 fn expired_parked_invites_are_hidden_from_list_and_accept() {
3359 let (_tmp, _guard) = init_test_db();
3360 let now = now_secs();
3361 let live = "aa".repeat(32);
3362 let expired = "bb".repeat(32);
3363 let permanent = "cc".repeat(32);
3364
3365 save_pending_invite(&live, "{\"live\":1}", "npub1x", now + 3600).unwrap();
3366 save_pending_invite(&expired, "{\"dead\":1}", "npub1x", now - 1).unwrap();
3367 save_pending_invite(&permanent, "{\"forever\":1}", "npub1x", 0).unwrap();
3370
3371 let listed: Vec<String> = list_pending_invites().unwrap().into_iter().map(|i| i.community_id).collect();
3372 assert!(listed.contains(&live), "an unexpired invite still lists");
3373 assert!(listed.contains(&permanent), "a no-deadline invite still lists");
3374 assert!(!listed.contains(&expired), "an expired invite is hidden from the list");
3375
3376 assert!(get_pending_invite(&live).unwrap().is_some());
3377 assert!(get_pending_invite(&permanent).unwrap().is_some());
3378 assert!(
3379 get_pending_invite(&expired).unwrap().is_none(),
3380 "an expired invite must not be redeemable"
3381 );
3382
3383 assert!(pending_invite_exists(&expired).unwrap(), "hidden, not yet deleted");
3385 assert_eq!(purge_expired_pending_invites().unwrap(), 1);
3386 assert!(!pending_invite_exists(&expired).unwrap());
3387 assert!(pending_invite_exists(&live).unwrap(), "the sweep spares live invites");
3388 assert!(pending_invite_exists(&permanent).unwrap(), "and no-deadline ones");
3389 }
3390
3391 #[test]
3394 fn expiry_follows_the_senders_deadline_not_receipt_time() {
3395 let (_tmp, _guard) = init_test_db();
3396 let cid = "de".repeat(32);
3397 save_pending_invite(&cid, "{}", "npub1x", now_secs() - 10).unwrap();
3400 assert!(list_pending_invites().unwrap().is_empty(), "receipt time does not extend the deadline");
3401 assert!(get_pending_invite(&cid).unwrap().is_none());
3402 }
3403}
3404
3405pub fn set_community_pins(community_id: &str, channel_id: &str, content: &str, version: i64) -> Result<bool, String> {
3417 let enc = enc_txt(content)?;
3418 let conn = super::get_write_connection_guard_static()?;
3419 let changed = conn
3420 .execute(
3421 "INSERT INTO community_pins (community_id, channel_id, content, version) VALUES (?1, ?2, ?3, ?4)
3422 ON CONFLICT(community_id, channel_id) DO UPDATE SET
3423 content = excluded.content, version = excluded.version
3424 WHERE excluded.version >= community_pins.version",
3425 params![community_id, channel_id, enc, version],
3426 )
3427 .map_err(|e| format!("set pins: {e}"))?;
3428 Ok(changed > 0)
3429}
3430
3431pub fn get_community_pins(community_id: &str, channel_id: &str) -> Result<Option<(String, i64)>, String> {
3434 let conn = super::get_db_connection_guard_static()?;
3435 let row: Option<(String, i64)> = conn
3436 .query_row(
3437 "SELECT content, version FROM community_pins WHERE community_id = ?1 AND channel_id = ?2",
3438 params![community_id, channel_id],
3439 |r| Ok((r.get(0)?, r.get(1)?)),
3440 )
3441 .optional()
3442 .map_err(|e| format!("get pins: {e}"))?;
3443 Ok(row.map(|(content, version)| (dec_txt(&content), version)))
3444}