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 id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
590 let mut banned_hex: Vec<String> = conn
591 .prepare("SELECT npub FROM community_bans WHERE community_id = ?1")
592 .and_then(|mut st| {
593 st.query_map(params![id_hex], |r| r.get::<_, String>(0))
594 .map(|rows| rows.flatten().collect::<Vec<String>>())
595 })
596 .unwrap_or_default();
597 if banned_hex.is_empty() {
598 banned_hex = serde_json::from_str::<Vec<String>>(&banlist_json).unwrap_or_default();
599 }
600 let banned: Vec<PublicKey> = banned_hex
601 .iter()
602 .filter_map(|h| PublicKey::parse(h).ok())
604 .collect();
605
606 let icon = icon_json
607 .map(|j| serde_json::from_str(&j))
608 .transpose()
609 .map_err(|e| format!("icon json: {e}"))?;
610 let banner = banner_json
611 .map(|j| serde_json::from_str(&j))
612 .transpose()
613 .map_err(|e| format!("banner json: {e}"))?;
614
615 let server_root_key = ServerRootKey(dec_key(&root_blob)?);
616 let relays: Vec<String> = serde_json::from_str(&relays_json).map_err(|e| e.to_string())?;
617
618 let mut protected: Vec<PublicKey> = Vec::new();
625 if let Some(owner) = owner_attestation
626 .as_ref()
627 .and_then(|att| crate::community::owner::verify_owner_attestation(att, &id_hex))
628 {
629 protected.push(owner);
630 }
631 let banned: Vec<PublicKey> = banned.into_iter().filter(|pk| !protected.contains(pk)).collect();
632
633 let raw_channels: Vec<(String, Vec<u8>, i64, String)> = {
636 let mut stmt = conn
637 .prepare(
638 "SELECT channel_id, channel_key, epoch, name
639 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
640 )
641 .map_err(|e| e.to_string())?;
642 let rows = stmt
643 .query_map(params![id_hex], |r| {
644 Ok((
645 r.get::<_, String>(0)?,
646 r.get::<_, Vec<u8>>(1)?,
647 r.get::<_, i64>(2)?,
648 r.get::<_, String>(3)?,
649 ))
650 })
651 .map_err(|e| e.to_string())?;
652 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
653 };
654
655 let roster = get_community_roles(&id_hex).unwrap_or_default();
658
659 let mut channels = Vec::new();
660 for (cid_hex, key_blob, epoch, cname) in raw_channels {
661 let epoch_keys: Vec<(Epoch, crate::community::ChannelKey)> = {
665 let mut ek_stmt = conn
666 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
667 .map_err(|e| e.to_string())?;
668 let rows = ek_stmt
669 .query_map(params![id_hex, cid_hex], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
670 .map_err(|e| e.to_string())?;
671 let mut out = Vec::new();
672 for row in rows {
673 let (e, blob) = row.map_err(|e| e.to_string())?;
674 if let Ok(k) = dec_key(&blob) {
675 out.push((Epoch(e as u64), crate::community::ChannelKey(k)));
676 }
677 }
678 out
679 };
680 channels.push(Channel {
681 id: ChannelId(hex_id_to_32(&cid_hex)?),
682 key: ChannelKey(dec_key(&key_blob)?),
683 epoch: Epoch(epoch as u64),
686 name: dec_txt(&cname),
687 banned: banned.clone(),
688 protected: protected.clone(),
689 roster: roster.clone(),
690 epoch_keys,
691 dissolved,
692 });
693 }
694
695 Ok(Some(Community {
696 id: *id,
697 server_root_key,
698 server_root_epoch: Epoch(server_root_epoch as u64),
700 name,
701 description,
702 icon,
703 banner,
704 relays,
705 channels,
706 owner_attestation,
707 dissolved,
708 }))
709}
710
711pub fn store_message_key(
714 message_id: &str,
715 outer_event_id: &str,
716 ephemeral: &Keys,
717 relays: &[String],
718) -> Result<(), String> {
719 let conn = super::get_write_connection_guard_static()?;
720 let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?;
721 let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?;
722 let enc_secret = enc_key(&sk_bytes)?;
723 let enc_relays = enc_txt(&relays_json)?;
724 conn.execute(
725 "INSERT OR REPLACE INTO community_message_keys
726 (outer_event_id, message_id, ephemeral_secret, relays, created_at)
727 VALUES (?1, ?2, ?3, ?4, ?5)",
728 params![
729 outer_event_id,
730 message_id,
731 &enc_secret[..],
732 enc_relays,
733 now_secs(),
734 ],
735 )
736 .map_err(|e| format!("store message key: {e}"))?;
737 Ok(())
738}
739
740pub fn get_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
746 let conn = super::get_db_connection_guard_static()?;
747 let row = conn
748 .query_row(
749 "SELECT ephemeral_secret, outer_event_id, relays
750 FROM community_message_keys WHERE message_id = ?1",
751 params![message_id],
752 |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)),
753 )
754 .optional()
755 .map_err(|e| format!("get message key: {e}"))?;
756 let (secret_blob, outer_event_id, relays_json) = match row {
757 Some(t) => t,
758 None => return Ok(None),
759 };
760 let secret = SecretKey::from_slice(&dec_key(&secret_blob)?).map_err(|e| format!("ephemeral secret: {e}"))?;
761 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_json)).map_err(|e| e.to_string())?;
762 Ok(Some((Keys::new(secret), outer_event_id, relays)))
763}
764
765pub fn delete_message_key(message_id: &str) -> Result<(), String> {
767 let conn = super::get_write_connection_guard_static()?;
768 conn.execute(
769 "DELETE FROM community_message_keys WHERE message_id = ?1",
770 params![message_id],
771 )
772 .map_err(|e| format!("remove message key: {e}"))?;
773 Ok(())
774}
775
776pub fn take_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
779 let r = get_message_key(message_id)?;
780 if r.is_some() {
781 delete_message_key(message_id)?;
782 }
783 Ok(r)
784}
785
786static CHANNEL_COMMUNITY_CACHE: std::sync::LazyLock<
798 std::sync::RwLock<std::collections::HashMap<String, String>>,
799> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
800
801pub fn clear_channel_community_cache() {
803 CHANNEL_COMMUNITY_CACHE.write().unwrap().clear();
804}
805
806fn forget_community_channels(community_id: &str) {
809 CHANNEL_COMMUNITY_CACHE.write().unwrap().retain(|_, cid| cid != community_id);
810}
811
812pub fn community_id_for_channel(channel_id: &str) -> Result<Option<String>, String> {
813 if let Some(cid) = CHANNEL_COMMUNITY_CACHE.read().unwrap().get(channel_id) {
814 return Ok(Some(cid.clone()));
815 }
816 let conn = super::get_db_connection_guard_static()?;
817 let cid: Option<String> = conn
818 .query_row(
819 "SELECT community_id FROM community_channels WHERE channel_id = ?1",
820 params![channel_id],
821 |r| r.get::<_, String>(0),
822 )
823 .optional()
824 .map_err(|e| format!("community_id_for_channel: {e}"))?;
825 if let Some(ref c) = cid {
826 CHANNEL_COMMUNITY_CACHE.write().unwrap().insert(channel_id.to_string(), c.clone());
827 }
828 Ok(cid)
829}
830
831pub fn community_exists(id: &CommunityId) -> Result<bool, String> {
834 let conn = super::get_db_connection_guard_static()?;
835 let found: Option<i64> = conn
836 .query_row(
837 "SELECT 1 FROM communities WHERE community_id = ?1",
838 params![id.to_hex()],
839 |r| r.get(0),
840 )
841 .optional()
842 .map_err(|e| format!("community_exists: {e}"))?;
843 Ok(found.is_some())
844}
845
846#[derive(Debug, Clone, serde::Serialize)]
848pub struct PendingCommunityInvite {
849 pub community_id: String,
850 pub bundle_json: String,
851 pub inviter_npub: String,
852 pub received_at: i64,
853 pub expires_at: i64,
855}
856
857pub fn save_pending_invite(
862 community_id: &str,
863 bundle_json: &str,
864 inviter_npub: &str,
865 expires_at: i64,
866) -> Result<bool, String> {
867 const MAX_PENDING_INVITES: usize = 100;
871
872 let conn = super::get_write_connection_guard_static()?;
873 let enc_bundle = enc_txt(bundle_json)?;
874 let enc_inviter = enc_txt(inviter_npub)?;
875 let changed = conn
880 .execute(
881 "INSERT OR IGNORE INTO pending_community_invites
882 (community_id, bundle_json, inviter_npub, received_at, expires_at)
883 VALUES (?1, ?2, ?3, ?4, ?5)",
884 params![community_id, enc_bundle, enc_inviter, now_secs(), expires_at],
885 )
886 .map_err(|e| format!("save pending invite: {e}"))?;
887 if changed > 0 {
890 let _ = conn.execute(
891 "DELETE FROM pending_community_invites
892 WHERE community_id IN (
893 SELECT community_id FROM pending_community_invites
894 ORDER BY received_at DESC, community_id DESC
895 LIMIT -1 OFFSET ?1
896 )",
897 params![MAX_PENDING_INVITES],
898 );
899 }
900 Ok(changed > 0)
901}
902
903pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
909 let conn = super::get_write_connection_guard_static()?;
910 let n = conn
911 .execute(
912 "DELETE FROM pending_community_invites
913 WHERE community_id IN (SELECT community_id FROM communities)",
914 [],
915 )
916 .map_err(|e| format!("purge held pending invites: {e}"))?;
917 Ok(n)
918}
919
920pub fn purge_expired_pending_invites() -> Result<usize, String> {
928 let conn = super::get_write_connection_guard_static()?;
929 let now = now_secs();
930 let n = conn
931 .execute(
932 "DELETE FROM pending_community_invites
933 WHERE (expires_at != 0 AND expires_at <= ?1)
934 OR received_at <= ?2",
935 params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64],
936 )
937 .map_err(|e| format!("purge expired pending invites: {e}"))?;
938 Ok(n)
939}
940
941pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
943 let conn = super::get_db_connection_guard_static()?;
944 let mut stmt = conn
945 .prepare(
946 "SELECT community_id, bundle_json, inviter_npub, received_at, expires_at
947 FROM pending_community_invites
948 WHERE (expires_at = 0 OR expires_at > ?1)
949 AND received_at > ?2
950 ORDER BY received_at DESC",
951 )
952 .map_err(|e| e.to_string())?;
953 let now = now_secs();
954 let rows = stmt
955 .query_map(params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64], |r| {
956 Ok(PendingCommunityInvite {
957 community_id: r.get(0)?,
958 bundle_json: dec_txt(&r.get::<_, String>(1)?),
959 inviter_npub: dec_txt(&r.get::<_, String>(2)?),
960 received_at: r.get(3)?,
961 expires_at: r.get(4)?,
962 })
963 })
964 .map_err(|e| e.to_string())?;
965 let mut out = Vec::new();
966 for row in rows {
967 out.push(row.map_err(|e| e.to_string())?);
968 }
969 Ok(out)
970}
971
972pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
976 let conn = super::get_db_connection_guard_static()?;
977 let raw: Option<String> = conn
978 .query_row(
979 "SELECT bundle_json FROM pending_community_invites
980 WHERE community_id = ?1 AND (expires_at = 0 OR expires_at > ?2)",
981 params![community_id, now_secs()],
982 |r| r.get::<_, String>(0),
983 )
984 .optional()
985 .map_err(|e| format!("get pending invite: {e}"))?;
986 Ok(raw.map(|s| dec_txt(&s)))
987}
988
989pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
991 let conn = super::get_write_connection_guard_static()?;
992 conn.execute(
993 "DELETE FROM pending_community_invites WHERE community_id = ?1",
994 params![community_id],
995 )
996 .map_err(|e| format!("delete pending invite: {e}"))?;
997 Ok(())
998}
999
1000pub fn pending_invite_received_at(community_id: &str) -> Result<Option<i64>, String> {
1005 let conn = super::get_db_connection_guard_static()?;
1006 conn.query_row(
1007 "SELECT received_at FROM pending_community_invites WHERE community_id = ?1",
1008 params![community_id],
1009 |r| r.get(0),
1010 )
1011 .optional()
1012 .map_err(|e| format!("pending_invite_received_at: {e}"))
1013}
1014
1015pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
1016 let conn = super::get_db_connection_guard_static()?;
1017 let found: Option<i64> = conn
1018 .query_row(
1019 "SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
1020 params![community_id],
1021 |r| r.get(0),
1022 )
1023 .optional()
1024 .map_err(|e| format!("pending_invite_exists: {e}"))?;
1025 Ok(found.is_some())
1026}
1027
1028#[derive(Debug, Clone, serde::Serialize)]
1030pub struct PublicInviteRecord {
1031 pub token: String,
1033 pub community_id: String,
1034 pub url: String,
1035 pub expires_at: Option<i64>,
1036 pub created_at: i64,
1037 pub label: Option<String>,
1039 #[serde(default)]
1041 pub join_count: u64,
1042}
1043
1044pub fn save_public_invite(
1046 token: &str,
1047 community_id: &str,
1048 url: &str,
1049 expires_at: Option<i64>,
1050 label: Option<&str>,
1051) -> Result<(), String> {
1052 let conn = super::get_write_connection_guard_static()?;
1053 let enc_token = enc_txt(token)?;
1056 let enc_url = enc_txt(url)?;
1057 let enc_label = label.map(enc_txt).transpose()?;
1059 conn.execute(
1060 "INSERT OR REPLACE INTO community_public_invites
1061 (token, community_id, url, expires_at, created_at, label)
1062 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1063 params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
1064 )
1065 .map_err(|e| format!("save public invite: {e}"))?;
1066 Ok(())
1067}
1068
1069pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
1071 let conn = super::get_db_connection_guard_static()?;
1072 let mut stmt = conn
1073 .prepare(
1074 "SELECT token, community_id, url, expires_at, created_at, label
1075 FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
1076 )
1077 .map_err(|e| e.to_string())?;
1078 let rows = stmt
1079 .query_map(params![community_id], |r| {
1080 Ok(PublicInviteRecord {
1081 token: dec_txt(&r.get::<_, String>(0)?),
1082 community_id: r.get(1)?,
1083 url: dec_txt(&r.get::<_, String>(2)?),
1084 expires_at: r.get(3)?,
1085 created_at: r.get(4)?,
1086 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
1087 join_count: 0,
1088 })
1089 })
1090 .map_err(|e| e.to_string())?;
1091 let mut out = Vec::new();
1092 for row in rows {
1093 out.push(row.map_err(|e| e.to_string())?);
1094 }
1095 if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
1097 if let Ok(counts) = community_invite_join_counts(community_id, &me) {
1098 for rec in &mut out {
1099 if let Some(l) = rec.label.as_deref() {
1100 rec.join_count = counts.get(l).copied().unwrap_or(0);
1101 }
1102 }
1103 }
1104 }
1105 Ok(out)
1106}
1107
1108pub fn delete_public_invite(token: &str) -> Result<(), String> {
1110 let conn = super::get_write_connection_guard_static()?;
1111 let rows: Vec<(i64, String)> = {
1114 let mut stmt = conn
1115 .prepare("SELECT rowid, token FROM community_public_invites")
1116 .map_err(|e| e.to_string())?;
1117 let mapped = stmt
1118 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1119 .map_err(|e| e.to_string())?;
1120 mapped.filter_map(|r| r.ok()).collect()
1121 };
1122 for (rowid, stored) in rows {
1123 if dec_txt(&stored) == token {
1124 conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
1125 .map_err(|e| format!("delete public invite: {e}"))?;
1126 }
1127 }
1128 Ok(())
1129}
1130
1131pub fn list_all_public_invites() -> Result<Vec<PublicInviteRecord>, String> {
1133 let conn = super::get_db_connection_guard_static()?;
1134 let mut stmt = conn
1135 .prepare(
1136 "SELECT token, community_id, url, expires_at, created_at, label
1137 FROM community_public_invites ORDER BY created_at DESC",
1138 )
1139 .map_err(|e| e.to_string())?;
1140 let rows = stmt
1141 .query_map([], |r| {
1142 Ok(PublicInviteRecord {
1143 token: dec_txt(&r.get::<_, String>(0)?),
1144 community_id: r.get(1)?,
1145 url: dec_txt(&r.get::<_, String>(2)?),
1146 expires_at: r.get(3)?,
1147 created_at: r.get(4)?,
1148 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
1149 join_count: 0,
1150 })
1151 })
1152 .map_err(|e| e.to_string())?;
1153 let mut out = Vec::new();
1154 for row in rows {
1155 out.push(row.map_err(|e| e.to_string())?);
1156 }
1157 Ok(out)
1158}
1159
1160pub fn upsert_public_invite(
1165 token: &str,
1166 community_id: &str,
1167 url: &str,
1168 expires_at: Option<i64>,
1169 created_at: i64,
1170 label: Option<&str>,
1171) -> Result<bool, String> {
1172 let conn = super::get_write_connection_guard_static()?;
1173 let already = {
1174 let mut stmt = conn
1175 .prepare("SELECT token FROM community_public_invites WHERE community_id = ?1")
1176 .map_err(|e| e.to_string())?;
1177 let stored: Vec<String> = stmt
1178 .query_map(params![community_id], |r| r.get::<_, String>(0))
1179 .map_err(|e| e.to_string())?
1180 .filter_map(|r| r.ok())
1181 .collect();
1182 stored.iter().any(|s| dec_txt(s) == token)
1183 };
1184 if already {
1185 return Ok(false);
1186 }
1187 let enc_token = enc_txt(token)?;
1188 let enc_url = enc_txt(url)?;
1189 let enc_label = label.map(enc_txt).transpose()?;
1190 conn.execute(
1191 "INSERT INTO community_public_invites
1192 (token, community_id, url, expires_at, created_at, label)
1193 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1194 params![enc_token, community_id, enc_url, expires_at, created_at, enc_label],
1195 )
1196 .map_err(|e| format!("upsert public invite: {e}"))?;
1197 Ok(true)
1198}
1199
1200pub fn delete_community(community_id: &str) -> Result<(), String> {
1205 delete_community_inner(community_id, false)
1206}
1207
1208pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
1214 delete_community_inner(community_id, true)
1215}
1216
1217fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
1218 let conn = super::get_write_connection_guard_static()?;
1219 let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
1222 for sql in [
1223 Some("DELETE FROM communities WHERE community_id = ?1"),
1224 Some("DELETE FROM community_channels WHERE community_id = ?1"),
1225 (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
1229 Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
1230 Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
1231 Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
1232 Some("DELETE FROM pending_channel_keys WHERE community_id = ?1"),
1238 Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
1241 Some("DELETE FROM community_bans WHERE community_id = ?1"),
1242 ]
1243 .into_iter()
1244 .flatten()
1245 {
1246 tx.execute(sql, params![community_id])
1247 .map_err(|e| format!("delete community: {e}"))?;
1248 }
1249 tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
1250 BANLIST_CACHE.write().unwrap().remove(community_id);
1251 forget_community_channels(community_id);
1252 Ok(())
1257}
1258
1259pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
1266 community_member_activity_capped(community_id, true)
1267}
1268
1269pub fn community_member_activity_capped(community_id: &str, capped: bool) -> Result<Vec<(String, u64)>, String> {
1275 const COMMUNITY_MEMBER_CAP: usize = 500;
1278 use std::collections::HashMap;
1279
1280 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1281 Some(c) => c,
1282 None => return Ok(Vec::new()),
1283 };
1284 let owner_b32: Option<String> = community
1289 .owner_attestation
1290 .as_deref()
1291 .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
1292 .and_then(|pk| pk.to_bech32().ok());
1293
1294 let mut chat_ints: Vec<i64> = Vec::new();
1296 for ch in &community.channels {
1297 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1298 chat_ints.push(cid);
1299 }
1300 }
1301
1302 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1305
1306 let mut active: HashMap<String, u64> = HashMap::new();
1311 let mut left: HashMap<String, u64> = HashMap::new();
1312 if !chat_ints.is_empty() {
1315 let conn = super::get_db_connection_guard_static()?;
1316 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1317
1318 {
1319 let sql = format!(
1320 "SELECT npub, MAX(created_at) FROM events \
1321 WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
1322 GROUP BY npub"
1323 );
1324 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1325 let rows = stmt
1326 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1327 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
1328 })
1329 .map_err(|e| e.to_string())?;
1330 for row in rows {
1331 let (npub, at) = row.map_err(|e| e.to_string())?;
1332 active.insert(npub, at);
1333 }
1334 }
1335
1336 {
1338 let sql = format!(
1339 "SELECT npub, created_at, tags FROM events \
1340 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1341 );
1342 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1343 let rows = stmt
1344 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1345 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
1346 })
1347 .map_err(|e| e.to_string())?;
1348 for row in rows {
1349 let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
1350 let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
1352 .ok()
1353 .and_then(|tags| {
1354 tags.into_iter()
1355 .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
1356 .and_then(|t| t.into_iter().nth(1))
1357 });
1358 match etype.as_deref() {
1359 Some("1") => {
1360 let e = active.entry(npub).or_insert(0);
1361 if at > *e { *e = at; }
1362 }
1363 Some("0") => {
1364 let e = left.entry(npub).or_insert(0);
1365 if at > *e { *e = at; }
1366 }
1367 _ => {}
1368 }
1369 }
1370 }
1371 }
1372
1373 let banned: std::collections::HashSet<String> = community
1376 .channels
1377 .first()
1378 .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
1379 .unwrap_or_default();
1380
1381 let mut out: Vec<(String, u64)> = active
1383 .into_iter()
1384 .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l))
1385 .collect();
1386
1387 {
1394 let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
1395 let mut reassert = |npub: String| {
1396 if !banned.contains(&npub) && present.insert(npub.clone()) {
1397 out.push((npub, now_secs() as u64));
1398 }
1399 };
1400 if let Some(o) = owner_b32 {
1401 reassert(o);
1402 }
1403 if let Ok(roles) = get_community_roles(community_id) {
1404 for g in &roles.grants {
1405 if g.role_ids.is_empty() {
1406 continue; }
1408 if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
1409 reassert(b32);
1410 }
1411 }
1412 }
1413 }
1414 out.sort_by(|a, b| b.1.cmp(&a.1));
1415 if capped {
1416 out.truncate(COMMUNITY_MEMBER_CAP);
1417 }
1418 Ok(out)
1419}
1420
1421pub fn community_invite_join_counts(
1426 community_id: &str,
1427 inviter_npub: &str,
1428) -> Result<std::collections::HashMap<String, u64>, String> {
1429 use std::collections::{HashMap, HashSet};
1430 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1431 Some(c) => c,
1432 None => return Ok(HashMap::new()),
1433 };
1434 let mut chat_ints: Vec<i64> = Vec::new();
1435 for ch in &community.channels {
1436 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1437 chat_ints.push(cid);
1438 }
1439 }
1440 if chat_ints.is_empty() {
1441 return Ok(HashMap::new());
1442 }
1443 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1444 let conn = super::get_db_connection_guard_static()?;
1445 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1446 let sql = format!(
1447 "SELECT npub, tags FROM events \
1448 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1449 );
1450 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1451 let rows = stmt
1452 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1453 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1454 })
1455 .map_err(|e| e.to_string())?;
1456 let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
1458 for row in rows {
1459 let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
1460 let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
1461 Ok(t) => t,
1462 Err(_) => continue,
1463 };
1464 let tag_val = |key: &str| -> Option<String> {
1465 tags.iter()
1466 .find(|t| t.first().map(|s| s == key).unwrap_or(false))
1467 .and_then(|t| t.get(1).cloned())
1468 };
1469 if tag_val("event-type").as_deref() != Some("1") {
1471 continue;
1472 }
1473 if tag_val("invited-by").as_deref() != Some(inviter_npub) {
1474 continue;
1475 }
1476 if let Some(label) = tag_val("invited-label") {
1477 per_label.entry(label).or_default().insert(joiner);
1478 }
1479 }
1480 Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
1481}
1482
1483#[derive(Debug, Clone)]
1487pub struct AuthorFootprint {
1488 pub npub: String,
1489 pub messages: u64,
1490 pub first_secs: u64,
1491 pub last_secs: u64,
1492}
1493
1494pub fn community_chat_row_ids(community_id: &str) -> Result<Vec<i64>, String> {
1498 let channel_ids: Vec<String> = {
1499 let conn = super::get_db_connection_guard_static()?;
1500 let mut stmt = conn
1501 .prepare("SELECT channel_id FROM community_channels WHERE community_id = ?1")
1502 .map_err(|e| e.to_string())?;
1503 let rows = stmt
1504 .query_map(params![community_id], |r| r.get::<_, String>(0))
1505 .map_err(|e| e.to_string())?;
1506 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
1507 };
1508 Ok(channel_ids.iter().filter_map(|c| super::id_cache::get_chat_id_by_identifier(c).ok()).collect())
1509}
1510
1511#[derive(Debug, Clone)]
1514pub struct StoredPolicy {
1515 pub policy_id: String,
1518 pub bytes: String,
1521 pub hash: String,
1522 pub enabled: bool,
1523 pub updated_at: u64,
1524}
1525
1526pub fn get_community_policies(community_id: &str) -> Result<Vec<StoredPolicy>, String> {
1529 let conn = super::get_db_connection_guard_static()?;
1530 let mut stmt = conn
1531 .prepare(
1532 "SELECT policy_id, bytes, hash, enabled, updated_at FROM community_policies \
1533 WHERE community_id = ?1 ORDER BY policy_id",
1534 )
1535 .map_err(|e| e.to_string())?;
1536 let rows = stmt
1537 .query_map(params![community_id], |r| {
1538 Ok(StoredPolicy {
1539 policy_id: r.get::<_, String>(0)?,
1540 bytes: dec_txt(&r.get::<_, String>(1)?),
1541 hash: r.get::<_, String>(2)?,
1542 enabled: r.get::<_, i64>(3)? != 0,
1543 updated_at: r.get::<_, i64>(4)?.max(0) as u64,
1544 })
1545 })
1546 .map_err(|e| e.to_string())?;
1547 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
1548}
1549
1550pub fn set_community_policy(
1553 community_id: &str,
1554 policy_id: &str,
1555 bytes: &str,
1556 hash: &str,
1557 enabled: bool,
1558 updated_at: u64,
1559) -> Result<(), String> {
1560 let conn = super::get_write_connection_guard_static()?;
1561 conn.execute(
1562 "INSERT INTO community_policies (community_id, policy_id, bytes, hash, enabled, updated_at)
1563 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1564 ON CONFLICT(community_id, policy_id) DO UPDATE SET bytes=?3, hash=?4, enabled=?5, updated_at=?6",
1565 params![community_id, policy_id, enc_txt(bytes)?, hash, enabled as i64, updated_at as i64],
1566 )
1567 .map_err(|e| format!("save policy: {e}"))?;
1568 Ok(())
1569}
1570
1571pub fn delete_community_policy(community_id: &str, policy_id: &str) -> Result<(), String> {
1572 let conn = super::get_write_connection_guard_static()?;
1573 conn.execute(
1574 "DELETE FROM community_policies WHERE community_id = ?1 AND policy_id = ?2",
1575 params![community_id, policy_id],
1576 )
1577 .map_err(|e| format!("delete policy: {e}"))?;
1578 Ok(())
1579}
1580
1581#[derive(Debug, Clone)]
1584pub struct PolicyMessage {
1585 pub id: String,
1588 pub npub: String,
1589 pub channel_id: String,
1591 pub at_ms: u64,
1593 pub text: String,
1594 pub mentions: u32,
1596}
1597
1598pub fn community_policy_messages(community_id: &str, limit: usize) -> Result<Vec<PolicyMessage>, String> {
1602 let channel_ids: Vec<String> = {
1603 let conn = super::get_db_connection_guard_static()?;
1604 let mut stmt = conn
1605 .prepare("SELECT channel_id FROM community_channels WHERE community_id = ?1")
1606 .map_err(|e| e.to_string())?;
1607 let rows = stmt.query_map(params![community_id], |r| r.get::<_, String>(0)).map_err(|e| e.to_string())?;
1608 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
1609 };
1610 if channel_ids.is_empty() || limit == 0 {
1611 return Ok(Vec::new());
1612 }
1613 let mut chat_to_channel: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
1615 for c in &channel_ids {
1616 if let Ok(row) = super::id_cache::get_chat_id_by_identifier(c) {
1617 chat_to_channel.insert(row, c.clone());
1618 }
1619 }
1620 if chat_to_channel.is_empty() {
1621 return Ok(Vec::new());
1622 }
1623 let text = crate::stored_event::event_kind::PRIVATE_DIRECT_MESSAGE;
1624 let chat_ints: Vec<i64> = chat_to_channel.keys().copied().collect();
1625 let rows: Vec<(String, String, i64, i64, String)> = {
1626 let conn = super::get_db_connection_guard_static()?;
1627 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1628 let sql = format!(
1629 "SELECT id, npub, chat_id, created_at, content FROM events \
1630 WHERE chat_id IN ({placeholders}) AND kind = {text} \
1631 AND npub IS NOT NULL AND npub != '' \
1632 ORDER BY created_at DESC LIMIT {limit}"
1633 );
1634 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1635 let mapped = stmt
1636 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1637 Ok((
1638 r.get::<_, String>(0)?,
1639 r.get::<_, String>(1)?,
1640 r.get::<_, i64>(2)?,
1641 r.get::<_, i64>(3)?,
1642 r.get::<_, String>(4)?,
1643 ))
1644 })
1645 .map_err(|e| e.to_string())?;
1646 mapped.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
1647 };
1648 let mut out: Vec<PolicyMessage> = rows
1649 .into_iter()
1650 .filter_map(|(id, npub, chat_id, created_at, content)| {
1651 let channel_id = chat_to_channel.get(&chat_id)?.clone();
1652 let content = crate::crypto::maybe_decrypt_text(&content);
1653 let mentions = crate::community::policy::matchers::count_mentions(&content);
1658 Some(PolicyMessage {
1659 id,
1660 npub,
1661 channel_id,
1662 at_ms: (created_at.max(0) as u64).saturating_mul(1000),
1663 text: content,
1664 mentions,
1665 })
1666 })
1667 .collect();
1668 out.reverse(); Ok(out)
1670}
1671
1672pub fn community_author_footprints(community_id: &str) -> Result<Vec<AuthorFootprint>, String> {
1676 let chat_ints = community_chat_row_ids(community_id)?;
1677 if chat_ints.is_empty() {
1678 return Ok(Vec::new());
1679 }
1680 let text = crate::stored_event::event_kind::PRIVATE_DIRECT_MESSAGE;
1681 let file = crate::stored_event::event_kind::FILE_ATTACHMENT;
1682 let conn = super::get_db_connection_guard_static()?;
1683 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1684 let sql = format!(
1685 "SELECT npub, COUNT(*), MIN(created_at), MAX(created_at) FROM events \
1686 WHERE chat_id IN ({placeholders}) AND kind IN ({text}, {file}) \
1687 AND npub IS NOT NULL AND npub != '' \
1688 GROUP BY npub"
1689 );
1690 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1691 let rows = stmt
1692 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1693 Ok(AuthorFootprint {
1694 npub: r.get::<_, String>(0)?,
1695 messages: r.get::<_, i64>(1)?.max(0) as u64,
1696 first_secs: r.get::<_, i64>(2)?.max(0) as u64,
1697 last_secs: r.get::<_, i64>(3)?.max(0) as u64,
1698 })
1699 })
1700 .map_err(|e| e.to_string())?;
1701 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
1702}
1703
1704pub fn community_recent_texts(community_id: &str, limit: usize) -> Result<Vec<(String, u64, String)>, String> {
1709 let chat_ints = community_chat_row_ids(community_id)?;
1710 if chat_ints.is_empty() || limit == 0 {
1711 return Ok(Vec::new());
1712 }
1713 let text = crate::stored_event::event_kind::PRIVATE_DIRECT_MESSAGE;
1714 let rows: Vec<(String, u64, String)> = {
1715 let conn = super::get_db_connection_guard_static()?;
1716 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1717 let sql = format!(
1718 "SELECT npub, created_at, content FROM events \
1719 WHERE chat_id IN ({placeholders}) AND kind = {text} \
1720 AND npub IS NOT NULL AND npub != '' \
1721 ORDER BY created_at DESC LIMIT {limit}"
1722 );
1723 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1724 let mapped = stmt
1725 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1726 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
1727 })
1728 .map_err(|e| e.to_string())?;
1729 mapped.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
1730 };
1731 Ok(rows
1732 .into_iter()
1733 .map(|(npub, at, content)| (npub, at, crate::crypto::maybe_decrypt_text(&content)))
1734 .collect())
1735}
1736
1737static BANLIST_CACHE: std::sync::LazyLock<
1749 std::sync::RwLock<std::collections::HashMap<String, std::sync::Arc<std::collections::HashSet<[u8; 32]>>>>,
1750> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
1751
1752fn banlist_set_from_hexes(hexes: &[String]) -> std::collections::HashSet<[u8; 32]> {
1753 hexes.iter().filter_map(|h| crate::simd::hex::hex_to_bytes_32_checked(h)).collect()
1754}
1755
1756pub fn clear_banlist_cache() {
1758 BANLIST_CACHE.write().unwrap().clear();
1759}
1760
1761pub fn banned_set(community_id: &str) -> std::sync::Arc<std::collections::HashSet<[u8; 32]>> {
1764 if let Some(set) = BANLIST_CACHE.read().unwrap().get(community_id) {
1765 return std::sync::Arc::clone(set);
1766 }
1767 let set = std::sync::Arc::new(banlist_set_from_hexes(
1768 &get_community_banlist(community_id).unwrap_or_default(),
1769 ));
1770 BANLIST_CACHE
1771 .write()
1772 .unwrap()
1773 .insert(community_id.to_string(), std::sync::Arc::clone(&set));
1774 set
1775}
1776
1777pub fn is_author_banned(community_id: &str, author: &PublicKey) -> bool {
1780 let set = banned_set(community_id);
1781 !set.is_empty() && set.contains(&author.to_bytes())
1782}
1783
1784fn forget_console_verdict(community_id: &str) {
1790 crate::VectorCore::invalidate_raid_report(community_id);
1791}
1792
1793pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
1794 let conn = super::get_write_connection_guard_static()?;
1795 let current_at: Option<i64> = conn
1802 .query_row("SELECT banlist_at FROM communities WHERE community_id = ?1", params![community_id], |r| r.get(0))
1803 .optional()
1804 .map_err(|e| format!("read banlist_at: {e}"))?
1805 .flatten();
1806 if current_at.is_some_and(|have| have > at) {
1807 crate::log_warn!(
1808 "[Banlist] refused a stale edition (v{at} behind held v{}) — un-banning is the fail-open direction",
1809 current_at.unwrap_or(0)
1810 );
1811 return Ok(());
1812 }
1813 let tx = conn.unchecked_transaction().map_err(|e| format!("banlist tx: {e}"))?;
1819 tx.execute("DELETE FROM community_bans WHERE community_id = ?1", params![community_id])
1820 .map_err(|e| format!("clear banlist: {e}"))?;
1821 {
1822 let mut ins = tx
1823 .prepare("INSERT OR IGNORE INTO community_bans (community_id, npub) VALUES (?1, ?2)")
1824 .map_err(|e| format!("prepare banlist insert: {e}"))?;
1825 for hex in banned_hex {
1826 let joinable = PublicKey::from_hex(hex)
1832 .ok()
1833 .and_then(|pk| pk.to_bech32().ok())
1834 .unwrap_or_else(|| hex.clone());
1835 ins.execute(params![community_id, joinable])
1836 .map_err(|e| format!("insert ban: {e}"))?;
1837 }
1838 }
1839 tx.execute(
1840 "UPDATE communities SET banlist_at = ?1 WHERE community_id = ?2",
1841 params![at, community_id],
1842 )
1843 .map_err(|e| format!("set banlist_at: {e}"))?;
1844 tx.commit().map_err(|e| format!("commit banlist: {e}"))?;
1845 forget_console_verdict(community_id);
1846 BANLIST_CACHE
1849 .write()
1850 .unwrap()
1851 .insert(community_id.to_string(), std::sync::Arc::new(banlist_set_from_hexes(banned_hex)));
1852 Ok(())
1853}
1854
1855pub fn get_community_ban_marks(community_id: &str) -> Result<std::collections::BTreeMap<String, u64>, String> {
1860 let conn = super::get_db_connection_guard_static()?;
1861 let json: Option<String> = conn
1862 .query_row(
1863 "SELECT banlist_marks FROM communities WHERE community_id = ?1",
1864 params![community_id],
1865 |r| r.get(0),
1866 )
1867 .optional()
1868 .map_err(|e| format!("get ban marks: {e}"))?;
1869 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1870}
1871
1872pub fn merge_community_ban_marks(community_id: &str, marks: &std::collections::BTreeMap<String, u64>) -> Result<bool, String> {
1877 if marks.is_empty() {
1878 return Ok(false);
1879 }
1880 let mut stored = get_community_ban_marks(community_id)?;
1881 let mut changed = false;
1882 for (npub, at) in marks {
1883 let slot = stored.entry(npub.clone()).or_insert(0);
1884 if *at > *slot {
1885 *slot = *at;
1886 changed = true;
1887 }
1888 }
1889 if !changed {
1890 return Ok(false);
1891 }
1892 let json = enc_txt(&serde_json::to_string(&stored).map_err(|e| e.to_string())?)?;
1893 let conn = super::get_write_connection_guard_static()?;
1894 conn.execute(
1895 "UPDATE communities SET banlist_marks = ?1 WHERE community_id = ?2",
1896 params![json, community_id],
1897 )
1898 .map_err(|e| format!("set ban marks: {e}"))?;
1899 Ok(true)
1900}
1901
1902pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
1905 let conn = super::get_db_connection_guard_static()?;
1906 let at: Option<i64> = conn
1907 .query_row(
1908 "SELECT banlist_at FROM communities WHERE community_id = ?1",
1909 params![community_id],
1910 |r| r.get(0),
1911 )
1912 .optional()
1913 .map_err(|e| format!("get banlist_at: {e}"))?;
1914 Ok(at.unwrap_or(0))
1915}
1916
1917pub fn set_community_roles(
1922 community_id: &str,
1923 roles: &crate::community::roles::CommunityRoles,
1924 at: i64,
1925) -> Result<(), String> {
1926 forget_console_verdict(community_id);
1927 let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
1928 let conn = super::get_write_connection_guard_static()?;
1929 conn.execute(
1930 "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
1931 params![json, at, community_id],
1932 )
1933 .map_err(|e| format!("set roles: {e}"))?;
1934 Ok(())
1935}
1936
1937pub fn get_community_roles(
1939 community_id: &str,
1940) -> Result<crate::community::roles::CommunityRoles, String> {
1941 let conn = super::get_db_connection_guard_static()?;
1942 let json: Option<String> = conn
1943 .query_row(
1944 "SELECT roles FROM communities WHERE community_id = ?1",
1945 params![community_id],
1946 |r| r.get(0),
1947 )
1948 .optional()
1949 .map_err(|e| format!("get roles: {e}"))?;
1950 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1951}
1952
1953pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
1955 let conn = super::get_db_connection_guard_static()?;
1956 let at: Option<i64> = conn
1957 .query_row(
1958 "SELECT roles_at FROM communities WHERE community_id = ?1",
1959 params![community_id],
1960 |r| r.get(0),
1961 )
1962 .optional()
1963 .map_err(|e| format!("get roles_at: {e}"))?;
1964 Ok(at.unwrap_or(0))
1965}
1966
1967pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
1972 set_edition_head_inner(community_id, entity_id, version, self_hash, None, None)
1973}
1974
1975pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1978 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), None)
1979}
1980
1981pub fn delete_edition_head(community_id: &str, entity_id: &str) -> Result<(), String> {
1995 let conn = super::get_write_connection_guard_static()?;
1996 conn.execute(
1997 "DELETE FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1998 params![community_id, entity_id],
1999 )
2000 .map_err(|e| format!("delete edition head: {e}"))?;
2001 Ok(())
2002}
2003
2004pub 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> {
2005 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), Some(epoch))
2006}
2007
2008fn 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> {
2009 let conn = super::get_write_connection_guard_static()?;
2010 conn.execute(
2016 "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
2017 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
2018 ON CONFLICT(community_id, entity_id) DO UPDATE SET
2019 version = excluded.version,
2020 self_hash = excluded.self_hash,
2021 inner_id = excluded.inner_id,
2022 epoch = excluded.epoch
2023 WHERE excluded.epoch > community_edition_heads.epoch
2024 OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
2025 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)],
2026 )
2027 .map_err(|e| format!("set edition head: {e}"))?;
2028 Ok(())
2029}
2030
2031pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
2041 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, None)
2042}
2043
2044pub 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> {
2048 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, Some(epoch))
2049}
2050
2051fn 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> {
2052 let conn = super::get_write_connection_guard_static()?;
2053 conn.execute(
2056 "UPDATE community_edition_heads
2057 SET self_hash = ?4, inner_id = ?5
2058 WHERE community_id = ?1 AND entity_id = ?2
2059 AND version = ?3
2060 AND epoch = COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
2061 AND (inner_id IS NULL OR ?5 < inner_id)",
2062 params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice(), epoch.map(|e| e as i64)],
2063 )
2064 .map_err(|e| format!("converge edition head: {e}"))?;
2065 Ok(())
2066}
2067
2068pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
2073 let conn = super::get_db_connection_guard_static()?;
2074 let row: Option<Option<Vec<u8>>> = conn
2075 .query_row(
2076 "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
2077 params![community_id, entity_id],
2078 |r| r.get(0),
2079 )
2080 .optional()
2081 .map_err(|e| format!("get edition head inner_id: {e}"))?;
2082 match row.flatten() {
2083 Some(blob) if blob.len() == 32 => {
2084 let mut h = [0u8; 32];
2085 h.copy_from_slice(&blob);
2086 Ok(Some(h))
2087 }
2088 _ => Ok(None),
2089 }
2090}
2091
2092pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
2095 let conn = super::get_db_connection_guard_static()?;
2096 let row: Option<(i64, Vec<u8>)> = conn
2097 .query_row(
2098 "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
2099 params![community_id, entity_id],
2100 |r| Ok((r.get(0)?, r.get(1)?)),
2101 )
2102 .optional()
2103 .map_err(|e| format!("get edition head: {e}"))?;
2104 match row {
2105 Some((v, hash)) if hash.len() == 32 => {
2106 let mut h = [0u8; 32];
2107 h.copy_from_slice(&hash);
2108 Ok(Some((v as u64, h)))
2109 }
2110 _ => Ok(None),
2111 }
2112}
2113
2114pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
2119 let conn = super::get_db_connection_guard_static()?;
2120 let mut stmt = conn
2121 .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
2122 .map_err(|e| e.to_string())?;
2123 let rows = stmt
2124 .query_map(params![community_id], |r| r.get::<_, String>(0))
2125 .map_err(|e| e.to_string())?;
2126 let mut out = std::collections::HashSet::new();
2127 for row in rows {
2128 out.insert(row.map_err(|e| e.to_string())?);
2129 }
2130 Ok(out)
2131}
2132
2133
2134pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
2140 let conn = super::get_db_connection_guard_static()?;
2141 let mut stmt = conn
2142 .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
2143 .map_err(|e| e.to_string())?;
2144 let rows = stmt
2145 .query_map(params![community_id], |r| {
2146 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
2147 })
2148 .map_err(|e| e.to_string())?;
2149 let mut out = std::collections::HashMap::new();
2150 for row in rows {
2151 let (entity, version, hash) = row.map_err(|e| e.to_string())?;
2152 if hash.len() == 32 {
2153 let mut h = [0u8; 32];
2154 h.copy_from_slice(&hash);
2155 out.insert(entity, (version as u64, h));
2156 }
2157 }
2158 Ok(out)
2159}
2160
2161pub fn get_all_edition_heads_full(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32], Option<[u8; 32]>)>, String> {
2169 let conn = super::get_db_connection_guard_static()?;
2170 let mut stmt = conn
2171 .prepare("SELECT entity_id, epoch, version, self_hash, inner_id FROM community_edition_heads WHERE community_id = ?1")
2172 .map_err(|e| e.to_string())?;
2173 let rows = stmt
2174 .query_map(params![community_id], |r| {
2175 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, Option<Vec<u8>>>(4)?))
2176 })
2177 .map_err(|e| e.to_string())?;
2178 let mut out = std::collections::HashMap::new();
2179 for row in rows {
2180 let (entity, epoch, version, hash, inner) = row.map_err(|e| e.to_string())?;
2181 if hash.len() == 32 {
2182 let mut h = [0u8; 32];
2183 h.copy_from_slice(&hash);
2184 let inner_id = inner.and_then(|b| {
2185 (b.len() == 32).then(|| {
2186 let mut i = [0u8; 32];
2187 i.copy_from_slice(&b);
2188 i
2189 })
2190 });
2191 out.insert(entity, (epoch as u64, version as u64, h, inner_id));
2192 }
2193 }
2194 Ok(out)
2195}
2196
2197pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32])>, String> {
2198 let conn = super::get_db_connection_guard_static()?;
2199 let mut stmt = conn
2200 .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
2201 .map_err(|e| e.to_string())?;
2202 let rows = stmt
2203 .query_map(params![community_id], |r| {
2204 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
2205 })
2206 .map_err(|e| e.to_string())?;
2207 let mut out = std::collections::HashMap::new();
2208 for row in rows {
2209 let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
2210 if hash.len() == 32 {
2211 let mut h = [0u8; 32];
2212 h.copy_from_slice(&hash);
2213 out.insert(entity, (epoch as u64, version as u64, h));
2214 }
2215 }
2216 Ok(out)
2217}
2218
2219pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
2221 let conn = super::get_db_connection_guard_static()?;
2222 let mut stmt = conn
2223 .prepare("SELECT npub FROM community_bans WHERE community_id = ?1")
2224 .map_err(|e| format!("prepare get banlist: {e}"))?;
2225 let rows = stmt
2226 .query_map(params![community_id], |r| r.get::<_, String>(0))
2227 .map_err(|e| format!("get banlist: {e}"))?;
2228 let mut out: Vec<String> = rows
2230 .flatten()
2231 .map(|b| PublicKey::parse(&b).map(|pk| pk.to_hex()).unwrap_or(b))
2232 .collect();
2233 if !out.is_empty() {
2234 return Ok(out);
2235 }
2236 drop(stmt);
2241 let legacy: Option<String> = conn
2242 .query_row(
2243 "SELECT banlist FROM communities WHERE community_id = ?1 AND banlist IS NOT NULL AND banlist != ''",
2244 params![community_id],
2245 |r| r.get(0),
2246 )
2247 .optional()
2248 .map_err(|e| format!("read legacy banlist: {e}"))?
2249 .flatten();
2250 let Some(blob) = legacy else { return Ok(out) };
2251 let decoded = dec_txt(&blob);
2252 let Ok(list) = serde_json::from_str::<Vec<String>>(&decoded) else { return Ok(out) };
2253 if list.is_empty() {
2254 return Ok(out);
2255 }
2256 let tx = conn.unchecked_transaction().map_err(|e| format!("adopt tx: {e}"))?;
2257 {
2258 let mut ins = tx
2259 .prepare("INSERT OR IGNORE INTO community_bans (community_id, npub) VALUES (?1, ?2)")
2260 .map_err(|e| format!("prepare adopt: {e}"))?;
2261 for hex in &list {
2262 let joinable = PublicKey::from_hex(hex)
2263 .ok()
2264 .and_then(|pk| pk.to_bech32().ok())
2265 .unwrap_or_else(|| hex.clone());
2266 ins.execute(params![community_id, joinable]).map_err(|e| format!("adopt ban: {e}"))?;
2267 }
2268 }
2269 tx.execute("UPDATE communities SET banlist = NULL WHERE community_id = ?1", params![community_id])
2270 .map_err(|e| format!("retire legacy banlist: {e}"))?;
2271 tx.commit().map_err(|e| format!("commit adopt: {e}"))?;
2272 crate::log_info!("[Banlist] adopted {} legacy ban(s) into community_bans", list.len());
2273 out = list;
2274 Ok(out)
2275}
2276
2277pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
2281 let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
2282 let conn = super::get_write_connection_guard_static()?;
2283 conn.execute(
2284 "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
2285 params![json, community_id],
2286 )
2287 .map_err(|e| format!("set invite registry: {e}"))?;
2288 Ok(())
2289}
2290
2291pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
2294 let conn = super::get_db_connection_guard_static()?;
2295 let json: Option<String> = conn
2296 .query_row(
2297 "SELECT invite_registry FROM communities WHERE community_id = ?1",
2298 params![community_id],
2299 |r| r.get(0),
2300 )
2301 .optional()
2302 .map_err(|e| format!("get invite registry: {e}"))?;
2303 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
2304}
2305
2306pub struct InviteLinkSetRow {
2309 pub creator_hex: String,
2310 pub locators: Vec<String>,
2311}
2312
2313pub fn invite_link_set_creators(community_id: &str) -> Result<Vec<String>, String> {
2316 let conn = super::get_write_connection_guard_static()?;
2317 let mut stmt = conn
2318 .prepare("SELECT creator FROM community_invite_link_sets WHERE community_id = ?1")
2319 .map_err(|e| e.to_string())?;
2320 let rows = stmt
2321 .query_map(params![community_id], |r| r.get::<_, String>(0))
2322 .map_err(|e| e.to_string())?
2323 .filter_map(|r| r.ok())
2324 .map(|stored| dec_txt(&stored))
2325 .collect();
2326 Ok(rows)
2327}
2328
2329pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
2333 let mut conn = super::get_write_connection_guard_static()?;
2334 let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
2335 tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
2336 .map_err(|e| format!("clear invite-link-sets: {e}"))?;
2337 for s in sets {
2338 if s.locators.is_empty() {
2339 continue; }
2341 let enc_creator = enc_txt(&s.creator_hex)?;
2342 let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
2343 tx.execute(
2346 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
2347 params![community_id, enc_creator, enc_locators],
2348 )
2349 .map_err(|e| format!("insert invite-link-set: {e}"))?;
2350 }
2351 tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
2352 Ok(())
2353}
2354
2355pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
2358 let conn = super::get_write_connection_guard_static()?;
2359 let existing_rowid: Option<i64> = {
2361 let mut stmt = conn
2362 .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
2363 .map_err(|e| e.to_string())?;
2364 let rows = stmt
2365 .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
2366 .map_err(|e| e.to_string())?;
2367 let mut found = None;
2368 for row in rows {
2369 let (rowid, stored) = row.map_err(|e| e.to_string())?;
2370 if dec_txt(&stored) == creator_hex {
2371 found = Some(rowid);
2372 break;
2373 }
2374 }
2375 found
2376 };
2377 if locators.is_empty() {
2378 if let Some(rowid) = existing_rowid {
2379 conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
2380 .map_err(|e| format!("delete invite-link-set: {e}"))?;
2381 }
2382 return Ok(());
2383 }
2384 let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
2385 match existing_rowid {
2386 Some(rowid) => {
2387 conn.execute(
2388 "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
2389 params![enc_locators, rowid],
2390 )
2391 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
2392 }
2393 None => {
2394 let enc_creator = enc_txt(creator_hex)?;
2395 conn.execute(
2396 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
2397 params![community_id, enc_creator, enc_locators],
2398 )
2399 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
2400 }
2401 }
2402 Ok(())
2403}
2404
2405pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
2408 let conn = super::get_db_connection_guard_static()?;
2409 let mut stmt = conn
2410 .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
2411 .map_err(|e| format!("prepare invite-link-sets: {e}"))?;
2412 let rows = stmt
2413 .query_map(params![community_id], |r| {
2414 let creator_hex: String = r.get(0)?;
2415 let json: String = r.get(1)?;
2416 Ok((creator_hex, json))
2417 })
2418 .map_err(|e| format!("query invite-link-sets: {e}"))?;
2419 let mut out = Vec::new();
2420 for row in rows {
2421 let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
2422 let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
2423 out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
2424 }
2425 Ok(out)
2426}
2427
2428pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
2432 let conn = super::get_write_connection_guard_static()?;
2433 conn.execute(
2434 "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
2435 params![pending as i64, community_id],
2436 )
2437 .map_err(|e| format!("set read_cut_pending: {e}"))?;
2438 Ok(())
2439}
2440
2441pub fn set_community_dissolved(community_id: &str) -> Result<bool, String> {
2449 let conn = super::get_write_connection_guard_static()?;
2450 let changed = conn
2451 .execute(
2452 "UPDATE communities SET dissolved = 1 WHERE community_id = ?1 AND dissolved = 0",
2453 params![community_id],
2454 )
2455 .map_err(|e| format!("set dissolved: {e}"))?;
2456 Ok(changed > 0)
2457}
2458
2459pub fn set_migration_pointer(community_id: &str, payload_json: &str) -> Result<(), String> {
2467 let conn = super::get_write_connection_guard_static()?;
2468 let wrapped = enc_txt(payload_json)?;
2469 conn.execute(
2470 "UPDATE communities SET migration_pointer = ?2, migration_checked = 1 WHERE community_id = ?1",
2471 params![community_id, wrapped],
2472 )
2473 .map_err(|e| format!("set migration pointer: {e}"))?;
2474 Ok(())
2475}
2476
2477pub fn get_migration_pointer(community_id: &str) -> Result<Option<String>, String> {
2479 let conn = super::get_db_connection_guard_static()?;
2480 let v: Option<Option<String>> = conn
2481 .query_row(
2482 "SELECT migration_pointer FROM communities WHERE community_id = ?1",
2483 params![community_id],
2484 |r| r.get(0),
2485 )
2486 .optional()
2487 .map_err(|e| format!("get migration pointer: {e}"))?;
2488 Ok(v.flatten().map(|s| dec_txt(&s)))
2489}
2490
2491pub fn set_migrated_to(community_id: &str, v2_community_id: &str) -> Result<(), String> {
2494 let conn = super::get_write_connection_guard_static()?;
2495 conn.execute(
2496 "UPDATE communities SET migrated_to = ?2 WHERE community_id = ?1 AND migrated_to IS NULL",
2497 params![community_id, v2_community_id],
2498 )
2499 .map_err(|e| format!("set migrated_to: {e}"))?;
2500 Ok(())
2501}
2502
2503pub fn get_migrated_to(community_id: &str) -> Result<Option<String>, String> {
2505 let conn = super::get_db_connection_guard_static()?;
2506 let v: Option<Option<String>> = conn
2507 .query_row(
2508 "SELECT migrated_to FROM communities WHERE community_id = ?1",
2509 params![community_id],
2510 |r| r.get(0),
2511 )
2512 .optional()
2513 .map_err(|e| format!("get migrated_to: {e}"))?;
2514 Ok(v.flatten())
2515}
2516
2517pub fn set_migration_checked(community_id: &str) -> Result<(), String> {
2521 let conn = super::get_write_connection_guard_static()?;
2522 conn.execute(
2523 "UPDATE communities SET migration_checked = 1 WHERE community_id = ?1",
2524 params![community_id],
2525 )
2526 .map_err(|e| format!("set migration checked: {e}"))?;
2527 Ok(())
2528}
2529
2530pub fn set_migration_ledger(v1_community_id: &str, v2_community_id: &str, phase: i64, twin_json: &str) -> Result<(), String> {
2537 let conn = super::get_write_connection_guard_static()?;
2538 let wrapped = enc_txt(twin_json)?;
2539 let now = now_secs();
2542 conn.execute(
2543 "INSERT INTO community_migrations (community_id, v2_community_id, phase, twin, updated_at)
2544 VALUES (?1, ?2, ?3, ?4, ?5)
2545 ON CONFLICT(community_id) DO UPDATE SET v2_community_id=?2, phase=?3, twin=?4, updated_at=?5",
2546 params![v1_community_id, v2_community_id, phase, wrapped, now],
2547 )
2548 .map_err(|e| format!("set migration ledger: {e}"))?;
2549 Ok(())
2550}
2551
2552pub fn get_migration_ledger(v1_community_id: &str) -> Result<Option<(String, i64, String)>, String> {
2554 let conn = super::get_db_connection_guard_static()?;
2555 let row = conn
2556 .query_row(
2557 "SELECT v2_community_id, phase, twin FROM community_migrations WHERE community_id = ?1",
2558 params![v1_community_id],
2559 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)),
2560 )
2561 .optional()
2562 .map_err(|e| format!("get migration ledger: {e}"))?;
2563 Ok(row.map(|(v2, phase, twin)| (v2, phase, dec_txt(&twin))))
2564}
2565
2566pub fn reparent_channels_and_fence(v1_community_id: &str, v2_community_id: &str) -> Result<(), String> {
2576 let conn = super::get_write_connection_guard_static()?;
2577 let tx = conn.unchecked_transaction().map_err(|e| format!("flip txn: {e}"))?;
2578 tx.execute(
2579 "UPDATE community_channels SET community_id = ?2 WHERE community_id = ?1",
2580 params![v1_community_id, v2_community_id],
2581 )
2582 .map_err(|e| format!("reparent channels: {e}"))?;
2583 tx.execute(
2587 "UPDATE communities SET migrated_to = ?2, dissolved = 1 WHERE community_id = ?1 AND migrated_to IS NULL",
2588 params![v1_community_id, v2_community_id],
2589 )
2590 .map_err(|e| format!("set fence: {e}"))?;
2591 tx.commit().map_err(|e| format!("flip commit: {e}"))?;
2592 forget_community_channels(v1_community_id);
2596 Ok(())
2597}
2598
2599pub fn migration_sweep_candidates() -> Result<Vec<String>, String> {
2603 let conn = super::get_db_connection_guard_static()?;
2604 let mut stmt = conn
2612 .prepare(
2613 "SELECT community_id FROM communities
2614 WHERE migrated_to IS NULL AND migration_checked = 0
2615 AND (protocol IS NULL OR protocol = 1)
2616 ORDER BY dissolved DESC
2617 LIMIT 40",
2618 )
2619 .map_err(|e| e.to_string())?;
2620 let rows = stmt
2621 .query_map([], |r| r.get::<_, String>(0))
2622 .map_err(|e| e.to_string())?;
2623 Ok(rows.flatten().collect())
2624}
2625
2626pub fn migration_flip_candidates() -> Result<Vec<String>, String> {
2630 let conn = super::get_db_connection_guard_static()?;
2631 let mut stmt = conn
2632 .prepare(
2633 "SELECT community_id FROM communities
2634 WHERE migration_pointer IS NOT NULL AND migrated_to IS NULL",
2635 )
2636 .map_err(|e| e.to_string())?;
2637 let rows = stmt
2638 .query_map([], |r| r.get::<_, String>(0))
2639 .map_err(|e| e.to_string())?;
2640 Ok(rows.flatten().collect())
2641}
2642
2643pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
2646 let conn = super::get_db_connection_guard_static()?;
2647 let v: Option<i64> = conn
2648 .query_row(
2649 "SELECT dissolved FROM communities WHERE community_id = ?1",
2650 params![community_id],
2651 |r| r.get(0),
2652 )
2653 .optional()
2654 .map_err(|e| format!("get dissolved: {e}"))?;
2655 Ok(v.unwrap_or(0) != 0)
2656}
2657
2658pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
2661 let conn = super::get_db_connection_guard_static()?;
2662 let v: Option<i64> = conn
2663 .query_row(
2664 "SELECT read_cut_pending FROM communities WHERE community_id = ?1",
2665 params![community_id],
2666 |r| r.get(0),
2667 )
2668 .optional()
2669 .map_err(|e| format!("get read_cut_pending: {e}"))?;
2670 Ok(v.unwrap_or(0) != 0)
2671}
2672
2673pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
2678 let conn = super::get_write_connection_guard_static()?;
2679 conn.execute(
2680 "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
2681 params![target as i64, community_id],
2682 )
2683 .map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
2684 Ok(())
2685}
2686
2687pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
2690 let conn = super::get_db_connection_guard_static()?;
2691 let v: Option<i64> = conn
2692 .query_row(
2693 "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
2694 params![community_id],
2695 |r| r.get(0),
2696 )
2697 .optional()
2698 .map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
2699 Ok(v.unwrap_or(0) as u64)
2700}
2701
2702pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
2705 let conn = super::get_db_connection_guard_static()?;
2706 let v: Option<i64> = conn
2707 .query_row(
2708 "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
2709 params![community_id, channel_id],
2710 |r| r.get(0),
2711 )
2712 .optional()
2713 .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
2714 Ok(v.unwrap_or(0) as u64)
2715}
2716
2717pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
2721 let conn = super::get_write_connection_guard_static()?;
2722 conn.execute(
2723 "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
2724 params![server_epoch as i64, community_id, channel_id],
2725 )
2726 .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
2727 Ok(())
2728}
2729
2730pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
2732 let conn = super::get_db_connection_guard_static()?;
2733 let mut stmt = conn
2734 .prepare("SELECT community_id FROM communities ORDER BY created_at")
2735 .map_err(|e| e.to_string())?;
2736 let rows = stmt
2737 .query_map([], |r| r.get::<_, String>(0))
2738 .map_err(|e| e.to_string())?;
2739 let mut ids = Vec::new();
2740 for row in rows {
2741 ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
2742 }
2743 Ok(ids)
2744}
2745
2746pub fn community_protocol(id: &CommunityId) -> Result<Option<crate::community::ConcordProtocol>, String> {
2757 let conn = super::get_db_connection_guard_static()?;
2758 let n: Option<i64> = conn
2759 .query_row("SELECT protocol FROM communities WHERE community_id = ?1", params![id.to_hex()], |r| r.get(0))
2760 .optional()
2761 .map_err(|e| e.to_string())?;
2762 Ok(n.map(crate::community::ConcordProtocol::from_i64))
2763}
2764
2765#[derive(serde::Serialize, serde::Deserialize, Default)]
2768struct CommunityMetaStash {
2769 #[serde(default, skip_serializing_if = "Option::is_none")]
2770 custom: Option<serde_json::Map<String, serde_json::Value>>,
2771 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
2772 extra: serde_json::Map<String, serde_json::Value>,
2773}
2774
2775#[derive(serde::Serialize, serde::Deserialize, Default)]
2777struct ChannelMetaStash {
2778 #[serde(default, skip_serializing_if = "Option::is_none")]
2779 voice: Option<bool>,
2780 #[serde(default, skip_serializing_if = "Option::is_none")]
2781 custom: Option<serde_json::Map<String, serde_json::Value>>,
2782 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
2783 extra: serde_json::Map<String, serde_json::Value>,
2784}
2785
2786pub fn save_community_v2(c: &crate::community::v2::community::CommunityV2) -> Result<(), String> {
2789 let conn = super::get_write_connection_guard_static()?;
2790 let id_hex = crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0);
2791 let relays_json = serde_json::to_string(&c.relays).map_err(|e| e.to_string())?;
2792 let created = (c.created_at_ms / 1000) as i64;
2793
2794 let enc_root = enc_key(&c.community_root)?;
2795 let enc_name = enc_txt(&c.name)?;
2796 let enc_relays = enc_txt(&relays_json)?;
2797 let enc_desc = enc_txt_opt(&c.description)?;
2798 let enc_owner_pk = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_xonly))?;
2799 let enc_owner_salt = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_salt))?;
2800 let icon_json = c.icon.as_ref().map(|i| serde_json::to_string(i).map_err(|e| e.to_string())).transpose()?;
2804 let banner_json = c.banner.as_ref().map(|b| serde_json::to_string(b).map_err(|e| e.to_string())).transpose()?;
2805 let enc_icon = enc_txt_opt(&icon_json)?;
2806 let enc_banner = enc_txt_opt(&banner_json)?;
2807 let stash_json = (c.meta_custom.is_some() || !c.meta_extra.is_empty())
2808 .then(|| serde_json::to_string(&CommunityMetaStash { custom: c.meta_custom.clone(), extra: c.meta_extra.clone() }).map_err(|e| e.to_string()))
2809 .transpose()?;
2810 let enc_stash = enc_txt_opt(&stash_json)?;
2811 let enc_control_pk = enc_txt_opt(&c.control_pk.map(|p| p.to_hex()))?;
2814 let enc_control_root = c.control_root.as_ref().map(enc_key).transpose()?;
2815
2816 let tx = conn.unchecked_transaction().map_err(|e| format!("save v2 community tx: {e}"))?;
2817 tx.execute(
2818 "INSERT INTO communities
2819 (community_id, server_root_key, name, relays, created_at, description,
2820 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt, icon, banner, meta_extra,
2821 control_pk, control_root)
2822 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
2823 ON CONFLICT(community_id) DO UPDATE SET
2824 server_root_key=?2, name=?3, relays=?4, description=?6,
2825 server_root_epoch=?7, dissolved=?8, protocol=2, owner_pubkey=?9, owner_salt=?10,
2826 icon=?11, banner=?12, meta_extra=?13, control_pk=?14, control_root=?15",
2827 params![
2828 id_hex, enc_root, enc_name, enc_relays, created, enc_desc,
2829 c.root_epoch.0 as i64, c.dissolved as i64, enc_owner_pk, enc_owner_salt,
2830 enc_icon, enc_banner, enc_stash, enc_control_pk, enc_control_root,
2831 ],
2832 )
2833 .map_err(|e| format!("save v2 community: {e}"))?;
2834
2835 for ch in &c.channels {
2836 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2837 let owner_of: Option<String> = tx
2843 .query_row("SELECT community_id FROM community_channels WHERE channel_id=?1", params![ch_hex], |r| r.get(0))
2844 .optional()
2845 .map_err(|e| format!("channel ownership check: {e}"))?;
2846 if owner_of.is_some_and(|existing| existing != id_hex) {
2847 continue;
2853 }
2854 let stored_key = ch.key.unwrap_or(c.community_root);
2858 let enc_ch_key = enc_key(&stored_key)?;
2859 let enc_ch_name = enc_txt(&ch.name)?;
2860 let ch_stash_json = (ch.voice.is_some() || ch.meta_custom.is_some() || !ch.meta_extra.is_empty())
2861 .then(|| {
2862 serde_json::to_string(&ChannelMetaStash { voice: ch.voice, custom: ch.meta_custom.clone(), extra: ch.meta_extra.clone() })
2863 .map_err(|e| e.to_string())
2864 })
2865 .transpose()?;
2866 let enc_ch_stash = enc_txt_opt(&ch_stash_json)?;
2867 tx.execute(
2868 "INSERT INTO community_channels
2869 (channel_id, community_id, channel_key, epoch, name, created_at, private, meta_extra)
2870 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
2871 ON CONFLICT(channel_id) DO UPDATE SET
2872 channel_key=?3, epoch=?4, name=?5, private=?7, meta_extra=?8",
2873 params![ch_hex, id_hex, enc_ch_key, ch.epoch.0 as i64, enc_ch_name, created, ch.private as i64, enc_ch_stash],
2874 )
2875 .map_err(|e| format!("save v2 channel: {e}"))?;
2876 }
2877
2878 let keep: Vec<String> = c.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
2883 if keep.is_empty() {
2884 tx.execute("DELETE FROM community_channels WHERE community_id=?1", params![id_hex])
2885 .map_err(|e| format!("prune v2 channels: {e}"))?;
2886 } else {
2887 let placeholders = std::iter::repeat("?").take(keep.len()).collect::<Vec<_>>().join(",");
2888 let sql = format!("DELETE FROM community_channels WHERE community_id=? AND channel_id NOT IN ({placeholders})");
2889 let mut binds: Vec<String> = Vec::with_capacity(keep.len() + 1);
2890 binds.push(id_hex.clone());
2891 binds.extend(keep);
2892 tx.execute(&sql, rusqlite::params_from_iter(binds.iter()))
2893 .map_err(|e| format!("prune v2 channels: {e}"))?;
2894 }
2895
2896 tx.commit().map_err(|e| format!("commit v2 community: {e}"))?;
2897 forget_community_channels(&id_hex);
2900 Ok(())
2901}
2902
2903pub fn load_community_v2(id: &CommunityId) -> Result<Option<crate::community::v2::community::CommunityV2>, String> {
2905 use crate::community::v2::community::{ChannelV2, CommunityV2};
2906 use crate::community::v2::control::CommunityIdentity;
2907 let conn = super::get_db_connection_guard_static()?;
2908 let id_hex = id.to_hex();
2909
2910 let row = conn
2911 .query_row(
2912 "SELECT server_root_key, name, relays, created_at, description,
2913 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt,
2914 icon, banner, meta_extra, control_pk, control_root
2915 FROM communities WHERE community_id = ?1",
2916 params![id_hex],
2917 |r| {
2918 Ok((
2919 r.get::<_, Vec<u8>>(0)?,
2920 r.get::<_, String>(1)?,
2921 r.get::<_, String>(2)?,
2922 r.get::<_, i64>(3)?,
2923 r.get::<_, Option<String>>(4)?,
2924 r.get::<_, i64>(5)?,
2925 r.get::<_, i64>(6)?,
2926 r.get::<_, i64>(7)?,
2927 r.get::<_, Option<String>>(8)?,
2928 r.get::<_, Option<String>>(9)?,
2929 r.get::<_, Option<String>>(10)?,
2930 r.get::<_, Option<String>>(11)?,
2931 r.get::<_, Option<String>>(12)?,
2932 r.get::<_, Option<String>>(13)?,
2933 r.get::<_, Option<Vec<u8>>>(14)?,
2934 ))
2935 },
2936 )
2937 .optional()
2938 .map_err(|e| e.to_string())?;
2939 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
2940 else {
2941 return Ok(None);
2942 };
2943 if crate::community::ConcordProtocol::from_i64(protocol) != crate::community::ConcordProtocol::V2 {
2944 return Ok(None);
2945 }
2946 let (Some(owner_pk_e), Some(owner_salt_e)) = (owner_pk_e, owner_salt_e) else {
2947 return Err("v2 community row is missing its owner commitment".to_string());
2948 };
2949
2950 let community_root = dec_key(&root_blob)?;
2951 let owner_xonly = parse_hex32(&dec_txt(&owner_pk_e))?;
2952 let owner_salt = parse_hex32(&dec_txt(&owner_salt_e))?;
2953 let identity = CommunityIdentity { community_id: *id, owner_xonly, owner_salt };
2954 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_e)).unwrap_or_default();
2955
2956 let mut channels = Vec::new();
2957 {
2958 let mut stmt = conn
2959 .prepare(
2960 "SELECT channel_id, channel_key, epoch, name, private, meta_extra
2961 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
2962 )
2963 .map_err(|e| e.to_string())?;
2964 let rows = stmt
2965 .query_map(params![id_hex], |r| {
2966 Ok((
2967 r.get::<_, String>(0)?,
2968 r.get::<_, Vec<u8>>(1)?,
2969 r.get::<_, i64>(2)?,
2970 r.get::<_, String>(3)?,
2971 r.get::<_, i64>(4)?,
2972 r.get::<_, Option<String>>(5)?,
2973 ))
2974 })
2975 .map_err(|e| e.to_string())?;
2976 for row in rows {
2977 let (ch_hex, key_blob, epoch, name_e, private, ch_stash_e) = row.map_err(|e| e.to_string())?;
2978 let private = private != 0;
2979 let key = dec_key(&key_blob)?;
2980 let ch_stash: ChannelMetaStash = ch_stash_e
2983 .map(|s| dec_txt(&s))
2984 .and_then(|j| serde_json::from_str(&j).ok())
2985 .unwrap_or_default();
2986 channels.push(ChannelV2 {
2987 id: ChannelId(hex_id_to_32(&ch_hex)?),
2988 name: dec_txt(&name_e),
2989 private,
2990 key: (private && key != community_root).then_some(key),
2997 epoch: Epoch(epoch as u64),
2998 voice: ch_stash.voice,
2999 meta_custom: ch_stash.custom,
3000 meta_extra: ch_stash.extra,
3001 });
3002 }
3003 }
3004
3005 let icon = icon_e
3008 .map(|s| dec_txt(&s))
3009 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
3010 let banner = banner_e
3011 .map(|s| dec_txt(&s))
3012 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
3013 let stash: CommunityMetaStash = stash_e
3014 .map(|s| dec_txt(&s))
3015 .and_then(|j| serde_json::from_str(&j).ok())
3016 .unwrap_or_default();
3017
3018 let control_pk = control_pk_e
3023 .as_deref()
3024 .and_then(|e| nostr_sdk::prelude::PublicKey::from_hex(&dec_txt(e)).ok());
3025 let control_root = match (control_pk, control_root_b) {
3026 (Some(pk), Some(blob)) => dec_key(&blob).ok().filter(|cr| {
3027 crate::community::v2::derive::control_signer_group_key(cr, id, Epoch(root_epoch as u64)).pk() == pk
3028 }),
3029 _ => None,
3030 };
3031
3032 Ok(Some(CommunityV2 {
3033 identity,
3034 community_root,
3035 root_epoch: Epoch(root_epoch as u64),
3036 control_pk,
3037 control_root,
3038 name: dec_txt(&name_e),
3039 description: desc_e.map(|d| dec_txt(&d)),
3040 icon,
3041 banner,
3042 meta_custom: stash.custom,
3043 meta_extra: stash.extra,
3044 relays,
3045 channels,
3046 dissolved: dissolved != 0,
3047 created_at_ms: (created as u64).saturating_mul(1000),
3048 }))
3049}
3050
3051fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
3052 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
3053 return Err("stored value is not 32-byte hex".to_string());
3054 }
3055 Ok(crate::simd::hex::hex_to_bytes_32(hex))
3056}
3057
3058pub fn get_guestbook(community_id: &str) -> Result<(Vec<crate::community::v2::guestbook::GuestbookEvent>, u64), String> {
3062 let conn = super::get_db_connection_guard_static()?;
3063 let row = conn
3064 .query_row(
3065 "SELECT events, cursor_secs FROM community_guestbook WHERE community_id = ?1",
3066 params![community_id],
3067 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
3068 )
3069 .optional()
3070 .map_err(|e| format!("load guestbook: {e}"))?;
3071 let Some((events_e, cursor)) = row else {
3072 return Ok((Vec::new(), 0));
3073 };
3074 let events = serde_json::from_str(&dec_txt(&events_e)).unwrap_or_default();
3075 Ok((events, cursor.max(0) as u64))
3076}
3077
3078pub fn set_guestbook(
3081 community_id: &str,
3082 events: &[crate::community::v2::guestbook::GuestbookEvent],
3083 cursor_secs: u64,
3084) -> Result<(), String> {
3085 forget_console_verdict(community_id);
3086 let conn = super::get_write_connection_guard_static()?;
3087 let json = serde_json::to_string(events).map_err(|e| e.to_string())?;
3088 let enc = enc_txt(&json)?;
3089 conn.execute(
3090 "INSERT INTO community_guestbook (community_id, events, cursor_secs)
3091 VALUES (?1, ?2, ?3)
3092 ON CONFLICT(community_id) DO UPDATE SET events=?2, cursor_secs=?3",
3093 params![community_id, enc, cursor_secs as i64],
3094 )
3095 .map_err(|e| format!("save guestbook: {e}"))?;
3096 Ok(())
3097}
3098
3099#[cfg(test)]
3100mod tests {
3101 use nostr_sdk::prelude::FinalizeEvent;
3102 use super::*;
3103
3104 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
3105
3106 fn make_test_npub(n: u32) -> String {
3109 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3110 let mut payload = vec![b'q'; 58];
3111 let mut x = n as u64;
3112 let mut i = 58;
3113 while x > 0 && i > 0 {
3114 i -= 1;
3115 payload[i] = BECH32[(x as usize) % 32];
3116 x /= 32;
3117 }
3118 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
3119 }
3120
3121 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
3122 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3123 crate::db::close_database();
3124 crate::db::clear_id_caches();
3127 let tmp = tempfile::tempdir().unwrap();
3128 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3129 let account = make_test_npub(n);
3130 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3131 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
3132 crate::db::set_current_account(account.clone()).unwrap();
3133 crate::db::init_database(&account).unwrap();
3134 (tmp, guard)
3135 }
3136
3137 #[test]
3138 fn edition_head_round_trips_and_upserts() {
3139 let (_tmp, _guard) = init_test_db();
3140 let cid = "f".repeat(64);
3141 let entity = "a".repeat(64);
3142
3143 assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
3145
3146 let h1 = [0x11u8; 32];
3148 set_edition_head(&cid, &entity, 1, &h1).unwrap();
3149 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
3150
3151 let h2 = [0x22u8; 32];
3153 set_edition_head(&cid, &entity, 2, &h2).unwrap();
3154 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
3155
3156 set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
3159 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
3160 set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
3161 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
3162
3163 let other = "b".repeat(64);
3165 assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
3166 }
3167
3168 #[test]
3169 fn guestbook_round_trips_events_and_cursor() {
3170 let (_tmp, _guard) = init_test_db();
3171 let member = nostr_sdk::prelude::Keys::generate();
3172 let ev = crate::community::v2::guestbook::GuestbookEvent { epoch: None, observed_at: None,
3173 rumor_id: [7u8; 32],
3174 entry: crate::community::v2::guestbook::GuestbookEntry::Join {
3175 member: member.public_key(),
3176 invited_by: Some(("creator".into(), "label".into())),
3177 at_ms: 1_000,
3178 },
3179 };
3180 let cid = "d".repeat(64);
3181 assert_eq!(get_guestbook(&cid).unwrap(), (Vec::new(), 0), "absent reads as empty at cursor 0");
3182 set_guestbook(&cid, std::slice::from_ref(&ev), 42).unwrap();
3183 let (events, cursor) = get_guestbook(&cid).unwrap();
3184 assert_eq!(events, vec![ev], "events round-trip through the encrypted blob");
3185 assert_eq!(cursor, 42);
3186 }
3187
3188 #[test]
3189 fn v2_images_round_trip_and_read_as_v1_community_images() {
3190 let (_tmp, _guard) = init_test_db();
3191 let owner = nostr_sdk::prelude::Keys::generate();
3192 let g = crate::community::v2::control::genesis(
3193 &owner,
3194 crate::community::v2::control::CommunityMetadata { name: "Icons".into(), ..Default::default() },
3195 1_000,
3196 )
3197 .unwrap();
3198 let mut c = crate::community::v2::community::CommunityV2::from_genesis(&g, "Icons", None, vec!["wss://r".into()], 1_000);
3199 let mut extra = serde_json::Map::new();
3200 extra.insert("ext".into(), serde_json::Value::String("webp".into()));
3201 c.icon = Some(crate::community::v2::control::ImageRef {
3202 url: "https://blossom.example/abc".into(),
3203 key: "0".repeat(64),
3204 nonce: "1".repeat(32),
3205 hash: "a".repeat(64),
3206 extra,
3207 });
3208 c.meta_custom = Some({
3209 let mut m = serde_json::Map::new();
3210 m.insert("k".into(), serde_json::Value::from("v"));
3211 m
3212 });
3213 c.channels[0].voice = Some(true);
3214 c.channels[0].meta_extra.insert("vnd".into(), serde_json::Value::from(7));
3215 save_community_v2(&c).unwrap();
3216
3217 let re = load_community_v2(c.id()).unwrap().unwrap();
3219 assert_eq!(re.icon, c.icon);
3220 assert_eq!(re.banner, None);
3221 assert_eq!(re.meta_custom, c.meta_custom);
3223 assert_eq!(re.channels[0].voice, Some(true));
3224 assert_eq!(re.channels[0].meta_extra.get("vnd"), Some(&serde_json::Value::from(7)));
3225
3226 let v1 = load_community(c.id()).unwrap().unwrap();
3230 let img = v1.icon.expect("v1 reader sees the v2 icon");
3231 assert_eq!(img.url, "https://blossom.example/abc");
3232 assert_eq!(img.ext, "webp");
3233 assert_eq!(img.hash, "a".repeat(64));
3234 }
3235
3236 #[test]
3237 fn server_root_epoch_round_trips() {
3238 let (_tmp, _guard) = init_test_db();
3240 let mut c = Community::create("HQ", "general", vec![]);
3241 save_community(&c).unwrap();
3242 assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
3243
3244 c.server_root_epoch = Epoch(5);
3245 c.server_root_key = ServerRootKey([0x42u8; 32]);
3246 save_community(&c).unwrap();
3247 let loaded = load_community(&c.id).unwrap().unwrap();
3248 assert_eq!(loaded.server_root_epoch, Epoch(5));
3249 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
3250 }
3251
3252 #[test]
3253 fn epoch_key_archive_retains_every_epoch() {
3254 let (_tmp, _guard) = init_test_db();
3257 let cid = "f".repeat(64);
3258 let scope = "a".repeat(64);
3259
3260 store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
3261 store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
3262 store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
3263
3264 let held = held_epoch_keys(&cid, &scope).unwrap();
3265 assert_eq!(held.len(), 3, "all three epoch keys retained");
3266 assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
3267 assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
3268 assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
3269
3270 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
3272 assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
3273
3274 store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
3276 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
3277 assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
3278
3279 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
3282 assert_eq!(
3283 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
3284 None,
3285 "epoch 1 under a different scope is not the channel's key"
3286 );
3287 }
3288
3289 #[test]
3290 fn save_community_populates_the_epoch_archive() {
3291 let (_tmp, _guard) = init_test_db();
3294 let c = Community::create("HQ", "general", vec![]);
3295 save_community(&c).unwrap();
3296 let cid = c.id.to_hex();
3297
3298 assert_eq!(
3300 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
3301 Some(c.server_root_key.as_bytes())
3302 );
3303 let chan = &c.channels[0];
3305 assert_eq!(
3306 held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
3307 Some(chan.key.as_bytes())
3308 );
3309 }
3310
3311 #[test]
3312 fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
3313 let (_tmp, _guard) = init_test_db();
3314 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
3317 crate::state::set_encryption_enabled(true);
3318
3319 let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
3320 c.server_root_key = ServerRootKey([0x42u8; 32]);
3321 c.description = Some("top secret".into());
3322 save_community(&c).unwrap();
3323 let cid = c.id.to_hex();
3324 set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
3325
3326 {
3329 let conn = crate::db::get_db_connection_guard_static().unwrap();
3330 let (root_len, name, banlist): (i64, String, String) = conn
3331 .query_row(
3332 "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
3333 params![cid],
3334 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
3335 )
3336 .unwrap();
3337 assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
3338 assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
3339 assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
3340 let _ = &banlist;
3346 let banned_npub: String = conn
3347 .query_row("SELECT npub FROM community_bans WHERE community_id = ?1 LIMIT 1", params![cid], |r| r.get(0))
3348 .unwrap();
3349 assert!(
3352 PublicKey::parse(&banned_npub).is_ok(),
3353 "the banlist is queryable plaintext by design, got {banned_npub}"
3354 );
3355 assert!(
3356 banned_npub.starts_with("npub1"),
3357 "stored in the form `events.npub` uses, or the join matches nothing"
3358 );
3359 let key_len: i64 = conn
3360 .query_row(
3361 "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
3362 params![cid],
3363 |r| r.get(0),
3364 )
3365 .unwrap();
3366 assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
3367 }
3368
3369 let loaded = load_community(&c.id).unwrap().unwrap();
3371 assert_eq!(loaded.name, "Secret HQ");
3372 assert_eq!(loaded.description.as_deref(), Some("top secret"));
3373 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
3374 assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
3375 assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
3376
3377 crate::state::set_encryption_enabled(false);
3378 crate::state::ENCRYPTION_KEY.clear(&[]);
3379 }
3380
3381 #[test]
3382 fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
3383 let (_tmp, _guard) = init_test_db();
3386 crate::state::set_encryption_enabled(false);
3387 let mut c = Community::create("Legacy HQ", "general", vec![]);
3388 c.server_root_key = ServerRootKey([0x42u8; 32]);
3389 save_community(&c).unwrap();
3390
3391 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
3392 crate::state::set_encryption_enabled(true);
3393 let loaded = load_community(&c.id).unwrap().unwrap();
3394 assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
3395 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
3396
3397 crate::state::set_encryption_enabled(false);
3398 crate::state::ENCRYPTION_KEY.clear(&[]);
3399 }
3400
3401 #[test]
3402 fn save_and_load_round_trip() {
3403 let (_tmp, _guard) = init_test_db();
3404 let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
3405 save_community(&original).unwrap();
3406
3407 let loaded = load_community(&original.id).unwrap().expect("present");
3408 assert_eq!(loaded.id, original.id);
3409 assert_eq!(loaded.name, "Vector HQ");
3410 assert_eq!(loaded.relays, original.relays);
3411 assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
3413 assert_eq!(loaded.channels.len(), 1);
3415 assert_eq!(loaded.channels[0].id, original.channels[0].id);
3416 assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
3417 assert_eq!(loaded.channels[0].epoch, Epoch(0));
3418 assert_eq!(loaded.channels[0].name, "general");
3419 }
3420
3421 #[test]
3422 fn owner_is_protected_from_the_banlist_a_member_is_not() {
3423 let (_tmp, _guard) = init_test_db();
3424 let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
3425 let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
3427 community.owner_attestation = Some(
3428 crate::community::owner::build_owner_attestation_unsigned(
3429 owner_id.public_key(),
3430 &community.id.to_hex(),
3431 )
3432 .finalize(&owner_id)
3433 .unwrap()
3434 .as_json(),
3435 );
3436 save_community(&community).unwrap();
3437
3438 let member = Keys::generate();
3440 set_community_banlist(
3441 &community.id.to_hex(),
3442 &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
3443 1,
3444 )
3445 .unwrap();
3446
3447 let loaded = load_community(&community.id).unwrap().unwrap();
3448 let ch = &loaded.channels[0];
3449 assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
3451 assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
3452 assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
3454 }
3455
3456 #[test]
3457 fn loaded_keys_actually_decrypt() {
3458 let (_tmp, _guard) = init_test_db();
3461 let original = Community::create("HQ", "general", vec![]);
3462 save_community(&original).unwrap();
3463 let loaded = load_community(&original.id).unwrap().unwrap();
3464
3465 let author = nostr_sdk::prelude::Keys::generate();
3466 let chan = &original.channels[0];
3467 let sealed = crate::community::envelope::seal_message(
3468 &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
3469 )
3470 .unwrap();
3471 let opened = crate::community::envelope::open_message(
3472 &sealed,
3473 &loaded.channels[0].key,
3474 &loaded.channels[0].id,
3475 loaded.channels[0].epoch,
3476 )
3477 .unwrap();
3478 assert_eq!(opened.content, "persisted!");
3479 }
3480
3481 #[test]
3482 fn member_view_round_trips() {
3483 let (_tmp, _guard) = init_test_db();
3486 let member = Community {
3487 id: CommunityId([7u8; 32]),
3488 server_root_key: ServerRootKey([8u8; 32]),
3489 server_root_epoch: Epoch(0),
3490 name: "Joined".into(),
3491 description: None,
3492 icon: None,
3493 banner: None,
3494 relays: vec!["wss://r".into()],
3495 channels: vec![Channel {
3496 id: ChannelId([9u8; 32]),
3497 key: ChannelKey([10u8; 32]),
3498 epoch: Epoch(0),
3499 name: "general".into(),
3500 banned: Vec::new(),
3501 protected: Vec::new(), roster: Default::default(),
3502 epoch_keys: Vec::new(),
3503 dissolved: false,
3504 }],
3505 owner_attestation: None,
3506 dissolved: false,
3507 };
3508 save_community(&member).unwrap();
3509 let loaded = load_community(&member.id).unwrap().expect("present");
3510 assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
3511 assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
3512 }
3513
3514 #[test]
3515 fn large_epoch_round_trips_losslessly() {
3516 let (_tmp, _guard) = init_test_db();
3518 let mut c = Community::create("HQ", "g", vec![]);
3519 c.channels[0].epoch = Epoch(u64::MAX - 7);
3520 save_community(&c).unwrap();
3521 let loaded = load_community(&c.id).unwrap().unwrap();
3522 assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
3523 }
3524
3525 #[test]
3526 fn malformed_channel_id_row_errors_not_corrupts() {
3527 let (_tmp, _guard) = init_test_db();
3530 let c = Community::create("HQ", "g", vec![]);
3531 save_community(&c).unwrap();
3532 {
3533 let conn = crate::db::get_write_connection_guard_static().unwrap();
3534 conn.execute(
3535 "INSERT OR REPLACE INTO community_channels
3536 (channel_id, community_id, channel_key, epoch, name, created_at)
3537 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
3538 rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
3539 )
3540 .unwrap();
3541 }
3542 assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
3543 }
3544
3545 #[test]
3546 fn message_key_store_take_round_trip() {
3547 let (_tmp, _guard) = init_test_db();
3548 let eph = Keys::generate();
3549 let relays = vec!["wss://r.one".to_string()];
3550 store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
3552
3553 let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
3554 assert_eq!(
3555 loaded.secret_key().as_secret_bytes(),
3556 eph.secret_key().as_secret_bytes()
3557 );
3558 assert_eq!(outer, "outer_evid");
3559 assert_eq!(r, relays);
3560 assert!(take_message_key("inner_msg_id").unwrap().is_none());
3562 }
3563
3564 #[test]
3565 fn missing_community_is_none() {
3566 let (_tmp, _guard) = init_test_db();
3567 let absent = CommunityId([0x33u8; 32]);
3568 assert!(load_community(&absent).unwrap().is_none());
3569 }
3570
3571 #[test]
3572 fn list_ids_reflects_saved() {
3573 let (_tmp, _guard) = init_test_db();
3574 let a = Community::create("A", "g", vec![]);
3575 let b = Community::create("B", "g", vec![]);
3576 save_community(&a).unwrap();
3577 save_community(&b).unwrap();
3578 let ids = list_community_ids().unwrap();
3579 assert_eq!(ids.len(), 2);
3580 assert!(ids.contains(&a.id) && ids.contains(&b.id));
3581 }
3582
3583 #[test]
3584 fn delete_community_clears_all_local_state() {
3585 let (_tmp, _guard) = init_test_db();
3586 let c = Community::create("HQ", "general", vec!["r1".into()]);
3587 save_community(&c).unwrap();
3588 let cid = c.id.to_hex();
3589 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
3590 save_pending_invite(&"cd".repeat(32), "{}", "npub1x", 0).unwrap();
3591 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
3592
3593 assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
3595
3596 delete_community(&cid).unwrap();
3597 assert!(!community_exists(&c.id).unwrap());
3598 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
3599 assert!(list_public_invites(&cid).unwrap().is_empty());
3600 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
3601 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
3602 }
3603
3604 #[test]
3605 fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
3606 let (_tmp, _guard) = init_test_db();
3609 let c = Community::create("HQ", "general", vec!["r1".into()]);
3610 save_community(&c).unwrap();
3611 let cid = c.id.to_hex();
3612 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
3613 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
3614
3615 let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
3616 let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
3617 assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
3618
3619 delete_community_retain_keys(&cid).unwrap();
3620
3621 assert!(!community_exists(&c.id).unwrap());
3623 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
3624 assert!(list_public_invites(&cid).unwrap().is_empty());
3625 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
3626 assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
3628 "base epoch keys retained for self-scrub");
3629 assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
3630 "channel epoch keys retained for self-scrub");
3631 }
3632
3633 #[test]
3634 fn channel_resolves_to_owning_community() {
3635 let (_tmp, _guard) = init_test_db();
3636 let c = Community::create("HQ", "general", vec![]);
3637 save_community(&c).unwrap();
3638 let chan = c.channels[0].id.to_hex();
3639 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
3640 assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
3641 }
3642
3643 #[test]
3644 fn community_exists_reflects_saved() {
3645 let (_tmp, _guard) = init_test_db();
3646 let c = Community::create("A", "g", vec![]);
3647 assert!(!community_exists(&c.id).unwrap());
3648 save_community(&c).unwrap();
3649 assert!(community_exists(&c.id).unwrap());
3650 }
3651
3652 #[test]
3653 fn reparent_moves_channels_stamps_fence_and_invalidates_cache() {
3654 let (_tmp, _guard) = init_test_db();
3655 let v1 = Community::create("HQ", "general", vec![]);
3656 save_community(&v1).unwrap();
3657 let v1_cid = v1.id.to_hex();
3658 let v2_cid = "ab".repeat(32);
3659 let chan = v1.channels[0].id.to_hex();
3660
3661 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v1_cid.as_str()));
3663 reparent_channels_and_fence(&v1_cid, &v2_cid).unwrap();
3664
3665 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v2_cid.as_str()),
3667 "stale v1 cache entry must not survive the re-parent");
3668 assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()));
3669 assert!(get_community_dissolved(&v1_cid).unwrap(), "flip seals v1 (fence layer 0)");
3670
3671 reparent_channels_and_fence(&v1_cid, &"cd".repeat(32)).unwrap();
3673 assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()),
3674 "migrated_to is one-way — a second flip cannot repoint it");
3675 }
3676
3677 #[test]
3678 fn migration_sweep_candidates_include_unsealed_v1() {
3679 let (_tmp, _guard) = init_test_db();
3680 let a = Community::create("A", "g", vec![]);
3681 let b = Community::create("B", "g", vec![]);
3682 let c = Community::create("C", "g", vec![]);
3683 for x in [&a, &b, &c] { save_community(x).unwrap(); }
3684 set_community_dissolved(&a.id.to_hex()).unwrap();
3685 set_community_dissolved(&b.id.to_hex()).unwrap();
3686 set_migrated_to(&b.id.to_hex(), &"ab".repeat(32)).unwrap();
3687 let cands = migration_sweep_candidates().unwrap();
3688 assert!(cands.contains(&a.id.to_hex()), "sealed + unchecked is a candidate");
3689 assert!(!cands.contains(&b.id.to_hex()), "flipped is not a candidate");
3690 assert!(cands.contains(&c.id.to_hex()), "UNSEALED v1 is a candidate too");
3694 set_migration_checked(&a.id.to_hex()).unwrap();
3696 set_migration_checked(&c.id.to_hex()).unwrap();
3697 let after = migration_sweep_candidates().unwrap();
3698 assert!(!after.contains(&a.id.to_hex()) && !after.contains(&c.id.to_hex()));
3699 }
3700
3701 #[test]
3702 fn pending_invite_first_wins_and_round_trips() {
3703 let (_tmp, _guard) = init_test_db();
3704 let cid = "ab".repeat(32);
3705 assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter", 0).unwrap());
3708 assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other", 0).unwrap());
3709 assert!(pending_invite_exists(&cid).unwrap());
3710
3711 let listed = list_pending_invites().unwrap();
3712 assert_eq!(listed.len(), 1);
3713 assert_eq!(listed[0].community_id, cid);
3714 assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
3715 assert_eq!(listed[0].inviter_npub, "npub1inviter");
3716
3717 assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
3719 assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
3720 delete_pending_invite(&cid).unwrap();
3721 assert!(!pending_invite_exists(&cid).unwrap());
3722 assert!(get_pending_invite(&cid).unwrap().is_none());
3723 }
3724
3725 #[test]
3726 fn purge_drops_invites_for_held_communities_only() {
3727 let (_tmp, _guard) = init_test_db();
3728 let held = Community::create("Held", "general", vec![]);
3731 save_community(&held).unwrap();
3732 let held_hex = held.id.to_hex();
3733 save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter", 0).unwrap();
3734 let stranger = "ab".repeat(32);
3736 save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter", 0).unwrap();
3737
3738 let n = purge_pending_invites_for_held_communities().unwrap();
3739 assert_eq!(n, 1, "only the held community's invite is purged");
3740 assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
3741 assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
3742 }
3743
3744 #[test]
3745 fn decline_drops_pending_invite() {
3746 let (_tmp, _guard) = init_test_db();
3747 let cid = "cd".repeat(32);
3748 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3749 delete_pending_invite(&cid).unwrap();
3750 assert!(!pending_invite_exists(&cid).unwrap());
3751 }
3752
3753 #[test]
3754 fn pending_invites_are_capped_keeping_the_newest() {
3755 let (_tmp, _guard) = init_test_db();
3756 for i in 0..150u32 {
3761 let cid = format!("{:064x}", i);
3762 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3763 }
3764 let all = list_pending_invites().unwrap();
3765 assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
3766 for i in 150..400u32 {
3768 let cid = format!("{:064x}", i);
3769 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3770 }
3771 assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
3772 }
3773
3774 #[test]
3779 fn expired_parked_invites_are_hidden_from_list_and_accept() {
3780 let (_tmp, _guard) = init_test_db();
3781 let now = now_secs();
3782 let live = "aa".repeat(32);
3783 let expired = "bb".repeat(32);
3784 let permanent = "cc".repeat(32);
3785
3786 save_pending_invite(&live, "{\"live\":1}", "npub1x", now + 3600).unwrap();
3787 save_pending_invite(&expired, "{\"dead\":1}", "npub1x", now - 1).unwrap();
3788 save_pending_invite(&permanent, "{\"forever\":1}", "npub1x", 0).unwrap();
3791
3792 let listed: Vec<String> = list_pending_invites().unwrap().into_iter().map(|i| i.community_id).collect();
3793 assert!(listed.contains(&live), "an unexpired invite still lists");
3794 assert!(listed.contains(&permanent), "a no-deadline invite still lists");
3795 assert!(!listed.contains(&expired), "an expired invite is hidden from the list");
3796
3797 assert!(get_pending_invite(&live).unwrap().is_some());
3798 assert!(get_pending_invite(&permanent).unwrap().is_some());
3799 assert!(
3800 get_pending_invite(&expired).unwrap().is_none(),
3801 "an expired invite must not be redeemable"
3802 );
3803
3804 assert!(pending_invite_exists(&expired).unwrap(), "hidden, not yet deleted");
3806 assert_eq!(purge_expired_pending_invites().unwrap(), 1);
3807 assert!(!pending_invite_exists(&expired).unwrap());
3808 assert!(pending_invite_exists(&live).unwrap(), "the sweep spares live invites");
3809 assert!(pending_invite_exists(&permanent).unwrap(), "and no-deadline ones");
3810 }
3811
3812 #[test]
3815 fn expiry_follows_the_senders_deadline_not_receipt_time() {
3816 let (_tmp, _guard) = init_test_db();
3817 let cid = "de".repeat(32);
3818 save_pending_invite(&cid, "{}", "npub1x", now_secs() - 10).unwrap();
3821 assert!(list_pending_invites().unwrap().is_empty(), "receipt time does not extend the deadline");
3822 assert!(get_pending_invite(&cid).unwrap().is_none());
3823 }
3824}
3825
3826pub fn set_community_pins(community_id: &str, channel_id: &str, content: &str, version: i64) -> Result<bool, String> {
3838 let enc = enc_txt(content)?;
3839 let conn = super::get_write_connection_guard_static()?;
3840 let changed = conn
3841 .execute(
3842 "INSERT INTO community_pins (community_id, channel_id, content, version) VALUES (?1, ?2, ?3, ?4)
3843 ON CONFLICT(community_id, channel_id) DO UPDATE SET
3844 content = excluded.content, version = excluded.version
3845 WHERE excluded.version >= community_pins.version",
3846 params![community_id, channel_id, enc, version],
3847 )
3848 .map_err(|e| format!("set pins: {e}"))?;
3849 Ok(changed > 0)
3850}
3851
3852pub fn get_community_pins(community_id: &str, channel_id: &str) -> Result<Option<(String, i64)>, String> {
3855 let conn = super::get_db_connection_guard_static()?;
3856 let row: Option<(String, i64)> = conn
3857 .query_row(
3858 "SELECT content, version FROM community_pins WHERE community_id = ?1 AND channel_id = ?2",
3859 params![community_id, channel_id],
3860 |r| Ok((r.get(0)?, r.get(1)?)),
3861 )
3862 .optional()
3863 .map_err(|e| format!("get pins: {e}"))?;
3864 Ok(row.map(|(content, version)| (dec_txt(&content), version)))
3865}