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 web_time::SystemTime::now()
22 .duration_since(web_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 crate::community::v2::realtime::invalidate_held_v2_cache();
55 let conn = super::get_write_connection_guard_static()?;
56 let relays_json = serde_json::to_string(&community.relays).map_err(|e| e.to_string())?;
57 let community_id = community.id.to_hex();
58
59 let icon_json = community
61 .icon
62 .as_ref()
63 .map(serde_json::to_string)
64 .transpose()
65 .map_err(|e| e.to_string())?;
66 let banner_json = community
67 .banner
68 .as_ref()
69 .map(serde_json::to_string)
70 .transpose()
71 .map_err(|e| e.to_string())?;
72 let tx = conn.unchecked_transaction().map_err(|e| format!("save community tx: {e}"))?;
75 let enc_root = enc_key(community.server_root_key.as_bytes())?;
80 let enc_name = enc_txt(&community.name)?;
81 let enc_relays = enc_txt(&relays_json)?;
82 let enc_desc = enc_txt_opt(&community.description)?;
83 let enc_icon = enc_txt_opt(&icon_json)?;
84 let enc_banner = enc_txt_opt(&banner_json)?;
85 let enc_owner = enc_txt_opt(&community.owner_attestation)?;
86 tx.execute(
87 "INSERT INTO communities
88 (community_id, server_root_key, name, relays, created_at,
89 description, icon, banner, owner_attestation, server_root_epoch)
90 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
91 ON CONFLICT(community_id) DO UPDATE SET
92 server_root_key=excluded.server_root_key, name=excluded.name, relays=excluded.relays,
93 description=excluded.description, icon=excluded.icon, banner=excluded.banner,
94 owner_attestation=excluded.owner_attestation, server_root_epoch=excluded.server_root_epoch",
95 params![
96 community_id,
97 &enc_root[..],
98 enc_name,
99 enc_relays,
100 now_secs(),
101 enc_desc,
102 enc_icon,
103 enc_banner,
104 enc_owner,
105 community.server_root_epoch.0 as i64,
106 ],
107 )
108 .map_err(|e| format!("save community: {e}"))?;
109
110 store_epoch_key_tx(&tx, &community_id, crate::community::SERVER_ROOT_SCOPE_HEX,
112 community.server_root_epoch.0, community.server_root_key.as_bytes())?;
113
114 for channel in &community.channels {
115 let enc_chan_key = enc_key(channel.key.as_bytes())?;
116 let enc_chan_name = enc_txt(&channel.name)?;
117 tx.execute(
120 "INSERT INTO community_channels
121 (channel_id, community_id, channel_key, epoch, name, created_at, rekeyed_at_server_epoch)
122 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
123 ON CONFLICT(channel_id) DO UPDATE SET
124 community_id=excluded.community_id, channel_key=excluded.channel_key,
125 epoch=excluded.epoch, name=excluded.name",
126 params![
127 channel.id.to_hex(),
128 community_id,
129 &enc_chan_key[..],
130 channel.epoch.0 as i64,
134 enc_chan_name,
135 now_secs(),
136 community.server_root_epoch.0 as i64,
138 ],
139 )
140 .map_err(|e| format!("save channel: {e}"))?;
141 store_epoch_key_tx(&tx, &community_id, &channel.id.to_hex(), channel.epoch.0, channel.key.as_bytes())?;
145 }
146 tx.commit().map_err(|e| format!("save community commit: {e}"))?;
147 Ok(())
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct PendingChannelKey {
155 pub id: i64,
157 pub channel_id: String,
158 pub epoch: u64,
159 pub key: [u8; 32],
160 pub sender: String,
162 pub received_at: i64,
163}
164
165const MAX_PARKED_PER_CHANNEL: usize = 4;
171const MAX_PARKED_PER_COMMUNITY: usize = 64;
172
173pub fn park_channel_key(
185 community_id: &str,
186 channel_id: &str,
187 epoch: u64,
188 key: &[u8; 32],
189 sender: &str,
190) -> Result<(), String> {
191 let conn = super::get_write_connection_guard_static()?;
192 let tx = conn.unchecked_transaction().map_err(|e| format!("park channel key tx: {e}"))?;
193 let enc = enc_key(key)?;
194 let enc_sender = enc_txt(sender)?;
195 tx.execute(
196 "INSERT INTO pending_channel_keys
197 (community_id, channel_id, epoch, channel_key, sender, received_at)
198 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
199 params![community_id, channel_id, epoch as i64, &enc[..], enc_sender, now_secs()],
200 )
201 .map_err(|e| format!("park channel key: {e}"))?;
202 tx.execute(
206 "DELETE FROM pending_channel_keys WHERE id IN (
207 SELECT id FROM pending_channel_keys
208 WHERE community_id = ?1 AND channel_id = ?2
209 ORDER BY id DESC LIMIT -1 OFFSET ?3)",
210 params![community_id, channel_id, MAX_PARKED_PER_CHANNEL as i64],
211 )
212 .map_err(|e| format!("trim parked channel keys: {e}"))?;
213 tx.execute(
214 "DELETE FROM pending_channel_keys WHERE id IN (
215 SELECT id FROM pending_channel_keys
216 WHERE community_id = ?1
217 ORDER BY id DESC LIMIT -1 OFFSET ?2)",
218 params![community_id, MAX_PARKED_PER_COMMUNITY as i64],
219 )
220 .map_err(|e| format!("trim parked community keys: {e}"))?;
221 tx.commit().map_err(|e| format!("park channel key commit: {e}"))?;
222 Ok(())
223}
224
225pub fn get_pending_channel_keys(community_id: &str) -> Result<Vec<PendingChannelKey>, String> {
227 let conn = super::get_db_connection_guard_static()?;
228 let mut stmt = conn
231 .prepare("SELECT id, channel_id, epoch, channel_key, sender, received_at FROM pending_channel_keys WHERE community_id = ?1 ORDER BY id DESC")
232 .map_err(|e| e.to_string())?;
233 let rows = stmt
234 .query_map(params![community_id], |row| {
235 Ok((
236 row.get::<_, i64>(0)?,
237 row.get::<_, String>(1)?,
238 row.get::<_, i64>(2)?,
239 row.get::<_, Vec<u8>>(3)?,
240 row.get::<_, String>(4)?,
241 row.get::<_, i64>(5)?,
242 ))
243 })
244 .map_err(|e| e.to_string())?;
245 let mut out = Vec::new();
246 for row in rows {
247 let (id, channel_id, epoch, blob, sender, received_at) = row.map_err(|e| e.to_string())?;
248 let Ok(key) = dec_key(&blob) else { continue };
251 out.push(PendingChannelKey { id, channel_id, epoch: epoch as u64, key, sender: dec_txt(&sender), received_at });
252 }
253 Ok(out)
254}
255
256pub fn seat_channel_key(
266 community_id: &str,
267 channel_id: &str,
268 epoch: u64,
269 key: &[u8; 32],
270) -> Result<(), String> {
271 let conn = super::get_write_connection_guard_static()?;
272 let tx = conn.unchecked_transaction().map_err(|e| format!("seat channel key tx: {e}"))?;
273 let placeholder: Vec<u8> = tx
278 .query_row(
279 "SELECT server_root_key FROM communities WHERE community_id = ?1",
280 params![community_id],
281 |r| r.get(0),
282 )
283 .map_err(|e| format!("seat channel key root: {e}"))?;
284 let current: Vec<u8> = tx
285 .query_row(
286 "SELECT channel_key FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
287 params![community_id, channel_id],
288 |r| r.get(0),
289 )
290 .map_err(|e| format!("seat channel key read: {e}"))?;
291 if dec_key(¤t)? != dec_key(&placeholder)? {
293 return Err("channel already holds a key — seating would downgrade it".to_string());
294 }
295 store_epoch_key_tx(&tx, community_id, channel_id, epoch, key)?;
296 let enc = enc_key(key)?;
297 tx.execute(
298 "UPDATE community_channels SET epoch = ?1, channel_key = ?2
299 WHERE community_id = ?3 AND channel_id = ?4",
300 params![epoch as i64, &enc[..], community_id, channel_id],
301 )
302 .map_err(|e| format!("seat channel key: {e}"))?;
303 tx.commit().map_err(|e| format!("seat channel key commit: {e}"))?;
304 Ok(())
305}
306
307pub fn drop_pending_channel_key(id: i64) -> Result<(), String> {
309 let conn = super::get_write_connection_guard_static()?;
310 conn.execute("DELETE FROM pending_channel_keys WHERE id = ?1", params![id])
311 .map_err(|e| format!("drop pending channel key: {e}"))?;
312 Ok(())
313}
314
315pub fn drop_pending_channel_keys_for(community_id: &str, channel_id: &str) -> Result<(), String> {
317 let conn = super::get_write_connection_guard_static()?;
318 conn.execute(
319 "DELETE FROM pending_channel_keys WHERE community_id = ?1 AND channel_id = ?2",
320 params![community_id, channel_id],
321 )
322 .map_err(|e| format!("drop pending channel keys: {e}"))?;
323 Ok(())
324}
325
326pub fn store_epoch_key(community_id: &str, scope_id: &str, epoch: u64, key: &[u8; 32]) -> Result<(), String> {
333 let conn = super::get_write_connection_guard_static()?;
334 store_epoch_key_tx(&conn, community_id, scope_id, epoch, key)
335}
336
337fn store_epoch_key_tx<C: std::ops::Deref<Target = rusqlite::Connection>>(
341 conn: &C,
342 community_id: &str,
343 scope_id: &str,
344 epoch: u64,
345 key: &[u8; 32],
346) -> Result<(), String> {
347 let enc = enc_key(key)?;
348 conn.execute(
349 "INSERT OR REPLACE INTO community_epoch_keys
350 (community_id, scope_id, epoch, key, created_at)
351 VALUES (?1, ?2, ?3, ?4, ?5)",
352 params![community_id, scope_id, epoch as i64, &enc[..], now_secs()],
354 )
355 .map_err(|e| format!("store epoch key: {e}"))?;
356 Ok(())
357}
358
359pub fn advance_channel_epoch(
366 community_id: &str,
367 channel_id: &str,
368 new_epoch: u64,
369 new_key: &[u8; 32],
370) -> Result<bool, String> {
371 let conn = super::get_write_connection_guard_static()?;
372 let tx = conn.unchecked_transaction().map_err(|e| format!("advance channel epoch tx: {e}"))?;
373 store_epoch_key_tx(&tx, community_id, channel_id, new_epoch, new_key)?;
375 let cur: Option<i64> = tx
377 .query_row(
378 "SELECT epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
379 params![community_id, channel_id],
380 |r| r.get(0),
381 )
382 .optional()
383 .map_err(|e| format!("read channel head: {e}"))?;
384 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
385 if advanced {
386 let enc = enc_key(new_key)?;
387 tx.execute(
388 "UPDATE community_channels SET epoch = ?1, channel_key = ?2
389 WHERE community_id = ?3 AND channel_id = ?4",
390 params![new_epoch as i64, &enc[..], community_id, channel_id],
391 )
392 .map_err(|e| format!("advance channel head: {e}"))?;
393 }
394 tx.commit().map_err(|e| format!("advance channel epoch commit: {e}"))?;
395 Ok(advanced)
396}
397
398pub fn get_server_root_epoch(community_id: &str) -> Result<Option<u64>, String> {
407 let conn = super::get_db_connection_guard_static()?;
408 conn.query_row(
409 "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
410 params![community_id],
411 |r| r.get::<_, i64>(0),
412 )
413 .optional()
414 .map(|v| v.map(|e| e as u64))
415 .map_err(|e| format!("get server root epoch: {e}"))
416}
417
418pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
419 let conn = super::get_write_connection_guard_static()?;
420 let tx = conn.unchecked_transaction().map_err(|e| format!("advance server root tx: {e}"))?;
421 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch, new_root)?;
424 let cur: Option<i64> = tx
425 .query_row(
426 "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
427 params![community_id],
428 |r| r.get(0),
429 )
430 .optional()
431 .map_err(|e| format!("read server-root epoch: {e}"))?;
432 let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
433 if advanced {
434 let enc = enc_key(new_root)?;
435 tx.execute(
436 "UPDATE communities SET server_root_epoch = ?1, server_root_key = ?2 WHERE community_id = ?3",
437 params![new_epoch as i64, &enc[..], community_id],
438 )
439 .map_err(|e| format!("advance server-root head: {e}"))?;
440 }
441 tx.commit().map_err(|e| format!("advance server root commit: {e}"))?;
442 Ok(advanced)
443}
444
445pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
452 let conn = super::get_write_connection_guard_static()?;
453 let tx = conn.unchecked_transaction().map_err(|e| format!("converge server root tx: {e}"))?;
454 store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, epoch, new_root)?;
455 let enc = enc_key(new_root)?;
456 let switched = tx
457 .execute(
458 "UPDATE communities SET server_root_key = ?1 WHERE community_id = ?2 AND server_root_epoch = ?3",
459 params![&enc[..], community_id, epoch as i64],
460 )
461 .map_err(|e| format!("converge server-root head: {e}"))?
462 > 0;
463 tx.commit().map_err(|e| format!("converge server root commit: {e}"))?;
464 Ok(switched)
465}
466
467pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, new_key: &[u8; 32]) -> Result<bool, String> {
472 let conn = super::get_write_connection_guard_static()?;
473 let tx = conn.unchecked_transaction().map_err(|e| format!("converge channel tx: {e}"))?;
474 store_epoch_key_tx(&tx, community_id, channel_id, epoch, new_key)?;
475 let enc = enc_key(new_key)?;
476 let switched = tx
477 .execute(
478 "UPDATE community_channels SET channel_key = ?1 WHERE community_id = ?2 AND channel_id = ?3 AND epoch = ?4",
479 params![&enc[..], community_id, channel_id, epoch as i64],
480 )
481 .map_err(|e| format!("converge channel head: {e}"))?
482 > 0;
483 tx.commit().map_err(|e| format!("converge channel commit: {e}"))?;
484 Ok(switched)
485}
486
487pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result<Vec<(Epoch, [u8; 32])>, String> {
491 let conn = super::get_db_connection_guard_static()?;
492 let mut stmt = conn
493 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
494 .map_err(|e| e.to_string())?;
495 let rows = stmt
496 .query_map(params![community_id, scope_id], |r| {
497 Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?))
498 })
499 .map_err(|e| e.to_string())?;
500 let mut out: Vec<(Epoch, [u8; 32])> = Vec::new();
501 for row in rows {
502 let (epoch, key_blob) = row.map_err(|e| e.to_string())?;
503 out.push((Epoch(epoch as u64), dec_key(&key_blob)?));
504 }
505 out.sort_by_key(|(e, _)| e.0);
506 Ok(out)
507}
508
509pub fn held_epoch_key(community_id: &str, scope_id: &str, epoch: u64) -> Result<Option<[u8; 32]>, String> {
512 let conn = super::get_db_connection_guard_static()?;
513 let blob: Option<Vec<u8>> = conn
514 .query_row(
515 "SELECT key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2 AND epoch = ?3",
516 params![community_id, scope_id, epoch as i64],
517 |r| r.get(0),
518 )
519 .optional()
520 .map_err(|e| format!("held epoch key: {e}"))?;
521 blob.map(|b| dec_key(&b)).transpose()
522}
523
524pub fn community_created_at_ms(id: &CommunityId) -> Option<u64> {
528 let conn = super::get_db_connection_guard_static().ok()?;
529 conn.query_row(
530 "SELECT created_at FROM communities WHERE community_id = ?1",
531 params![id.to_hex()],
532 |r| r.get::<_, i64>(0),
533 )
534 .optional()
535 .ok()
536 .flatten()
537 .map(|secs| (secs.max(0) as u64) * 1000)
538}
539
540pub fn load_community(id: &CommunityId) -> Result<Option<Community>, String> {
542 let conn = super::get_db_connection_guard_static()?;
543 let id_hex = id.to_hex();
544
545 let row = conn
546 .query_row(
547 "SELECT server_root_key, name, relays,
548 description, icon, banner, banlist, owner_attestation, server_root_epoch, dissolved
549 FROM communities WHERE community_id = ?1",
550 params![id_hex],
551 |r| {
552 Ok((
553 r.get::<_, Vec<u8>>(0)?,
554 r.get::<_, String>(1)?,
555 r.get::<_, String>(2)?,
556 r.get::<_, Option<String>>(3)?,
557 r.get::<_, Option<String>>(4)?,
558 r.get::<_, Option<String>>(5)?,
559 r.get::<_, String>(6)?,
560 r.get::<_, Option<String>>(7)?,
561 r.get::<_, i64>(8)?,
562 r.get::<_, i64>(9)?,
563 ))
564 },
565 )
566 .optional()
567 .map_err(|e| format!("load community: {e}"))?;
568
569 let (root_blob, name, relays_json, description, icon_json, banner_json, banlist_json, owner_attestation, server_root_epoch, dissolved_int) =
570 match row {
571 Some(t) => t,
572 None => return Ok(None),
573 };
574 let dissolved = dissolved_int != 0;
575
576 let name = dec_txt(&name);
578 let relays_json = dec_txt(&relays_json);
579 let description = description.map(|s| dec_txt(&s));
580 let icon_json = icon_json.map(|s| dec_txt(&s));
581 let banner_json = banner_json.map(|s| dec_txt(&s));
582 let banlist_json = dec_txt(&banlist_json);
583 let owner_attestation = owner_attestation.map(|s| dec_txt(&s));
584
585 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
591 let mut banned_hex: Vec<String> = conn
592 .prepare("SELECT npub FROM community_bans WHERE community_id = ?1")
593 .and_then(|mut st| {
594 st.query_map(params![id_hex], |r| r.get::<_, String>(0))
595 .map(|rows| rows.flatten().collect::<Vec<String>>())
596 })
597 .unwrap_or_default();
598 if banned_hex.is_empty() {
599 banned_hex = serde_json::from_str::<Vec<String>>(&banlist_json).unwrap_or_default();
600 }
601 let banned: Vec<PublicKey> = banned_hex
602 .iter()
603 .filter_map(|h| PublicKey::parse(h).ok())
605 .collect();
606
607 let icon = icon_json
608 .map(|j| serde_json::from_str(&j))
609 .transpose()
610 .map_err(|e| format!("icon json: {e}"))?;
611 let banner = banner_json
612 .map(|j| serde_json::from_str(&j))
613 .transpose()
614 .map_err(|e| format!("banner json: {e}"))?;
615
616 let server_root_key = ServerRootKey(dec_key(&root_blob)?);
617 let relays: Vec<String> = serde_json::from_str(&relays_json).map_err(|e| e.to_string())?;
618
619 let mut protected: Vec<PublicKey> = Vec::new();
626 if let Some(owner) = owner_attestation
627 .as_ref()
628 .and_then(|att| crate::community::owner::verify_owner_attestation(att, &id_hex))
629 {
630 protected.push(owner);
631 }
632 let banned: Vec<PublicKey> = banned.into_iter().filter(|pk| !protected.contains(pk)).collect();
633
634 let raw_channels: Vec<(String, Vec<u8>, i64, String)> = {
637 let mut stmt = conn
638 .prepare(
639 "SELECT channel_id, channel_key, epoch, name
640 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
641 )
642 .map_err(|e| e.to_string())?;
643 let rows = stmt
644 .query_map(params![id_hex], |r| {
645 Ok((
646 r.get::<_, String>(0)?,
647 r.get::<_, Vec<u8>>(1)?,
648 r.get::<_, i64>(2)?,
649 r.get::<_, String>(3)?,
650 ))
651 })
652 .map_err(|e| e.to_string())?;
653 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
654 };
655
656 let roster = get_community_roles(&id_hex).unwrap_or_default();
659
660 let mut channels = Vec::new();
661 for (cid_hex, key_blob, epoch, cname) in raw_channels {
662 let epoch_keys: Vec<(Epoch, crate::community::ChannelKey)> = {
666 let mut ek_stmt = conn
667 .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
668 .map_err(|e| e.to_string())?;
669 let rows = ek_stmt
670 .query_map(params![id_hex, cid_hex], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
671 .map_err(|e| e.to_string())?;
672 let mut out = Vec::new();
673 for row in rows {
674 let (e, blob) = row.map_err(|e| e.to_string())?;
675 if let Ok(k) = dec_key(&blob) {
676 out.push((Epoch(e as u64), crate::community::ChannelKey(k)));
677 }
678 }
679 out
680 };
681 channels.push(Channel {
682 id: ChannelId(hex_id_to_32(&cid_hex)?),
683 key: ChannelKey(dec_key(&key_blob)?),
684 epoch: Epoch(epoch as u64),
687 name: dec_txt(&cname),
688 banned: banned.clone(),
689 protected: protected.clone(),
690 roster: roster.clone(),
691 epoch_keys,
692 dissolved,
693 });
694 }
695
696 Ok(Some(Community {
697 id: *id,
698 server_root_key,
699 server_root_epoch: Epoch(server_root_epoch as u64),
701 name,
702 description,
703 icon,
704 banner,
705 relays,
706 channels,
707 owner_attestation,
708 dissolved,
709 }))
710}
711
712pub fn store_message_key(
715 message_id: &str,
716 outer_event_id: &str,
717 ephemeral: &Keys,
718 relays: &[String],
719) -> Result<(), String> {
720 let conn = super::get_write_connection_guard_static()?;
721 let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?;
722 let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?;
723 let enc_secret = enc_key(&sk_bytes)?;
724 let enc_relays = enc_txt(&relays_json)?;
725 conn.execute(
726 "INSERT OR REPLACE INTO community_message_keys
727 (outer_event_id, message_id, ephemeral_secret, relays, created_at)
728 VALUES (?1, ?2, ?3, ?4, ?5)",
729 params![
730 outer_event_id,
731 message_id,
732 &enc_secret[..],
733 enc_relays,
734 now_secs(),
735 ],
736 )
737 .map_err(|e| format!("store message key: {e}"))?;
738 Ok(())
739}
740
741pub fn get_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
747 let conn = super::get_db_connection_guard_static()?;
748 let row = conn
749 .query_row(
750 "SELECT ephemeral_secret, outer_event_id, relays
751 FROM community_message_keys WHERE message_id = ?1",
752 params![message_id],
753 |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)),
754 )
755 .optional()
756 .map_err(|e| format!("get message key: {e}"))?;
757 let (secret_blob, outer_event_id, relays_json) = match row {
758 Some(t) => t,
759 None => return Ok(None),
760 };
761 let secret = SecretKey::from_slice(&dec_key(&secret_blob)?).map_err(|e| format!("ephemeral secret: {e}"))?;
762 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_json)).map_err(|e| e.to_string())?;
763 Ok(Some((Keys::new(secret), outer_event_id, relays)))
764}
765
766pub fn delete_message_key(message_id: &str) -> Result<(), String> {
768 let conn = super::get_write_connection_guard_static()?;
769 conn.execute(
770 "DELETE FROM community_message_keys WHERE message_id = ?1",
771 params![message_id],
772 )
773 .map_err(|e| format!("remove message key: {e}"))?;
774 Ok(())
775}
776
777pub fn take_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
780 let r = get_message_key(message_id)?;
781 if r.is_some() {
782 delete_message_key(message_id)?;
783 }
784 Ok(r)
785}
786
787static CHANNEL_COMMUNITY_CACHE: std::sync::LazyLock<
799 std::sync::RwLock<std::collections::HashMap<String, String>>,
800> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
801
802pub fn clear_channel_community_cache() {
804 CHANNEL_COMMUNITY_CACHE.write().unwrap().clear();
805 crate::notify::invalidate_live_channels();
806}
807
808fn forget_community_channels(community_id: &str) {
811 CHANNEL_COMMUNITY_CACHE.write().unwrap().retain(|_, cid| cid != community_id);
812 crate::notify::invalidate_live_channels();
815}
816
817pub fn community_id_for_channel(channel_id: &str) -> Result<Option<String>, String> {
818 if let Some(cid) = CHANNEL_COMMUNITY_CACHE.read().unwrap().get(channel_id) {
819 return Ok(Some(cid.clone()));
820 }
821 let conn = super::get_db_connection_guard_static()?;
822 let cid: Option<String> = conn
823 .prepare_cached("SELECT community_id FROM community_channels WHERE channel_id = ?1")
824 .and_then(|mut stmt| stmt.query_row(params![channel_id], |r| r.get::<_, String>(0)))
825 .optional()
826 .map_err(|e| format!("community_id_for_channel: {e}"))?;
827 if let Some(ref c) = cid {
828 CHANNEL_COMMUNITY_CACHE.write().unwrap().insert(channel_id.to_string(), c.clone());
829 }
830 Ok(cid)
831}
832
833pub fn community_exists(id: &CommunityId) -> Result<bool, String> {
836 let conn = super::get_db_connection_guard_static()?;
837 let found: Option<i64> = conn
838 .query_row(
839 "SELECT 1 FROM communities WHERE community_id = ?1",
840 params![id.to_hex()],
841 |r| r.get(0),
842 )
843 .optional()
844 .map_err(|e| format!("community_exists: {e}"))?;
845 Ok(found.is_some())
846}
847
848#[derive(Debug, Clone, serde::Serialize)]
850pub struct PendingCommunityInvite {
851 pub community_id: String,
852 pub bundle_json: String,
853 pub inviter_npub: String,
854 pub received_at: i64,
855 pub expires_at: i64,
857}
858
859pub fn save_pending_invite(
864 community_id: &str,
865 bundle_json: &str,
866 inviter_npub: &str,
867 expires_at: i64,
868) -> Result<bool, String> {
869 const MAX_PENDING_INVITES: usize = 100;
873
874 let conn = super::get_write_connection_guard_static()?;
875 let enc_bundle = enc_txt(bundle_json)?;
876 let enc_inviter = enc_txt(inviter_npub)?;
877 let changed = conn
882 .execute(
883 "INSERT OR IGNORE INTO pending_community_invites
884 (community_id, bundle_json, inviter_npub, received_at, expires_at)
885 VALUES (?1, ?2, ?3, ?4, ?5)",
886 params![community_id, enc_bundle, enc_inviter, now_secs(), expires_at],
887 )
888 .map_err(|e| format!("save pending invite: {e}"))?;
889 if changed > 0 {
892 let _ = conn.execute(
893 "DELETE FROM pending_community_invites
894 WHERE community_id IN (
895 SELECT community_id FROM pending_community_invites
896 ORDER BY received_at DESC, community_id DESC
897 LIMIT -1 OFFSET ?1
898 )",
899 params![MAX_PENDING_INVITES],
900 );
901 }
902 Ok(changed > 0)
903}
904
905pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
911 let conn = super::get_write_connection_guard_static()?;
912 let n = conn
913 .execute(
914 "DELETE FROM pending_community_invites
915 WHERE community_id IN (SELECT community_id FROM communities)",
916 [],
917 )
918 .map_err(|e| format!("purge held pending invites: {e}"))?;
919 Ok(n)
920}
921
922pub fn purge_expired_pending_invites() -> Result<usize, String> {
930 let conn = super::get_write_connection_guard_static()?;
931 let now = now_secs();
932 let n = conn
933 .execute(
934 "DELETE FROM pending_community_invites
935 WHERE (expires_at != 0 AND expires_at <= ?1)
936 OR received_at <= ?2",
937 params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64],
938 )
939 .map_err(|e| format!("purge expired pending invites: {e}"))?;
940 Ok(n)
941}
942
943pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
945 let conn = super::get_db_connection_guard_static()?;
946 let mut stmt = conn
947 .prepare(
948 "SELECT community_id, bundle_json, inviter_npub, received_at, expires_at
949 FROM pending_community_invites
950 WHERE (expires_at = 0 OR expires_at > ?1)
951 AND received_at > ?2
952 ORDER BY received_at DESC",
953 )
954 .map_err(|e| e.to_string())?;
955 let now = now_secs();
956 let rows = stmt
957 .query_map(params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64], |r| {
958 Ok(PendingCommunityInvite {
959 community_id: r.get(0)?,
960 bundle_json: dec_txt(&r.get::<_, String>(1)?),
961 inviter_npub: dec_txt(&r.get::<_, String>(2)?),
962 received_at: r.get(3)?,
963 expires_at: r.get(4)?,
964 })
965 })
966 .map_err(|e| e.to_string())?;
967 let mut out = Vec::new();
968 for row in rows {
969 out.push(row.map_err(|e| e.to_string())?);
970 }
971 Ok(out)
972}
973
974pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
978 let conn = super::get_db_connection_guard_static()?;
979 let raw: Option<String> = conn
980 .query_row(
981 "SELECT bundle_json FROM pending_community_invites
982 WHERE community_id = ?1 AND (expires_at = 0 OR expires_at > ?2)",
983 params![community_id, now_secs()],
984 |r| r.get::<_, String>(0),
985 )
986 .optional()
987 .map_err(|e| format!("get pending invite: {e}"))?;
988 Ok(raw.map(|s| dec_txt(&s)))
989}
990
991pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
993 let conn = super::get_write_connection_guard_static()?;
994 conn.execute(
995 "DELETE FROM pending_community_invites WHERE community_id = ?1",
996 params![community_id],
997 )
998 .map_err(|e| format!("delete pending invite: {e}"))?;
999 Ok(())
1000}
1001
1002pub fn pending_invite_received_at(community_id: &str) -> Result<Option<i64>, String> {
1007 let conn = super::get_db_connection_guard_static()?;
1008 conn.query_row(
1009 "SELECT received_at FROM pending_community_invites WHERE community_id = ?1",
1010 params![community_id],
1011 |r| r.get(0),
1012 )
1013 .optional()
1014 .map_err(|e| format!("pending_invite_received_at: {e}"))
1015}
1016
1017pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
1018 let conn = super::get_db_connection_guard_static()?;
1019 let found: Option<i64> = conn
1020 .query_row(
1021 "SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
1022 params![community_id],
1023 |r| r.get(0),
1024 )
1025 .optional()
1026 .map_err(|e| format!("pending_invite_exists: {e}"))?;
1027 Ok(found.is_some())
1028}
1029
1030#[derive(Debug, Clone, serde::Serialize)]
1032pub struct PublicInviteRecord {
1033 pub token: String,
1035 pub community_id: String,
1036 pub url: String,
1037 pub expires_at: Option<i64>,
1038 pub created_at: i64,
1039 pub label: Option<String>,
1041 #[serde(default)]
1043 pub join_count: u64,
1044}
1045
1046pub fn save_public_invite(
1048 token: &str,
1049 community_id: &str,
1050 url: &str,
1051 expires_at: Option<i64>,
1052 label: Option<&str>,
1053) -> Result<(), String> {
1054 let conn = super::get_write_connection_guard_static()?;
1055 let enc_token = enc_txt(token)?;
1058 let enc_url = enc_txt(url)?;
1059 let enc_label = label.map(enc_txt).transpose()?;
1061 conn.execute(
1062 "INSERT OR REPLACE INTO community_public_invites
1063 (token, community_id, url, expires_at, created_at, label)
1064 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1065 params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
1066 )
1067 .map_err(|e| format!("save public invite: {e}"))?;
1068 Ok(())
1069}
1070
1071pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
1073 let conn = super::get_db_connection_guard_static()?;
1074 let mut stmt = conn
1075 .prepare(
1076 "SELECT token, community_id, url, expires_at, created_at, label
1077 FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
1078 )
1079 .map_err(|e| e.to_string())?;
1080 let rows = stmt
1081 .query_map(params![community_id], |r| {
1082 Ok(PublicInviteRecord {
1083 token: dec_txt(&r.get::<_, String>(0)?),
1084 community_id: r.get(1)?,
1085 url: dec_txt(&r.get::<_, String>(2)?),
1086 expires_at: r.get(3)?,
1087 created_at: r.get(4)?,
1088 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
1089 join_count: 0,
1090 })
1091 })
1092 .map_err(|e| e.to_string())?;
1093 let mut out = Vec::new();
1094 for row in rows {
1095 out.push(row.map_err(|e| e.to_string())?);
1096 }
1097 if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
1099 if let Ok(counts) = community_invite_join_counts(community_id, &me) {
1100 for rec in &mut out {
1101 if let Some(l) = rec.label.as_deref() {
1102 rec.join_count = counts.get(l).copied().unwrap_or(0);
1103 }
1104 }
1105 }
1106 }
1107 Ok(out)
1108}
1109
1110pub fn delete_public_invite(token: &str) -> Result<(), String> {
1112 let conn = super::get_write_connection_guard_static()?;
1113 let rows: Vec<(i64, String)> = {
1116 let mut stmt = conn
1117 .prepare("SELECT rowid, token FROM community_public_invites")
1118 .map_err(|e| e.to_string())?;
1119 let mapped = stmt
1120 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1121 .map_err(|e| e.to_string())?;
1122 mapped.filter_map(|r| r.ok()).collect()
1123 };
1124 for (rowid, stored) in rows {
1125 if dec_txt(&stored) == token {
1126 conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
1127 .map_err(|e| format!("delete public invite: {e}"))?;
1128 }
1129 }
1130 Ok(())
1131}
1132
1133pub fn list_all_public_invites() -> Result<Vec<PublicInviteRecord>, String> {
1135 let conn = super::get_db_connection_guard_static()?;
1136 let mut stmt = conn
1137 .prepare(
1138 "SELECT token, community_id, url, expires_at, created_at, label
1139 FROM community_public_invites ORDER BY created_at DESC",
1140 )
1141 .map_err(|e| e.to_string())?;
1142 let rows = stmt
1143 .query_map([], |r| {
1144 Ok(PublicInviteRecord {
1145 token: dec_txt(&r.get::<_, String>(0)?),
1146 community_id: r.get(1)?,
1147 url: dec_txt(&r.get::<_, String>(2)?),
1148 expires_at: r.get(3)?,
1149 created_at: r.get(4)?,
1150 label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
1151 join_count: 0,
1152 })
1153 })
1154 .map_err(|e| e.to_string())?;
1155 let mut out = Vec::new();
1156 for row in rows {
1157 out.push(row.map_err(|e| e.to_string())?);
1158 }
1159 Ok(out)
1160}
1161
1162pub fn upsert_public_invite(
1167 token: &str,
1168 community_id: &str,
1169 url: &str,
1170 expires_at: Option<i64>,
1171 created_at: i64,
1172 label: Option<&str>,
1173) -> Result<bool, String> {
1174 let conn = super::get_write_connection_guard_static()?;
1175 let already = {
1176 let mut stmt = conn
1177 .prepare("SELECT token FROM community_public_invites WHERE community_id = ?1")
1178 .map_err(|e| e.to_string())?;
1179 let stored: Vec<String> = stmt
1180 .query_map(params![community_id], |r| r.get::<_, String>(0))
1181 .map_err(|e| e.to_string())?
1182 .filter_map(|r| r.ok())
1183 .collect();
1184 stored.iter().any(|s| dec_txt(s) == token)
1185 };
1186 if already {
1187 return Ok(false);
1188 }
1189 let enc_token = enc_txt(token)?;
1190 let enc_url = enc_txt(url)?;
1191 let enc_label = label.map(enc_txt).transpose()?;
1192 conn.execute(
1193 "INSERT INTO community_public_invites
1194 (token, community_id, url, expires_at, created_at, label)
1195 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1196 params![enc_token, community_id, enc_url, expires_at, created_at, enc_label],
1197 )
1198 .map_err(|e| format!("upsert public invite: {e}"))?;
1199 Ok(true)
1200}
1201
1202pub fn delete_community(community_id: &str) -> Result<(), String> {
1207 crate::community::v2::realtime::invalidate_held_v2_cache();
1208 delete_community_inner(community_id, false)
1209}
1210
1211pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
1217 crate::community::v2::realtime::invalidate_held_v2_cache();
1218 delete_community_inner(community_id, true)
1219}
1220
1221fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
1222 let conn = super::get_write_connection_guard_static()?;
1223 let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
1226 for sql in [
1227 Some("DELETE FROM communities WHERE community_id = ?1"),
1228 Some("DELETE FROM community_channels WHERE community_id = ?1"),
1229 (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
1233 Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
1234 Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
1235 Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
1236 Some("DELETE FROM pending_channel_keys WHERE community_id = ?1"),
1242 Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
1245 Some("DELETE FROM community_bans WHERE community_id = ?1"),
1246 ]
1247 .into_iter()
1248 .flatten()
1249 {
1250 tx.execute(sql, params![community_id])
1251 .map_err(|e| format!("delete community: {e}"))?;
1252 }
1253 tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
1254 BANLIST_CACHE.write().unwrap().remove(community_id);
1255 forget_community_channels(community_id);
1256 Ok(())
1261}
1262
1263pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
1270 community_member_activity_capped(community_id, true)
1271}
1272
1273pub fn community_member_activity_capped(community_id: &str, capped: bool) -> Result<Vec<(String, u64)>, String> {
1279 const COMMUNITY_MEMBER_CAP: usize = 500;
1282 use std::collections::HashMap;
1283
1284 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1285 Some(c) => c,
1286 None => return Ok(Vec::new()),
1287 };
1288 let owner_b32: Option<String> = community
1293 .owner_attestation
1294 .as_deref()
1295 .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
1296 .and_then(|pk| pk.to_bech32().ok());
1297
1298 let mut chat_ints: Vec<i64> = Vec::new();
1300 for ch in &community.channels {
1301 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1302 chat_ints.push(cid);
1303 }
1304 }
1305
1306 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1309
1310 let mut active: HashMap<String, u64> = HashMap::new();
1315 let mut left: HashMap<String, u64> = HashMap::new();
1316 if !chat_ints.is_empty() {
1319 let conn = super::get_db_connection_guard_static()?;
1320 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1321
1322 {
1323 let sql = format!(
1324 "SELECT npub, MAX(created_at) FROM events \
1325 WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
1326 GROUP BY npub"
1327 );
1328 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1329 let rows = stmt
1330 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1331 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
1332 })
1333 .map_err(|e| e.to_string())?;
1334 for row in rows {
1335 let (npub, at) = row.map_err(|e| e.to_string())?;
1336 active.insert(npub, at);
1337 }
1338 }
1339
1340 {
1342 let sql = format!(
1343 "SELECT npub, created_at, tags FROM events \
1344 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1345 );
1346 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1347 let rows = stmt
1348 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1349 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
1350 })
1351 .map_err(|e| e.to_string())?;
1352 for row in rows {
1353 let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
1354 let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
1356 .ok()
1357 .and_then(|tags| {
1358 tags.into_iter()
1359 .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
1360 .and_then(|t| t.into_iter().nth(1))
1361 });
1362 match etype.as_deref() {
1363 Some("1") => {
1364 let e = active.entry(npub).or_insert(0);
1365 if at > *e { *e = at; }
1366 }
1367 Some("0") => {
1368 let e = left.entry(npub).or_insert(0);
1369 if at > *e { *e = at; }
1370 }
1371 _ => {}
1372 }
1373 }
1374 }
1375 }
1376
1377 let banned: std::collections::HashSet<String> = community
1380 .channels
1381 .first()
1382 .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
1383 .unwrap_or_default();
1384
1385 let mut out: Vec<(String, u64)> = active
1387 .into_iter()
1388 .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).is_none_or(|l| at >= l))
1389 .collect();
1390
1391 {
1398 let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
1399 let mut reassert = |npub: String| {
1400 if !banned.contains(&npub) && present.insert(npub.clone()) {
1401 out.push((npub, now_secs() as u64));
1402 }
1403 };
1404 if let Some(o) = owner_b32 {
1405 reassert(o);
1406 }
1407 if let Ok(roles) = get_community_roles(community_id) {
1408 for g in &roles.grants {
1409 if g.role_ids.is_empty() {
1410 continue; }
1412 if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
1413 reassert(b32);
1414 }
1415 }
1416 }
1417 }
1418 out.sort_by_key(|m| std::cmp::Reverse(m.1));
1419 if capped {
1420 out.truncate(COMMUNITY_MEMBER_CAP);
1421 }
1422 Ok(out)
1423}
1424
1425pub fn community_invite_join_counts(
1430 community_id: &str,
1431 inviter_npub: &str,
1432) -> Result<std::collections::HashMap<String, u64>, String> {
1433 use std::collections::{HashMap, HashSet};
1434 let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1435 Some(c) => c,
1436 None => return Ok(HashMap::new()),
1437 };
1438 let mut chat_ints: Vec<i64> = Vec::new();
1439 for ch in &community.channels {
1440 if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1441 chat_ints.push(cid);
1442 }
1443 }
1444 if chat_ints.is_empty() {
1445 return Ok(HashMap::new());
1446 }
1447 let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1448 let conn = super::get_db_connection_guard_static()?;
1449 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1450 let sql = format!(
1451 "SELECT npub, tags FROM events \
1452 WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1453 );
1454 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1455 let rows = stmt
1456 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1457 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1458 })
1459 .map_err(|e| e.to_string())?;
1460 let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
1462 for row in rows {
1463 let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
1464 let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
1465 Ok(t) => t,
1466 Err(_) => continue,
1467 };
1468 let tag_val = |key: &str| -> Option<String> {
1469 tags.iter()
1470 .find(|t| t.first().map(|s| s == key).unwrap_or(false))
1471 .and_then(|t| t.get(1).cloned())
1472 };
1473 if tag_val("event-type").as_deref() != Some("1") {
1475 continue;
1476 }
1477 if tag_val("invited-by").as_deref() != Some(inviter_npub) {
1478 continue;
1479 }
1480 if let Some(label) = tag_val("invited-label") {
1481 per_label.entry(label).or_default().insert(joiner);
1482 }
1483 }
1484 Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
1485}
1486
1487#[derive(Debug, Clone)]
1491pub struct AuthorFootprint {
1492 pub npub: String,
1493 pub messages: u64,
1494 pub first_secs: u64,
1495 pub last_secs: u64,
1496}
1497
1498pub fn community_chat_row_ids(community_id: &str) -> Result<Vec<i64>, String> {
1502 let channel_ids: Vec<String> = {
1503 let conn = super::get_db_connection_guard_static()?;
1504 let mut stmt = conn
1505 .prepare("SELECT channel_id FROM community_channels WHERE community_id = ?1")
1506 .map_err(|e| e.to_string())?;
1507 let rows = stmt
1508 .query_map(params![community_id], |r| r.get::<_, String>(0))
1509 .map_err(|e| e.to_string())?;
1510 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
1511 };
1512 Ok(channel_ids.iter().filter_map(|c| super::id_cache::get_chat_id_by_identifier(c).ok()).collect())
1513}
1514
1515#[derive(Debug, Clone)]
1518pub struct StoredPolicy {
1519 pub policy_id: String,
1522 pub bytes: String,
1525 pub hash: String,
1526 pub enabled: bool,
1527 pub updated_at: u64,
1528}
1529
1530pub fn get_community_policies(community_id: &str) -> Result<Vec<StoredPolicy>, String> {
1533 let conn = super::get_db_connection_guard_static()?;
1534 let mut stmt = conn
1535 .prepare(
1536 "SELECT policy_id, bytes, hash, enabled, updated_at FROM community_policies \
1537 WHERE community_id = ?1 ORDER BY policy_id",
1538 )
1539 .map_err(|e| e.to_string())?;
1540 let rows = stmt
1541 .query_map(params![community_id], |r| {
1542 Ok(StoredPolicy {
1543 policy_id: r.get::<_, String>(0)?,
1544 bytes: dec_txt(&r.get::<_, String>(1)?),
1545 hash: r.get::<_, String>(2)?,
1546 enabled: r.get::<_, i64>(3)? != 0,
1547 updated_at: r.get::<_, i64>(4)?.max(0) as u64,
1548 })
1549 })
1550 .map_err(|e| e.to_string())?;
1551 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
1552}
1553
1554pub fn set_community_policy(
1557 community_id: &str,
1558 policy_id: &str,
1559 bytes: &str,
1560 hash: &str,
1561 enabled: bool,
1562 updated_at: u64,
1563) -> Result<(), String> {
1564 let conn = super::get_write_connection_guard_static()?;
1565 conn.execute(
1566 "INSERT INTO community_policies (community_id, policy_id, bytes, hash, enabled, updated_at)
1567 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1568 ON CONFLICT(community_id, policy_id) DO UPDATE SET bytes=?3, hash=?4, enabled=?5, updated_at=?6",
1569 params![community_id, policy_id, enc_txt(bytes)?, hash, enabled as i64, updated_at as i64],
1570 )
1571 .map_err(|e| format!("save policy: {e}"))?;
1572 Ok(())
1573}
1574
1575pub fn delete_community_policy(community_id: &str, policy_id: &str) -> Result<(), String> {
1576 let conn = super::get_write_connection_guard_static()?;
1577 conn.execute(
1578 "DELETE FROM community_policies WHERE community_id = ?1 AND policy_id = ?2",
1579 params![community_id, policy_id],
1580 )
1581 .map_err(|e| format!("delete policy: {e}"))?;
1582 Ok(())
1583}
1584
1585#[derive(Debug, Clone)]
1588pub struct PolicyMessage {
1589 pub id: String,
1592 pub npub: String,
1593 pub channel_id: String,
1595 pub at_ms: u64,
1597 pub text: String,
1598 pub mentions: u32,
1600}
1601
1602pub fn community_policy_messages(community_id: &str, limit: usize) -> Result<Vec<PolicyMessage>, String> {
1606 let channel_ids: Vec<String> = {
1607 let conn = super::get_db_connection_guard_static()?;
1608 let mut stmt = conn
1609 .prepare("SELECT channel_id FROM community_channels WHERE community_id = ?1")
1610 .map_err(|e| e.to_string())?;
1611 let rows = stmt.query_map(params![community_id], |r| r.get::<_, String>(0)).map_err(|e| e.to_string())?;
1612 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
1613 };
1614 if channel_ids.is_empty() || limit == 0 {
1615 return Ok(Vec::new());
1616 }
1617 let mut chat_to_channel: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
1619 for c in &channel_ids {
1620 if let Ok(row) = super::id_cache::get_chat_id_by_identifier(c) {
1621 chat_to_channel.insert(row, c.clone());
1622 }
1623 }
1624 if chat_to_channel.is_empty() {
1625 return Ok(Vec::new());
1626 }
1627 let text = crate::stored_event::event_kind::PRIVATE_DIRECT_MESSAGE;
1628 let chat_ints: Vec<i64> = chat_to_channel.keys().copied().collect();
1629 let rows: Vec<(String, String, i64, i64, String)> = {
1630 let conn = super::get_db_connection_guard_static()?;
1631 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1632 let sql = format!(
1633 "SELECT id, npub, chat_id, created_at, content FROM events \
1634 WHERE chat_id IN ({placeholders}) AND kind = {text} \
1635 AND npub IS NOT NULL AND npub != '' \
1636 ORDER BY created_at DESC LIMIT {limit}"
1637 );
1638 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1639 let mapped = stmt
1640 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1641 Ok((
1642 r.get::<_, String>(0)?,
1643 r.get::<_, String>(1)?,
1644 r.get::<_, i64>(2)?,
1645 r.get::<_, i64>(3)?,
1646 r.get::<_, String>(4)?,
1647 ))
1648 })
1649 .map_err(|e| e.to_string())?;
1650 mapped.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
1651 };
1652 let mut out: Vec<PolicyMessage> = rows
1653 .into_iter()
1654 .filter_map(|(id, npub, chat_id, created_at, content)| {
1655 let channel_id = chat_to_channel.get(&chat_id)?.clone();
1656 let content = crate::crypto::maybe_decrypt_text(&content);
1657 let mentions = crate::community::policy::matchers::count_mentions(&content);
1662 Some(PolicyMessage {
1663 id,
1664 npub,
1665 channel_id,
1666 at_ms: (created_at.max(0) as u64).saturating_mul(1000),
1667 text: content,
1668 mentions,
1669 })
1670 })
1671 .collect();
1672 out.reverse(); Ok(out)
1674}
1675
1676pub fn community_author_footprints(community_id: &str) -> Result<Vec<AuthorFootprint>, String> {
1680 let chat_ints = community_chat_row_ids(community_id)?;
1681 if chat_ints.is_empty() {
1682 return Ok(Vec::new());
1683 }
1684 let text = crate::stored_event::event_kind::PRIVATE_DIRECT_MESSAGE;
1685 let file = crate::stored_event::event_kind::FILE_ATTACHMENT;
1686 let conn = super::get_db_connection_guard_static()?;
1687 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1688 let sql = format!(
1689 "SELECT npub, COUNT(*), MIN(created_at), MAX(created_at) FROM events \
1690 WHERE chat_id IN ({placeholders}) AND kind IN ({text}, {file}) \
1691 AND npub IS NOT NULL AND npub != '' \
1692 GROUP BY npub"
1693 );
1694 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1695 let rows = stmt
1696 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1697 Ok(AuthorFootprint {
1698 npub: r.get::<_, String>(0)?,
1699 messages: r.get::<_, i64>(1)?.max(0) as u64,
1700 first_secs: r.get::<_, i64>(2)?.max(0) as u64,
1701 last_secs: r.get::<_, i64>(3)?.max(0) as u64,
1702 })
1703 })
1704 .map_err(|e| e.to_string())?;
1705 rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
1706}
1707
1708pub fn community_recent_texts(community_id: &str, limit: usize) -> Result<Vec<(String, u64, String)>, String> {
1713 let chat_ints = community_chat_row_ids(community_id)?;
1714 if chat_ints.is_empty() || limit == 0 {
1715 return Ok(Vec::new());
1716 }
1717 let text = crate::stored_event::event_kind::PRIVATE_DIRECT_MESSAGE;
1718 let rows: Vec<(String, u64, String)> = {
1719 let conn = super::get_db_connection_guard_static()?;
1720 let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1721 let sql = format!(
1722 "SELECT npub, created_at, content FROM events \
1723 WHERE chat_id IN ({placeholders}) AND kind = {text} \
1724 AND npub IS NOT NULL AND npub != '' \
1725 ORDER BY created_at DESC LIMIT {limit}"
1726 );
1727 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1728 let mapped = stmt
1729 .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1730 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
1731 })
1732 .map_err(|e| e.to_string())?;
1733 mapped.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
1734 };
1735 Ok(rows
1736 .into_iter()
1737 .map(|(npub, at, content)| (npub, at, crate::crypto::maybe_decrypt_text(&content)))
1738 .collect())
1739}
1740
1741type BanlistMap = std::collections::HashMap<String, std::sync::Arc<std::collections::HashSet<[u8; 32]>>>;
1753
1754static BANLIST_CACHE: std::sync::LazyLock<std::sync::RwLock<BanlistMap>> =
1755 std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
1756
1757fn banlist_set_from_hexes(hexes: &[String]) -> std::collections::HashSet<[u8; 32]> {
1758 hexes.iter().filter_map(|h| crate::simd::hex::hex_to_bytes_32_checked(h)).collect()
1759}
1760
1761pub fn clear_banlist_cache() {
1763 BANLIST_CACHE.write().unwrap().clear();
1764}
1765
1766pub fn banned_set(community_id: &str) -> std::sync::Arc<std::collections::HashSet<[u8; 32]>> {
1769 if let Some(set) = BANLIST_CACHE.read().unwrap().get(community_id) {
1770 return std::sync::Arc::clone(set);
1771 }
1772 let set = std::sync::Arc::new(banlist_set_from_hexes(
1773 &get_community_banlist(community_id).unwrap_or_default(),
1774 ));
1775 BANLIST_CACHE
1776 .write()
1777 .unwrap()
1778 .insert(community_id.to_string(), std::sync::Arc::clone(&set));
1779 set
1780}
1781
1782pub fn is_author_banned(community_id: &str, author: &PublicKey) -> bool {
1785 let set = banned_set(community_id);
1786 !set.is_empty() && set.contains(&author.to_bytes())
1787}
1788
1789fn forget_console_verdict(community_id: &str) {
1795 crate::VectorCore::invalidate_raid_report(community_id);
1796}
1797
1798pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
1799 let conn = super::get_write_connection_guard_static()?;
1800 let current_at: Option<i64> = conn
1807 .query_row("SELECT banlist_at FROM communities WHERE community_id = ?1", params![community_id], |r| r.get(0))
1808 .optional()
1809 .map_err(|e| format!("read banlist_at: {e}"))?
1810 .flatten();
1811 if current_at.is_some_and(|have| have > at) {
1812 crate::log_warn!(
1813 "[Banlist] refused a stale edition (v{at} behind held v{}) — un-banning is the fail-open direction",
1814 current_at.unwrap_or(0)
1815 );
1816 return Ok(());
1817 }
1818 let tx = conn.unchecked_transaction().map_err(|e| format!("banlist tx: {e}"))?;
1824 tx.execute("DELETE FROM community_bans WHERE community_id = ?1", params![community_id])
1825 .map_err(|e| format!("clear banlist: {e}"))?;
1826 {
1827 let mut ins = tx
1828 .prepare("INSERT OR IGNORE INTO community_bans (community_id, npub) VALUES (?1, ?2)")
1829 .map_err(|e| format!("prepare banlist insert: {e}"))?;
1830 for hex in banned_hex {
1831 let joinable = PublicKey::from_hex(hex)
1837 .ok()
1838 .and_then(|pk| pk.to_bech32().ok())
1839 .unwrap_or_else(|| hex.clone());
1840 ins.execute(params![community_id, joinable])
1841 .map_err(|e| format!("insert ban: {e}"))?;
1842 }
1843 }
1844 tx.execute(
1845 "UPDATE communities SET banlist_at = ?1 WHERE community_id = ?2",
1846 params![at, community_id],
1847 )
1848 .map_err(|e| format!("set banlist_at: {e}"))?;
1849 tx.commit().map_err(|e| format!("commit banlist: {e}"))?;
1850 forget_console_verdict(community_id);
1851 BANLIST_CACHE
1854 .write()
1855 .unwrap()
1856 .insert(community_id.to_string(), std::sync::Arc::new(banlist_set_from_hexes(banned_hex)));
1857 Ok(())
1858}
1859
1860pub fn get_community_ban_marks(community_id: &str) -> Result<std::collections::BTreeMap<String, u64>, String> {
1865 let conn = super::get_db_connection_guard_static()?;
1866 let json: Option<String> = conn
1867 .query_row(
1868 "SELECT banlist_marks FROM communities WHERE community_id = ?1",
1869 params![community_id],
1870 |r| r.get(0),
1871 )
1872 .optional()
1873 .map_err(|e| format!("get ban marks: {e}"))?;
1874 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1875}
1876
1877pub fn merge_community_ban_marks(community_id: &str, marks: &std::collections::BTreeMap<String, u64>) -> Result<bool, String> {
1882 if marks.is_empty() {
1883 return Ok(false);
1884 }
1885 let mut stored = get_community_ban_marks(community_id)?;
1886 let mut changed = false;
1887 for (npub, at) in marks {
1888 let slot = stored.entry(npub.clone()).or_insert(0);
1889 if *at > *slot {
1890 *slot = *at;
1891 changed = true;
1892 }
1893 }
1894 if !changed {
1895 return Ok(false);
1896 }
1897 let json = enc_txt(&serde_json::to_string(&stored).map_err(|e| e.to_string())?)?;
1898 let conn = super::get_write_connection_guard_static()?;
1899 conn.execute(
1900 "UPDATE communities SET banlist_marks = ?1 WHERE community_id = ?2",
1901 params![json, community_id],
1902 )
1903 .map_err(|e| format!("set ban marks: {e}"))?;
1904 Ok(true)
1905}
1906
1907pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
1910 let conn = super::get_db_connection_guard_static()?;
1911 let at: Option<i64> = conn
1912 .query_row(
1913 "SELECT banlist_at FROM communities WHERE community_id = ?1",
1914 params![community_id],
1915 |r| r.get(0),
1916 )
1917 .optional()
1918 .map_err(|e| format!("get banlist_at: {e}"))?;
1919 Ok(at.unwrap_or(0))
1920}
1921
1922pub fn set_community_roles(
1927 community_id: &str,
1928 roles: &crate::community::roles::CommunityRoles,
1929 at: i64,
1930) -> Result<(), String> {
1931 forget_console_verdict(community_id);
1932 let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
1933 let conn = super::get_write_connection_guard_static()?;
1934 conn.execute(
1935 "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
1936 params![json, at, community_id],
1937 )
1938 .map_err(|e| format!("set roles: {e}"))?;
1939 Ok(())
1940}
1941
1942pub fn get_community_roles(
1944 community_id: &str,
1945) -> Result<crate::community::roles::CommunityRoles, String> {
1946 let conn = super::get_db_connection_guard_static()?;
1947 let json: Option<String> = conn
1948 .query_row(
1949 "SELECT roles FROM communities WHERE community_id = ?1",
1950 params![community_id],
1951 |r| r.get(0),
1952 )
1953 .optional()
1954 .map_err(|e| format!("get roles: {e}"))?;
1955 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1956}
1957
1958pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
1960 let conn = super::get_db_connection_guard_static()?;
1961 let at: Option<i64> = conn
1962 .query_row(
1963 "SELECT roles_at FROM communities WHERE community_id = ?1",
1964 params![community_id],
1965 |r| r.get(0),
1966 )
1967 .optional()
1968 .map_err(|e| format!("get roles_at: {e}"))?;
1969 Ok(at.unwrap_or(0))
1970}
1971
1972pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
1977 set_edition_head_inner(community_id, entity_id, version, self_hash, None, None)
1978}
1979
1980pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1983 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), None)
1984}
1985
1986pub fn delete_edition_head(community_id: &str, entity_id: &str) -> Result<(), String> {
2000 let conn = super::get_write_connection_guard_static()?;
2001 conn.execute(
2002 "DELETE FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
2003 params![community_id, entity_id],
2004 )
2005 .map_err(|e| format!("delete edition head: {e}"))?;
2006 Ok(())
2007}
2008
2009pub 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> {
2010 set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), Some(epoch))
2011}
2012
2013fn 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> {
2014 let conn = super::get_write_connection_guard_static()?;
2015 conn.execute(
2021 "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
2022 VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
2023 ON CONFLICT(community_id, entity_id) DO UPDATE SET
2024 version = excluded.version,
2025 self_hash = excluded.self_hash,
2026 inner_id = excluded.inner_id,
2027 epoch = excluded.epoch
2028 WHERE excluded.epoch > community_edition_heads.epoch
2029 OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
2030 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)],
2031 )
2032 .map_err(|e| format!("set edition head: {e}"))?;
2033 Ok(())
2034}
2035
2036pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
2046 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, None)
2047}
2048
2049pub 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> {
2053 converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, Some(epoch))
2054}
2055
2056fn 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> {
2057 let conn = super::get_write_connection_guard_static()?;
2058 conn.execute(
2061 "UPDATE community_edition_heads
2062 SET self_hash = ?4, inner_id = ?5
2063 WHERE community_id = ?1 AND entity_id = ?2
2064 AND version = ?3
2065 AND epoch = COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
2066 AND (inner_id IS NULL OR ?5 < inner_id)",
2067 params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice(), epoch.map(|e| e as i64)],
2068 )
2069 .map_err(|e| format!("converge edition head: {e}"))?;
2070 Ok(())
2071}
2072
2073pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
2078 let conn = super::get_db_connection_guard_static()?;
2079 let row: Option<Option<Vec<u8>>> = conn
2080 .query_row(
2081 "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
2082 params![community_id, entity_id],
2083 |r| r.get(0),
2084 )
2085 .optional()
2086 .map_err(|e| format!("get edition head inner_id: {e}"))?;
2087 match row.flatten() {
2088 Some(blob) if blob.len() == 32 => {
2089 let mut h = [0u8; 32];
2090 h.copy_from_slice(&blob);
2091 Ok(Some(h))
2092 }
2093 _ => Ok(None),
2094 }
2095}
2096
2097pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
2100 let conn = super::get_db_connection_guard_static()?;
2101 let row: Option<(i64, Vec<u8>)> = conn
2102 .query_row(
2103 "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
2104 params![community_id, entity_id],
2105 |r| Ok((r.get(0)?, r.get(1)?)),
2106 )
2107 .optional()
2108 .map_err(|e| format!("get edition head: {e}"))?;
2109 match row {
2110 Some((v, hash)) if hash.len() == 32 => {
2111 let mut h = [0u8; 32];
2112 h.copy_from_slice(&hash);
2113 Ok(Some((v as u64, h)))
2114 }
2115 _ => Ok(None),
2116 }
2117}
2118
2119pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
2124 let conn = super::get_db_connection_guard_static()?;
2125 let mut stmt = conn
2126 .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
2127 .map_err(|e| e.to_string())?;
2128 let rows = stmt
2129 .query_map(params![community_id], |r| r.get::<_, String>(0))
2130 .map_err(|e| e.to_string())?;
2131 let mut out = std::collections::HashSet::new();
2132 for row in rows {
2133 out.insert(row.map_err(|e| e.to_string())?);
2134 }
2135 Ok(out)
2136}
2137
2138
2139pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
2145 let conn = super::get_db_connection_guard_static()?;
2146 let mut stmt = conn
2147 .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
2148 .map_err(|e| e.to_string())?;
2149 let rows = stmt
2150 .query_map(params![community_id], |r| {
2151 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
2152 })
2153 .map_err(|e| e.to_string())?;
2154 let mut out = std::collections::HashMap::new();
2155 for row in rows {
2156 let (entity, version, hash) = row.map_err(|e| e.to_string())?;
2157 if hash.len() == 32 {
2158 let mut h = [0u8; 32];
2159 h.copy_from_slice(&hash);
2160 out.insert(entity, (version as u64, h));
2161 }
2162 }
2163 Ok(out)
2164}
2165
2166type EditionHeadFull = (u64, u64, [u8; 32], Option<[u8; 32]>);
2175type EditionHead = (u64, u64, [u8; 32]);
2177
2178pub fn get_all_edition_heads_full(community_id: &str) -> Result<std::collections::HashMap<String, EditionHeadFull>, String> {
2179 let conn = super::get_db_connection_guard_static()?;
2180 let mut stmt = conn
2181 .prepare("SELECT entity_id, epoch, version, self_hash, inner_id FROM community_edition_heads WHERE community_id = ?1")
2182 .map_err(|e| e.to_string())?;
2183 let rows = stmt
2184 .query_map(params![community_id], |r| {
2185 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, Option<Vec<u8>>>(4)?))
2186 })
2187 .map_err(|e| e.to_string())?;
2188 let mut out = std::collections::HashMap::new();
2189 for row in rows {
2190 let (entity, epoch, version, hash, inner) = row.map_err(|e| e.to_string())?;
2191 if hash.len() == 32 {
2192 let mut h = [0u8; 32];
2193 h.copy_from_slice(&hash);
2194 let inner_id = inner.and_then(|b| {
2195 (b.len() == 32).then(|| {
2196 let mut i = [0u8; 32];
2197 i.copy_from_slice(&b);
2198 i
2199 })
2200 });
2201 out.insert(entity, (epoch as u64, version as u64, h, inner_id));
2202 }
2203 }
2204 Ok(out)
2205}
2206
2207pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, EditionHead>, String> {
2208 let conn = super::get_db_connection_guard_static()?;
2209 let mut stmt = conn
2210 .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
2211 .map_err(|e| e.to_string())?;
2212 let rows = stmt
2213 .query_map(params![community_id], |r| {
2214 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
2215 })
2216 .map_err(|e| e.to_string())?;
2217 let mut out = std::collections::HashMap::new();
2218 for row in rows {
2219 let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
2220 if hash.len() == 32 {
2221 let mut h = [0u8; 32];
2222 h.copy_from_slice(&hash);
2223 out.insert(entity, (epoch as u64, version as u64, h));
2224 }
2225 }
2226 Ok(out)
2227}
2228
2229pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
2231 let conn = super::get_db_connection_guard_static()?;
2232 let mut stmt = conn
2233 .prepare("SELECT npub FROM community_bans WHERE community_id = ?1")
2234 .map_err(|e| format!("prepare get banlist: {e}"))?;
2235 let rows = stmt
2236 .query_map(params![community_id], |r| r.get::<_, String>(0))
2237 .map_err(|e| format!("get banlist: {e}"))?;
2238 let mut out: Vec<String> = rows
2240 .flatten()
2241 .map(|b| PublicKey::parse(&b).map(|pk| pk.to_hex()).unwrap_or(b))
2242 .collect();
2243 if !out.is_empty() {
2244 return Ok(out);
2245 }
2246 drop(stmt);
2251 let legacy: Option<String> = conn
2252 .query_row(
2253 "SELECT banlist FROM communities WHERE community_id = ?1 AND banlist IS NOT NULL AND banlist != ''",
2254 params![community_id],
2255 |r| r.get(0),
2256 )
2257 .optional()
2258 .map_err(|e| format!("read legacy banlist: {e}"))?
2259 .flatten();
2260 let Some(blob) = legacy else { return Ok(out) };
2261 let decoded = dec_txt(&blob);
2262 let Ok(list) = serde_json::from_str::<Vec<String>>(&decoded) else { return Ok(out) };
2263 if list.is_empty() {
2264 return Ok(out);
2265 }
2266 let tx = conn.unchecked_transaction().map_err(|e| format!("adopt tx: {e}"))?;
2267 {
2268 let mut ins = tx
2269 .prepare("INSERT OR IGNORE INTO community_bans (community_id, npub) VALUES (?1, ?2)")
2270 .map_err(|e| format!("prepare adopt: {e}"))?;
2271 for hex in &list {
2272 let joinable = PublicKey::from_hex(hex)
2273 .ok()
2274 .and_then(|pk| pk.to_bech32().ok())
2275 .unwrap_or_else(|| hex.clone());
2276 ins.execute(params![community_id, joinable]).map_err(|e| format!("adopt ban: {e}"))?;
2277 }
2278 }
2279 tx.execute("UPDATE communities SET banlist = NULL WHERE community_id = ?1", params![community_id])
2280 .map_err(|e| format!("retire legacy banlist: {e}"))?;
2281 tx.commit().map_err(|e| format!("commit adopt: {e}"))?;
2282 crate::log_info!("[Banlist] adopted {} legacy ban(s) into community_bans", list.len());
2283 out = list;
2284 Ok(out)
2285}
2286
2287pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
2291 let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
2292 let conn = super::get_write_connection_guard_static()?;
2293 conn.execute(
2294 "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
2295 params![json, community_id],
2296 )
2297 .map_err(|e| format!("set invite registry: {e}"))?;
2298 Ok(())
2299}
2300
2301pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
2304 let conn = super::get_db_connection_guard_static()?;
2305 let json: Option<String> = conn
2306 .query_row(
2307 "SELECT invite_registry FROM communities WHERE community_id = ?1",
2308 params![community_id],
2309 |r| r.get(0),
2310 )
2311 .optional()
2312 .map_err(|e| format!("get invite registry: {e}"))?;
2313 Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
2314}
2315
2316pub struct InviteLinkSetRow {
2319 pub creator_hex: String,
2320 pub locators: Vec<String>,
2321}
2322
2323pub fn invite_link_set_creators(community_id: &str) -> Result<Vec<String>, String> {
2326 let conn = super::get_write_connection_guard_static()?;
2327 let mut stmt = conn
2328 .prepare("SELECT creator FROM community_invite_link_sets WHERE community_id = ?1")
2329 .map_err(|e| e.to_string())?;
2330 let rows = stmt
2331 .query_map(params![community_id], |r| r.get::<_, String>(0))
2332 .map_err(|e| e.to_string())?
2333 .filter_map(|r| r.ok())
2334 .map(|stored| dec_txt(&stored))
2335 .collect();
2336 Ok(rows)
2337}
2338
2339pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
2343 let mut conn = super::get_write_connection_guard_static()?;
2344 let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
2345 tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
2346 .map_err(|e| format!("clear invite-link-sets: {e}"))?;
2347 for s in sets {
2348 if s.locators.is_empty() {
2349 continue; }
2351 let enc_creator = enc_txt(&s.creator_hex)?;
2352 let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
2353 tx.execute(
2356 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
2357 params![community_id, enc_creator, enc_locators],
2358 )
2359 .map_err(|e| format!("insert invite-link-set: {e}"))?;
2360 }
2361 tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
2362 Ok(())
2363}
2364
2365pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
2368 let conn = super::get_write_connection_guard_static()?;
2369 let existing_rowid: Option<i64> = {
2371 let mut stmt = conn
2372 .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
2373 .map_err(|e| e.to_string())?;
2374 let rows = stmt
2375 .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
2376 .map_err(|e| e.to_string())?;
2377 let mut found = None;
2378 for row in rows {
2379 let (rowid, stored) = row.map_err(|e| e.to_string())?;
2380 if dec_txt(&stored) == creator_hex {
2381 found = Some(rowid);
2382 break;
2383 }
2384 }
2385 found
2386 };
2387 if locators.is_empty() {
2388 if let Some(rowid) = existing_rowid {
2389 conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
2390 .map_err(|e| format!("delete invite-link-set: {e}"))?;
2391 }
2392 return Ok(());
2393 }
2394 let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
2395 match existing_rowid {
2396 Some(rowid) => {
2397 conn.execute(
2398 "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
2399 params![enc_locators, rowid],
2400 )
2401 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
2402 }
2403 None => {
2404 let enc_creator = enc_txt(creator_hex)?;
2405 conn.execute(
2406 "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
2407 params![community_id, enc_creator, enc_locators],
2408 )
2409 .map_err(|e| format!("upsert invite-link-set: {e}"))?;
2410 }
2411 }
2412 Ok(())
2413}
2414
2415pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
2418 let conn = super::get_db_connection_guard_static()?;
2419 let mut stmt = conn
2420 .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
2421 .map_err(|e| format!("prepare invite-link-sets: {e}"))?;
2422 let rows = stmt
2423 .query_map(params![community_id], |r| {
2424 let creator_hex: String = r.get(0)?;
2425 let json: String = r.get(1)?;
2426 Ok((creator_hex, json))
2427 })
2428 .map_err(|e| format!("query invite-link-sets: {e}"))?;
2429 let mut out = Vec::new();
2430 for row in rows {
2431 let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
2432 let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
2433 out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
2434 }
2435 Ok(out)
2436}
2437
2438pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
2442 let conn = super::get_write_connection_guard_static()?;
2443 conn.execute(
2444 "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
2445 params![pending as i64, community_id],
2446 )
2447 .map_err(|e| format!("set read_cut_pending: {e}"))?;
2448 Ok(())
2449}
2450
2451pub fn set_community_dissolved(community_id: &str) -> Result<bool, String> {
2459 crate::community::v2::realtime::invalidate_held_v2_cache();
2460 let conn = super::get_write_connection_guard_static()?;
2461 let changed = conn
2462 .execute(
2463 "UPDATE communities SET dissolved = 1 WHERE community_id = ?1 AND dissolved = 0",
2464 params![community_id],
2465 )
2466 .map_err(|e| format!("set dissolved: {e}"))?;
2467 Ok(changed > 0)
2468}
2469
2470pub fn set_migration_pointer(community_id: &str, payload_json: &str) -> Result<(), String> {
2478 let conn = super::get_write_connection_guard_static()?;
2479 let wrapped = enc_txt(payload_json)?;
2480 conn.execute(
2481 "UPDATE communities SET migration_pointer = ?2, migration_checked = 1 WHERE community_id = ?1",
2482 params![community_id, wrapped],
2483 )
2484 .map_err(|e| format!("set migration pointer: {e}"))?;
2485 Ok(())
2486}
2487
2488pub fn get_migration_pointer(community_id: &str) -> Result<Option<String>, String> {
2490 let conn = super::get_db_connection_guard_static()?;
2491 let v: Option<Option<String>> = conn
2492 .query_row(
2493 "SELECT migration_pointer FROM communities WHERE community_id = ?1",
2494 params![community_id],
2495 |r| r.get(0),
2496 )
2497 .optional()
2498 .map_err(|e| format!("get migration pointer: {e}"))?;
2499 Ok(v.flatten().map(|s| dec_txt(&s)))
2500}
2501
2502pub fn set_migrated_to(community_id: &str, v2_community_id: &str) -> Result<(), String> {
2505 let conn = super::get_write_connection_guard_static()?;
2506 conn.execute(
2507 "UPDATE communities SET migrated_to = ?2 WHERE community_id = ?1 AND migrated_to IS NULL",
2508 params![community_id, v2_community_id],
2509 )
2510 .map_err(|e| format!("set migrated_to: {e}"))?;
2511 Ok(())
2512}
2513
2514pub fn get_migrated_to(community_id: &str) -> Result<Option<String>, String> {
2516 let conn = super::get_db_connection_guard_static()?;
2517 let v: Option<Option<String>> = conn
2518 .query_row(
2519 "SELECT migrated_to FROM communities WHERE community_id = ?1",
2520 params![community_id],
2521 |r| r.get(0),
2522 )
2523 .optional()
2524 .map_err(|e| format!("get migrated_to: {e}"))?;
2525 Ok(v.flatten())
2526}
2527
2528pub fn set_migration_checked(community_id: &str) -> Result<(), String> {
2532 let conn = super::get_write_connection_guard_static()?;
2533 conn.execute(
2534 "UPDATE communities SET migration_checked = 1 WHERE community_id = ?1",
2535 params![community_id],
2536 )
2537 .map_err(|e| format!("set migration checked: {e}"))?;
2538 Ok(())
2539}
2540
2541pub fn set_migration_ledger(v1_community_id: &str, v2_community_id: &str, phase: i64, twin_json: &str) -> Result<(), String> {
2548 let conn = super::get_write_connection_guard_static()?;
2549 let wrapped = enc_txt(twin_json)?;
2550 let now = now_secs();
2553 conn.execute(
2554 "INSERT INTO community_migrations (community_id, v2_community_id, phase, twin, updated_at)
2555 VALUES (?1, ?2, ?3, ?4, ?5)
2556 ON CONFLICT(community_id) DO UPDATE SET v2_community_id=?2, phase=?3, twin=?4, updated_at=?5",
2557 params![v1_community_id, v2_community_id, phase, wrapped, now],
2558 )
2559 .map_err(|e| format!("set migration ledger: {e}"))?;
2560 Ok(())
2561}
2562
2563pub fn get_migration_ledger(v1_community_id: &str) -> Result<Option<(String, i64, String)>, String> {
2565 let conn = super::get_db_connection_guard_static()?;
2566 let row = conn
2567 .query_row(
2568 "SELECT v2_community_id, phase, twin FROM community_migrations WHERE community_id = ?1",
2569 params![v1_community_id],
2570 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)),
2571 )
2572 .optional()
2573 .map_err(|e| format!("get migration ledger: {e}"))?;
2574 Ok(row.map(|(v2, phase, twin)| (v2, phase, dec_txt(&twin))))
2575}
2576
2577pub fn reparent_channels_and_fence(v1_community_id: &str, v2_community_id: &str) -> Result<(), String> {
2587 let conn = super::get_write_connection_guard_static()?;
2588 let tx = conn.unchecked_transaction().map_err(|e| format!("flip txn: {e}"))?;
2589 tx.execute(
2590 "UPDATE community_channels SET community_id = ?2 WHERE community_id = ?1",
2591 params![v1_community_id, v2_community_id],
2592 )
2593 .map_err(|e| format!("reparent channels: {e}"))?;
2594 tx.execute(
2598 "UPDATE communities SET migrated_to = ?2, dissolved = 1 WHERE community_id = ?1 AND migrated_to IS NULL",
2599 params![v1_community_id, v2_community_id],
2600 )
2601 .map_err(|e| format!("set fence: {e}"))?;
2602 tx.commit().map_err(|e| format!("flip commit: {e}"))?;
2603 forget_community_channels(v1_community_id);
2607 Ok(())
2608}
2609
2610pub fn migration_sweep_candidates() -> Result<Vec<String>, String> {
2614 let conn = super::get_db_connection_guard_static()?;
2615 let mut stmt = conn
2623 .prepare(
2624 "SELECT community_id FROM communities
2625 WHERE migrated_to IS NULL AND migration_checked = 0
2626 AND (protocol IS NULL OR protocol = 1)
2627 ORDER BY dissolved DESC
2628 LIMIT 40",
2629 )
2630 .map_err(|e| e.to_string())?;
2631 let rows = stmt
2632 .query_map([], |r| r.get::<_, String>(0))
2633 .map_err(|e| e.to_string())?;
2634 Ok(rows.flatten().collect())
2635}
2636
2637pub fn migration_flip_candidates() -> Result<Vec<String>, String> {
2641 let conn = super::get_db_connection_guard_static()?;
2642 let mut stmt = conn
2643 .prepare(
2644 "SELECT community_id FROM communities
2645 WHERE migration_pointer IS NOT NULL AND migrated_to IS NULL",
2646 )
2647 .map_err(|e| e.to_string())?;
2648 let rows = stmt
2649 .query_map([], |r| r.get::<_, String>(0))
2650 .map_err(|e| e.to_string())?;
2651 Ok(rows.flatten().collect())
2652}
2653
2654pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
2657 let conn = super::get_db_connection_guard_static()?;
2658 let v: Option<i64> = conn
2659 .query_row(
2660 "SELECT dissolved FROM communities WHERE community_id = ?1",
2661 params![community_id],
2662 |r| r.get(0),
2663 )
2664 .optional()
2665 .map_err(|e| format!("get dissolved: {e}"))?;
2666 Ok(v.unwrap_or(0) != 0)
2667}
2668
2669pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
2672 let conn = super::get_db_connection_guard_static()?;
2673 let v: Option<i64> = conn
2674 .query_row(
2675 "SELECT read_cut_pending FROM communities WHERE community_id = ?1",
2676 params![community_id],
2677 |r| r.get(0),
2678 )
2679 .optional()
2680 .map_err(|e| format!("get read_cut_pending: {e}"))?;
2681 Ok(v.unwrap_or(0) != 0)
2682}
2683
2684pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
2689 let conn = super::get_write_connection_guard_static()?;
2690 conn.execute(
2691 "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
2692 params![target as i64, community_id],
2693 )
2694 .map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
2695 Ok(())
2696}
2697
2698pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
2701 let conn = super::get_db_connection_guard_static()?;
2702 let v: Option<i64> = conn
2703 .query_row(
2704 "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
2705 params![community_id],
2706 |r| r.get(0),
2707 )
2708 .optional()
2709 .map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
2710 Ok(v.unwrap_or(0) as u64)
2711}
2712
2713pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
2716 let conn = super::get_db_connection_guard_static()?;
2717 let v: Option<i64> = conn
2718 .query_row(
2719 "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
2720 params![community_id, channel_id],
2721 |r| r.get(0),
2722 )
2723 .optional()
2724 .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
2725 Ok(v.unwrap_or(0) as u64)
2726}
2727
2728pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
2732 let conn = super::get_write_connection_guard_static()?;
2733 conn.execute(
2734 "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
2735 params![server_epoch as i64, community_id, channel_id],
2736 )
2737 .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
2738 Ok(())
2739}
2740
2741pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
2743 let conn = super::get_db_connection_guard_static()?;
2744 let mut stmt = conn
2745 .prepare("SELECT community_id FROM communities ORDER BY created_at")
2746 .map_err(|e| e.to_string())?;
2747 let rows = stmt
2748 .query_map([], |r| r.get::<_, String>(0))
2749 .map_err(|e| e.to_string())?;
2750 let mut ids = Vec::new();
2751 for row in rows {
2752 ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
2753 }
2754 Ok(ids)
2755}
2756
2757pub fn community_protocol(id: &CommunityId) -> Result<Option<crate::community::ConcordProtocol>, String> {
2768 let conn = super::get_db_connection_guard_static()?;
2769 let n: Option<i64> = conn
2770 .query_row("SELECT protocol FROM communities WHERE community_id = ?1", params![id.to_hex()], |r| r.get(0))
2771 .optional()
2772 .map_err(|e| e.to_string())?;
2773 Ok(n.map(crate::community::ConcordProtocol::from_i64))
2774}
2775
2776#[derive(serde::Serialize, serde::Deserialize, Default)]
2779struct CommunityMetaStash {
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
2786#[derive(serde::Serialize, serde::Deserialize, Default)]
2788struct ChannelMetaStash {
2789 #[serde(default, skip_serializing_if = "Option::is_none")]
2790 voice: Option<bool>,
2791 #[serde(default, skip_serializing_if = "Option::is_none")]
2792 custom: Option<serde_json::Map<String, serde_json::Value>>,
2793 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
2794 extra: serde_json::Map<String, serde_json::Value>,
2795}
2796
2797pub fn save_community_v2(c: &crate::community::v2::community::CommunityV2) -> Result<(), String> {
2800 crate::community::v2::realtime::invalidate_held_v2_cache();
2801 let conn = super::get_write_connection_guard_static()?;
2802 let id_hex = crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0);
2803 let relays_json = serde_json::to_string(&c.relays).map_err(|e| e.to_string())?;
2804 let created = (c.created_at_ms / 1000) as i64;
2805
2806 let enc_root = enc_key(&c.community_root)?;
2807 let enc_name = enc_txt(&c.name)?;
2808 let enc_relays = enc_txt(&relays_json)?;
2809 let enc_desc = enc_txt_opt(&c.description)?;
2810 let enc_owner_pk = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_xonly))?;
2811 let enc_owner_salt = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_salt))?;
2812 let icon_json = c.icon.as_ref().map(|i| serde_json::to_string(i).map_err(|e| e.to_string())).transpose()?;
2816 let banner_json = c.banner.as_ref().map(|b| serde_json::to_string(b).map_err(|e| e.to_string())).transpose()?;
2817 let enc_icon = enc_txt_opt(&icon_json)?;
2818 let enc_banner = enc_txt_opt(&banner_json)?;
2819 let stash_json = (c.meta_custom.is_some() || !c.meta_extra.is_empty())
2820 .then(|| serde_json::to_string(&CommunityMetaStash { custom: c.meta_custom.clone(), extra: c.meta_extra.clone() }).map_err(|e| e.to_string()))
2821 .transpose()?;
2822 let enc_stash = enc_txt_opt(&stash_json)?;
2823 let enc_control_pk = enc_txt_opt(&c.control_pk.map(|p| p.to_hex()))?;
2826 let enc_control_root = c.control_root.as_ref().map(enc_key).transpose()?;
2827
2828 let tx = conn.unchecked_transaction().map_err(|e| format!("save v2 community tx: {e}"))?;
2829 tx.execute(
2830 "INSERT INTO communities
2831 (community_id, server_root_key, name, relays, created_at, description,
2832 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt, icon, banner, meta_extra,
2833 control_pk, control_root)
2834 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
2835 ON CONFLICT(community_id) DO UPDATE SET
2836 server_root_key=?2, name=?3, relays=?4, description=?6,
2837 server_root_epoch=?7, dissolved=?8, protocol=2, owner_pubkey=?9, owner_salt=?10,
2838 icon=?11, banner=?12, meta_extra=?13, control_pk=?14, control_root=?15",
2839 params![
2840 id_hex, enc_root, enc_name, enc_relays, created, enc_desc,
2841 c.root_epoch.0 as i64, c.dissolved as i64, enc_owner_pk, enc_owner_salt,
2842 enc_icon, enc_banner, enc_stash, enc_control_pk, enc_control_root,
2843 ],
2844 )
2845 .map_err(|e| format!("save v2 community: {e}"))?;
2846
2847 for ch in &c.channels {
2848 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2849 let owner_of: Option<String> = tx
2855 .query_row("SELECT community_id FROM community_channels WHERE channel_id=?1", params![ch_hex], |r| r.get(0))
2856 .optional()
2857 .map_err(|e| format!("channel ownership check: {e}"))?;
2858 if let Some(existing) = owner_of.filter(|existing| *existing != id_hex) {
2859 crate::log_warn!(
2867 "[v2:{}] channel {} not saved: its row belongs to community {} (unflipped migration, or a hijack attempt)",
2868 &id_hex[..8],
2869 &ch_hex[..8],
2870 &existing[..8.min(existing.len())]
2871 );
2872 continue;
2873 }
2874 let stored_key = ch.key.unwrap_or(c.community_root);
2878 let enc_ch_key = enc_key(&stored_key)?;
2879 let enc_ch_name = enc_txt(&ch.name)?;
2880 let ch_stash_json = (ch.voice.is_some() || ch.meta_custom.is_some() || !ch.meta_extra.is_empty())
2881 .then(|| {
2882 serde_json::to_string(&ChannelMetaStash { voice: ch.voice, custom: ch.meta_custom.clone(), extra: ch.meta_extra.clone() })
2883 .map_err(|e| e.to_string())
2884 })
2885 .transpose()?;
2886 let enc_ch_stash = enc_txt_opt(&ch_stash_json)?;
2887 tx.execute(
2888 "INSERT INTO community_channels
2889 (channel_id, community_id, channel_key, epoch, name, created_at, private, meta_extra)
2890 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
2891 ON CONFLICT(channel_id) DO UPDATE SET
2892 channel_key=?3, epoch=?4, name=?5, private=?7, meta_extra=?8",
2893 params![ch_hex, id_hex, enc_ch_key, ch.epoch.0 as i64, enc_ch_name, created, ch.private as i64, enc_ch_stash],
2894 )
2895 .map_err(|e| format!("save v2 channel: {e}"))?;
2896 }
2897
2898 let keep: Vec<String> = c.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
2903 if keep.is_empty() {
2904 tx.execute("DELETE FROM community_channels WHERE community_id=?1", params![id_hex])
2905 .map_err(|e| format!("prune v2 channels: {e}"))?;
2906 } else {
2907 let placeholders = std::iter::repeat_n("?", keep.len()).collect::<Vec<_>>().join(",");
2908 let sql = format!("DELETE FROM community_channels WHERE community_id=? AND channel_id NOT IN ({placeholders})");
2909 let mut binds: Vec<String> = Vec::with_capacity(keep.len() + 1);
2910 binds.push(id_hex.clone());
2911 binds.extend(keep);
2912 tx.execute(&sql, rusqlite::params_from_iter(binds.iter()))
2913 .map_err(|e| format!("prune v2 channels: {e}"))?;
2914 }
2915
2916 tx.commit().map_err(|e| format!("commit v2 community: {e}"))?;
2917 forget_community_channels(&id_hex);
2920 Ok(())
2921}
2922
2923pub fn load_community_v2(id: &CommunityId) -> Result<Option<crate::community::v2::community::CommunityV2>, String> {
2925 use crate::community::v2::community::{ChannelV2, CommunityV2};
2926 use crate::community::v2::control::CommunityIdentity;
2927 let conn = super::get_db_connection_guard_static()?;
2928 let id_hex = id.to_hex();
2929
2930 let row = conn
2931 .query_row(
2932 "SELECT server_root_key, name, relays, created_at, description,
2933 server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt,
2934 icon, banner, meta_extra, control_pk, control_root
2935 FROM communities WHERE community_id = ?1",
2936 params![id_hex],
2937 |r| {
2938 Ok((
2939 r.get::<_, Vec<u8>>(0)?,
2940 r.get::<_, String>(1)?,
2941 r.get::<_, String>(2)?,
2942 r.get::<_, i64>(3)?,
2943 r.get::<_, Option<String>>(4)?,
2944 r.get::<_, i64>(5)?,
2945 r.get::<_, i64>(6)?,
2946 r.get::<_, i64>(7)?,
2947 r.get::<_, Option<String>>(8)?,
2948 r.get::<_, Option<String>>(9)?,
2949 r.get::<_, Option<String>>(10)?,
2950 r.get::<_, Option<String>>(11)?,
2951 r.get::<_, Option<String>>(12)?,
2952 r.get::<_, Option<String>>(13)?,
2953 r.get::<_, Option<Vec<u8>>>(14)?,
2954 ))
2955 },
2956 )
2957 .optional()
2958 .map_err(|e| e.to_string())?;
2959 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
2960 else {
2961 return Ok(None);
2962 };
2963 if crate::community::ConcordProtocol::from_i64(protocol) != crate::community::ConcordProtocol::V2 {
2964 return Ok(None);
2965 }
2966 let (Some(owner_pk_e), Some(owner_salt_e)) = (owner_pk_e, owner_salt_e) else {
2967 return Err("v2 community row is missing its owner commitment".to_string());
2968 };
2969
2970 let community_root = dec_key(&root_blob)?;
2971 let owner_xonly = parse_hex32(&dec_txt(&owner_pk_e))?;
2972 let owner_salt = parse_hex32(&dec_txt(&owner_salt_e))?;
2973 let identity = CommunityIdentity { community_id: *id, owner_xonly, owner_salt };
2974 let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_e)).unwrap_or_default();
2975
2976 let mut channels = Vec::new();
2977 {
2978 let mut stmt = conn
2979 .prepare(
2980 "SELECT channel_id, channel_key, epoch, name, private, meta_extra
2981 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
2982 )
2983 .map_err(|e| e.to_string())?;
2984 let rows = stmt
2985 .query_map(params![id_hex], |r| {
2986 Ok((
2987 r.get::<_, String>(0)?,
2988 r.get::<_, Vec<u8>>(1)?,
2989 r.get::<_, i64>(2)?,
2990 r.get::<_, String>(3)?,
2991 r.get::<_, i64>(4)?,
2992 r.get::<_, Option<String>>(5)?,
2993 ))
2994 })
2995 .map_err(|e| e.to_string())?;
2996 for row in rows {
2997 let (ch_hex, key_blob, epoch, name_e, private, ch_stash_e) = row.map_err(|e| e.to_string())?;
2998 let private = private != 0;
2999 let key = dec_key(&key_blob)?;
3000 let ch_stash: ChannelMetaStash = ch_stash_e
3003 .map(|s| dec_txt(&s))
3004 .and_then(|j| serde_json::from_str(&j).ok())
3005 .unwrap_or_default();
3006 channels.push(ChannelV2 {
3007 id: ChannelId(hex_id_to_32(&ch_hex)?),
3008 name: dec_txt(&name_e),
3009 private,
3010 key: (private && key != community_root).then_some(key),
3017 epoch: Epoch(epoch as u64),
3018 voice: ch_stash.voice,
3019 meta_custom: ch_stash.custom,
3020 meta_extra: ch_stash.extra,
3021 });
3022 }
3023 }
3024
3025 let icon = icon_e
3028 .map(|s| dec_txt(&s))
3029 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
3030 let banner = banner_e
3031 .map(|s| dec_txt(&s))
3032 .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
3033 let stash: CommunityMetaStash = stash_e
3034 .map(|s| dec_txt(&s))
3035 .and_then(|j| serde_json::from_str(&j).ok())
3036 .unwrap_or_default();
3037
3038 let control_pk = control_pk_e
3043 .as_deref()
3044 .and_then(|e| nostr_sdk::prelude::PublicKey::from_hex(&dec_txt(e)).ok());
3045 let control_root = match (control_pk, control_root_b) {
3046 (Some(pk), Some(blob)) => dec_key(&blob).ok().filter(|cr| {
3047 crate::community::v2::derive::control_signer_group_key(cr, id, Epoch(root_epoch as u64)).pk() == pk
3048 }),
3049 _ => None,
3050 };
3051
3052 Ok(Some(CommunityV2 {
3053 identity,
3054 community_root,
3055 root_epoch: Epoch(root_epoch as u64),
3056 control_pk,
3057 control_root,
3058 name: dec_txt(&name_e),
3059 description: desc_e.map(|d| dec_txt(&d)),
3060 icon,
3061 banner,
3062 meta_custom: stash.custom,
3063 meta_extra: stash.extra,
3064 relays,
3065 channels,
3066 dissolved: dissolved != 0,
3067 created_at_ms: (created as u64).saturating_mul(1000),
3068 }))
3069}
3070
3071fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
3072 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
3073 return Err("stored value is not 32-byte hex".to_string());
3074 }
3075 Ok(crate::simd::hex::hex_to_bytes_32(hex))
3076}
3077
3078pub fn get_guestbook(community_id: &str) -> Result<(Vec<crate::community::v2::guestbook::GuestbookEvent>, u64), String> {
3082 let conn = super::get_db_connection_guard_static()?;
3083 let row = conn
3084 .query_row(
3085 "SELECT events, cursor_secs FROM community_guestbook WHERE community_id = ?1",
3086 params![community_id],
3087 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
3088 )
3089 .optional()
3090 .map_err(|e| format!("load guestbook: {e}"))?;
3091 let Some((events_e, cursor)) = row else {
3092 return Ok((Vec::new(), 0));
3093 };
3094 let events = serde_json::from_str(&dec_txt(&events_e)).unwrap_or_default();
3095 Ok((events, cursor.max(0) as u64))
3096}
3097
3098pub fn set_guestbook(
3101 community_id: &str,
3102 events: &[crate::community::v2::guestbook::GuestbookEvent],
3103 cursor_secs: u64,
3104) -> Result<(), String> {
3105 forget_console_verdict(community_id);
3106 let conn = super::get_write_connection_guard_static()?;
3107 let json = serde_json::to_string(events).map_err(|e| e.to_string())?;
3108 let enc = enc_txt(&json)?;
3109 conn.execute(
3110 "INSERT INTO community_guestbook (community_id, events, cursor_secs)
3111 VALUES (?1, ?2, ?3)
3112 ON CONFLICT(community_id) DO UPDATE SET events=?2, cursor_secs=?3",
3113 params![community_id, enc, cursor_secs as i64],
3114 )
3115 .map_err(|e| format!("save guestbook: {e}"))?;
3116 Ok(())
3117}
3118
3119#[cfg(test)]
3120mod tests {
3121 use nostr_sdk::prelude::FinalizeEvent;
3122 use super::*;
3123
3124 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
3125
3126 fn make_test_npub(n: u32) -> String {
3129 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3130 let mut payload = vec![b'q'; 58];
3131 let mut x = n as u64;
3132 let mut i = 58;
3133 while x > 0 && i > 0 {
3134 i -= 1;
3135 payload[i] = BECH32[(x as usize) % 32];
3136 x /= 32;
3137 }
3138 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
3139 }
3140
3141 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
3142 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3143 crate::db::close_database();
3144 crate::db::clear_id_caches();
3147 let tmp = tempfile::tempdir().unwrap();
3148 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3149 let account = make_test_npub(n);
3150 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3151 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
3152 crate::db::set_current_account(account.clone()).unwrap();
3153 crate::db::init_database(&account).unwrap();
3154 (tmp, guard)
3155 }
3156
3157 #[test]
3158 fn edition_head_round_trips_and_upserts() {
3159 let (_tmp, _guard) = init_test_db();
3160 let cid = "f".repeat(64);
3161 let entity = "a".repeat(64);
3162
3163 assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
3165
3166 let h1 = [0x11u8; 32];
3168 set_edition_head(&cid, &entity, 1, &h1).unwrap();
3169 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
3170
3171 let h2 = [0x22u8; 32];
3173 set_edition_head(&cid, &entity, 2, &h2).unwrap();
3174 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
3175
3176 set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
3179 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
3180 set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
3181 assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
3182
3183 let other = "b".repeat(64);
3185 assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
3186 }
3187
3188 #[test]
3189 fn guestbook_round_trips_events_and_cursor() {
3190 let (_tmp, _guard) = init_test_db();
3191 let member = nostr_sdk::prelude::Keys::generate();
3192 let ev = crate::community::v2::guestbook::GuestbookEvent { epoch: None, observed_at: None,
3193 rumor_id: [7u8; 32],
3194 entry: crate::community::v2::guestbook::GuestbookEntry::Join {
3195 member: member.public_key(),
3196 invited_by: Some(("creator".into(), "label".into())),
3197 at_ms: 1_000,
3198 },
3199 };
3200 let cid = "d".repeat(64);
3201 assert_eq!(get_guestbook(&cid).unwrap(), (Vec::new(), 0), "absent reads as empty at cursor 0");
3202 set_guestbook(&cid, std::slice::from_ref(&ev), 42).unwrap();
3203 let (events, cursor) = get_guestbook(&cid).unwrap();
3204 assert_eq!(events, vec![ev], "events round-trip through the encrypted blob");
3205 assert_eq!(cursor, 42);
3206 }
3207
3208 #[test]
3209 fn v2_images_round_trip_and_read_as_v1_community_images() {
3210 let (_tmp, _guard) = init_test_db();
3211 let owner = nostr_sdk::prelude::Keys::generate();
3212 let g = crate::community::v2::control::genesis(
3213 &owner,
3214 crate::community::v2::control::CommunityMetadata { name: "Icons".into(), ..Default::default() },
3215 1_000,
3216 )
3217 .unwrap();
3218 let mut c = crate::community::v2::community::CommunityV2::from_genesis(&g, "Icons", None, vec!["wss://r".into()], 1_000);
3219 let mut extra = serde_json::Map::new();
3220 extra.insert("ext".into(), serde_json::Value::String("webp".into()));
3221 c.icon = Some(crate::community::v2::control::ImageRef {
3222 url: "https://blossom.example/abc".into(),
3223 key: "0".repeat(64),
3224 nonce: "1".repeat(32),
3225 hash: "a".repeat(64),
3226 extra,
3227 });
3228 c.meta_custom = Some({
3229 let mut m = serde_json::Map::new();
3230 m.insert("k".into(), serde_json::Value::from("v"));
3231 m
3232 });
3233 c.channels[0].voice = Some(true);
3234 c.channels[0].meta_extra.insert("vnd".into(), serde_json::Value::from(7));
3235 save_community_v2(&c).unwrap();
3236
3237 let re = load_community_v2(c.id()).unwrap().unwrap();
3239 assert_eq!(re.icon, c.icon);
3240 assert_eq!(re.banner, None);
3241 assert_eq!(re.meta_custom, c.meta_custom);
3243 assert_eq!(re.channels[0].voice, Some(true));
3244 assert_eq!(re.channels[0].meta_extra.get("vnd"), Some(&serde_json::Value::from(7)));
3245
3246 let v1 = load_community(c.id()).unwrap().unwrap();
3250 let img = v1.icon.expect("v1 reader sees the v2 icon");
3251 assert_eq!(img.url, "https://blossom.example/abc");
3252 assert_eq!(img.ext, "webp");
3253 assert_eq!(img.hash, "a".repeat(64));
3254 }
3255
3256 #[test]
3257 fn server_root_epoch_round_trips() {
3258 let (_tmp, _guard) = init_test_db();
3260 let mut c = Community::create("HQ", "general", vec![]);
3261 save_community(&c).unwrap();
3262 assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
3263
3264 c.server_root_epoch = Epoch(5);
3265 c.server_root_key = ServerRootKey([0x42u8; 32]);
3266 save_community(&c).unwrap();
3267 let loaded = load_community(&c.id).unwrap().unwrap();
3268 assert_eq!(loaded.server_root_epoch, Epoch(5));
3269 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
3270 }
3271
3272 #[test]
3273 fn epoch_key_archive_retains_every_epoch() {
3274 let (_tmp, _guard) = init_test_db();
3277 let cid = "f".repeat(64);
3278 let scope = "a".repeat(64);
3279
3280 store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
3281 store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
3282 store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
3283
3284 let held = held_epoch_keys(&cid, &scope).unwrap();
3285 assert_eq!(held.len(), 3, "all three epoch keys retained");
3286 assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
3287 assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
3288 assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
3289
3290 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
3292 assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
3293
3294 store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
3296 assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
3297 assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
3298
3299 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
3302 assert_eq!(
3303 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
3304 None,
3305 "epoch 1 under a different scope is not the channel's key"
3306 );
3307 }
3308
3309 #[test]
3310 fn save_community_populates_the_epoch_archive() {
3311 let (_tmp, _guard) = init_test_db();
3314 let c = Community::create("HQ", "general", vec![]);
3315 save_community(&c).unwrap();
3316 let cid = c.id.to_hex();
3317
3318 assert_eq!(
3320 held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
3321 Some(c.server_root_key.as_bytes())
3322 );
3323 let chan = &c.channels[0];
3325 assert_eq!(
3326 held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
3327 Some(chan.key.as_bytes())
3328 );
3329 }
3330
3331 #[test]
3332 fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
3333 let (_tmp, _guard) = init_test_db();
3334 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
3337 crate::state::set_encryption_enabled(true);
3338
3339 let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
3340 c.server_root_key = ServerRootKey([0x42u8; 32]);
3341 c.description = Some("top secret".into());
3342 save_community(&c).unwrap();
3343 let cid = c.id.to_hex();
3344 set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
3345
3346 {
3349 let conn = crate::db::get_db_connection_guard_static().unwrap();
3350 let (root_len, name, banlist): (i64, String, String) = conn
3351 .query_row(
3352 "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
3353 params![cid],
3354 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
3355 )
3356 .unwrap();
3357 assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
3358 assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
3359 assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
3360 let _ = &banlist;
3366 let banned_npub: String = conn
3367 .query_row("SELECT npub FROM community_bans WHERE community_id = ?1 LIMIT 1", params![cid], |r| r.get(0))
3368 .unwrap();
3369 assert!(
3372 PublicKey::parse(&banned_npub).is_ok(),
3373 "the banlist is queryable plaintext by design, got {banned_npub}"
3374 );
3375 assert!(
3376 banned_npub.starts_with("npub1"),
3377 "stored in the form `events.npub` uses, or the join matches nothing"
3378 );
3379 let key_len: i64 = conn
3380 .query_row(
3381 "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
3382 params![cid],
3383 |r| r.get(0),
3384 )
3385 .unwrap();
3386 assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
3387 }
3388
3389 let loaded = load_community(&c.id).unwrap().unwrap();
3391 assert_eq!(loaded.name, "Secret HQ");
3392 assert_eq!(loaded.description.as_deref(), Some("top secret"));
3393 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
3394 assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
3395 assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
3396
3397 crate::state::set_encryption_enabled(false);
3398 crate::state::ENCRYPTION_KEY.clear(&[]);
3399 }
3400
3401 #[test]
3402 fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
3403 let (_tmp, _guard) = init_test_db();
3406 crate::state::set_encryption_enabled(false);
3407 let mut c = Community::create("Legacy HQ", "general", vec![]);
3408 c.server_root_key = ServerRootKey([0x42u8; 32]);
3409 save_community(&c).unwrap();
3410
3411 crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
3412 crate::state::set_encryption_enabled(true);
3413 let loaded = load_community(&c.id).unwrap().unwrap();
3414 assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
3415 assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
3416
3417 crate::state::set_encryption_enabled(false);
3418 crate::state::ENCRYPTION_KEY.clear(&[]);
3419 }
3420
3421 #[test]
3422 fn save_and_load_round_trip() {
3423 let (_tmp, _guard) = init_test_db();
3424 let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
3425 save_community(&original).unwrap();
3426
3427 let loaded = load_community(&original.id).unwrap().expect("present");
3428 assert_eq!(loaded.id, original.id);
3429 assert_eq!(loaded.name, "Vector HQ");
3430 assert_eq!(loaded.relays, original.relays);
3431 assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
3433 assert_eq!(loaded.channels.len(), 1);
3435 assert_eq!(loaded.channels[0].id, original.channels[0].id);
3436 assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
3437 assert_eq!(loaded.channels[0].epoch, Epoch(0));
3438 assert_eq!(loaded.channels[0].name, "general");
3439 }
3440
3441 #[test]
3442 fn owner_is_protected_from_the_banlist_a_member_is_not() {
3443 let (_tmp, _guard) = init_test_db();
3444 let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
3445 let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
3447 community.owner_attestation = Some(
3448 crate::community::owner::build_owner_attestation_unsigned(
3449 owner_id.public_key(),
3450 &community.id.to_hex(),
3451 )
3452 .finalize(&owner_id)
3453 .unwrap()
3454 .as_json(),
3455 );
3456 save_community(&community).unwrap();
3457
3458 let member = Keys::generate();
3460 set_community_banlist(
3461 &community.id.to_hex(),
3462 &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
3463 1,
3464 )
3465 .unwrap();
3466
3467 let loaded = load_community(&community.id).unwrap().unwrap();
3468 let ch = &loaded.channels[0];
3469 assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
3471 assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
3472 assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
3474 }
3475
3476 #[test]
3477 fn loaded_keys_actually_decrypt() {
3478 let (_tmp, _guard) = init_test_db();
3481 let original = Community::create("HQ", "general", vec![]);
3482 save_community(&original).unwrap();
3483 let loaded = load_community(&original.id).unwrap().unwrap();
3484
3485 let author = nostr_sdk::prelude::Keys::generate();
3486 let chan = &original.channels[0];
3487 let sealed = crate::community::envelope::seal_message(
3488 &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
3489 )
3490 .unwrap();
3491 let opened = crate::community::envelope::open_message(
3492 &sealed,
3493 &loaded.channels[0].key,
3494 &loaded.channels[0].id,
3495 loaded.channels[0].epoch,
3496 )
3497 .unwrap();
3498 assert_eq!(opened.content, "persisted!");
3499 }
3500
3501 #[test]
3502 fn member_view_round_trips() {
3503 let (_tmp, _guard) = init_test_db();
3506 let member = Community {
3507 id: CommunityId([7u8; 32]),
3508 server_root_key: ServerRootKey([8u8; 32]),
3509 server_root_epoch: Epoch(0),
3510 name: "Joined".into(),
3511 description: None,
3512 icon: None,
3513 banner: None,
3514 relays: vec!["wss://r".into()],
3515 channels: vec![Channel {
3516 id: ChannelId([9u8; 32]),
3517 key: ChannelKey([10u8; 32]),
3518 epoch: Epoch(0),
3519 name: "general".into(),
3520 banned: Vec::new(),
3521 protected: Vec::new(), roster: Default::default(),
3522 epoch_keys: Vec::new(),
3523 dissolved: false,
3524 }],
3525 owner_attestation: None,
3526 dissolved: false,
3527 };
3528 save_community(&member).unwrap();
3529 let loaded = load_community(&member.id).unwrap().expect("present");
3530 assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
3531 assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
3532 }
3533
3534 #[test]
3535 fn large_epoch_round_trips_losslessly() {
3536 let (_tmp, _guard) = init_test_db();
3538 let mut c = Community::create("HQ", "g", vec![]);
3539 c.channels[0].epoch = Epoch(u64::MAX - 7);
3540 save_community(&c).unwrap();
3541 let loaded = load_community(&c.id).unwrap().unwrap();
3542 assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
3543 }
3544
3545 #[test]
3546 fn malformed_channel_id_row_errors_not_corrupts() {
3547 let (_tmp, _guard) = init_test_db();
3550 let c = Community::create("HQ", "g", vec![]);
3551 save_community(&c).unwrap();
3552 {
3553 let conn = crate::db::get_write_connection_guard_static().unwrap();
3554 conn.execute(
3555 "INSERT OR REPLACE INTO community_channels
3556 (channel_id, community_id, channel_key, epoch, name, created_at)
3557 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
3558 rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
3559 )
3560 .unwrap();
3561 }
3562 assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
3563 }
3564
3565 #[test]
3566 fn message_key_store_take_round_trip() {
3567 let (_tmp, _guard) = init_test_db();
3568 let eph = Keys::generate();
3569 let relays = vec!["wss://r.one".to_string()];
3570 store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
3572
3573 let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
3574 assert_eq!(
3575 loaded.secret_key().as_secret_bytes(),
3576 eph.secret_key().as_secret_bytes()
3577 );
3578 assert_eq!(outer, "outer_evid");
3579 assert_eq!(r, relays);
3580 assert!(take_message_key("inner_msg_id").unwrap().is_none());
3582 }
3583
3584 #[test]
3585 fn missing_community_is_none() {
3586 let (_tmp, _guard) = init_test_db();
3587 let absent = CommunityId([0x33u8; 32]);
3588 assert!(load_community(&absent).unwrap().is_none());
3589 }
3590
3591 #[test]
3592 fn list_ids_reflects_saved() {
3593 let (_tmp, _guard) = init_test_db();
3594 let a = Community::create("A", "g", vec![]);
3595 let b = Community::create("B", "g", vec![]);
3596 save_community(&a).unwrap();
3597 save_community(&b).unwrap();
3598 let ids = list_community_ids().unwrap();
3599 assert_eq!(ids.len(), 2);
3600 assert!(ids.contains(&a.id) && ids.contains(&b.id));
3601 }
3602
3603 #[test]
3604 fn delete_community_clears_all_local_state() {
3605 let (_tmp, _guard) = init_test_db();
3606 let c = Community::create("HQ", "general", vec!["r1".into()]);
3607 save_community(&c).unwrap();
3608 let cid = c.id.to_hex();
3609 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
3610 save_pending_invite(&"cd".repeat(32), "{}", "npub1x", 0).unwrap();
3611 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
3612
3613 assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
3615
3616 delete_community(&cid).unwrap();
3617 assert!(!community_exists(&c.id).unwrap());
3618 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
3619 assert!(list_public_invites(&cid).unwrap().is_empty());
3620 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
3621 assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
3622 }
3623
3624 #[test]
3625 fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
3626 let (_tmp, _guard) = init_test_db();
3629 let c = Community::create("HQ", "general", vec!["r1".into()]);
3630 save_community(&c).unwrap();
3631 let cid = c.id.to_hex();
3632 save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
3633 set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
3634
3635 let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
3636 let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
3637 assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
3638
3639 delete_community_retain_keys(&cid).unwrap();
3640
3641 assert!(!community_exists(&c.id).unwrap());
3643 assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
3644 assert!(list_public_invites(&cid).unwrap().is_empty());
3645 assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
3646 assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
3648 "base epoch keys retained for self-scrub");
3649 assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
3650 "channel epoch keys retained for self-scrub");
3651 }
3652
3653 #[test]
3654 fn channel_resolves_to_owning_community() {
3655 let (_tmp, _guard) = init_test_db();
3656 let c = Community::create("HQ", "general", vec![]);
3657 save_community(&c).unwrap();
3658 let chan = c.channels[0].id.to_hex();
3659 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
3660 assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
3661 }
3662
3663 #[test]
3664 fn community_exists_reflects_saved() {
3665 let (_tmp, _guard) = init_test_db();
3666 let c = Community::create("A", "g", vec![]);
3667 assert!(!community_exists(&c.id).unwrap());
3668 save_community(&c).unwrap();
3669 assert!(community_exists(&c.id).unwrap());
3670 }
3671
3672 #[test]
3673 fn reparent_moves_channels_stamps_fence_and_invalidates_cache() {
3674 let (_tmp, _guard) = init_test_db();
3675 let v1 = Community::create("HQ", "general", vec![]);
3676 save_community(&v1).unwrap();
3677 let v1_cid = v1.id.to_hex();
3678 let v2_cid = "ab".repeat(32);
3679 let chan = v1.channels[0].id.to_hex();
3680
3681 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v1_cid.as_str()));
3683 reparent_channels_and_fence(&v1_cid, &v2_cid).unwrap();
3684
3685 assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v2_cid.as_str()),
3687 "stale v1 cache entry must not survive the re-parent");
3688 assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()));
3689 assert!(get_community_dissolved(&v1_cid).unwrap(), "flip seals v1 (fence layer 0)");
3690
3691 reparent_channels_and_fence(&v1_cid, &"cd".repeat(32)).unwrap();
3693 assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()),
3694 "migrated_to is one-way — a second flip cannot repoint it");
3695 }
3696
3697 #[test]
3698 fn migration_sweep_candidates_include_unsealed_v1() {
3699 let (_tmp, _guard) = init_test_db();
3700 let a = Community::create("A", "g", vec![]);
3701 let b = Community::create("B", "g", vec![]);
3702 let c = Community::create("C", "g", vec![]);
3703 for x in [&a, &b, &c] { save_community(x).unwrap(); }
3704 set_community_dissolved(&a.id.to_hex()).unwrap();
3705 set_community_dissolved(&b.id.to_hex()).unwrap();
3706 set_migrated_to(&b.id.to_hex(), &"ab".repeat(32)).unwrap();
3707 let cands = migration_sweep_candidates().unwrap();
3708 assert!(cands.contains(&a.id.to_hex()), "sealed + unchecked is a candidate");
3709 assert!(!cands.contains(&b.id.to_hex()), "flipped is not a candidate");
3710 assert!(cands.contains(&c.id.to_hex()), "UNSEALED v1 is a candidate too");
3714 set_migration_checked(&a.id.to_hex()).unwrap();
3716 set_migration_checked(&c.id.to_hex()).unwrap();
3717 let after = migration_sweep_candidates().unwrap();
3718 assert!(!after.contains(&a.id.to_hex()) && !after.contains(&c.id.to_hex()));
3719 }
3720
3721 #[test]
3722 fn pending_invite_first_wins_and_round_trips() {
3723 let (_tmp, _guard) = init_test_db();
3724 let cid = "ab".repeat(32);
3725 assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter", 0).unwrap());
3728 assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other", 0).unwrap());
3729 assert!(pending_invite_exists(&cid).unwrap());
3730
3731 let listed = list_pending_invites().unwrap();
3732 assert_eq!(listed.len(), 1);
3733 assert_eq!(listed[0].community_id, cid);
3734 assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
3735 assert_eq!(listed[0].inviter_npub, "npub1inviter");
3736
3737 assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
3739 assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
3740 delete_pending_invite(&cid).unwrap();
3741 assert!(!pending_invite_exists(&cid).unwrap());
3742 assert!(get_pending_invite(&cid).unwrap().is_none());
3743 }
3744
3745 #[test]
3746 fn purge_drops_invites_for_held_communities_only() {
3747 let (_tmp, _guard) = init_test_db();
3748 let held = Community::create("Held", "general", vec![]);
3751 save_community(&held).unwrap();
3752 let held_hex = held.id.to_hex();
3753 save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter", 0).unwrap();
3754 let stranger = "ab".repeat(32);
3756 save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter", 0).unwrap();
3757
3758 let n = purge_pending_invites_for_held_communities().unwrap();
3759 assert_eq!(n, 1, "only the held community's invite is purged");
3760 assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
3761 assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
3762 }
3763
3764 #[test]
3765 fn decline_drops_pending_invite() {
3766 let (_tmp, _guard) = init_test_db();
3767 let cid = "cd".repeat(32);
3768 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3769 delete_pending_invite(&cid).unwrap();
3770 assert!(!pending_invite_exists(&cid).unwrap());
3771 }
3772
3773 #[test]
3774 fn pending_invites_are_capped_keeping_the_newest() {
3775 let (_tmp, _guard) = init_test_db();
3776 for i in 0..150u32 {
3781 let cid = format!("{:064x}", i);
3782 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3783 }
3784 let all = list_pending_invites().unwrap();
3785 assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
3786 for i in 150..400u32 {
3788 let cid = format!("{:064x}", i);
3789 save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3790 }
3791 assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
3792 }
3793
3794 #[test]
3799 fn expired_parked_invites_are_hidden_from_list_and_accept() {
3800 let (_tmp, _guard) = init_test_db();
3801 let now = now_secs();
3802 let live = "aa".repeat(32);
3803 let expired = "bb".repeat(32);
3804 let permanent = "cc".repeat(32);
3805
3806 save_pending_invite(&live, "{\"live\":1}", "npub1x", now + 3600).unwrap();
3807 save_pending_invite(&expired, "{\"dead\":1}", "npub1x", now - 1).unwrap();
3808 save_pending_invite(&permanent, "{\"forever\":1}", "npub1x", 0).unwrap();
3811
3812 let listed: Vec<String> = list_pending_invites().unwrap().into_iter().map(|i| i.community_id).collect();
3813 assert!(listed.contains(&live), "an unexpired invite still lists");
3814 assert!(listed.contains(&permanent), "a no-deadline invite still lists");
3815 assert!(!listed.contains(&expired), "an expired invite is hidden from the list");
3816
3817 assert!(get_pending_invite(&live).unwrap().is_some());
3818 assert!(get_pending_invite(&permanent).unwrap().is_some());
3819 assert!(
3820 get_pending_invite(&expired).unwrap().is_none(),
3821 "an expired invite must not be redeemable"
3822 );
3823
3824 assert!(pending_invite_exists(&expired).unwrap(), "hidden, not yet deleted");
3826 assert_eq!(purge_expired_pending_invites().unwrap(), 1);
3827 assert!(!pending_invite_exists(&expired).unwrap());
3828 assert!(pending_invite_exists(&live).unwrap(), "the sweep spares live invites");
3829 assert!(pending_invite_exists(&permanent).unwrap(), "and no-deadline ones");
3830 }
3831
3832 #[test]
3835 fn expiry_follows_the_senders_deadline_not_receipt_time() {
3836 let (_tmp, _guard) = init_test_db();
3837 let cid = "de".repeat(32);
3838 save_pending_invite(&cid, "{}", "npub1x", now_secs() - 10).unwrap();
3841 assert!(list_pending_invites().unwrap().is_empty(), "receipt time does not extend the deadline");
3842 assert!(get_pending_invite(&cid).unwrap().is_none());
3843 }
3844}
3845
3846pub fn set_community_pins(community_id: &str, channel_id: &str, content: &str, version: i64) -> Result<bool, String> {
3858 let enc = enc_txt(content)?;
3859 let conn = super::get_write_connection_guard_static()?;
3860 let changed = conn
3861 .execute(
3862 "INSERT INTO community_pins (community_id, channel_id, content, version) VALUES (?1, ?2, ?3, ?4)
3863 ON CONFLICT(community_id, channel_id) DO UPDATE SET
3864 content = excluded.content, version = excluded.version
3865 WHERE excluded.version >= community_pins.version",
3866 params![community_id, channel_id, enc, version],
3867 )
3868 .map_err(|e| format!("set pins: {e}"))?;
3869 Ok(changed > 0)
3870}
3871
3872pub fn get_community_pins(community_id: &str, channel_id: &str) -> Result<Option<(String, i64)>, String> {
3875 let conn = super::get_db_connection_guard_static()?;
3876 let row: Option<(String, i64)> = conn
3877 .query_row(
3878 "SELECT content, version FROM community_pins WHERE community_id = ?1 AND channel_id = ?2",
3879 params![community_id, channel_id],
3880 |r| Ok((r.get(0)?, r.get(1)?)),
3881 )
3882 .optional()
3883 .map_err(|e| format!("get pins: {e}"))?;
3884 Ok(row.map(|(content, version)| (dec_txt(&content), version)))
3885}